iOS Extension的功能点

应用扩展(App Extension)是iOS8最值得人期待的功能之一。它们让开发者在整个操作系统的其他部分扩展应用程序的内容和功能。iOS8是一个开放的平台允许用户在他们的设备上进行更多的交互。应用扩展使开发人员能够在他们没有自己的应用程序的地方提供自定义功能的权利,甚至包括Apple的股票应用。

总览:

扩展的接入点:
  • Today扩展-在下拉的通知中心的”今天”的面板中添加一个widget
  • 分享扩展: 点击分享按钮后将网站或者照片通过应用分享
  • 动作扩展 点击Action按钮通过判断上下文将内容发送到应用
  • 照片编辑扩展 在系统的照片应用总提供照片编辑的能力
  • 文档提供扩展 提供和管理文件内容
  • 自定义键盘 提供一个可以用在所有应用的替代系统键盘或者输入法

更详细的描述:点击查看

extension

extension并不是一个独立的app,他有一个包含在app bundle中的独立bundle,extension的bundle后缀名是.appex.它的生命周期也和普通app不同,这些后文会详细描述。

extension不能单独存在,必须有一个包含它的containing app.

另外。extension需要用户手动激活,不同的extension激活方式也不相同,比如Today中的widget需要在Today中激活和关闭; Customer keyboard需要在设置中进行相关设置;Phone Edit需要在使用照片时在照片的管理器中激活或者关闭;storage Provider可以在选择文件时出现;share和Action可以在任何应用里被激活,但前提是开发者需要设置Activation Rules,以确定extension需要在合适出现。

containing app

尽管苹果开放了extension,但是在iOS中extension并不能单独存在,要想提交App store,必须将extension包含在一个app中提交 ,并且app的实现部分不能为空,这个包含extension的app就叫做containing app.

ExtensionContext是一个NSExtensionContext对象,分享的数据都封装在其inputItems属性中,这是一个包含NSExtensionItem对象的数组。实际上,不管你分享的多少内容,文字,多张图片或者链接,都封装在一个NSExtensionItem对象里,该对象中最重要的属性是attachments,是个NSItemProvider对象数组,每个NSItemProvider对象包含了单个分享的数据,可以是文字、图片、视频或者URL,这些数据都是由载体应用供应,但是UIActivityViewContoller来实现分享的时候传递的数据就封装在NSItemapProvider对象里,也就是说实际上我们要从NSItemProvider对象里面提取分享的数据。

extension和containing app、host app

extension和host app之间可以通过extensionContext属性直接通信,该属性是新增加的UIViewController类别:

1
2
3
@interface UIViewController(NSExtensionAdditions) <NSExtensionRequestHandling>
@property (nonatomic,readonly,retain) NSExtensionContext *extensionContext NS_AVAILABLE_IOS(8_0);
@end

实际上extension和host app之间是用过IPC(interprocess communication)实现的,只是苹果把调用接口高度抽象了 我们并不需要关注那么底层的东西。

其他关系

containing app和host app之间没有任何直接关系,也从来不需要通信。
extension和containing app这二者之间的关系最复杂的,纠纠缠缠这不清关系。

三者关系图

三者之间的关系可以通过官网给两张图片形象的说明

各部件的关系

不能直接通信

尽管extension的bundle是放在containing app的bundle中,但是他们是两个完全独立的进程,之间不能直接通信。不过extension可以通过openURL的方式启动containing app(当然也能启动其他App),不过必须通过extensionContext借助host app来实现:

1
2
3
4
[self.extensionContext openURL:[NSURL URLWithString:@"appextension://123"]
completionHandler:^(BOOL success) {
NSLog(@"open url result:%d",success);
}];

extension是无法直接使用openURL的。

可以共享Shared resources

extension和containing app可以共同读写一个被称为Shared resources的存储区域,这是通过App Groups实现的,后文将会详述。详细的图片可以看上面的图片设计

containing app能够控制extension的出现和隐藏

通过以下代码,containing app可以让extension出现隐藏(当然extension也可以让自己隐藏)

