iOS快捷指令实现浮窗搜题

本文介绍如何使用快捷指令结合触控功能,实现在不打开app的情况下,通过长按屏幕弹出搜题浮窗。

1. 添加Intent

我们需要在工程内引入Intents ExtensionIntents UI Extension。其中Intents Extension用于处理快捷指令,Intents UI Extension用于设置快捷指令触发后的浮窗UI。

添加Extension

添加Intent Definition File,这是个定义文件,我们可以在其中添加我们app支持的快捷指令。

Intent Definition File

我们把intent.intentdefinition的app、Intents、IntentsUI的target都勾选上,以便它们都能调用我们的自定义Intent类。

勾选target

添加新的Intent,并配置相应的选项:

  • Category 设置Intent类型,不同的类型弹窗的UI和交互略有不同
  • Custom Class 系统默认生成的Intent只读头文件,包含Intent对应的类和handler需遵循的协议
  • User confirmation required 会先弹一个包含 下一步取消 按钮的弹窗
  • Intent is user-configurable in the Shortcuts app and Add to Siri 指令是否能在快捷指令app中找到
  • 为Intent添加一个参数 image ,并将其type选择为File,File Type选择image
  • 在Shortcuts app中的Input parameter选中 image , 将上一个指令的输出,作为 image 参数输入
添加新Intent
配置Intent 1
配置intent 2

添加完Intent后,需要修改主app、Intents、IntentsUI的info.plist, 添加对SearchQuestionIntent的关联

主app info.plist
Intents info.plist
IntentsUI info.plist
⚠️ Swift编译问题解决方案

当使用swift时添加 image 参数编译会报错。这是因为在SearchQuestionIntent.swift中两个swift方法指向了同一个objc方法导致的。这个文件由Xcode自动生成的只读文件,我们没法修改。这是Xcode 14的bug,不过我使用Xcode 15.3也复现了。解决的办法是:升级Xcode或者添加Extension的时候选择Objective-C语言。参考:xcode-14-release-notes

编译报错
方法定义
Xcode14 note

2. 代码实现要点

现在我们已经配置完快捷指令了,之后需要在代码层面进行处理。

2.1 AppDelegate

修改AppDelegate内关于UserActivity的代理方法,这个是快捷指令打开app时触发(包括指令直接调起app,以及点击siri浮窗进入等情况)。

- (BOOL)application:(UIApplication *)application willContinueUserActivityWithType:(NSString *)userActivityType {
    return YES;
}

- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
    INIntent *intent = userActivity.interaction.intent;
    if (intent) {
        //需 #import "SearchQuestionIntent.h"
        if ([intent isKindOfClass:[SearchQuestionIntent class]]) {
            if (@available(iOS 13.0, *)) {
                SearchQuestionIntent *sqIntent = (SearchQuestionIntent *)intent;
                INFile *imageFile = sqIntent.image;
                if (imageFile.fileURL) {
                    // UIImage *image = [UIImage imageWithContentsOfFile:imageFile.fileURL.path];
                    // 点击浮窗区域跳转进app
                    // 处理代码省略
                }
            }
        }
    }
    return YES;
}

2.2 IntentHandler

修改IntentHandler文件,返回我们的自定义Handler

- (id)handlerForIntent:(INIntent *)intent {
    // This is the default implementation.  If you want different objects to handle different intents,
    // you can override this and return the handler you want for that particular intent.
    
    if ([intent isKindOfClass:[SearchQuestionIntent class]]) {
        return [SearchQuestionIntentHandler new];
    }
    
    return self;
}

SearchQuestionIntentHandler需要实现 SearchQuestionIntentHandling 的代理方法。我们这里实现了3个方法,他们的调用顺序是 comfirm -> resolveImage -> handlehandle 方法返回了一个带有code的response,code是在 SearchQuestionIntent.h 中定义的枚举,每个code对应一种状态,其弹窗和交互也略有不同。比如 ContinueInApp 是不弹窗直接跳转到app,Success 弹出一个带有勾号的弹窗,InProgress 也是弹窗但不带勾号。(具体的UI和交互可能在不同的iOS系统版本下略有不同)

#import "SearchQuestionIntentHandler.h"
#import "SearchQuestionIntent.h"

@interface SearchQuestionIntentHandler() <SearchQuestionIntentHandling>
@end

@implementation SearchQuestionIntentHandler

#pragma mark - SearchQuestionIntentHandling
// 调用顺序: comfirm -> resolveImage -> handle

//- (void)confirmSearchQuestion:(SearchQuestionIntent *)intent completion:(void (^)(SearchQuestionIntentResponse * _Nonnull))completion {
//}

