iOS開發日記16-通知欄擴展 (App Extension)

今天博主有一個App Extension的需求,遇到了一些困難點,在此和你們分享,但願可以共同進步.html

總覽ios

擴展 (Extension) 是 iOS 8 和 OSX 10.10 加入的一個很是大的功能點,開發者能夠經過系統提供給咱們的擴展接入點 (Extension point) 來爲系統特定的服務提供某些附加的功能。對於 iOS 來講,可使用的擴展接入點有如下幾個:swift

  • Today 擴展 - 在下拉的通知中心的 "今天" 的面板中添加一個 widget
  • 分享擴展 - 點擊分享按鈕後將網站或者照片經過應用分享
  • 動做擴展 - 點擊 Action 按鈕後經過判斷上下文來將內容發送到應用
  • 照片編輯擴展 - 在系統的照片應用中提供照片編輯的能力
  • 文檔提供擴展 - 提供和管理文件內容
  • 自定義鍵盤 - 提供一個能夠用在全部應用的替代系統鍵盤的自定義鍵盤或輸入法

系統爲咱們提供的接入點雖然還比較有限,可是很多已是在開發者和 iOS 的用戶中呼聲很高的了。而經過利用這些接入點提供相應的功能,也能夠極大地豐富系統的功能和可用性。本文將先不失通常性地介紹一下各類擴展的共通特性,而後再以一個實際的例子着重介紹下通知中心的 Today 擴展的開發方法,以期爲 iOS 8 的擴展的學習提供一個平滑的入口。app

Apple 指出,iOS 8 中開發者的中心並不該該發生改變,依然應該是圍繞 app。在 app 中提供優秀交互和有用的功能,如今是,未來也會是 iOS 應用開發的核心任務。而擴展在 iOS 中是不能以單獨的形式存在的,也就是說咱們不能直接在 AppStore 提供一個擴展的下載,擴展必定是隨着一個應用一塊兒打包提供的。用戶在安裝了帶有擴展的應用後,將能夠在通知中心的今日界面中,或者是系統的設置中來選擇開啓仍是關閉你的擴展。而對於開發者來講,提供擴展的方式是在 app 的項目中加入相應的擴展的 target。由於擴展通常來講是展示在系統級別的 UI 或者是其餘應用中的,Apple 特別指出,擴展應該保持輕巧迅速,而且專一功能單一,在不打擾或者中斷用戶使用當前應用的前提下完成本身的功能點。由於用戶是能夠本身選擇禁用擴展的,因此若是你的擴展表現欠佳的話,極可能會遭到用戶棄用,甚至致使他們將你的 app 也一併卸載。ide

擴展的生命週期

擴展的生命週期和包含該擴展的你的容器 app (container app) 自己的生命週期是獨立的,準確地說。它們是兩個獨立的進程,默認狀況下互相不該該知道對方的存在。擴展須要對宿主 app (host app,即調用該擴展的 app) 的請求作出響應,固然,經過進行配置和一些手段,咱們能夠在擴展中訪問和共享一些容器 app 的資源,這個咱們稍後再說。模塊化

由於擴展實際上是依賴於調用其的宿主 app 的,所以其生命週期也是由用戶在宿主 app 中的行爲所決定的。通常來講,用戶在宿主 app 中觸發了該擴展後,擴展的生命週期就開始了:好比在分享選項中選擇了你的擴展,或者向通知中心中添加了你的 widget 等等。而全部的擴展都是由 ViewController 進行定義的,在用戶決定使用某個擴展時,其對應的 ViewController 就會被加載,所以你能夠像在編寫傳統 app 的 ViewController 那樣獲取到諸如 viewDidLoad 這樣的方法,並進行界面構建及作相應的邏輯。擴展應該保持功能的單一專一,而且迅速處理任務,在執行完成必要的任務,或者是在後臺預定完成任務後,通常須要儘快經過回調將控制權交回給宿主 app,至今生命週期結束。post

