Today Extension -数据共享与后台下载

之前学习Today Extension的时候,被几个问题困住无法解决,网上也没有很好的解答。最近问了大师一些处理的思路,也查找了一些官方的文档,就按照自己的思路将Today Extension的开发过程记录下来。

1. 基础概念
1.1 文档结构
1.2 配置
1.3 NCWidgetProviding协议
2.数据共享
2.1 主流应用分析
2.2 后台下载分析

1. 基础

Today Widget出现在系统的两个位置:

按压应用图标出现的弹框:
点击应用图标的Today Widget弹框

该弹框大小固定且没有任何模式切换,较为简单。

通知中心的Today 模块中
通知中心的Today Widget

通知模块中的Today Widget较为复杂,能进行模式切换以及大小的改变,当然在大小的切换中也能添加动画的效果。

1.1 文档结构

在Target中创建Today Extension之后,在工程中出现Extension的文档结构:

Today Extension的文档结构

主要分为TodayViewController、storyboard和Info.plist三个主要文件。类似项目初创时的结构但是仍有不同,具体原因是:

An app extension is different from an app. Although you must use an app 
to contain and deliver your extensions, each extension is a separate 
binary that runs independent of the app used to deliver it.
一个应用扩展是不同于一个应用的。尽管你必须使用一个应用来包括并且交付你的扩展,但是每一
个扩展是一个独立的二进制独立于交付的应用运行。

①Extension依赖于容器应用存在,由宿主应用触发启动,所以并没有main函数的入口。
②每一个Extension都是一个独立的二进制文件,所以存在Info.plist文件能进行独立的配置。

在应用的bundle包中Extension的独立文件
1.2 配置
Info.plist文件中键值主要是用户易读的模式,通过右击选择Show Raw Keys/values,转换为官方文档中标记模式。

进入Today Extension中的Info.plist配置文件中,有三点需要注意的部分:

① HTTP请求

如果在Today Extension中进行HTTP请求,需要对Extension中的Info.plist文件进行HTTP请求安全配置,不然系统会警告。


1.png
②更换Today Extension的显示名称
The displayed name of your app extension is provided by the extension 
target’s CFBundleDisplayName value, which you can edit in the 
extension’s Info.plist file. If you don’t provide a value for the 
CFBundleDisplayName key, your extension uses the name of its containing 
app, as it appears in the CFBundleName value.
由扩展目标中的CFBundleDisplayName值提供你应用扩展的显示名称,能在扩展的
Info.plist文件中进行编辑。如果你并没有提供该值,你的扩展使用出现在CFBundleName值中
的容器的名称。

官方文档中的意思是能通过CFBundleDisplayName的值更改Extension的名称,但是基本上目前大多数的Today Extension都是容器应用的名称。

要求Today Extension显示名称必须和容器应用的名称存在对应关系
③NSExtension
默认的NSExtension配置

默认的NSExtension只有两项:

NSExtensionMainStoryboard :MainInterface

Extension默认使用storyboard故事板进行界面布局,如果想通过纯代码模式,将该行改为NSExtensionPrincipalClass 键和对应的主视图控制器的名称:


切换成纯代码布局模式
NSExtensionPointIdentifier键是扩展点反转的DNS名

该键是系统必须的配置,并且值不变(不需要改变)。

1.3 NCWidgetProviding协议

NCWidgetProviding是对一个自定义内容的可选协议,因为系统对Extension在内存、显示方面有很严格的限制,所以该协议实现只包括以下的三个方法:

//系统将会在合适的机会为小部件更新它的状态,当通知中心可视化和它在后台时。
//相反的,应该从viewWillAppear中加载缓存的状态
- (void)widgetPerformUpdateWithCompletionHandler:(void (^)(NCUpdateResult result))completionHandler;

//当激活的显示状态模式改变的时候调用,小部件可能希望改变它的preferredContentSize更好的适应新的显示模式。
//需要注意,固定两种模式下小部件的宽度为设备的宽度,所以传任何值都不会有任何影响,一般直接写0即可。
- (void)widgetActiveDisplayModeDidChange:(NCWidgetDisplayMode)activeDisplayMode withMaximumSize:(CGSize)maxSize NS_AVAILABLE_IOS(10_0);

