iOS開發之構建Widget

發表於2015-03-27

伴隨這iOS 8 系統多達4000項API更新而來同樣還有Today Extension.而對iOS而言,有了Today Extension 開發者可以很好藉助系統提供的接入點為系統定製的服務,提供自定義的附加功能.這意味著什麼呢?從iOS 7版本嘗試開路到現在iOS 8更新的到來終於向開發者開放Widget接入,這意味著系統應用和第三方應用都可以通知中心(Notification Center)裡面實現互動.

Notification Center Widget [Via Apple]

其實相對於Android,因其特有開放性Widget外掛已經發展了很多年,擁有極高自由定製性,在新版本的Android系統中甚至可以將部分外掛擺在鎖屏頁.而Google和各大軟體廠商製作的Widget外掛也能很好與系統的整體風格進行無縫的融合,而直到目前iOS 8版本中,Widget也就只是能擺在通知中心(Notification Center)今天通知欄中而已,相對於Android也聽到很多人把這個作為”iOS不夠開放”一個有力的依據.針對這個問題其實Apple也在iOS Human Interface Guidelines中提到:

iOS 8 中開發者的中心並不應該發生改變,依然應該是圍繞 app.在 app 中提供優秀互動和有用的功能,現在是,將來也會是 iOS 應用開發的核心任務。而Widget在 iOS 中是不能以單獨的形式存在的,一定是隨著一個應用一起打包提供的。

從這個側面可見,Apple對開放一直持有審慎的態度,開放的目的是力求保證整體體驗完整性,雖然iOS的Widget相比Android自定義性太低,但基於Apple目前的開放程度而言是能夠很有效控制Widget與系統的更好的融合.雖似戴著鐐銬起舞,但卻能捕獲人心.

而從使用者角度來看,在無需開啟應用前提下就可以對訊息進行處理的互動特性,使它在很多場景裡有效提升了使用者操作效率.例如在Widget中快速回復email,即時完成Todo日程等.這種互動更多從更巨集觀角度重新定義了訊息,通知中心(Notification Center)通過獲取使用者上一行為,還可以起到承接下一行為的作用(雖然目前開放API只能做到系統級的行為).點雖小,但這對使用者使用習慣改變卻是巨大的.

Widget on hands [Via Yalantis]

有人看到這肯定一定會問為何沒有提到Windows Phone平臺?因為無論從通知中心快捷入口數量還是談到可以互動的點一句話而概之WP的現狀是“一窮二白”,你想作為曾經走過WP7時代使用者根本不知道通知中心為何物的,而是用了足足兩年時間WP8上才有體現,而那些被其他平臺玩膩的希望習以為常通知中心互動,就像這樣:


WP 通知中心[Via PCGGroup]

你就像看這張靜態圖片一樣也就是停留只是看看程度而已(除了刪除操作之外),MS針對通知中心現在最新訊息是未來會支援類似可以通知中心直接回復簡訊等互動,至於什麼時候能夠等到,誰知道呢.

說了這麼多,迴歸正題.

1.互動

在開始構建Widget之前,如果想對Widget實現技術細節和互動特點有一個完整概覽,我覺得沒有什麼文件比官方App Extension Programming Guide更值得一讀了.剛開始接觸iOS通知中心,一直很疑惑為何通知中心採用兩個不同Tab“今日”和“通知”來對訊息進行分離.其實這和Widget工作機制有關.

Widget是放在“今日”Tab之中,而它工作機制是隻有使用者下拉通知中心時才會去重新整理獲取最新資料,這種做法和Android不同在於,Android更偏向於把整個Widget一直放在後臺實時持續的更新.設想一下,如果我們看同樣天氣資訊,Android會持續消耗資源去做一件使用者不會實時預覽資訊,這也就能解釋為何經常看到Android使用者抱怨耗電問題.而對於即時訊息,iOS做法是直接把這些訊息實時歸類到”通知“Tab中.其實這種做法很好解決採用消耗最少資源前提下保證其操作的靈活性.

因為現有Widget一般來說是展現在系統級別的 UI上,所以在App Extension Programming Guide中Apple對Widget互動提出如下明確的要求:

擴充套件應該保持輕巧迅速,並且專注功能單一,在不打擾或者中斷使用者使用當前應用的前提下完成自己的功能點.

類似一直摯愛Todo應用Clear則互動上堪稱上典範:

Clear’s Widget

當然如果動點腦子會發現,Widget開放iOS上實現應用之間Launcher成為了可能,類似早期一直很魔性應用”Launcher”:

Launcher’s Widget

可以讓用在 iOS 的通知中心裡,以類似應用程式捷徑的方式直接快速切換 App 的小工具,其實當初在推出沒多久後,便被 Apple 以”誤用 / 濫用”Widgets 為理由下架,但有意思的就在幾天前3月20日又重新上架.

2.構建

在Widget技術實現細節上,並不打算在本篇把所有技術細節通覽一遍,我只會寫我個人(其實就是初學者)認為值得寫的容易出錯的點或者耗費一些時間找到一些問題的解決方案.

