本文介绍如何使用快捷指令结合触控功能,实现在不打开app的情况下,通过长按屏幕弹出搜题浮窗。
我们需要在工程内引入Intents Extension、Intents UI Extension。其中Intents Extension用于处理快捷指令,Intents UI Extension用于设置快捷指令触发后的浮窗UI。
添加Intent Definition File,这是个定义文件,我们可以在其中添加我们app支持的快捷指令。
我们把intent.intentdefinition的app、Intents、IntentsUI的target都勾选上,以便它们都能调用我们的自定义Intent类。
添加新的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后,需要修改主app、Intents、IntentsUI的info.plist, 添加对SearchQuestionIntent的关联
当使用swift时添加 image 参数编译会报错。这是因为在SearchQuestionIntent.swift中两个swift方法指向了同一个objc方法导致的。这个文件由Xcode自动生成的只读文件,我们没法修改。这是Xcode 14的bug,不过我使用Xcode 15.3也复现了。解决的办法是:升级Xcode或者添加Extension的时候选择Objective-C语言。参考:xcode-14-release-notes
现在我们已经配置完快捷指令了,之后需要在代码层面进行处理。
修改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;
}
修改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 -> handle 。handle 方法返回了一个带有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);
接下来我们需要修改IntentsUI的代码,来修改我们Siri浮窗的展示。系统对Siri浮窗的UI限制较多,我们只被允许修改中间的部分内容。这部分内容由IntentViewController提供。加载流程如下图
我们实现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
主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中
在主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各自对应的权限声明文件。
#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;
}
如果需要调试Intents或者IntentsUI,我们需要选中对应的target(比如SearchIntentUI),点击build后在 Choose an app to run 弹窗中选择Shortcuts。
主app、Intents、IntentsUI都需要各自的bundle identifier,需要在apple developer后台添加对应的Identifier,并使用同一个签名证书生成各自的Profiles。 在build或者打包的时候,需要让主app和Extension的 Signing & Capability下 Signing Certificate 保持一致,同时 Build Settings 下的 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 Dependencies 和 Embed Foundation Extensions 查看。打包时它们以Embed Without Signing的方式嵌入的,因为它们自己已经进行了签名,不需要主app再次对它们进行签名。
打包后如下图
打开 快捷指令app,在我们的app下能看到所添加 “搜题🔍”。我们新建一个快捷指令,分别添加“截屏”、“搜题🔍”,如下图。可以看到截屏的结果已经作为“搜题🔍”的图片参数输入了。“运行时显示”打开时才能显示Siri浮窗。我们点击分享,生成快捷指令的iCloud链接,之后我们就可以通过iCloud链接引导用户快速地构建这个指令。我们生成的链接是:https://www.icloud.com/shortcuts/3b76dbdcd840459fa4819a7974b6b08e,用 UIApplication 的openURL: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];
打开 设置-辅助功能-触控-辅助触控 页面,打开辅助触控功能,在 自定义操作 中选择一个手势,比如长按,选中我们的快捷指令“搜题🔍”。之后我们就可以在手机任意页面,通过长按触控球来进行搜题了。