按照 Apple 的說法,擴展可使用的內存是遠遠低於 app 可使用的內存的。在內存吃緊的時候,系統更傾向於優先搞掉擴展,而不會是把宿主 app 殺死。所以在開發擴展的時候,也必定須要注意內存佔用的限制。另外一點是好比像通知中心擴展,你的擴展可能會和其餘開發人員的擴展共存,這樣若是擴展阻塞了主線程的話,就會引發整個通知中心失去響應。這種狀況下你的擴展和應用也就基本和用戶說再見了..學習

擴展和容器應用的交互

擴展和容器應用自己並不共享一個進程,可是做爲擴展,實際上是主體應用功能的延伸,確定不可避免地須要使用到應用自己的邏輯甚至界面。在這種狀況下,咱們可使用 iOS 8 新引入的自制 framework 的方式來組織須要重用的代碼,這樣在連接 framework 後 app 和擴展就都能使用相同的代碼了。測試

另外一個常見需求就是數據共享,即擴展和應用互相但願訪問對方的數據。這能夠經過開啓 App Groups 和進行相應的配置來開啓在兩個進程間的數據共享。這包括了使用 NSUserDefaults 進行小數據的共享,或者使用 NSFileCoordinator 和 NSFilePresenter 甚至是 CoreData 和 SQLite 來進行更大的文件或者是更復雜的數據交互。網站

另外,一直以來的自定義的 url scheme 也是從擴展嚮應用反饋數據和交互的渠道之一。

這些常見的手段和策略在接下來的 demo 中都會用到。一張圖片能頂千言萬語,而一個 demo 能頂千張圖片。那麼,咱們開始吧。

Timer Demo

初始工程運行起來的界面大概是這樣的:

簡單說整個項目只有一個 ViewController,點擊開始按鈕時咱們經過設定但願的計時時間來建立一個 Timer 實例,而後調用它的 start 方法。這個方法接收兩個參數,分別是每次剩餘時間更新,以及計時結束(不管是計時時間到的完成仍是計時被用戶打斷)時的回調方法。另外這個方法返回一個 tuple,用來表示是否開始成功以及可能的錯誤。

剩餘時間更新的回調中刷新界面 UI,計時結束的回調裏回收了 Timer 實例,而且顯示了一個 UIAlertController。用戶經過點擊 Stop 按鈕能夠直接調用 stop 方法來打斷計時。直接簡單,沒什麼其餘的 trick。

咱們如今計劃爲這個 app 作一個 Today 擴展,來在通知中心中顯示並更新當前的剩餘時間,而且在計時完成後顯示一個按鈕,點擊後能夠回到 app 本體,並彈出一個完成的提示。

添加擴展 Target

第一步固然是爲咱們的 app 添加擴展。正如在總覽中所提到的,擴展是項目中的一個單獨的 target。在 Xcode 6 中, Apple 爲咱們準備了對應各種不一樣擴展點的 target 模板,這使得向 app 中添加擴展很是容易。對於咱們如今想作的 Today 擴展,只需點選菜單的 File > New > Target...,而後選擇 iOS 中的 Application Extension 的 Today Extension 就好了。

在彈出的菜單中將新的 target 命名爲 SimpleTimerTodayExtenstion,而且讓 Xcode 自動生成新的 Scheme,以方便測試使用。咱們的工程中如今會多出一個和新建的 target 同名的文件夾,裏面主要包含了一個 .swift 的 ViewController 程序文件,一個叫作 MainInterface 的 storyboard 文件和 Info.plist。其中在 plist 裏 的 NSExtension 中定義了這個 擴展的類型和入口,而配套的 ViewController 和 StoryBoard 就是咱們的擴展的具體內容和實現了。