- (void)handleSearchQuestion:(nonnull SearchQuestionIntent *)intent completion:(nonnull void (^)(SearchQuestionIntentResponse * _Nonnull))completion {
    NSUserActivity *userActivity = [[NSUserActivity alloc] initWithActivityType:NSStringFromClass([SearchQuestionIntent class])];
    SearchQuestionIntentResponse *response = [[SearchQuestionIntentResponse alloc] initWithCode:SearchQuestionIntentResponseCodeInProgress userActivity:userActivity];
    completion(response);
}

- (void)resolveImageForSearchQuestion:(nonnull SearchQuestionIntent *)intent withCompletion:(nonnull void (^)(INFileResolutionResult * _Nonnull))completion  API_AVAILABLE(ios(13.0)){
    INFile *image = intent.image;
    if (image != nil) {
        INFileResolutionResult *result = [INFileResolutionResult successWithResolvedFile:intent.image];
        completion(result);
    } else {
        // image 参数无效,提示用户重新输入
        INFileResolutionResult *result = [INFileResolutionResult needsValue];
        completion(result);
    }
}
/*!
 @abstract Constants indicating the state of the response.
 */
typedef NS_ENUM(NSInteger, SearchQuestionIntentResponseCode) {
    SearchQuestionIntentResponseCodeUnspecified = 0,
    SearchQuestionIntentResponseCodeReady,
    SearchQuestionIntentResponseCodeContinueInApp,
    SearchQuestionIntentResponseCodeInProgress,
    SearchQuestionIntentResponseCodeSuccess,
    SearchQuestionIntentResponseCodeFailure,
    SearchQuestionIntentResponseCodeFailureRequiringAppLaunch
} API_AVAILABLE(ios(12.0), macos(11.0), watchos(5.0)) API_UNAVAILABLE(tvos);

2.3 自定义浮窗UI

接下来我们需要修改IntentsUI的代码,来修改我们Siri浮窗的展示。系统对Siri浮窗的UI限制较多,我们只被允许修改中间的部分内容。这部分内容由IntentViewController提供。加载流程如下图

IntentsUI加载流程

我们实现configureViewForParameters:ofInteraction:interactiveBehavior:context:completion:来定制我们的ui,参考intentsUI文档。对于不同的Intent,我们应该实现不同的ViewController,并经其添加到self.view当中。

需要注意的是,受苹果限制,我们添加到siri浮窗中的自定义视图不支持触摸事件,因为无法实现点击按钮、滑动等效果。但是当用户点击自定义视图的整体部分时,系统会跳转到我们的app内部。

@implementation IntentViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    UIView *view = self.view;
    while (view) {
        view.backgroundColor = [UIColor clearColor];
        view = view.superview;
    }
}

#pragma mark - INUIHostedViewControlling

// Prepare your view controller for the interaction to handle.
- (void)configureViewForParameters:(NSSet <INParameter *> *)parameters ofInteraction:(INInteraction *)interaction interactiveBehavior:(INUIInteractiveBehavior)interactiveBehavior context:(INUIHostedViewContext)context completion:(void (^)(BOOL success, NSSet <INParameter *> *configuredParameters, CGSize desiredSize))completion {
    // Do configuration here, including preparing views and calculating a desired size for presentation.
    
    CGSize desiredSize = [self extensionContext].hostedViewMaximumAllowedSize;
    if ([interaction.intent isKindOfClass:[SearchQuestionIntent class]]) {
        SearchQuestionResultViewController *searchResultVC = [[SearchQuestionResultViewController alloc] initWithIntent:(SearchQuestionIntent *)interaction.intent completion:^(BOOL success, CGFloat contentHeight) {
            CGSize size = CGSizeMake(desiredSize.width, MIN(desiredSize.height, contentHeight));
            if (completion) {
                completion(success, parameters, size);
            }
        }];
        [self addChildViewController:searchResultVC];
        [self.view addSubview:searchResultVC.view];
        searchResultVC.view.frame = CGRectMake(0, 0, desiredSize.width, desiredSize.height);
    } else {
        if (completion) {
            completion(YES, parameters, desiredSize);
        }
    }
}

- (CGSize)desiredSize {
    CGSize size = [self extensionContext].hostedViewMaximumAllowedSize;
    return size;
}

@end

2.4 数据同步

主app是作为 application 运行,intents和intentsUI则是作为 plugin 运行,它们有不同的沙盒和进程。如果需要在它们之间进行数据共享,比如同步cookie、用户配置等,可以采用Keychain Group 或者 App Group,我们采用的是Keychain Group方案。

app的沙盒在Containers/Data/Application下,intents的沙盒在 Containers/Data/PluginKitPlugin下, appGroup的沙盒在 Containers/Shared/AppGroup下,Keychain Group则是加密存储在系统的keychain中

2.4.1 添加Keychain Group