1
2
3
4
5
6
7
8
9
10
11
//让隐藏的插件重新显示
- (void)showTodayExtension
{
[[NCWidgetController widgetController] setHasContent:YES forWidgetWithBundleIdentifier:@"com.wangzz.app.extension"];
}

//隐藏插件
- (void)hiddeTodayExtension
{
[[NCWidgetController widgetController] setHasContent:NO forWidgetWithBundleIdentifier:@"com.wangzz.app.extension"];
}

App Groups

这是iOS8新开放的功能,在OS X上早就可以用了。他主要用于同一个group下的app共享同一份读写空间,以实现数据共享。

extension和containing app共同读写一份数据是很合理的需求,比如系统的股市应用,widget和app中都需要展示几个公司的股票数据,这就可以通过App Groups实现。

App Groups的设置

如果想实现数据同步,必须要保证containing app中的App groups和extension中的App Groups是相同的。设置App Group的路径:TARGETS–>TodayExtension–>Capabilities–>App Groups。

App Groups在应用和扩展之间定义一套标志符,只有 拥有相同标识符的扩展和 容器应用才能使用共享的数据。App group的设置如下图,在容器应用中开启后,默认添加以[group]开头的标志符,这些标志符都会登记到你的开发者账号中老同事目录会多出一个和target名称相同的授权文件。在去对应的扩展的capabilities页面下,刚才在容器应用下添加的标识符已经出现了,只需要勾选即可。

设置App group总是会遇到问题,在看那些开始设置App Groups的文章是这点总让人很沮丧。我第一次设置遇到了签名问题,解决方案可以看这里:点击这里查看问题

具体怎么使用App Groups中的标识符来共享数据,一些类支持配置App Groups标识符来在容器应用和扩展之间共享数据,此时不能使用常规设置。

extension和containing app的数据共享

App Groups给我们的提供同一个group内app可以共同读写的区域,可以通过以下方式实现数据共享:

NSUserDefaults:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

写入数据:

1. 保存诗句的时候必须指明group id;
2. 要注意NSUserDefaults能够处理的数据只能是可plist化的对象
3. 为了防止出现数据同步问题,不要忘记synchronize

NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:@"group.wangzz"];
[shared setObject:_textField.text forKey:@"wangzz"];
[shared synchronize];

读取数据:
NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:@"group.wangzz"];
NSString *value = [shared valueForKey:@"wangzz"];

NSURLSession:

1
2
3
NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@“xxx.backgroundsession”];
config.sharedContainerIdentifier = @“group.xxx”;
NSURLSession *mySession = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];

通过NSFileManager共享数据
NSFileManager:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
写入信息到文件中
- (BOOL)saveTextByNSFileManager
{
NSError *err = nil;
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.wangzz"];
containerURL = [containerURL URLByAppendingPathComponent:@"Library/Caches/good"];

NSString *value = _textField.text;
BOOL result = [value writeToURL:containerURL atomically:YES encoding:NSUTF8StringEncoding error:&err];
if (!result) {
NSLog(@"%@",err);
} else {
NSLog(@"save value:%@ success.",value);
}
return result;
}


读取文件信息
NSError *err = nil;
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.wangzz"];
containerURL = [containerURL URLByAppendingPathComponent:@"Library/Caches/good"];
NSString *value = [NSString stringWithContentsOfURL:containerURL encoding:NSUTF8StringEncoding error:&err];

在这里我们试着保存和读取的是字符串数据 ,但读写SQlite我相信也是没有问题的

两个应用共同读取同一份数据,就会引发数据同步问题,WWDC2014的视频中建议使用NSFileCoordinate实现普通文件的读写同步,而数据库可以使用Core Data,SQLLite也支持同步。

extension和containing app代码共享

和数据共享类似,extension和containing app很自然的会有一些业务逻辑上可以共用的代码,这时可以通过iOS8中刚开放使用的framework实现。即将framework分别嵌入到extension和containing app的target中实现代码共享。但这样岂不是需要分别将framework分别copy到extension和containing app的main bundle中?

参考extension和containing app的数据分享,我试想能不能讲framework值保存一份放在App Groups区域?

copy framework到App Groups