咱們的主題程序在編譯連接後會生成一個後綴爲 .app 的包,裏面包含主程序的二進制文件和各類資源。而擴展 target 將單獨生成一個後綴名爲 .appex的文件包。這個文件包將隨着主體程序被安裝,並由用戶選擇激活或者添加(對於 Today widget 的話在通知中心 Today 視圖中的編輯刪增,對於其餘的擴展的話,使用系統的設置進行管理)。咱們能夠看到,如今項目的 Product 中已經新增了一個擴展了。

若是你有心已經打開了 MainInterface 文件的話,能夠注意到 Apple 已經爲咱們準備了一個默認的 Hello World 的 label 了。咱們這時候只要運行主程序,擴展就會一併安裝了。將 Scheme 設爲 Simple Timer 的主程序,Cmd + R,而後點擊 Home 鍵將 app 切到後臺,拉下通知中心。這時候你應該能在 Toady 視圖中找到叫作 SimpleTimerTodayExtenstion 的項目,顯示了一個 Hello World 的標籤。若是沒有的話,能夠點擊下面的編輯按鈕看看是否是沒有啓用,若是在編輯菜單中也沒有的話,恭喜你遇到了和 Session 視頻裏的演講者一樣的 bug,你可能須要刪除應用,清理工程,而後再安裝試試看。通常來講卸載再安裝能夠解決如今的 beta 版大部分的沒法加載的問題,若是仍是遇到問題的話,你還能夠嘗試重啓設備(按照以往幾年的 SDK 的狀況來看,beta 版裏這很正常,正式版中應該就沒什麼問題了)。

若是一切正常的話,你能看到的通知中心應該相似這樣:

這種方式運行的擴展咱們沒法對其進行調試,由於咱們的調試器並無 attach 到這個擴展的 target 上。有兩種方法讓咱們調試擴展,一種是將 Scheme 設爲以前 Xcode 爲咱們生成的 SimpleTimerTodayExtenstion,而後運行時選擇從 Today 視圖進行運行,如圖;另外一種是在擴展運行時使用菜單中的 Debug > Attach to Process > By Process Identifier (PID) or name,而後輸入你的擴展的名字(在咱們的 demo 中是 com.onevcat.SimpleTimer.SimpleTimerTodayExtension)來把調試器掛載到進程上去。

在應用和擴展間共享數據 - App Groups

擴展既然是個 ViewController,那各類鏈接 IBOutlet,使用 viewDidLoad 之類的生命週期方法來設置 UI 什麼的天然不在話下。咱們如今的第一個難點就是,如何獲取應用主體在退出時計時器的剩餘時間。只要知道了還剩多久以及什麼時候退出,咱們就能在通知中心中顯示出計時器正確的剩餘時間了。

對 iOS 開發者來講,沙盒限制了咱們在設備上隨意讀取和寫入。可是對於應用和其對應的擴展來講,Apple 在 iOS 8 中爲咱們提供了一種可能性,那就是 App Groups。App Groups 爲同一個 vender 的應用或者擴展定義了一組域,在這個域中同一個 group 能夠共享一些資源。對於咱們的例子來講,咱們只須要使用同一個 group 下的 NSUserDefaults 就能在主體應用不活躍時向其中存儲數據,而後在擴展初始化時從同一處進行讀取就好了。

首先咱們須要開啓 App Groups。得益於 Xcode 5 開始引入的 Capabilities,這變得很是簡單(至少再也不須要去 developer portal 了)。選擇主 target SimpleTimer,打開它的 Capabilities 選項卡,找到 App Groups 並打開開關,而後添加一個你能記得的 group 名字,好比 group.simpleTimerSharedDefaults。接下來你還須要爲 SimpleTimerTodayExtension 這個 target 進行一樣的配置,只不過再也不須要新建 group,而是勾選剛纔建立的 group 就行。

而後讓咱們開始寫代碼吧!首先是在主體程序的 ViewController.swift 中添加一個程序失去前臺的監聽,在 viewDidLoad 中加入:

NSNotificationCenter.defaultCenter() .addObserver(self, selector: "applicationWillResignActive",name: UIApplicationWillResignActiveNotification, object: nil) 

