概述
從iOS 8 開始Apple引入了擴展(Extension)用於增強系統應用服務和應用之間的交互。它的出現讓自定義鍵盤、系統分享集成等這些依靠系統服務的開發變成了可能。WWDC 2016上眾多更新也都是圍繞擴展這一主題來進行了的,例如開發的Siri、iMessage Apps其實都是依靠擴展來工作的。在最新的Xcode 8 beta中也增加了眾多的Extension 模板幫助開發者更快的實現不同類型的擴展。因此今天有必要介紹一下擴展相關的開發內容。
擴展的生命周期
iOS對於擴展的支持已經由最初的6類到了如今iOS10的19類(相信隨著iOS的發展擴展的覆蓋面也會越來越廣),當然不同類型的擴展其用途和用法均不盡相同,但是其工作原理和開發方式是類似的。下面列出擴展的幾個共同點:
擴展依附於應用而不能單獨發布和部署;
擴展和包含擴展的應用(containing app)生命周期是獨立的,分別運行在兩個不同的進程中;
擴展的運行依賴於宿主應用(或者叫載體應用 host app,而不是containing app)其生命周期由宿主應用確定;
對開發者而言擴展作為一個單獨的target而存在;
擴展通常展現在系統UI或者其他應用中,運行應該盡可能的迅速而功能單一;
由於目前iOS 10正式版尚未發布,官方文檔僅就目前9類擴展做了詳細指導說明,感興趣的話大家可以前往查看。
官方對於應用擴展的生命周期描述如下圖:
通常用戶選擇了一個擴展的操作時宿主會向擴展發出一個請求來啟動此擴展,擴展的生命周期也由此開始(例如用戶在分享菜單中選擇了你的分享擴展),由於擴展本身由控制器組成,因此此時就會調用類似於viewDidLoad之類的方法進行界面布局和邏輯處理,執行完相應任務之後應該盡快將控制權交給宿主應用,擴展生命周期結束。
盡管擴展和容器應用的生命周期之間沒有直接關系,但是擴展本身就是作為容器應用的擴展而存在的,因此擴展和容器應用之間的交互又是不可避免的。通常擴展會通過自定義Scheme的形式來調用容器應用,而容器應用完成響應操作之後通過數據共享將數據共享給擴展來使用。
Today擴展演示
前面說過目前iOS支持19類擴展入口,現在就以Today擴展(也叫做Widget)為例進行說明,在開始之前先對Today擴展有一個簡單的認識,下圖是微博、墨跡天氣、網易雲音樂的的Today擴展截圖,微博擴展可以用來發送微博、查看更新,墨跡天氣則用來展示今日和明日的天氣,網易雲音樂則是推薦一些相關的歌單、專輯。
我們今天的例子將利用Today擴展實現一個簡單的“to do list”查看功能,在容器應用ToDoList中可以增加和刪除待辦事項,而Today插件則展示最新的幾條待辦事項,如果沒有待辦事項則展示添加按鈕,點擊添加或列表則導航到ToDoList應用。應用的主界面和Today擴展最終截圖如下:
在開發之前首先思考一下要實現一個這樣的ToDoList擴展需要注意哪些問題:
首先ToDoList容器應用需要思考如何存儲數據,因為容器應用完成之後要在Today中展現,前面說過擴展和容器應用沒有任何關系,二者處於兩個不同的沙盒之中,要實現數據資源共享則必須在開發之前思考如何存儲數據的問題?
由於ToDoList容器應用和其擴展ToDoListTodayExtension均要訪問讀取數據那麼兩者就存在重復讀取數據的操作,也就是兩者可能會存在較多的重復代碼,如何復用這些代碼?
點擊擴展列表或添加按鈕要回到容器應用,由於擴展中禁用了UIApplication的openURL該如何實現跳轉(事實上擴展中很多類型和方法被標記為NS_EXTENSION_UNAVAILABLE,其實思考一下也是合理的,擴展中的UIApplication是宿主應用並非容器應用,如果開發人員直接操作Today的宿主應用豈不危險?)?
這幾個問題在下面的演示中將逐一解答,首先要簡單實現一個ToDoList應用,這裡就不得不考慮第一個問題,怎麼樣存儲數據才能保證後面的擴展開發能夠正常訪問這些數據。事實上iOS 8 新增了App Groups功能用於實現應用之間的數據共享問題(當然這個功能在OS X現在應該叫做macOS,早就出現了),在Xcode中開啟並設置App Groups,Xcode - Capabilities中找到App Groups打開並添加一個名為“group.com.cmjstudio.todolist”組(注意組名稱必須以group開頭,這一步操作相當於在iOS的開發證書中啟用App Groups服務並注冊分組,同時在Xcode - Build Settings - Code Signing Entitlements中配置對應的分組配置文件。從Xcode 8開始,證書配置將變得異常簡單,不用過多的登錄開發者賬號管理證書)。添加完分組之後將在項目中生成一個ToDoList.entitlements文件(這其實就是一個xml配置文件,事實上日後如果添加其他服務,其配置也會添加到這個文件中)。既然App Groups和開發證書相關,也就是說同一個開發證書下發布的應用只要配置了相同的組就可以實現數據的共享。App Groups支持的常用數據共享包括NSUserDefaults、NSFileManager、NSFileCoordinator、NSFilePresenter、UIPasteboard、KeyChain、NSURLSession等,這裡不妨將數據存儲到NSUserDefaults中。
下面將快速創建一個簡單的ToDoList,使用UITableView進行展示,數據的操作邏輯放到TaskService.swift中:
import Foundation let TaskServiceDataKey = "TaskServiceData" public struct TaskService { public static let ToDoListGroupName = "group.com.cmjstudio.todolist" public static func addItem(title:String){ let userDefault = NSUserDefaults(suiteName: TaskService.ToDoListGroupName) var items = self.getItems() items.append(title) userDefault?.setObject(items, forKey: TaskServiceDataKey) userDefault?.synchronize() } public static func removeItem(title:String){ let items = self.getItems() let newItems = items.filter { (item) -> Bool in item != title } let userDefault = NSUserDefaults(suiteName: TaskService.ToDoListGroupName) userDefault?.setObject(newItems, forKey: TaskServiceDataKey) userDefault?.synchronize() } public static func getItems() -> [String]{ let userDefault = NSUserDefaults(suiteName: TaskService.ToDoListGroupName) var tasks = [String]() if let array = userDefault?.stringArrayForKey(TaskServiceDataKey) { tasks = array } return tasks } }
實現了ToDoList之後接下來就是進行擴展開發。首先在項目中添加一個名為“ToDoListTodayExtension”的Today Extension類型的Target,並選擇激活這個Scheme以便後面測試。然後可以看到在項目根目錄創建了一個“ToDoListTodayExtension”文件夾,它包含一個TodayViewController、MainInterface.storyboard和一個info.plist。在info.plist中定義了擴展入口點“com.apple.widget-extension”同時指定了MainInterface作為展示入口,當然很容易就可以猜到TodayViewController是MainInterface.storyboard中控制器對應的class。TodayViewController.swift是一個UIViewController控制器:
class TodayViewController: UIViewController, NCWidgetProviding { override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view from its nib. } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } func widgetPerformUpdate(completionHandler: ((NCUpdateResult) -> Void)) { // Perform any setup necessary in order to update the view. // If an error is encountered, use NCUpdateResult.Failed // If there's no update required, use NCUpdateResult.NoData // If there's an update, use NCUpdateResult.NewData completionHandler(NCUpdateResult.newData) } }
可以看出這個類還實現了NCWidgetProviding協議,其中最重要的兩個方法就是用於自定義邊距的widgetMarginInsets方法和更新插件的widgetPerformUpdate方法。此時如果編譯運行(注意之前已經激活擴展的sheme,也就是從擴展運行)並且選擇宿主程序Today就會看到一個帶有“Hello World”字樣的擴展,這其實就是MainInterface的默認布局(注意此時在Products中會生成一個ToDoListTodayExtension.appex就是對應的擴展包)。
接下來就可以進行擴展的界面布局了,你可以選擇Storyboard或者code布局,需要注意的是Today擴展的寬度永遠都會是屏幕寬度,布局時不需要過多關心,而高度則需要通過調整TodayViewController的preferredContentSize來完成。
另外,這裡我們需要思考一個問題:如何使用之前容器應用中編寫的TaskService.swift,因為它已經包含了數據的讀取方法,我們沒有必要在擴展中再實現一遍相同的操作。根據前面文章中關於Swift的命名空間和作用域的介紹應該可以想到將其提取到一個公共的命名空間中,而命名空間的實現通常是使用一個target實現的,這也正是官方推薦的做法。創建一個framework類型的Target並且將TaskSerivce.swift放到這個framework中,ToDoList和ToDoListTodayExtension均使用這個framework(在項目中增加一個名為“ToDoListKit”的Cocoa Touch Framework類型的Target,同時注意將TaskService.swift和對應的類和方法聲明為公共方法,在使用TaskService的中使用import ToDoListKit導入這個Framework)。
在TodayViewController中增加UITableView和UIButton,當沒有數據時展示UIButton,點擊按鈕可以通過extensionContext跳轉到容器應用並增加新的代辦事項,前面提到過在擴展中是無法直接利用UIApplication打開應用的因為擴展在宿主應用中運行,但是在控制器中增加了一個NSExtensionContext類型的上下文來管理擴展操作,這樣也就解決了上面說到的第三個問題。擴展的高度則通過preferredContentSize來進行設置,然後根據記錄數動態設置其高度,沒有數據則設置為一行記錄的高度來展示添加按鈕。
import UIKit import NotificationCenter import ToDoListKit private let TodayViewControllerMaxCellCount = 3 private let TodayViewControllerCellHeight:CGFloat = 44.0 private let TodayViewControllerTableViewCellKey = "TodayViewControllerTableViewCell" class TodayViewController: UIViewController, NCWidgetProviding,UITableViewDataSource,UITableViewDelegate { override func viewDidLoad() { super.viewDidLoad() self.setup() self.loadData() } func widgetPerformUpdate(completionHandler: ((NCUpdateResult) -> Void)) { // Perform any setup necessary in order to update the view. // If an error is encountered, use NCUpdateResult.Failed // If there's no update required, use NCUpdateResult.NoData // If there's an update, use NCUpdateResult.NewData self.loadData() completionHandler(NCUpdateResult.NewData) } func widgetMarginInsetsForProposedMarginInsets(defaultMarginInsets: UIEdgeInsets) -> UIEdgeInsets { return UIEdgeInsetsZero } // MARK: - UITableView數據源和代理方法 func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return self.data.count } func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { var cell:UITableViewCell! = tableView.dequeueReusableCellWithIdentifier(TodayViewControllerTableViewCellKey) if cell == nil { cell = UITableViewCell(style: .Subtitle, reuseIdentifier: TodayViewControllerTableViewCellKey) cell.textLabel?.textColor = UIColor.whiteColor() cell.detailTextLabel?.textColor = UIColor.whiteColor() } let item = self.data[indexPath.row] cell.imageView?.image = UIImage(named: "calendar") cell.textLabel?.text = "Date & Time" cell.detailTextLabel?.text = item return cell } // MARK: - 事件響應 @IBAction func addButtonClick(sender: UIButton) { let url = NSURL(string: "todolist://add") self.extensionContext?.openURL(url!, completionHandler: nil) } // MARK: - 私有方法 private func setup(){ self.addButton.layer.cornerRadius = 3.0 self.tableView.rowHeight = TodayViewControllerCellHeight } private func loadData(){ self.data = [String]() let items = TaskService.getItems() // 控制最多顯示條數 for i in 0..= TodayViewControllerMaxCellCount { break } } self.layoutUI() self.tableView.reloadData() } private func layoutUI(){ if self.data.count > 0 { self.addButton.hidden = true self.tableView.hidden = false self.preferredContentSize.height = CGFloat(self.data.count) * TodayViewControllerCellHeight } else { self.addButton.hidden = false self.tableView.hidden = true self.preferredContentSize.height = TodayViewControllerCellHeight } } // MARK: - 私有屬性 @IBOutlet weak var tableView: UITableView! @IBOutlet weak var addButton: UIButton! private var data:[String]! }
注意:官方已經明確指出Today擴展不支持UIScrollView滾動,建議顯示最新數據或者更多的數據通過分頁實現。
此外在擴展中使用了一個日歷圖標calendar,而在容器應用ToDoList中這個圖片已經存在於Assets.xcassets中,但在擴展中沒辦法直接訪問容器應用中的資源。一種解決方式是直接往擴展中添加一個calendar圖標;另一種就是直接選擇擴展這個Target—Build Phases—Copy Bundle Resources 然後添加容器中的資源。這麼做的好處是盡管實際運行中存在兩份資源,但是開發過程中只需要維護一份。在ToDoListTodayExtension中我們選擇第二種方式(當然如果你確實需要進行資源文件共享而不是使用兩份資源,你也可以通過NSFileManager.defaultManager().containerURLForSecurityApplicationGroupIdentifier(gropuName)來讀取容器應用中的文件,但在這裡不太適合)。
當然接下來就是給ToDoListTodayExtension擴展配置App Groups,配置方法類似,唯一需要注意的是Group名稱必須和前面保持一致,設置為“group.com.cmjstudio.todolist”。最後運行結果如下:
分享擴展
前面說過現在iOS支持的擴展類型越來越多,給開發者提供了更多的交互方式,除了Today擴展之外分享擴展應該是另一個比較常見得擴展類型,比如常用的QQ、微信、微博等都實現了分享擴展。下面再以一個分享擴展為例簡單介紹一下這種擴展的開發過程。
假設現在有一個圖片社區應用“MyPicture”,用戶可以分享各種圖片和攝影作品,在系統相冊中用戶可以選擇自己喜歡的圖片直接分享到“MyPicture”。關於應用和擴展的創建過程不再贅述,假設已經創建完應用擴展“MyPictureShareExtension”。默認情況下分享擴展編輯界面如下:
首先這個擴展的info.plist相比Today Extension多了一些配置選項,例如可以編輯擴展名稱、語言等。這裡進行設置如下:
擴展顯示名稱Bundle display name名稱為“MyPicture”。
配置擴展激活的規則NSExtensionActivationRule,增加最大支持分享圖片數NSExtensionActivationSupportsImageWithMaxCount為9,如果超過九張則不顯示分享按鈕,同時此項配置也確保在網頁分享、文件分享中不再出現“MyPicture”擴展。
更多配置參加Apple官方文檔 (SystemExtensionKeys),事實上激活規則還支持更為復雜的斷言配置。
其次,Share Extension對應的控制器繼承於SLComposeServiceViewController,其中最常用的方法和屬性如下:
charactersRemaining:剩余字符數,顯示在分享界面左下方,例如這裡設置為最大200。
isContentValid():分享內容驗證(例如驗證分享內容中是否包含特殊字符),此方法再編輯過程中會不斷調用,如果此方法返回false則分享按鈕不可用,這裡可以通過判斷輸入動態修改charactersRemaining。
didSelectPost():發送點擊事件,通常在此方法中會上傳圖片和內容。
configurationItems():用於自定義sheet選項,顯示在分享界面下方,可以接收點擊事件,這裡我們會導航到另一個自定義編輯界面用於選擇分類。
下圖是我們即將實現的最終效果,點擊Category可以選擇圖片分類:
這裡重點關注圖片的發送過程,在Share Extension中是無法直接獲取到圖片的(因為我們分享的內容可能是圖片,也可能是網頁、視頻等,因此SLComposeServiceViewController也不太可能會直接提供圖片訪問接口),所有的訪問數據包含進在extensionContext的inputItems中,這是一個NSInputItem類型的數組。每個NSInputItem都包含一個attachments集合,它的每個元素都是NSItemProvider類型,每個NSItemProvider就包含了對應的圖片、視頻、鏈接、文件等信息,通過它就可以獲取到我們需要的圖片資源。但是需要注意,通過NSItemProvider進行資源獲取的過程較長,同時也會阻塞線程,如果直接在didSelectPost方法中獲取圖片資源勢必造成用戶長時間等待,比較好的體驗是在presentationAnimationDidFinish方法中就異步調用NSItemProvider的loadItemForTypeIdentifier方法進行圖片資源加載,並存儲到數組中以便在didSelectPost方法中使用。
此外,為了獲取更好的用戶體驗,圖片的上傳過程同樣需要放到後台進行,首先想到的就是使用NSURLSession的後台會話模式,值得一提的是在這個過程中必須指定NSURLSessionConfiguration的sharedContainerIdentifier,因為上傳的過程中首先會將資源緩存到本地,而擴展是沒辦法直接訪問宿主應用的緩存空間的,配置sharedContainerIdentifier以便利通過App Group使用容器應用的緩存空間。具體實現如下:
import UIKit import Social import MobileCoreServices import Alamofire private let ShareViewControllerContentTextMax = 200 private let ShareViewControllerDefaultCategoryTitle = "Category" class ShareViewController: SLComposeServiceViewController { override func viewDidLoad() { super.viewDidLoad() self.imageDatas = [NSData]() self.charactersRemaining = ShareViewControllerContentTextMax self.placeholder = "Please enter description" } // 顯示分享界面,在此時則異步加載圖片到self.images,避免在didSelectPost中再加載圖片影響體驗 override func presentationAnimationDidFinish() { // 用戶輸入項 guard let extensionItem = self.extensionContext?.inputItems.first else { return } guard let attachments = extensionItem.attachments as? [NSItemProvider] else { return } for attachment in attachments { let imageType = kUTTypeImage as String if attachment.hasItemConformingToTypeIdentifier(imageType) { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { attachment.loadItemForTypeIdentifier(imageType, options: nil, completionHandler: { (coding, error) in if error == nil { guard let fileURL = coding as? NSURL else { return } guard let data = NSData(contentsOfURL: fileURL) else { return } self.imageDatas.append(data) // guard let image = UIImage(data: data) else { return } // self.images.append(image) } }) }) } } } // 內容驗證,輸入過程中會不斷調用此方法 override func isContentValid() -> Bool { if let text = self.contentText { let len = text.characters.count if len > ShareViewControllerContentTextMax { return false } self.charactersRemaining = ShareViewControllerContentTextMax - len } return true } // 發送分享內容 override func didSelectPost() { // 上傳圖片和編輯內容、分類 self.upload() // 通知host app 操作完成 self.extensionContext!.completeRequestReturningItems([], completionHandler: nil) } // 自定義分享編輯界面sheet override func configurationItems() -> [AnyObject]! { return [self.categorySheetItem] } // MARK: - 私有方法 private func selectCategory(){ let temp = CategoryTableViewController(style: .Grouped) temp.selectedCategory = self.categorySheetItem.title temp.selectedCategoryHandler = { [weak self]category in guard let weakSelf = self else { return } weakSelf.categorySheetItem.title = category } self.pushConfigurationViewController(temp) } private func upload(){ let urlStr = "http://requestb.in/v34h3lv3" self.manager.upload(.POST,urlStr, multipartFormData: { (formData) -> Void in for data in self.imageDatas { formData.appendBodyPart(data: data, name: "image", mimeType: "image/jpeg") } // add parameter if self.contentText != nil { formData.appendBodyPart(data: self.contentText.dataUsingEncoding(NSUTF8StringEncoding)!, name: "content") } if self.categorySheetItem.title != ShareViewControllerDefaultCategoryTitle { formData.appendBodyPart(data: self.categorySheetItem.title.dataUsingEncoding(NSUTF8StringEncoding)!, name: "category") } }){ encodingResult in switch encodingResult { case Manager.MultipartFormDataEncodingResult.Success(_, _, _): debugPrint("request") case let Manager.MultipartFormDataEncodingResult.Failure(error): debugPrint(error) } } } // MARK: - 私有屬性 private lazy var categorySheetItem:SLComposeSheetConfigurationItem = { let temp = SLComposeSheetConfigurationItem() temp.title = ShareViewControllerDefaultCategoryTitle temp.tapHandler = self.selectCategory return temp }() // 自定義上傳配置,在後台上傳避免阻塞UI,注意:由於NSURLSession上傳過程中需要先限緩存到本地但是擴展應用本身是沒辦法使用Host App緩存控件的,因此注意設置sharedContainerIdentifier,使用容器應用的空間 private lazy var manager:Alamofire.Manager = { let configName = "com.cmjstudio.mypicture.backgroundsession" let configuration = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier(configName) configuration.sharedContainerIdentifier = "group.com.cmjstudio.mypicture" // configuration.HTTPAdditionalHeaders = Alamofire.Manager.defaultHTTPHeaders let manager = Alamofire.Manager(configuration: configuration) manager.startRequestsImmediately = true manager.backgroundCompletionHandler = { debugPrint("completed.") } return manager }() private var imageDatas:[NSData]! }
注意:網絡操作部分這裡直接選擇Alamofire進行上傳,如果想自己實現圖片上傳,可以查看iOS開發系列--網絡開發。另外,如果需要自定義分享編輯界面可以讓ShareViewController繼承自UIViewController,具體細節參見Apple指導文檔。
由於使用了NSURLSession的後台會話,當執行完相關操作後會調用容器應用的application(application, identifier, completionHandler) 方法,如有必要有些操作可以在此方法中進行處理。
總結
本文著重介紹了Today Extension和Share Extension兩種擴展,其實擴展是比較大的一塊內容,各類擴展實現方法也不盡相同,但是其生命周期、核心原理是類似的,本文也不再一一探討。相信iOS 10中更加豐富的擴展類型也會讓應用之間的交互越來越豐富,有興趣的朋友也可以訪問下載Xcode 8 beta版進行探索,有時間我們也會寫一篇關於Intent Extension、Message Extensiond等新增擴展應用的文章。