攜程機票iOS Widget實踐
作者 | Derek Yang,攜程資深研發經理,專注于iOS開發&跨端技術研究,熱衷于新技術探索。
一、前言
2020年9月蘋果發布了iOS 14.0,相較之前有了很大的功能改觀,很重要的一點是用戶可以更加個性化的定義自己的桌面,Widget就是這項功能的主角。
近期接到一項產品需求,需要實現若干機票業務相關的Widget,此文總結該需求開發上線過程中的踩坑填坑經驗。
Widget俗稱小組件,是蘋果推出的眾多App Extension中的一款。因此在介紹Widget之前,需要先了解App Extension及其工作原理。
二、App Extension簡介
iOS 8.0開始,就支持了App Extension的開發來滿足豐富App的需要。
2.1 什么是App Extension?
App Extension顧名思義是應用擴展。所以它不是一個應用程序,而是實現一個特定的、范圍明確的自定義任務。
這個任務由開發人員自定義,并遵循系統規范的擴展策略,在用戶與其他應用或者系統交互時將其提供給用戶。
App Extension編譯后是一個后綴名為.appex的二進制文件,無法獨立分發和安裝,必須依附于App。
一個 App 可以掛載多個種類的App Extension。截止目前為止,蘋果已經陸續推出33款App Extension,常見的有照片編輯(Photo Editing)、共享(Share)、自定義鍵盤(Custom Keyboard),小組件(Widget)。如下圖:
2.2 App Extension工作原理
App Extension的生命周期與常規App不同,需要一個包含Extension的App(Containing App),以及喚起Extension的App(Host App)。
當用戶通過Host App喚起Extension時,系統實例化Extension,從此Extension的生命周期開始,Extension開始執行自己的任務。之后當任務執行結束或者用戶通過Host app結束任務時,或者系統由于某種原因將其進程結束,Extension的生命周期到此結束。
官方簡介圖:
Extension、Containing App和Host App三者之間的通信關系,如下官網圖示:
由圖可知App Extension與Host App可以直接通信,而App Extension和Containing App之間并不直接通信。
這樣設計可以保證App Extension在運行時與Containing App隔離,不依賴于App,甚至在Extension在運行時,Containing App都不會主動運行,Containing App和Host App兩者間沒有通信。
但是在實際應用場景中,仍然會有和Containing App通信的需求,這里系統給出的方案是在兩者之間使用共有存儲來解決數據通信的問題,App Extension需要打開Containing App 并附帶一些參數,則可以通過Open Url的方式來實現。
如下官方圖示說明:
詳細的數據共享方式將在后續Widget的篇幅中詳細介紹。初步了解App Extension后,接下來詳細分析Widget。
三、Widget簡介
Widget是能添加到用戶桌面或者在“今日視圖"中獨立運行的程序。
Widget前身是Today Extension,其在iOS 8.0第一次推出,在iOS 14.0被廢棄,Widget于iOS 14.0推出。實際兩者有較大的區別:
外觀上Today Extension只能添加到負一屏,只有展開和收起兩種尺寸,開發人員可以自定義這部分區域的布局大小。Widget不僅可以添加到負一屏,還可以添加到桌面,和App并列,同時支持三種樣式(小:2x2、中:4x2、大:4x4),這三種樣式不支持自定義尺寸。
Widget開發使用蘋果新推出的WidgetKit,UI開發只能使用SwiftUI,而Today Extension則使用UIKit。因此進行Widget開發,需要Swift和SwiftUI的技術知識。
Xcode12不再提供Today Extension的添加,對于已有Today Extension的App,系統仍然在負一屏保留的區域展示,并且不能像Widget一樣隨意拖動移動位置和刪除等操作,僅保留最初的規則
小中大三種樣式的展示效果:
圓角為系統自帶
三種尺寸在不同設備上的實際渲染尺寸,如下官網數據截圖:
iPhone
iPad
機票當前需求僅需支持小卡、中卡兩種樣式。
四、Widget的開發框架簡介
4.1 單/多個widget配置
單個和多個Widget在實際代碼中的入口不同。
單個 widget 需要實現 Widget protocol
@main
struct Widget1: Widget {
let kind: String = "widgetTag"
var body: some WidgetConfiguration {
}
}
多個 Widget 需要實現 WidgetBundle protocol
@main
struct TripWidgets: WidgetBundle {
@WidgetBundleBuilder
var body: some Widget {
Widget1()
Widget2()
Widget3()
}
}
Widget的添加操作需要用戶在系統添加小組件頁面進行,該頁面會展示一些簡單信息供用戶查看。
展示信息的具體配置如下:
struct Widget1: Widget {
let kind: String = "widgetTag"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
Widget1View(entry: entry)
}
.configurationDisplayName("旅行靈感")
.description("下段旅程,即刻啟程")
.supportedFamilies([WidgetFamily.systemSmall,WidgetFamily.systemMedium])
}
}
4.2 Widget整體結構
1)每個Widget都需要返回一個WidgetConfiguration,分為兩種:
- 可編輯的小組件 IntentConfiguration
- 不可編輯 StaticConfiguration2) 每個WidgetConfiguration都需要一個Provider和一個ViewContent。
Provider用于做數據層刷新,主要有三個function:
- placeholder (用于返回默認展示的數據Model)
- getSnapshot(用于渲染呼出添加小組件時的UI展示)
- getTimeline(用于添加到用戶桌面后的數據和UI刷新)
ViewContent用于UI展示,分三種大小:2x2(Small)、4x2(Medium)、4x4(Large)
API整體架構串聯圖:
4.3 Widget刷新策略
由于Widget是用戶添加到用戶桌面的,刷新也需要系統管理,系統為此定義了一個刷新規則。通過Provider的getTimeline來實現,基本原理是給系統提交一組未來時間內用于刷新UI的數據,每個數據與時間綁定,然后系統根據時間點,將預設的數據渲染給到用戶。
Provider定義如下:
public protocol TimelineProvider {
associatedtype Entry : TimelineEntry
typealias Context = TimelineProviderContext
func placeholder(in context: Self.Context) -> Self.Entry
func getSnapshot(in context: Self.Context, completion: @escaping (Self.Entry) -> Void)
func getTimeline(in context: Self.Context, completion: @escaping (Timeline<Self.Entry>) -> Void)
}
Timeline結構如下:
public struct Timeline<EntryType> where EntryType : TimelineEntry {
public let entries: [EntryType]
public let policy: TimelineReloadPolicy
public init(entries: [EntryType], policy: TimelineReloadPolicy)
}
構建Timeline的參數
entries: [EntryType] 做數據和時間綁定,自定義的數據實體需要遵守TimelineEntry的協議。
TimelineEntry的具體實現均需要一個date和一個數據。
TimelineEntry定義如下:
public protocol TimelineEntry {
var date: Date { get }
var relevance: TimelineEntryRelevance? { get }
}
policy: TimelineReloadPolicy 刷新策略
TimelineReloadPolicy是負責決定下一次更新策略的配置對象。
系統通過Provider的getTimeline來做數據刷新操作的回調,開發者在此方法中將獲取的數據提交封裝成TimelineEntry,并加上Timeline的刷新策略提交給系統,最終實現刷新。
此處刷新策略,系統給出了下面三種方式:
1)atEnd,按照entries中給到的所有日期和數據執行刷新操作后,再一次調用getTimeline來更新刷新策略。
2)after,用于指定未來的一個時間,調用getTimeline就更新刷新策略。
3)never,添加之后執行一次后,不再執行做策略刷新。
4.4 App和Widget關聯&互操作
1)Widget和App的數據關聯,遵循App Extension的規范,系統提供了NSUserDefaults和NSFileManger兩種方式來做數據共享。前提都需要開啟App Groups的功能。
NSUserDefaults方式
//存
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.xxx.xxx.xx"];
[userDefaults setObject:@"test_content" forKey:@"test"];
[userDefaults synchronize];
//取
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.xxx.xxx.xx"];
NSString *content = [userDefaults objectForKey:@"test"];
NSFileManger
// 存
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.xxx.xxx.xx"];
containerURL = [containerURL URLByAppendingPathComponent:@"testfile"];
[data writeToURL:containerURL atomically:YES];
//取
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.xxx.xxx.xx"];
containerURL = [containerURL URLByAppendingPathComponent:@"testfile"];
NSData *value = [NSData dataWithContentsOfURL:containerURL];
2)App的信息改變主動刷新Widget,系統提供了如下方式實現:
WidgetCenter.shared.reloadTimelines(ofKind: "widgetTag")
3)Widget喚醒App
以Unviersal Links /URL Schema跳轉,控件采用如下兩種配置即可實現:
- widgetURL(小卡只支持整個區域的點擊)
- Link(小卡不支持,中卡和大卡可以支持局部區域的跳轉)
卡片打開會調用App的如下生命周期方法,如需跳轉到具體頁面此處做路由即可。
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
//URLContexts.first?.url.absoluteString
.
}
五、項目開發經驗總結
總體來講按照官方開發文檔就能快速實現一個Widget,但是實際開發中總會遇到一些限制和問題。下面是我們在項目開發中遇到的一些問題和限制的總結。
5.1 Widget的數量限制
官方文檔表明每個App最多配置5種Widget,可以是App添加多個WidgetExtension的target,也可以是一個WidgetExtension的target中添加多種Widget,每種Widget最多支持三種樣式:systemSmall,systemMedium,systemLarge,總共最多可添加15種Widget到桌面。
每種Widget可以被添加多次,這個取決于用戶的操作。(實測本地模擬器環境可超過5種,實際發布上線未驗證)
5.2 不是所有的SwiftUI組件都可用
WidgetKit限制Widget UI需由SwiftUI實現,但并不是所有SwiftUI的組件都可供Widget使用。如果遇到不支持的組件,WidgetKit渲染時會忽略。
具體可使用的組件參見官方文檔。
5.3 圖片加載問題
由于系統提供的機制是需要提前預設數據,我們最初嘗試用像App一樣的方式去加載圖片控件,結果發現圖片并不加載。原因是這里不能做異步,需要同步獲取Image。
另外此處圖片不易過大,也會影響加載,具體size取決于當時系統的處理能力。(實測遇到200k的圖片無法加載的情況)
5.4 Widget點擊事件
小卡只支持widgetURL,整個卡片區域只能做一個事件響應。中卡和大卡可支持Link,可支持多個區域的點擊。點擊未設置widgetURL和Link的區域,都會默認喚起Containing App。
點擊Widget的Widget和Link方式,只能打開主Containing App,即使URL維護的是其他App的Schema,也是無法打開其他App的。
5.5 代碼共享注意點
官方介紹在共享代碼時強調引入的API必須是AppExtension支持的,否則在審核時會被拒。
- SharedApplication的相關API
- 帶有NS_EXTENSION_UNAVAILABLE標記的(iOS 8.0中的HealthKit、EventKit UI)
- 訪問攝像頭/麥克風(iMessage除外)
- 執行長時間的后臺任務
- 用AirDrop接受數據(可發送數據)
具體參見 Using an Embedded Framework to Share Code
5.6 刷新次數的限制
雖然系統給出了這些刷新方案,但是在實際運行時次數上會有一定的限制和出入。
- 策略刷新頻率至少相隔5分鐘(少于這個間隔可能會不準確,刷新機制雖然提供了API支持,但是實際刷新還是由系統掌控,并不是你添加的每次刷新都能準確的奏效)。
- 系統為了減負,在這個基礎上做了一層機器學習,實際的刷新會根據用戶手機上小組件的可見頻率時間、上次重新加載的時間以及主app的活動狀態做動態分配。
5.7 系統主動刷新機制
同時系統以下這些行為導致的刷新,將不會被統計到到刷新次數中:
- Widget對應的應用程序在前臺
- Widget對應的應用程序具有活動的音頻或導航會話
- 手機系統區域更改
- 動態類型或輔助功能設置更改
5.8 Size問題
Widget最終編譯為后綴名為.appex的二進制文件,這一點同AppExtension一樣,并在ipa內部,故size和主App共享。
5.9 熱修復問題
暫無熱修方案,故需要做好上線的測試以及兜底邏輯的處理。