iOS Extension 拓展--从开发到发布全流程
背景
项目接入第三方支付,需要在三方应用的分享面板的 Action 列表中显示我们的 app,且跳转到 app (containing app) 中,以上为需求背景。
WechatIMG28528.png如上图,是对一张照片进行分享操作,图片中红色部分就是 Share Extension ,蓝色部分是 Action Extension。
App Extension
1.什么是Extension
在 ios 8 之后 苹果引入了全新的功能 App Extension,涉及方方面面 例如 Today 、keyBoard 、短信拦截、电话号码过滤(之前一直想不明白,短信和电话拦截是如何做到的)等等 二十多项 Extension。
Extension 就是字面意思 拓展 也可以认为是插件,但不是你主 app (containing app) 的插件,是你把一些功能做成了系统的插件,比如拦截短信和电话、或上图中的功能,是你在系统功能上做的插件。
Extension 和主 app (containing app) 之间是没有直接关系,是两个独立的程序,最直接的联系就是 Extension 会跟随主 app 的安装一起安装,卸载一起卸载。代码不能相互调用、存储空间也不能相互访问。
但是,但是,Extension 的功能是真的强大,如果一旦了解 Extension 并且使用,就会打开新世界的大门。下面的以 Action Extension 这个为例,详细介绍一下。
2.Extension如何工作
Extension 一般是在被其他 app 调起的,那这个 其他 app 被称为 宿主应用 (Host App
) 宿主应用程序定义好了交流的上下文 extensionContext
(下面会讲到的NSItemProvider
和 NSExtensionItem
) 然后调起 Extension,然后 Extension 处理完宿主的请求任务之后,生命周期就结束了。
3.Extension 生命周期
创建
创建一个普通的项目,点击 项目名称,在 target 列表下端选择加号 添加 Extension:
extension_create.png select action extension.png
根据提示正常输入,我这里的名称是 Action 最终点击 finish 之后,就创建成功了,之后的目录结构是下图这样:
WX20210923-142735@2x.png红框中的就是 Extension 的目录结构和 Target
ActionVierController 中进行逻辑的开发工作,创建之后会默认生成以下代码,可以从代码中看出基本的操作逻辑,遍历 ExtensionContext.inputItems
、在遍历 NSExtensionItem
拿到 NSItemProvider
再然后 判断 NSItemProvider
中对应的 UTI
(这个概念后面说) 代码中 UTTypeImage.identifier
指的是图片类型,说明当前的 Action 逻辑只会处理图片类型。再往下就是会主线程设置拿到的图片就结束了。
- (void)viewDidLoad {
[super viewDidLoad];
// Get the item[s] we're handling from the extension context.
// For example, look for an image and place it into an image view.
// Replace this with something appropriate for the type[s] your extension supports.
BOOL imageFound = NO;
for (NSExtensionItem *item in self.extensionContext.inputItems) {
for (NSItemProvider *itemProvider in item.attachments) {
if ([itemProvider hasItemConformingToTypeIdentifier:UTTypeImage.identifier]) {
// This is an image. We'll load it, then place it in our image view.
__weak UIImageView *imageView = self.imageView;
[itemProvider loadItemForTypeIdentifier:UTTypeImage.identifier options:nil completionHandler:^(UIImage *image, NSError *error) {
if(image) {
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
[imageView setImage:image];
}];
}
}];
imageFound = YES;
break;
}
}
if (imageFound) {
// We only handle one image, so stop looking for more.
break;
}
}
}
- (IBAction)done {
// Return any edited content to the host app.
// This template doesn't do anything, so we just echo the passed in items.
[self.extensionContext completeRequestReturningItems:self.extensionContext.inputItems completionHandler:nil];
}
UTI
Uninform Type Identifier
字面意思 统一类型标识,Uniform type identifiers(UTIs)
提供了在整个系统里面标识数据的一个统一的方式,比如 documents (文档)、pasteboard data (剪贴板数据)和bundles (包)。
在使用系统分享的图片的时候会看到 微信、支付宝、淘宝等的 app icon,这是这些 app 在 share Extension 中设置的UTI
是支持图片类型,Extension 具体支持响应何种类型的数据,会在 info.plist 中进行设置,下面会讲。
UTI 的定义和我们开发 iOS 程序时填写组织时一样,采取的是反域名规则。如下面这几种类型,同时 iOS 定义好了一些常用的UTI
类型:
//自定义的
com.hk.hkicl
//机构 公司定义好的
com.adobe.image
// 苹果公司定义的
public.data
public.image
public.movie
UTI 有个一明显的优势就是在一个顺应结构中声明的,简单来说就是 UTI 是可以继承的,如下图:
WX20210923-153945.png
上图中 public.html
继承自 public.text
继承自 public.data
所以如果我们明确知道我们想要操作的内容是 HTML 格式的时候 我们使用 public.html
如果我们要操作的类型 包括 HTML /text/image等,这样我们就使用他们共同最近父类 public.data
就可以了。
上面只是简单说了一下UTI 因为下面我们要在 Action 中使用进行使用,如果需要更加详细,后续我可以出一篇关于UTI的仔细讲解。
Extension 设置 UTI
创建好的 Extension 目录中 会有 info.plist 文件 来对应的配置当前 Extension,我们打开当前 Action Extension 中的 info.plist 文件。
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>TRUEPREDICATE</string>
<key>NSExtensionServiceAllowsFinderPreviewItem</key>
<true/>
<key>NSExtensionServiceAllowsTouchBarItem</key>
<true/>
<key>NSExtensionServiceFinderPreviewIconName</key>
<string>NSActionTemplate</string>
<key>NSExtensionServiceTouchBarBezelColorName</key>
<string>TouchBarBezel</string>
<key>NSExtensionServiceTouchBarIconName</key>
<string>NSActionTemplate</string>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
</dict>
</dict>
</plist>
其中关键字 NSExtensionActivationRule
是 Extension 的相应规则
TRUEPREDICATE
代表 任意类型的数据 我都可以响应到 (就比如: 我分享图片里面有你,我分享视频里面有你,我分享文档里面还有你,苹果一看不得了啊 :兄弟,你隔这儿当海王啊! 吓得苹果赶紧发表声明:你提交 release ipa 到 app store 的时候,不能是这个规则,不然我给丫拒了。)
所以正常情况下,这个规则是不能使用的需要进行修改
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsAttachmentsWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
这样写代表着 你支持的类型 和当前一次最大可以操作的内容的数量。这样的规则是提交没有问题的。
自定义UTI
有一些情况,就比如 当前我们的 app 只想在特定的UTI 情况下去响应去操作,这种情况下就需要使用自定义的 UTI 了,写法如下:
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>
SUBQUERY (
extensionItems,
$extensionItem,
SUBQUERY (
$extensionItem.attachments,
$attachment,
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.abc.def"
).@count == $extensionItem.attachments.@count
).@count == 1
</string>
</dict>
这种写法 中的 com.abc.def
就属于自定义的 UTI,同时外部的 count == 1
代表目前只操作数量为1。
题外话
如何调起特定 UTI 的 Extension 呢,下面贴一下代码,方便大家做测试:
// 这里我们要使用到 NSItemProvider 对象,用来封装 分享的内容
NSString *url = @"http://www.baidu.com";
UIImage *image = [UIImage imageNamed:@"image1.png"];
NSItemProvider *provider = [[NSItemProvider alloc]
initWithItem:@{@"URL" : url, @"image" : image, @"BACK" : @"http://www.sina.com"}
typeIdentifier:@"public.image"];
NSExtensionItem *item = [[NSExtensionItem alloc] init];
item.accessibilityLabel = @"分享一二三";
item.attachments = @[ provider ];
UIActivityViewController *activityVC =
[[UIActivityViewController alloc] initWithActivityItems:@[ item ]
applicationActivities:nil];
从上诉代码中 我们可以看到 NSItemProvider
对象和 NSExtensionItem
使用该对象进行数据包裹,进行数据的分享,就很容易理解在 Action Extension 中 ActionViewController 中 拆解数据的逻辑了。
其中public.image
是我们使用 苹果 提供的 UTI ,如有需要使用特定的 UTI ,我们可以随时更改 com.abc.def
这样,就可以调起之前我们自定义的 UTI 的 Extension 了。
证书
Extension 的扩展应用同样需要创建 bundleId 和下发 profile 文件,这里说一下具体的步骤:
- 假如当前 主 App (containing app) 的 bundleid 是
com.organization.app
- Extension 的 bundleId 则应该是
com.organization.app.xx
的原则,在 主App 的 bundleId 后面拼一个名字,但是直接叫做com.organization.app.extension
貌似是不行的。 - 同样的是去 develop.apple.com 登录账号 创建 bundleId 然后制作 profile 文件。
- 如果需要实现数据和 主app 共享则需要在 bundleId 中打开数据共享开关,添加和 主app 同样的 groupid 即可,具体步骤会在下面的 数据共享中介绍,步骤一样。
通信
因为 Extension 和 主app 是分别独立的进程,所以之间是不能直接通信的,但并不是没有办法实现通信。主要的办法是通过 scheme 调用的方式,具体步骤:
- host app 即 宿主应用 通过发起操作打开 Extension app ,可以再 Extension app 中拿到 host app 的
UIApplication
,通过 主app 的scheme 用openURL 打开, 具体代码如下:
- (void)openAirStarBankApp {
NSURL *destinationURL = [NSURL URLWithString:appToAppScheme];
NSString *className = [[NSString alloc] initWithData:[NSData dataWithBytes:(unsigned char []){0x55, 0x49, 0x41, 0x70, 0x70, 0x6C, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6F, 0x6E} length:13] encoding:NSASCIIStringEncoding];
if (NSClassFromString(className)) {
id object = [NSClassFromString(className) performSelector:@selector(sharedApplication)];
[object performSelector:@selector(openURL:) withObject:destinationURL];
}
}
数据共享
因为Extension 和 主App (containing app)是两个相互独立的进程,所以是无法实现数据共享的,目前常用也是最好的方式 就是通过 GroupID 进行数据共享。
对 bundleId 添加 groupID 功能,
首先 先去苹果开发者中心 develop.apple.com 登录,然后修改 bundleId 的配置项,添加 groupId 功能,并且 创建 groupId 绑定到 bundleId 上:
步骤 分别是 1、2、3、4 因为这边我已经创建好了groupId,所以你们需要自己去创建,然后保存,之后跟新 profile 文件,action Extension 的证书 我会在下面提到。
groupId 的格式为 group.bundleId
比如:group.com.organization.app
在 xcode 中 打开 groupId 的逻辑 即可
上述操作是针对 主app (containing app) 。操作完成之后 就可以使用 共享数据模块的功能了,逻辑如下:
// 在 Extension 的进行 数据存储
NSUserDefaults *shareDefaults = [[NSUserDefaults alloc] initWithSuiteName:appGroupID];
NSMutableDictionary *params = [[NSMutableDictionary alloc] init];
[params setObject:paymentUrl forKey:paymentUrlKey];
NSString *paymentBackUrl = [dictionary objectForKey:paymentBackKey];
if (paymentBackUrl) {
[params setObject:paymentBackUrl forKey:paymentBackKey];
}
[shareDefaults setObject:params forKey:paymentInfoCacheKey];
[shareDefaults synchronize];
// 在主 app 中 使用数据 (主app 是用swift 写的)
let shareDefaults = UserDefaults.init(suiteName: appGroupID);
shareDefaults?.synchronize();
if let paymentInfo = shareDefaults?.object(forKey: paymentInfoCacheKey) as? [String : String] {
这样就实现了数据的共享操作。
常见问题
1. 自定义 UTI 中 count == 1 的逻辑
在完成项目开发之后,当前版本需要隐藏 Extension 功能,下个版本在发出去,这个时候,我天真的认为 修改自定义逻辑中的 count == 1
改成 count == 0
就可以了,
结果发现 除了自定义的 UTI 和 iOS 定义的UTI 无法响应之后,其余任意 自定义 UTI 都可以响应了。最好的做法 就是 先把 自定义的 UTI 用 UUID 代替。
2. jenkins 打包中的错误
error: exportArchive: "XXX.appex" requires a provisioning profile with the App Groups feature.
用 jenkins 打 Distribution 包的时候除了这个错误,这个应该是 打包 设置 ExportOptions.plist
文件配置的错误。
我在下面这篇内容里面解释一下这个问题:
https://www.jianshu.com/p/b52c35ee8ac2