//自定义默认的边缘间隙,但是已经在iOS 10.0版本中被抛弃
- (UIEdgeInsets)widgetMarginInsetsForProposedMarginInsets:(UIEdgeInsets)defaultMarginInsets NS_DEPRECATED_IOS(8_0, 10_0, "This method will not be called on widgets linked against iOS versions 10.0 and later.");

在Today Widget中,默认是紧凑的模式,必须将最大显示模式修改成NCWidgetDisplayModeExpanded模式:

//窗口小部件能改变他们能改变的最大显示模式
@property (nonatomic, assign) NCWidgetDisplayMode widgetLargestAvailableDisplayMode NS_AVAILABLE_IOS(10_0);

self.extensionContext.widgetLargestAvailableDisplayMode = NCWidgetDisplayModeExpanded;

同时根据协议中的方法不断监听该模式的切换,并且系统并不会在切换模式的时候计算小部件的大小,所以还是需要手动的设置preferredContentSize的大小:

-(void)widgetActiveDisplayModeDidChange:(NCWidgetDisplayMode)activeDisplayMode withMaximumSize:(CGSize)maxSize
{
    if (activeDisplayMode == NCWidgetDisplayModeCompact) {
         
           self.preferredContentSize = CGSizeMake(0, 110);
    } else if (activeDisplayMode == NCWidgetDisplayModeExpanded) {
            self.preferredContentSize = CGSizeMake(0, 250);
    }
}
2.数据共享
2.1 主流应用分析

Extension应用和容器应用间不能直接进行任何的交互,包括数据。所以在数据方面能使用官方文档中最详细的推荐就是共享的userDefaults的使用。
以当前的一些常用应用为例子分析数据方面的使用情况:

①静态布局

如支付宝和大麦等应用,Today Extension中只有一些静态按钮构成的简单界面,甚至没有扩展的模式的存在。


简单布局

其实Today Widget的目的是为了以最简单的方式展示最新的信息,这种方式更多是3D Touch提供快捷入口的方式。但是从Widget性能的严格要求等方面,也是一种保险,不会出错的方式。

②与容器应用间简单的数据交互

如京东等应用,除去静态的布局外,中间部分是需要和容器应用保持一样的倒计时功能。


京东Today Widget界面
倒计时功能实现
如果在宿主应用和Extension中存在相同的代码,可以自定义Framework。

在宿主应用中开启倒计时功能,当监听到应用即将进入后台的通知时,将倒计时关闭并且将当前的值存入共享的UserDefaults中;当监听到应用进入前台的通知时,将倒计时开启并且从共享NSUserDefaults中获得最新的倒计时数。

- (void)viewDidLoad {
    [super viewDidLoad];
    _index = 100;
    self.timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(countDownTime) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
    //应用即将进入后台的通知监听
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appResignActive) name:UIApplicationWillResignActiveNotification object:nil];
//应用已经被激活,进入前台的通知监听
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil];
}
//暂停当前的时间
- (void)stop {
    [self.timer setFireDate:[NSDate distantFuture]];
    //将时间保存在共享的userDafaults中
    NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.session.data"];
    [userDefaults setInteger:_index forKey:@"countDown"];
    [userDefaults synchronize];
}
- (void)resume {  
    NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.session.data"];
    _index = [userDefaults integerForKey:@"countDown"];
    [self.timer setFireDate:[NSDate date]];
}
- (void)countDownTime{
    _index --;
    if (_index == 0) {
        [self.timer invalidate];
        self.timer = nil;
    }  
   self.countLabel.text = [NSString stringWithFormat:@"倒计时:%lds",_index];
}
- (void)appResignActive{
    [self stop];
}
- (void)appBecomeActive{
    [self resume];
}

在Today Extension中也需要开启倒计时的功能,当倒计时为0时,自动打开容器应用。并且当倒计时值不为0的情况下离开,推荐在viewWillDisappear:方法中保存最新的数据状态。

//在viewWillAppear中加载缓存的数据
-(void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.session.data"];
    NSInteger index = [userDefaults integerForKey:@"countDown"];
    
    _index = index;
    self.timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(countDown) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}