2.1 純程式碼構建

Xcode 6中已經支援Today Extension建立Widget的模板,該模板會預設建立MainInterface.storyboard檔案來構建UI:

StoryBoard UI

當然對於一個純程式碼的擁躉而言,肯定直接刪除storyboard檔案採用純程式碼方式來進行構建,刪除完後之後注意需要找到Supporting Files下面的Info.plist中NSExtension欄位做如下兩個操作:

A:直接刪除NSExtensionMainStoryboard欄位

B:新增NSExtensionPrincipalClass欄位 並設為TodayViewController

如下:

修改後

注意當採用Xcode預設模板建立Widget時會自動把ViewController檔案命名設定為“TodayViewController”.當然這個ViewController命名其實是可以修改的,唯一值得注意的修改該ViewController檔案命名後還需要設定NSExtensionPrincipalClass的值與其保持一致即可.不然Widget編譯時會報找不到對應入口.

2.2 左側間隔

當第一次新增UI元素採用真機來執行Widget會發現,Widget左側到螢幕之間始終會有一段距離的間隔,導致調整佈局和效果圖差距甚遠,類似這樣:

左側間隔

其實這個問題主要是因為Widget裡面的檢視預設居左居下都會有一定距離的間隔,可以採用如下方式取消間隔,使佈局區域填充整個Widget:


取消間隔

這種方式把整個佈局填充區域間隔都設定為0,當然更簡潔的方式是你可以直接採用“return UIEdgeInsetsZero;”方式.而關於Widget上佈局處理則採用Masonry框架做的相對佈局,簡單快捷推薦.當然關於Masonry框架快速上手則不得不推薦閱讀Masonry介紹與使用實踐(快速上手Autolayout).

2.3 整個點選區域實現

如你所看當使用者拉開Widget時,因為Widget是依賴於應用程式在分發時是跟應用程式一塊打包的,希望點選Widget佈局任何區域都能喚起主應用程式,常用的方式在整個View增加Tap事件訂閱處理:


Tap事件

但這種方式會額外產生一個問題,如果Widget空白區域沒有任何UI元素則無法觸發該事件,那這裡有一個小技巧可以解決改問題,可以整個Widget增加一個透明的ImageView:


設定透明度

初始化時注意把imageview透明度設定為0.01最小值,那麼無論設定其背景色為什麼值肉眼都是不可見的.然後使用Masonry框架佈局來填充Widget整個背景如下:


填充整個背景

然後為imageview增加Tap事件訂閱即可:


增加事件訂閱

這樣就能整個Widget區域可點選效果.另外針對通過Widget中喚起主應用程式方式目前只支援url scheme方式來實現.同時也是Widget向主應用程式反饋資料和互動的渠道之一.

2.4 定時更新機制

Widget自身更新機制當使用者下拉通知中心(Notification Center)時立即更新資料,但我們仔細研究Widget使用者使用場景時發現,如果使用者鎖屏時間過長,開啟Widget後不做任何操作,這個時候針對一些即時類應用,類似我們天氣中可能涉及到災害預警它要求場景資料一旦產生就要實時展現給使用者,這就需要我們基於Widget自身機制外還要處理這個場景下天氣資料自動更新的問題.

這個時候我們需要構建一個定時更新的NSTimer:


初始化NSTimer

非常簡單,在NSTimer固定更新間隔執行的方法呼叫就是更新資料方法,當然重點不在這裡,而是觸發和關閉這個NSTimer時機.按照Widget生命週期來說,如果使用者是第一次下拉檢視Widget其實就是執行整個ViewController生命週期呼叫過程,這個並沒有什麼問題,但是還是存在一個特殊情況.系統為了保證Widget上資料是及時更新的,預設會擷取上次顯示成功Widget的快照.這個快照會一直儲存到新的資料或UI被更新才回被替換,那這就會帶來一個問題,當你拖拽通知中心(Notification Center)下拉過於頻繁時,Debug跟蹤程式碼執行路徑你會發現整個Widget生命週期執行過程和第一次下拉執行的路徑發生了變化.

第一次下拉執行路徑是viewDidLoad->viewWillAppear,而如果下拉過於頻繁你就會發現程式碼執行路徑直接只會執行viewWillAppear方法,這個就是系統預設儲存上次快照而導致的執行路徑上變化.這對我們選擇NSTimer更新時機以及後面會提到的Widget橫豎屏處理都會有影響.

那麼很明顯,為了保證這個定時更新機制能夠無論使用者什麼情況下操作都能起作用,我們需要把NSTimer fire觸發程式碼呼叫放到viewWillAppear方法中來.同理當Widget關閉後在viewDidDisappear方法取消NSTimer invalidate定時更新即可.

2.5 Widget橫屏支援

關於Widget橫屏支援在開發中耽誤一點時間來解決這個問題,在iPhone 6 & Plus上已經橫豎屏直接切換,Widget預設是豎屏,但如果你需求中橫屏UI的佈局和豎屏佈局完全不同,這個時候你就需要判斷當前Widget橫豎屏狀態來切換對應的佈局.

