之前学习Today Extension的时候,被几个问题困住无法解决,网上也没有很好的解答。最近问了大师一些处理的思路,也查找了一些官方的文档,就按照自己的思路将Today Extension的开发过程记录下来。
1. 基础概念
1.1 文档结构
1.2 配置
1.3 NCWidgetProviding协议
2.数据共享
2.1 主流应用分析
2.2 后台下载分析
1. 基础
Today Widget出现在系统的两个位置:
按压应用图标出现的弹框:
该弹框大小固定且没有任何模式切换,较为简单。
通知中心的Today 模块中
通知模块中的Today Widget较为复杂,能进行模式切换以及大小的改变,当然在大小的切换中也能添加动画的效果。
1.1 文档结构
在Target中创建Today Extension之后,在工程中出现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文件能进行独立的配置。
1.2 配置
Info.plist文件中键值主要是用户易读的模式,通过右击选择Show Raw Keys/values,转换为官方文档中标记模式。
进入Today Extension中的Info.plist配置文件中,有三点需要注意的部分:
① HTTP请求
如果在Today Extension中进行HTTP请求,需要对Extension中的Info.plist文件进行HTTP请求安全配置,不然系统会警告。
②更换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只有两项:
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性能的严格要求等方面,也是一种保险,不会出错的方式。
②与容器应用间简单的数据交互
如京东等应用,除去静态的布局外,中间部分是需要和容器应用保持一样的倒计时功能。
倒计时功能实现
如果在宿主应用和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的扩展中,历史记录是直接从共享的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];
除了图片的加载过程,也包括一些对新数据的网络直接下载。如果下载量过大会直接导致数据不显示,并且提示 --"无法载入"。
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的数据处理中,就有些难以处理的过程,也没有搜到相关完整的处理,所以将实现的过程一一记录下来。