- (void)countDown{
    _index --;
    if (_index == 0) {
//在index ==0的情况下通过openURL打开宿主应用
        [self.extensionContext openURL:[NSURL URLWithString:@"lizhou://TimerIsOut"] completionHandler:^(BOOL success) {
           
            if (success) {
                NSLog(@"success");
            } else {
                NSLog(@"failure");
            }
        }];
    }
    self.normalTitle.text = [NSString stringWithFormat:@"倒计时:%@s",self.timer];
}
-(void)viewWillDisappear:(BOOL)animated
{
    [super viewWillDisappear:animated];
    
    if (_index ! = 0) {
        NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.session.data"];
        
        [userDefaults setInteger:_index forKey:@"countDown"];
        [userDefaults synchronize];
    }
    
}

可以参考关于2014 Extension方面的笔记:
WWDC 2014 Session笔记 - iOS 通知中心扩展制作入门

③网络数据交互

如爱奇艺、优酷等一些视频应用中,对于新数据存在一定的要求。

爱奇艺的Today Extension

爱奇艺的Today Extension的扩展中,历史记录是直接从共享的userDefaults中获取的,但是对于图片资源而言,有两种方法:

#######以图片的URL进行存储
以URL进行存储时缓存占据较少,并且能及时的清理。但是如果在网络不稳定的情况下,第一次开启时会导致图片无法显示,所以还是需要本地的预存数据占位。
所以首先在viewWillApper中对共享数据中的图片URL进行获取,并且只有在数据获取成功的情况下开启倒计时:

-(void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    
    _index = 0;
    NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.session.data"];
    NSDictionary *historyDic = [userDefaults dictionaryForKey:@"history"];
    self.normalTitle.text = [historyDic objectForKey:@"title"];
    self.normalOfLastProgressLabel.text = [NSString stringWithFormat:@"剩余%@%%,继续看",[historyDic objectForKey:@"content"]];

    NSArray *imageArrs = [userDefaults objectForKey:@"images"];
    self.imagesArr = [imageArrs mutableCopy];

    if (self.imagesArr.count > 0) {
        self.timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(changeImage) userInfo:nil repeats:YES];
        [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
    }
}

在倒计时的方法中:当然是在数据加载成功之后停留5秒钟,并且需要考虑到下载过程中切换到后台的处理,这方面直接使用SDWebImage即可:

-(void)stopTimer{
    [self.timer setFireDate:[NSDate distantFuture]];
}
//图片展示提留5秒。如果设置为当前时间立刻执行,那么也不会考虑NSTimer的间隔时间,马上切换。
- (void)beginTimer{
    [self.timer setFireDate:[NSDate dateWithTimeIntervalSinceNow:5]];
}
- (void)changeImage{
    _index ++;
    [self stopTimer];
    
    __weak __typeof(self) ws= self;
    [self.detailOfChangeImage sd_setImageWithURL:[NSURL URLWithString:self.imagesArr[_index]] placeholderImage:[UIImage imageNamed:@"bookshelf_nodata"] options:SDWebImageContinueInBackground completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
        
        if (image) {
            NSLog(@"success");
            [ws beginTimer];
        }
    }];
    
    if (_index == self.imagesArr.count -1) {
        _index = 0;
    }
}

#######以图片UIImage的形式存储
对在容器应用中也存在的数据项而言,可以在容器应用缓存的同时保存一份到共享UserDefaults中,而Today Extension直接获取。

不要存储为NSData格式,因为不仅数据存储量大,而且imageWithData:方法本身就是一个同步方法
 [UIImage imageWithData:data];

除了图片的加载过程,也包括一些对新数据的网络直接下载。如果下载量过大会直接导致数据不显示,并且提示 --"无法载入"。

Today Widget提示“无法载入”
2.1 后台下载分析

在官方文档中反复的出现关于使用后台下载的方法,并且解释:

Users tend to return to the host app immediately after they finish their task in your app extension. If the task involves a potentially lengthy upload or download, you need to ensure that it can finish after your extension gets terminated. To perform an upload or download, use the NSURLSession class to create a URL session and initiate a background upload or download task.
用户倾向在你的应用扩展中结束了他们的任务后立刻返回宿主应用。如果任务包括一个潜在的长期的下载或者上传,你需要保证能在你的扩展终止后能完成。为了执行一个上传或下载的任务,使用NSURLSession类创建一个URL会话并且初始化一个后台上传或下载任务

这么一解释的话很有道理,但是Extension的后台下载不同于应用,有一段很重要的话:

If you include the UIBackgroundModes key in your app extension’s Info.plist file, the extension will be rejected by the App Store. (To learn more about this key, see UIBackgroundModes.)
如果在你的应用扩展的Info.plist文件中包括UIBackgroundModes键,扩展将会被应用商店拒绝
开始后台下载请求