在app首次启动的时候将framework放到App Groups区域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
有风险 必须等到app启动的时候才能进行framework进行拷贝操作。简单的说其实就是将通过containing app加载的类可以共享给extension使用
- (BOOL)copyFrameworkFromMainBundleToAppGroup
{
NSFileManager *manager = [NSFileManager defaultManager];
NSError *err = nil;
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.wangzz"];
NSString *sorPath = [NSString stringWithFormat:@"%@/Dylib.framework",[[NSBundle mainBundle] bundlePath]];
NSString *desPath = [NSString stringWithFormat:@"%@/Library/Caches/Dylib.framework",containerURL.path];

BOOL removeResult = [manager removeItemAtPath:desPath error:&err];
if (!removeResult) {
NSLog(@"%@",err);
} else {
NSLog(@"remove success.");
}

BOOL copyResult = [[NSFileManager defaultManager] copyItemAtPath:sorPath toPath:desPath error:&err];
if (!copyResult) {
NSLog(@"%@",err);
} else {
NSLog(@"copy success.");
}
return copyResult;
}

使用framework:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (BOOL)loadFrameworkInAppGroup
{
NSError *err = nil;
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.wangzz"];
NSString *desPath = [NSString stringWithFormat:@"%@/Library/Caches/Dylib.framework",containerURL.path];
NSBundle *bundle = [NSBundle bundleWithPath:desPath];
BOOL result = [bundle loadAndReturnError:&err];
if (result) {
Class root = NSClassFromString(@"Person");
if (root) {
Person *person = [[root alloc] init];
if (person) {
[person run];
}
}
} else {
NSLog(@"%@",err);
}

return result;
}

经过测试,竟然能够加载成功。需要说明的,这里只是说那么用是可以成功加载framework,但还面临不少问题,比如如果用户在启动app之前去使用extension,这时framework还没有copy过去,怎么处理,另外iOS的机制或者苹果的审核是否允许这样使用等。
在一切确定下来之前还是乖乖按文档中的方式使用。

生命周期

extension和普通app的最大区别之一是生命周期。

开始:在用户通过hos app点击extension时,系统就会实例化extension应用,这是生命周期的开始。
执行任务:在extension启动以后,开始执行他的生命
终止:在用户取消任务,或者任务执行结束,或者开启了一个长时后台任务时,系统就会将其杀掉。
由此可见:extension就是为了任务而生。

下图来自官方文档,它将生命周期划分的更详细:
生命周期的详细划分

图片编辑

NSItemProvider类也是iOS8中的新类,使用一种安全的方式在载体应用和扩展之前的传递数据,提供了一下两个方法来获取数据:

1
2
- (BOOL)hasItemConformingToTypeIdentifier:(NSString *)typeIdentifier//用于获取封装的数据类型,咋看之下非常别扭。需要提供 Uniform Type Identifier 简称 UTI 格式的标志符来找出数据类型。
- (void)loadItemForTypeIdentifier:options:completionHandler://指定格式来获取数据,异步执行。

关于UTI是什么玩意?主要是用于统一数据格式,想了解更多可以看到官方文档:
Econformance_hierarchy.gif

