天天看點

攜程機票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)。如下圖:

攜程機票iOS 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的生命周期到此結束。

官方簡介圖:

攜程機票iOS Widget實踐

Extension、Containing App和Host App三者之間的通信關系,如下官網圖示:

攜程機票iOS Widget實踐

由圖可知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的方式來實作。

如下官方圖示說明:

攜程機票iOS Widget實踐

詳細的資料共享方式将在後續Widget的篇幅中詳細介紹。初步了解App Extension後,接下來詳細分析Widget。

三、Widget簡介

Widget是能添加到使用者桌面或者在“今日視圖"中獨立運作的程式。

攜程機票iOS 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一樣随意拖動移動位置和删除等操作,僅保留最初的規則

小中大三種樣式的展示效果:

攜程機票iOS Widget實踐

圓角為系統自帶

三種尺寸在不同裝置上的實際渲染尺寸,如下官網資料截圖:

iPhone

攜程機票iOS Widget實踐

iPad

攜程機票iOS Widget實踐

機票目前需求僅需支援小卡、中卡兩種樣式。

四、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的添加操作需要使用者在系統添加小元件頁面進行,該頁面會展示一些簡單資訊供使用者檢視。

攜程機票iOS 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
  • • 不可編輯 StaticConfiguration

2) 每個WidgetConfiguration都需要一個Provider和一個ViewContent。

• Provider用于做資料層重新整理,主要有三個function:

-placeholder (用于傳回預設展示的資料Model)

-getSnapshot(用于渲染呼出添加小元件時的UI展示)

-getTimeline(用于添加到使用者桌面後的資料和UI重新整理)

• ViewContent用于UI展示,分三種大小:2x2(Small)、4x2(Medium)、4x4(Large)

API整體架構串聯圖:

攜程機票iOS Widget實踐

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. 1)atEnd,按照entries中給到的所有日期和資料執行重新整理操作後,再一次調用getTimeline來更新重新整理政策。
  2. 2)after,用于指定未來的一個時間,調用getTimeline就更新重新整理政策。
  3. 3)never,添加之後執行一次後,不再執行做政策重新整理。

4.4 App和Widget關聯&互操作

  1. 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];           
  1. 2)App的資訊改變主動重新整理Widget,系統提供了如下方式實作:
WidgetCenter.shared.reloadTimelines(ofKind: "widgetTag")           
  1. 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 熱修複問題

暫無熱修方案,故需要做好上線的測試以及兜底邏輯的處理。

參考文獻

  • • App Extension
  • • 建立Widget
  • • 重新整理機制
  • • Widget-Design設計

作者:Derek Yang

來源:微信公衆号:攜程技術

出處:https://mp.weixin.qq.com/s/mwX5dt4dRR25f9znKcxx-A