其中Identifier就是后台下载session会话的唯一标识符。 --->只在后台下载中使用

sharedContainerIdentifier属性指明下载缓存存放的位置 --->应用和应用扩展都能使用的共享文件

-(void)widgetPerformUpdateWithCompletionHandler:(void (^)(NCUpdateResult))completionHandler
{
//在整个应用中标识后台会话。
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"backgroundConfi-extension"];
   //下载数据存储在该共享容器中。 
    configuration.sharedContainerIdentifier = @"group.com.session.data";
    
    NSString *path = @"https://www.gitbook.com/download/pdf/book/frontendmasters/front-end-handbook";
    
    NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
//后台下载必须是delegate方法,不能使用block
    NSURLSessionDownloadTask *task = [session downloadTaskWithURL:[NSURL URLWithString:path]];
    [task resume];
}
后台下载数据回调
如果数据回调的过程中,应用扩展仍在运行中,则所有的数据回调都类似于普通的下载回调处理
-(void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
{
    NSLog(@"%@",location.absoluteString);
}
-(void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
{
    NSLog(@"当前下载量:::%lld 已经下载的总量:%lld",bytesWritten,totalBytesWritten);
}
如果在下载过程中应用扩展不再运行 ,这时候应用扩展会在短时间内终止,但是并不会影响数据的下载。
If the app has been terminated, it’s relaunched in the 
background. Your launch code should recreate the session, 
using the same identifier as before, to allow the system 
to reassociate the background download task with your 
session. Once the app has relaunched, the series of events
 is the same as if the app had been suspended and resumed, 
as discussed above.

如果应用被终止了,会在后台中重新启动。你的启动代码应用重新创建该会话,
使用之前一样的标识符,允许系统将后台下载任务和你的会话联系在一起。
一旦应用重新启动,这一系列的事件是一样的当应用被终止和继续。

所以这时候系统会在后台重新启动应用,并且调用-(void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler方法 ,在该方法中需要:
①将未完成的任务和创建的新的拥有相同标识符的session会话绑定
①保存回调的completionHandler
然后在AppleDelegate中以普通下载的方式对该下载的过程进行处理:

-(void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler
{
 if ([identifier isEqualToString:@"backgroundConfi-extension"]) {
        
        //官方文档中也是说直接进行绑定即可,那么就是说任务会自动的运行
        NSURLSession *session = [self setSessionByUnCompleteSessionConfiId:identifier];
        
        NSLog(@"重新将session和task 连接 %@",session);
        
        if (!self.completionHandlerDictonary) {
            self.completionHandlerDictonary = [NSMutableDictionary dictionary];
        }
        self.completionHandlerDictonary[identifier] = completionHandler;
    }
}

- (NSURLSession *)setSessionByUnCompleteSessionConfiId:(NSString *)identifer
{
    static NSURLSession *session = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        NSURLSessionConfiguration *confi = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:identifer];
        session = [NSURLSession sessionWithConfiguration:confi delegate:self delegateQueue:[NSOperationQueue mainQueue]];
    });
    
    return session;
}

//后台下载完成
-(void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session
{
    NSString *identifier = session.configuration.identifier;
    
    void (^handler)(void) = [self.completionHandlerDictonary objectForKey:identifier];
    
    if (handler) {
        [self.completionHandlerDictonary removeObjectForKey:identifier];
        NSLog(@"handler completion");
        
        dispatch_async(dispatch_get_main_queue(), ^{
            handler();
        });   
    }
}

-(void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
{
    NSString *str = location.absoluteString;
    NSLog(@"application :::%@",str);
}

其实只不过将后台下载放入了Extension的数据处理中,就有些难以处理的过程,也没有搜到相关完整的处理,所以将实现的过程一一记录下来。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,494评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,047评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,943评论 4 60
  • 在B2B的服务平台,企业的组织架构是设计的一部分。首先我们需要了解企业的组织架构性质和企业组织架构的设计原则。 七...
    做个傻子看人间冷暖阅读 2,757评论 0 3
  • 本书和作者 《黑天鹅》作者塔勒布的又一部全球畅销经典,更被作者视为自己的毕生杰作。《思考快与慢》作者丹尼尔·卡尼曼...
    让你更值钱阅读 9,644评论 0 8