而後是所調用的 applicationWillResignActive 方法:

@objc private func applicationWillResignActive() { if timer == nil { clearDefaults() } else { if timer.running { saveDefaults() } else { clearDefaults() } } } private func saveDefaults() { let userDefault = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults") userDefault.setInteger(Int(timer.leftTime), forKey: "com.onevcat.simpleTimer.lefttime") userDefault.setInteger(Int(NSDate().timeIntervalSince1970), forKey: "com.onevcat.simpleTimer.quitdate") userDefault.synchronize() } private func clearDefaults() { let userDefault = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults") userDefault.removeObjectForKey("com.onevcat.simpleTimer.lefttime") userDefault.removeObjectForKey("com.onevcat.simpleTimer.quitdate") userDefault.synchronize() } 

這樣,在應用切到後臺時,若是正在計時,咱們就將當前的剩餘時間和退出時的日期存到了 NSUserDefaults 中。這裏注意,可能通常咱們在使用 NSUserDefaults 時更多地是使用 standardUserDefaults,可是這裏咱們須要這兩個數據可以被擴展訪問到的話,咱們必須使用在 App Groups 中定義的名字來使用 NSUserDefaults

接下來,咱們能夠到擴展的 TodayViewController.swift 中去獲取這些數據了。在擴展 ViewController 的 viewDidLoad 中,添加如下代碼:

let userDefaults = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults") let leftTimeWhenQuit = userDefaults.integerForKey("com.onevcat.simpleTimer.lefttime") let quitDate = userDefaults.integerForKey("com.onevcat.simpleTimer.quitdate") let passedTimeFromQuit = NSDate().timeIntervalSinceDate(NSDate(timeIntervalSince1970: NSTimeInterval(quitDate))) let leftTime = leftTimeWhenQuit - Int(passedTimeFromQuit) lblTImer.text = "\(leftTime)" 

固然別忘了把 StoryBoard 的那個 label 拖出來:

@IBOutlet weak var lblTImer: UILabel! 

再次運行程序,並開始一個計時,而後按 Home 鍵切到後臺,拉出通知中心,perfect,咱們的擴展可以和主程序進行數據交互了:

在應用和擴展間共享代碼 - Framework

接下來的任務是在 Today 界面中進行計時,來刷新咱們的界面。這部分代碼其實咱們已經寫過(固然..確切來講是我寫的,你可能只是看過),沒錯,就是應用中的 Timer.swift 文件。咱們只須要在擴展的 ViewController 中用剩餘時間建立一個 Timer 的實例,而後在更新的 callback 裏設置 label 就行了嘛。可是問題是,這部分代碼是在應用中的,咱們要如何在擴展中也能使用它呢?

一個最直接也是最簡單的想法天然是把 Timer.swift 加入到擴展 target 的編譯文件中去,這樣在擴展中天然也就可使用了。可是 iOS 8 開始 Apple 爲咱們提供了一個更好的選擇,那就是作成 Framework。單個文件可能不會以爲有什麼差異,可是隨着須要共用的文件數量和種類的增長,將單個文件逐一添加到不一樣 target 這種管理方法很快就會將事情弄成一團亂麻。你須要考慮每個新加或者刪除的文件影響的範圍,以及它們分別須要適用何處,這簡直就是人間地獄。提供一個統一漂亮的 framework 會是更多人但願的選擇(其實也差很少成爲事實標準了)。使用 framework 進行模塊化的另外一個好處是能夠得益於良好的訪問控制,以保證你不會接觸到不該該使用的東西,而後,Swift 的 namespace 是基於模塊的,所以你也再也不須要擔憂命名衝突等等一攤子 objc 時代的煩心事兒。