當然一般思路我們都會按照端內處理橫豎屏方式來處理Widget,如果你翻過官方的開發文件,你會發現在iOS 6.0版本之前UIViewController之間橫豎屏切換,只需要設定shouldAutorotateToInterfaceOrientation函式即可.UIInterfaceOrientation是UIApplication.h標頭檔案中定義的列舉型別,總共有四個方向.在shouldAutorotateToInterfaceOrientation方法中返回相應的結果即可,如果直接返回YES將支援所有方向.而在iOS 6.0版本之後,UIViewController之間橫豎屏切換需要多設定一個supportedInterfaceOrientations函式返回UIInterfaceOrientationMask列舉型別.除了設定shouldAutorotateToInterfaceOrientation之外,還要將supportedInterfaceOrientations返回的方向與shouldAutorotateToInterfaceOrientation保持一致,否則會在兩個支援不同橫豎屏ViewController中切換時,會出現豎屏變橫屏,橫屏變豎屏的情況.但問題是這種方式是否適用Widget橫屏處理呢?

使用UIDeviceOrientationIsPortrait來判斷:


判斷橫屏方法一

當你執行這段程式碼除錯時你會發現,orientation方向的值始終都會是UIDeviceOrientationUnknown.如果你點開UIDeviceOrientation列舉你會看到.它包含了兩個扁平方向UIDeviceOrientationFaceUp和UIDeviceOrientationFaceDown,其實它代表的意思螢幕朝上或朝下平躺兩個方向的判斷.所以當你裝置平躺桌面時.即時你有時已經切換了橫屏你會發現它會返回FaceUp或FaceDown,所以你當你呼叫UIDeviceOrientationIsPortrait方法時它返回值其實是沒有意義的,因為裝置目前方向在平躺下Faceup和FaceDown既不是橫屏也不是豎屏.難道沒有更好的方式嘛?

可以採用如下方式能夠完美解決Widget橫豎屏切換狀態判斷的問題:


Widget橫豎屏狀態判斷

其實設定Widget顯示高度時就會發現,高度在橫豎屏狀態切換是不會變化的,但寬度會隨著橫豎屏狀態切換會發生變化,所以判斷螢幕寬度這個思路是可取的.因為橫豎屏UI佈局不同,呼叫時機則可以選擇在viewWillLayoutSubviews或viewDidLayoutSubviews方法中進行.因為這兩個方法都是viewWillAppear方法是必然執行的,這也就自然規避Widget自身因為下拉快照儲存機制導致程式碼執行路徑變化導致佈局更新的問題.

2.6 Widget國際化

在來說說這個Widget國際化,因為我們客戶端自身已經支援三種不同語言,這就是導致Widget也是需要根據端內語言變化必須有國際化的支援.其實我們端內已經做了一套完整的國際化機制.Widget最好處理方式能夠複用端內機制,而不需要單獨開發支援.iOS 8 新引入的自制 framework 的方式來組織需要重用的程式碼,這樣在連結 framework 後 app 和Widget就都能使用相同的程式碼. 包含Widget中資料請求和資料記憶其他能夠複用的程式碼。

這也是我們一開始打算解決方式,但發現剝離這部分程式碼時間週期明顯超過我們預期.所以在國際化處理上我們Widget獨立做了一套國際化處理,它和端內在處理機制上並沒有多大的不同:


Widget國際化處理

當然重點不再於它的實現,你可以發現我們Widget中國際化文字檔案Locallizable.string命名加了一個”WG”,這個問題是剛開始開發之初我們一直認為Widget作為端是獨立於主應用程式的.所以當初理解為只有把這個檔案命名為的“Locallizable.string”才是正常的能夠被識別的,但我們除錯時發現,Widget打包時會把這些國際化單獨放到PlugIns檔案下,這裡給出一個簡體中文全路徑:

/private/var/mobile/Containers/Bundle/Application/61C637FF-B5BC-432A-ADD5-BA64EBFE98E8/MojiWeather.app/PlugIns/MojiWidget.appex/zh-Hans.lproj

根據這個路徑你會發現檔案時可以找到的,但除錯時發現國際化取對應Key的值一直是取不到的,但我們任意非“Locallizable.string”時則是沒有問題的,後來我們發現當我們打包在不同機型上測試這個問題時,如果“Locallizable.string”名稱命名會導致除錯時ok,而最終打包上會出現找不到對應key值得問題.這個原因到我寫這篇blog一直沒有找到具體的原因.所以我們給出解決方案是一定要和主應用程式“Locallizable.string”保持不同即可解決.

當然關於Widget中閃現的問題,因為我們Widget存在兩個不同尺寸切換,導致這個問題很明顯,處理方式自然是viewWillLoad方式中做好Widget高度在不同場景高度初始化就可以完美避免.這裡就不做贅述.

如上只是我們解決Widget遇到一些大大小小的問題.解決問題方式雖然沒有給出細節,但思路是有的.有不清楚可以文後評論@我即可.

相關文章