哦,要了解的内容太多了点,现在就告诉我们怎么指定格式吧,上图就是一些常用的格式,@”public.image”就是一个UTI标示符,可以用于- (BOOL)hasItemConformingToTypeIdentifier:(NSString *)typeIdentifier方法,但这个方法好笨,要一个个查询,还有另外一个方法,可以直接得到NSItemProvider对象中的所有格式,好吧,只有一种,但为什么要用数组来记录,难道为了日后一个NSItemapProvider对象支持多个数据以及多数据类型,想不明白API为什么要设计得这么难用,或许是涉及UTI方面的考虑,是我想的太简单了,
得到数据格式后,使用- loadItemForTypeIdentifier:options:completionHandler:来获取数据,对于数据格式可以在options中指定图片大小,该方法在确定中确实拥有指定的格式后,会异步运行completionHandler闭包,该闭包第一个参数是个id对象,实际上需要你来指定数据类型,为了不影响分享界面的流畅性,在后台获取数据,下面是具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (void)fetchItemDataAtBackground
{
//后台获取
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSArray *inputItems = self.extensionContext.inputItems;
NSExtensionItem *item = inputItems.firstObject;//无论多少数据,实际上只有一个 NSExtensionItem 对象
for (NSItemProvider *provider in item.attachments) {
//completionHandler 是异步运行的
NSString *dataType = provider.registeredTypeIdentifiers.firstObject;//实际上一个NSItemProvider里也只有一种数据类型
if ([dataType isEqualToString:@"public.image"]) {
[provider loadItemForTypeIdentifier:dataType options:nil completionHandler:^(UIImage *image, NSError *error){
//collect image...
}];
}else if ([dataType isEqualToString:@"public.plain-text"]){
[provider loadItemForTypeIdentifier:dataType options:nil completionHandler:^(NSString *contentText, NSError *error){
//collect image...
}];
}else if ([dataType isEqualToString:@"public.url"]){
[provider loadItemForTypeIdentifier:dataType options:nil completionHandler:^(NSURL *url, NSError *error){
//collect url...
}];
}else
NSLog(@"don't support data type: %@", dataType);
}
});
}

获取数据的行为不要放在-(void)didSelectPost()里,这不用说明吧。在实际场景中,你可能会分享几张照片加上文字说明,或者URL链接加上文字说明,或是几个视频加上文字说明;总之基本上会有文字说明,而很少混合几种不同的类型,比如要同时分享照片,链接或者扩展视频,扩展是个轻量级的功能,混合类型让任务变得繁重,当然PM脑残允许这种方式的上传就开发的心情了。

//关于下载 官方给了一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
NSURLSession *mySession = [self configureMySession];
NSURL *url = [NSURL URLWithString:@"http://www.example.com/LargeFile.zip"];
NSURLSessionTask *myTask = [mySession downloadTaskWithURL:url];
[myTask resume];

-(NSURLSession *) configureMySession {
if (!mySession) {
NSURLSessionConfiguration* config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@“com.mycompany.myapp.backgroundsession”];
config.sharedContainerIdentifier = @“com.mycompany.myappgroupidentifier”;
mySession = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
}
return mySession;
}

对于一个后台上传任务,在NSURLSession上配置定好的标识符,这样容器应用接手时,就能知道是哪个扩展的任务,此时在容器中需要做些什么呢?当后台任务结束时,系统调用-application:handleEventsForBackgroundURLSession:completionHandler:在后台唤醒容器应用来处理。

定制扩展行为
分享扩展应该尽量可能轻量化,这需要添加很多限制,比如限制分享类型、数量。当不满足条件时,分享扩展不会出现在菜单中,而不是仅仅没有开启。扩展的很多设置都可以在info.plist文件中实现。

在扩展的info.plist文件中找到NSExtension项,默认设置如下:

NSExtensionActivationRule的默认类型是NSString,其值TRUEPREDICATE表示在分享菜单上一直显示扩展,这种设置对分享不做任何限制,上面提到印象笔记和Mail应用只支持最多5张图片,超出后将在分享菜单看不到;;NSExtensionActivationRule的设置为Dictionary,添加以下内容,扩展将仅支持最多5张图片,不支持URL和视频分享,如分享的对象不满足条件,分享菜单在该扩展会消失。

分享类型支持

注意:一旦你使用这种方式,没有出现在这里的数据类型将不会被支持,也就意味着该扩展不会出现;如果分享的数据不满足指定的数量限制,扩展也不会出现,点击这里查看

Today

暂时发现很多App都实现了这个功能,就是系统提供的下来菜单中展示自己app的功能。

1
2
3
4
5
6
7
8
//通常插件要消失的时候会调用,系统会在这个时候对插件截图,以便下次启动插件加载之前快速展示
- (void)widgetPerformUpdateWithCompletionHandler:(void (^)(NCUpdateResult))completionHandler;

打开App,通过url携带规范的信息实现app启动的界面,当然也可以通过信息共享的方式来操作(修改缓存区数据)
[self.extensionContext openURL:[NSURL URLWithString:@"appextension://123"]
completionHandler:^(BOOL success) {
NSLog(@"open url result:%d",success);
}];

更多扩展