本文介紹如何使用快捷指令結(jié)合觸控功能,實(shí)現(xiàn)在不打開app的情況下,通過長(zhǎng)按屏幕彈出搜題浮窗。
1. 添加Intent
我們需要在工程內(nèi)引入Intents Extension、Intents UI Extension。其中Intents Extension用于處理快捷指令,Intents UI Extension用于設(shè)置快捷指令觸發(fā)后的浮窗UI。

添加Intent Definition File,這是個(gè)定義文件,我們可以在其中添加我們app支持的快捷指令。

我們把intent.intentdefinition的app、Intents、IntentsUI的target都勾選上,以便它們都能調(diào)用我們的自定義Intent類。

添加新的Intent,并配置相應(yīng)的選項(xiàng):
- Category 設(shè)置Intent類型,不同的類型彈窗的UI和交互略有不同
- Custom Class 系統(tǒng)默認(rèn)生成的Intent只讀頭文件,包含Intent對(duì)應(yīng)的類和handler需遵循的協(xié)議
- User confirmation required 會(huì)先彈一個(gè)包含 下一步 和 取消 按鈕的彈窗
- Intent is user-configurable in the Shortcuts app and Add to Siri 指令是否能在快捷指令app中找到
- 為Intent添加一個(gè)參數(shù) image ,并將其type選擇為File,F(xiàn)ile Type選擇image
- 在Shortcuts app中的Input parameter選中 image , 將上一個(gè)指令的輸出,作為 image 參數(shù)輸入



添加完Intent后,需要修改主app、Intents、IntentsUI的info.plist, 添加對(duì)SearchQuestionIntent的關(guān)聯(lián)



?? Swift編譯問題解決方案
當(dāng)使用swift時(shí)添加 image 參數(shù)編譯會(huì)報(bào)錯(cuò)。這是因?yàn)樵?strong>SearchQuestionIntent.swift中兩個(gè)swift方法指向了同一個(gè)objc方法導(dǎo)致的。這個(gè)文件由Xcode自動(dòng)生成的只讀文件,我們沒法修改。這是Xcode 14的bug,不過我使用Xcode 15.3也復(fù)現(xiàn)了。解決的辦法是:升級(jí)Xcode或者添加Extension的時(shí)候選擇Objective-C語言。參考:xcode-14-release-notes



2. 代碼實(shí)現(xiàn)要點(diǎn)
現(xiàn)在我們已經(jīng)配置完快捷指令了,之后需要在代碼層面進(jìn)行處理。
2.1 AppDelegate
修改AppDelegate內(nèi)關(guān)于UserActivity的代理方法,這個(gè)是快捷指令打開app時(shí)觸發(fā)(包括指令直接調(diào)起app,以及點(diǎn)擊siri浮窗進(jìn)入等情況)。
- (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];
// 點(diǎn)擊浮窗區(qū)域跳轉(zhuǎn)進(jìn)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需要實(shí)現(xiàn) SearchQuestionIntentHandling 的代理方法。我們這里實(shí)現(xiàn)了3個(gè)方法,他們的調(diào)用順序是 comfirm -> resolveImage -> handle 。handle 方法返回了一個(gè)帶有code的response,code是在 SearchQuestionIntent.h 中定義的枚舉,每個(gè)code對(duì)應(yīng)一種狀態(tài),其彈窗和交互也略有不同。比如 ContinueInApp 是不彈窗直接跳轉(zhuǎn)到app,Success 彈出一個(gè)帶有勾號(hào)的彈窗,InProgress 也是彈窗但不帶勾號(hào)。(具體的UI和交互可能在不同的iOS系統(tǒng)版本下略有不同)
#import "SearchQuestionIntentHandler.h"
#import "SearchQuestionIntent.h"
@interface SearchQuestionIntentHandler() <SearchQuestionIntentHandling>
@end
@implementation SearchQuestionIntentHandler
#pragma mark - SearchQuestionIntentHandling
// 調(diào)用順序: 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 參數(shù)無效,提示用戶重新輸入
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浮窗的展示。系統(tǒng)對(duì)Siri浮窗的UI限制較多,我們只被允許修改中間的部分內(nèi)容。這部分內(nèi)容由IntentViewController提供。加載流程如下圖