在主app的target下,Signing & Capability -> + Capability -> Keychain Sharing, 添加新的Keychain group,比如 com.fenbi.share.searchDemo.share。同样地,在intents、intentsUI下添加相同的 Keychain Group 。Xcode会自动生成权限声明文件(.entitlements),其中 appIdentifierPrefix 是我们的apple开发团队id,他作为前缀拼接在前面,最终的 Keychain Group{开发团队id}.com.fenbi.share.searchDemo.share。我们可以在 Build Settings->Signing->Code Signing Entitlements 修改Debug、Release各自对应的权限声明文件。

entitlements文件

2.4.2 使用Security进行钥匙串读写

#import <Security/Security.h>

#define kKeychainGroup @"com.fenbi.share.searchDemo.share"
#define kKeychainUserService @"com.fenbi.share.searchDemo.userservice"

+ (NSString *)appIdentifierPrefix {
#pragma mark - todo 这是Demo随机生成的id,实际开发中需要替换成开发团队id
    return @"W4E5KLUTS8";
}

+ (NSString *)groupName {
    return [NSString stringWithFormat:@"%@.%@", [self appIdentifierPrefix], kKeychainGroup];
}

// 保存key-value到keychain
+ (BOOL)saveData:(nullable NSData *)data key:(NSString *)key {
    if (key == nil) {
        return NO;
    }
    NSMutableDictionary *query = @{
        (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
        (__bridge id)kSecAttrService: kKeychainUserService,
        (__bridge id)kSecAttrAccessGroup: [self groupName],
        (__bridge id)kSecAttrAccount: key,
    }.mutableCopy;
    
    // 先尝试删除数据
    SecItemDelete((__bridge CFDictionaryRef)query);
    if (data) {
        query[(__bridge id)kSecValueData] = data;
        OSStatus status = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
        if (status != errSecSuccess) {
            return NO;
        }
    }
    return YES;
}

// 从keychain读取value
+ (NSData *)getDataForkey:(NSString *)key {
    if (key == nil) {
        return nil;
    }
    NSDictionary *query = @{
        (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
        (__bridge id)kSecAttrService: kKeychainUserService,
        (__bridge id)kSecAttrAccount: key,
        (__bridge id)kSecAttrAccessGroup: [self groupName],
        (__bridge id)kSecReturnData : @YES,
    };

    CFTypeRef result = NULL;
    OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
    if (status != errSecSuccess) {
        return nil;
    }
    NSData *data = (__bridge_transfer NSData *)result;
    return data;
}

2.5 调试

如果需要调试Intents或者IntentsUI,我们需要选中对应的target(比如SearchIntentUI),点击build后在 Choose an app to run 弹窗中选择Shortcuts。

Choose an app to run.png

2.6 打包

主app、Intents、IntentsUI都需要各自的bundle identifier,需要在apple developer后台添加对应的Identifier,并使用同一个签名证书生成各自的Profiles。
在build或者打包的时候,需要让主app和Extension的 Signing & CapabilitySigning Certificate 保持一致,同时 Build Settings 下的 Architectures 的配置也应保持一致。

Architectures配置示例

否则当主app和Extension引入相同的第三方framework时,就会由于主app和Extentsion的签名证书或架构类型的不同而导致签名失败。
例如报错:Embedded binary is not signed with the same certificate as the parent app. Verify the embedded binary target's code sign settings match the parent app's.

Intents 和 IntentsUI 是主app target的工程依赖,打包时以app扩展的形式嵌入到主程序包中的。我们可以在主app target的 Build Phases 下的 Target DependenciesEmbed Foundation Extensions 查看。打包时它们以Embed Without Signing的方式嵌入的,因为它们自己已经进行了签名,不需要主app再次对它们进行签名。

Build Phases

打包后如下图


ipa包

3. 生成快捷指令iCloud链接

打开 快捷指令app,在我们的app下能看到所添加 “搜题🔍”。我们新建一个快捷指令,分别添加“截屏”、“搜题🔍”,如下图。可以看到截屏的结果已经作为“搜题🔍”的图片参数输入了。“运行时显示”打开时才能显示Siri浮窗。我们点击分享,生成快捷指令的iCloud链接,之后我们就可以通过iCloud链接引导用户快速地构建这个指令。我们生成的链接是:https://www.icloud.com/shortcuts/3b76dbdcd840459fa4819a7974b6b08e,用 UIApplicationopenURL:options:completionHandler:方法打开它。

构造快捷指令
//    NSString *urlString = @"ShortCut://create-shortCut";
    NSString *urlString = @"https://www.icloud.com/shortcuts/40e542ab5bdc4a3084dea5e8a0616de4";
    NSURL *url = [NSURL URLWithString:urlString];
    [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
添加窗口

4. 关联触控

打开 设置-辅助功能-触控-辅助触控 页面,打开辅助触控功能,在 自定义操作 中选择一个手势,比如长按,选中我们的快捷指令“搜题🔍”。之后我们就可以在手机任意页面,通过长按触控球来进行搜题了。

效果展示

5. Demo

demo地址:https://github.com/linjunyi/SearchApp

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容