如今讓咱們把 Timer.swift 放到 framework 裏吧。首先咱們新建一個 framework 的 target。File > New > Target... 中選擇 Framework & Library,選中 Cocoa Touch Framework (配圖中的另外幾個選項可能在你的 Xcode 中是沒有的,請無視它們,這是歷史遺留問題),而後肯定。按照 Apple 對 framework 的命名規範,也許 SimpleTimerKit 會是一個不錯的名字。

接下來,咱們將 Timer.swift 從應用中移動到 framework 中。很簡單,首先將其從應用的 target 中移除,而後加入到新建的 SimpleTimerKit 的 Compile Sources 中。

確認在應用中 link 了新的 framwork,而且在 ViewController.swift 中加上 import SimpleTimerKit 後試着編譯看看...好多錯誤,基本都是 ViewController 中說找不到 Timer 之類的。這是由於原來的實現是在同一個 module 中的,默認的 internal 的訪問層級就可讓 ViewController 訪問到關於 Timer 和相應方法的信息。可是如今它們處於不一樣的 module 中,因此咱們須要對 Timer.swift 的訪問權限進行一些修改,在須要外部訪問的地方加上 public 關鍵字。

接下來,在擴展的 ViewController 中也連接 SimpleTimerKit 並加入 import SimpleTimerKit,咱們就能夠在擴展中使用 Timer 了。將剛纔的直接設置 label 的代碼去掉,換成下面的:

override func viewDidLoad() { //... if (leftTime > 0) { timer = Timer(timeInteral: NSTimeInterval(leftTime)) timer.start(updateTick: { [weak self] leftTick in self!.updateLabel() }, stopHandler: nil) } else { //Do nothing now } } private func updateLabel() { lblTimer.text = timer.leftTimeString } 

咱們在擴展裏也像在 app 內同樣,建立 Timer,給定回調,坐等界面刷新。運行看看,先進入應用,開始一個計時。而後退出,打開通知中心。通知中心中如今也開始計時了,並且確實是從剩餘的時間開始的,一切都很完美:

經過擴展啓動主體應用

最後一個任務是,咱們想要在通知中心計時完畢後,在擴展上呈現一個 "完成啦" 的按鈕,並經過點擊這個按鈕能回到應用,並在應用內彈出結束的 alert。

這其實最關鍵的在於咱們要如何啓動主體容器應用,以及向其傳遞數據。可能不少同窗會想到 URL Scheme,沒錯經過 URL Scheme 咱們確實能夠啓動特定應用並攜帶數據。可是一個問題是爲了經過 URL 啓動應用,咱們通常須要調用 UIApplication 的 openURL 方法。若是細心的剛纔看了 NS_EXTENSION_UNAVAILABLE 的同窗可能會發現這個方法是被禁用的(這也是很 make sense 的一件事情,由於說白了擴展經過 sharedApplication拿到的實際上是宿主應用,宿主應用表示憑什麼要讓你拿到啊!)。爲了完成一樣的操做,Apple 爲擴展提供了一個 NSExtensionContext 類來與宿主應用進行交互。用戶在宿主應用中啓動擴展後,宿主應用提供一個上下文給擴展,裏面最主要的是包含了 inputItems 這樣的待處理的數據。固然對咱們如今的需求來講,咱們只要用到它的 openURL(URL:,completionHandler:) 方法就行了。

另外,咱們可能還須要調整一下擴展 widget 的尺寸,以讓咱們有更多的空間顯示按鈕,這能夠經過設定 preferredContentSize 來作到。在 TodayViewController.swift 中加入如下方法:

private func showOpenAppButton() {  
    lblTimer.text = "Finished" preferredContentSize = CGSizeMake(0, 100) let button = UIButton(frame: CGRectMake(0, 50, 50, 63)) button.setTitle("Open", forState: UIControlState.Normal) button.addTarget(self, action: "buttonPressed:", forControlEvents: UIControlEvents.TouchUpInside) view.addSubview(button) } 

在設定 preferredContentSize 時,指定的寬度都是無效的,系統會自動將其處理爲整屏的寬度,因此扔個 0 進去就行了。在這裏添加按鈕時我偷了個懶,原本應該使用Auto Layout 和添加約束的,可是這並非咱們這個 demo 的重點。另外一方面,爲了代碼清晰明瞭,就直接上座標了。

而後添加這個按鈕的 action:

@objc private func buttonPressed(sender: AnyObject!) { extensionContext.openURL(NSURL(string: "simpleTimer://finished"), completionHandler: nil) } 

咱們將傳遞的 URL 的 scheme 是 simpleTimer,以 host 的 finished 做爲參數,就能夠通知主體應用計時完成了。而後咱們須要在計時完成時調用 showOpenAppButton 來顯示按鈕,更新 viewDidLoad 中的內容:

override func viewDidLoad() { //... if (leftTime > 0) { timer = Timer(timeInteral: NSTimeInterval(leftTime)) timer.start(updateTick: { [weak self] leftTick in self!.updateLabel() }, stopHandler: { [weak self] finished in self!.showOpenAppButton() }) } else { showOpenAppButton() } } 

最後一步是在主體應用的 target 裏設置合適的 URL Scheme:

而後在 AppDelegate.swift 中捕獲這個打開事件,並檢測計時是否完成,而後作出相應:

func application(application: UIApplication!, openURL url: NSURL!, sourceApplication: String!, annotation: AnyObject!) -> Bool { if url.scheme == "simpleTimer" { if url.host == "finished" { NSNotificationCenter.defaultCenter() .postNotificationName(taskDidFinishedInWidgetNotification, object: nil) } return true } return false } 

在這個例子裏,咱們發了個通知。而在 ViewController 中咱們能夠一開始就監聽這個通知,而後收到後中止計時並彈出提示就好了。固然咱們可能須要一些小的重構,好比添加是手動打斷仍是計時完成的判斷以彈出不同的對話框等等,這些都很簡單再次就不贅述了。

這個計時器如今在應用中只在前臺或者通知中心顯示時工做,若是你退出應用後再打開應用,其實這段時間內是沒有計時的。所以這個項目以後可能的改進就是在返回應用的時候添加一下計時的斷定,來更新計時器的剩餘時間,或者是已經完成了的話就直接結束計時。

其餘

其實在 Xcode 爲咱們生成的模板文件中,還有這麼一段代碼也很重要:

func widgetPerformUpdateWithCompletionHandler(completionHandler: ((NCUpdateResult) -> Void)!) { // Perform any setup necessary in order to update the view. // If an error is encoutered, use NCUpdateResult.Failed // If there's no update required, use NCUpdateResult.NoData // If there's an update, use NCUpdateResult.NewData completionHandler(NCUpdateResult.NewData) } 

對於通知中心擴展,即便你的擴展示在不可見 (也就是用戶沒有拉開通知中心),系統也會時不時地調用實現了 NCWidgetProviding 的擴展的這個方法,來要求擴展刷新界面。

值得注意的一點是 Xcode (至少如今的 beta 4) 所提供的模板文件的 ViewController 裏雖然有這個方法,可是它默認並無 conform 這個接口,因此要用的話,咱們還須要在類聲明時加上 NCWidgetProviding

總結

這個 Demo 主要涉及了通知中心的 Toady widget 的添加和通常交互。其實擴展是一個至關大塊的內容,對於其餘像是分享或者是 Action 的擴展,其使用方式又會有所不一樣。可是核心的概念,生命週期以及與本體應用交互的方法都是類似的。Xcode 在咱們建立擴展時就爲咱們提供了很是好的模版文件,更多的時候咱們要作的只不過是在相應的方法內填上咱們的邏輯,而對於配置方面基本不太須要操心,這一點仍是很是方便的。

 

 

http://www.cocoachina.com/ios/20141023/10027.html

http://blog.csdn.net/phunxm/article/details/42715145

相關文章
相關標籤/搜索