我們實(shí)現(xiàn)configureViewForParameters:ofInteraction:interactiveBehavior:context:completion:來定制我們的ui,參考intentsUI文檔。對(duì)于不同的Intent,我們應(yīng)該實(shí)現(xiàn)不同的ViewController,并經(jīng)其添加到self.view當(dāng)中。
需要注意的是,受蘋果限制,我們添加到siri浮窗中的自定義視圖不支持觸摸事件,因?yàn)闊o法實(shí)現(xiàn)點(diǎn)擊按鈕、滑動(dòng)等效果。但是當(dāng)用戶點(diǎn)擊自定義視圖的整體部分時(shí),系統(tǒng)會(huì)跳轉(zhuǎn)到我們的app內(nèi)部。
@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 數(shù)據(jù)同步
主app是作為 application 運(yùn)行,intents和intentsUI則是作為 plugin 運(yùn)行,它們有不同的沙盒和進(jìn)程。如果需要在它們之間進(jìn)行數(shù)據(jù)共享,比如同步cookie、用戶配置等,可以采用Keychain Group 或者 App Group,我們采用的是Keychain Group方案。
app的沙盒在Containers/Data/Application下,intents的沙盒在 Containers/Data/PluginKitPlugin下, appGroup的沙盒在 Containers/Shared/AppGroup下,Keychain Group則是加密存儲(chǔ)在系統(tǒng)的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會(huì)自動(dòng)生成權(quán)限聲明文件(.entitlements),其中 appIdentifierPrefix 是我們的apple開發(fā)團(tuán)隊(duì)id,他作為前綴拼接在前面,最終的 Keychain Group 是 {開發(fā)團(tuán)隊(duì)id}.com.fenbi.share.searchDemo.share。我們可以在 Build Settings->Signing->Code Signing Entitlements 修改Debug、Release各自對(duì)應(yīng)的權(quán)限聲明文件。

2.4.2 使用Security進(jìn)行鑰匙串讀寫
#import <Security/Security.h>
#define kKeychainGroup @"com.fenbi.share.searchDemo.share"
#define kKeychainUserService @"com.fenbi.share.searchDemo.userservice"
+ (NSString *)appIdentifierPrefix {
#pragma mark - todo 這是Demo隨機(jī)生成的id,實(shí)際開發(fā)中需要替換成開發(fā)團(tuán)隊(duì)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;
// 先嘗試刪除數(shù)據(jù)
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 調(diào)試
如果需要調(diào)試Intents或者IntentsUI,我們需要選中對(duì)應(yīng)的target(比如SearchIntentUI),點(diǎn)擊build后在 Choose an app to run 彈窗中選擇Shortcuts。

2.6 打包
主app、Intents、IntentsUI都需要各自的bundle identifier,需要在apple developer后臺(tái)添加對(duì)應(yīng)的Identifier,并使用同一個(gè)簽名證書生成各自的Profiles。
在build或者打包的時(shí)候,需要讓主app和Extension的 Signing & Capability下 Signing Certificate 保持一致,同時(shí) Build Settings 下的 Architectures 的配置也應(yīng)保持一致。

否則當(dāng)主app和Extension引入相同的第三方framework時(shí),就會(huì)由于主app和Extentsion的簽名證書或架構(gòu)類型的不同而導(dǎo)致簽名失敗。
例如報(bào)錯(cuò):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的工程依賴,打包時(shí)以app擴(kuò)展的形式嵌入到主程序包中的。我們可以在主app target的 Build Phases 下的 Target Dependencies 和 Embed Foundation Extensions 查看。打包時(shí)它們以Embed Without Signing的方式嵌入的,因?yàn)樗鼈冏约阂呀?jīng)進(jìn)行了簽名,不需要主app再次對(duì)它們進(jìn)行簽名。

打包后如下圖

3. 生成快捷指令iCloud鏈接
打開 快捷指令app,在我們的app下能看到所添加 “搜題??”。我們新建一個(gè)快捷指令,分別添加“截屏”、“搜題??”,如下圖??梢钥吹浇仄恋慕Y(jié)果已經(jīng)作為“搜題??”的圖片參數(shù)輸入了?!?strong>運(yùn)行時(shí)顯示”打開時(shí)才能顯示Siri浮窗。我們點(diǎn)擊分享,生成快捷指令的iCloud鏈接,之后我們就可以通過iCloud鏈接引導(dǎo)用戶快速地構(gòu)建這個(gè)指令。我們生成的鏈接是: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];

4. 關(guān)聯(lián)觸控
打開 設(shè)置-輔助功能-觸控-輔助觸控 頁面,打開輔助觸控功能,在 自定義操作 中選擇一個(gè)手勢(shì),比如長(zhǎng)按,選中我們的快捷指令“搜題??”。之后我們就可以在手機(jī)任意頁面,通過長(zhǎng)按觸控球來進(jìn)行搜題了。
