之前學習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的數據處理中,就有些難以處理的過程,也沒有搜到相關完整的處理,所以將實現的過程一一記錄下來。