iOS開發系列--App擴展開發

概述

從iOS 8 開始Apple引入了擴展(Extension)用於加強系統應用服務和應用之間的交互。它的出現讓自定義鍵盤、系統分享集成等這些依靠系統服務的開發變成了可能。WWDC 2016上衆多更新也都是圍繞擴展這一主題來進行了的,例如開發的Siri、iMessage Apps其實都是依靠擴展來工做的。在最新的Xcode 8 beta中也增長了衆多的Extension 模板幫助開發者更快的實現不一樣類型的擴展。所以今天有必要介紹一下擴展相關的開發內容。html

擴展的生命週期

iOS對於擴展的支持已經由最初的6類到了現在iOS10的19類(相信隨着iOS的發展擴展的覆蓋面也會愈來愈廣),固然不一樣類型的擴展其用途和用法均不盡相同,可是其工做原理和開發方式是相似的。下面列出擴展的幾個共同點:ios

  • 擴展依附於應用而不能單獨發佈和部署;
  • 擴展和包含擴展的應用(containing app)生命週期是獨立的,分別運行在兩個不一樣的進程中;
  • 擴展的運行依賴於宿主應用(或者叫載體應用 host app,而不是containing app)其生命週期由宿主應用肯定;
  • 對開發者而言擴展做爲一個單獨的target而存在;
  • 擴展一般展示在系統UI或者其餘應用中,運行應該儘量的迅速而功能單一;swift

    因爲目前iOS 10正式版還沒有發佈,官方文檔僅就目前9類擴展作了詳細指導說明,感興趣的話你們能夠前往查看
    官方對於應用擴展的生命週期描述以下圖:數組

一般用戶選擇了一個擴展的操做時宿主會向擴展發出一個請求來啓動此擴展,擴展的生命週期也由此開始(例如用戶在分享菜單中選擇了你的分享擴展),因爲擴展自己由控制器組成,所以此時就會調用相似於viewDidLoad之類的方法進行界面佈局和邏輯處理,執行完相應任務以後應該儘快將控制權交給宿主應用,擴展生命週期結束。緩存

儘管擴展和容器應用的生命週期之間沒有直接關係,可是擴展自己就是做爲容器應用的擴展而存在的,所以擴展和容器應用之間的交互又是不可避免的。一般擴展會經過自定義Scheme的形式來調用容器應用,而容器應用完成響應操做以後經過數據共享將數據共享給擴展來使用。微信

Today擴展演示

前面說過目前iOS支持19類擴展入口,如今就以Today擴展(也叫作Widget)爲例進行說明,在開始以前先對Today擴展有一個簡單的認識,下圖是微博、墨跡天氣、網易雲音樂的的Today擴展截圖,微博擴展能夠用來發送微博、查看更新,墨跡天氣則用來展現今日和明日的天氣,網易雲音樂則是推薦一些相關的歌單、專輯。網絡

咱們今天的例子將利用Today擴展實現一個簡單的「to do list」查看功能,在容器應用ToDoList中能夠增長和刪除待辦事項,而Today插件則展現最新的幾條待辦事項,若是沒有待辦事項則展現添加按鈕,點擊添加或列表則導航到ToDoList應用。應用的主界面和Today擴展最終截圖以下:session

在開發以前首先思考一下要實現一個這樣的ToDoList擴展須要注意哪些問題:app

  1. 首先ToDoList容器應用須要思考如何存儲數據,由於容器應用完成以後要在Today中展示,前面說過擴展和容器應用沒有任何關係,兩者處於兩個不一樣的沙盒之中,要實現數據資源共享則必須在開發以前思考如何存儲數據的問題?
  2. 因爲ToDoList容器應用和其擴展ToDoListTodayExtension均要訪問讀取數據那麼二者就存在重複讀取數據的操做,也就是二者可能會存在較多的重複代碼,如何複用這些代碼?
  3. 點擊擴展列表或添加按鈕要回到容器應用,因爲擴展中禁用了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..<items.count {
            self.data.append(items[i])
            if i >= 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)[https://developer.apple.com/library/ios/documentation/General/Reference/InfoPlistKeyReference/Articles/SystemExtensionKeys.html#//apple_ref/doc/uid/TP40014212-SW2],事實上激活規則還支持更爲複雜的斷言配置。

其次,Share Extension對應的控制器繼承於SLComposeServiceViewController,其中最經常使用的方法和屬性以下:

  • charactersRemaining:剩餘字符數,顯示在分享界面左下方,例如這裏設置爲最大200。
  • isContentValid():分享內容驗證(例如驗證分享內容中是否包含特殊字符),此方法再編輯過程當中會不斷調用,若是此方法返回false則分享按鈕不可用,這裏能夠經過判斷輸入動態修改charactersRemaining
  • didSelectPost():發送點擊事件,一般在此方法中會上傳圖片和內容。
  • configurationItems():用於自定義sheet選項,顯示在分享界面下方,能夠接收點擊事件,這裏咱們會導航到另外一個自定義編輯界面用於選擇分類。

下圖是咱們即將實現的最終效果,點擊Category能夠選擇圖片分類:

這裏重點關注圖片的發送過程,在Share Extension中是沒法直接獲取到圖片的(由於咱們分享的內容多是圖片,也多是網頁、視頻等,所以SLComposeServiceViewController也不太可能會直接提供圖片訪問接口),全部的訪問數據包含進在extensionContextinputItems中,這是一個NSInputItem類型的數組。每一個NSInputItem都包含一個attachments集合,它的每一個元素都是NSItemProvider類型,每一個NSItemProvider就包含了對應的圖片、視頻、連接、文件等信息,經過它就能夠獲取到咱們須要的圖片資源。可是須要注意,經過NSItemProvider進行資源獲取的過程較長,同時也會阻塞線程,若是直接在didSelectPost方法中獲取圖片資源勢必形成用戶長時間等待,比較好的體驗是在presentationAnimationDidFinish方法中就異步調用NSItemProviderloadItemForTypeIdentifier方法進行圖片資源加載,並存儲到數組中以便在didSelectPost方法中使用。

此外,爲了獲取更好的用戶體驗,圖片的上傳過程一樣須要放到後臺進行,首先想到的就是使用NSURLSession的後臺會話模式,值得一提的是在這個過程當中必須指定NSURLSessionConfigurationsharedContainerIdentifier,由於上傳的過程當中首先會將資源緩存到本地,而擴展是沒辦法直接訪問宿主應用的緩存空間的,配置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等新增擴展應用的文章。

源代碼下載

相關文章
相關標籤/搜索