應用程式基礎

WhatsApp 的 Wear OS 歷程經驗談

喇叭

  • Martin Pelant

    Meta 軟體工程師

  • Subhag Bhatia

    Meta 軟體工程師

關於

講座簡介

WhatsApp 工程師將親自分享如何大規模升級 Wear OS 應用程式。本課程將介紹整合 Material 3 和採用 Wear 小工具時,在架構和使用者體驗方面的決策。

直接瞭解團隊在更新應用程式和提供一致使用者體驗時,所學到的經驗、採用的模式和工具。

摘要

  • 與 Wear OS 共用 Android 手機程式碼可減少重複工作,但需要依附元件插入和資訊清單覆寫。測試應比較多重繫結組合,以免模組化功能無故消失。
  • 執行階段通知橋接管理功能可避免重複顯示快訊,同時讓手錶獨立運作。在登入時關閉橋接功能,並在登出時重新開啟,可避免手機鏡像功能重複震動。
  • 在圓形螢幕上遷移至 Material 3 時,除了視覺樣式外,也必須管理行為變更。正確使用 TransformingLazyColumn 的方式是只在一個層級套用轉換,並避免使用 Compose 的 remember 快取位置。
  • 如果手錶未及時更新,應用程式到期限制可能會導致活躍使用者人數突然下降。在九十天的版本到期前,系統會提醒使用者前往 Google Play 進行排定的更新。

Gemini Notebook

主題

  • wear-os
  • material-3
  • jetpack-compose
  • 模組化
  • 通知
  • tiles-and-widgets

轉錄稿

簡介

  • 大家好!

  • 我是 Martin。

  • 我是 Subhag。

  • 我們是 Meta 的工程師,負責開發 Wear OS 版 WhatsApp。

  • 我們最近也翻新了應用程式。

  • 因此,今天我們將分享從遷移至共用程式碼集,以及在此基礎上建構新體驗中學到的經驗。

  • Wear OS 版 WhatsApp 的特別之處在於,不必搭配手機就能使用。

  • 連結後,該裝置會做為獨立的 WhatsApp 裝置運作,與手機一樣採用端對端加密,並擁有自己的資料庫和伺服器連線。

  • 因此,即使手機不在連線範圍內,仍可透過 Wi-Fi 或 LTE 繼續運作。

  • 我們在 2023 年首次推出 Wear OS 版 WhatsApp。

  • 這個殭屍網路是由以 Rust 編寫的小型可攜式用戶端驅動。

  • 讓我們能快速投入正式環境。

  • 不過,這是 WhatsApp 的第二個獨立實作,也就是說,每個通訊協定、加密和功能都必須建構兩次。

  • 因此手錶應用程式總是落後。

  • 由於可攜式,處理背景也變得棘手。

  • 用戶端會自行處理執行緒、計時器和通訊端,而 Android 則會為您管理這些項目。

  • 在 Android 上,背景有規則。

  • 在打盹模式下,網路會中斷。

  • 程序快取後,執行時間會受限或完全沒有。

  • 您需要服務、工作或推送,才能克服這個問題。

  • 因此,我們的手錶應用程式必須手動連結這些項目。

  • 但我們的 Android 程式碼集已經這麼做了。

  • 因此,我們決定重複使用並遷移至 Android 程式碼集,這個程式碼集已在數十億部裝置上經過實測,且已遵循 Android 規則。

改用共用程式碼集

  • 這樣一來,就不必重複建構新功能。

  • 目標很簡單:保留商業邏輯並移除 UI。

  • 剩下的就是無頭程式庫,使用 Kotlin 流程做為 API。

  • 不過,您無法一次解開這種大小的應用程式。

  • 部分模組會混合 UI 和基礎架構。

  • 其他則依附於 Wear OS 應用程式不需要的功能。

  • 因此,我們採取雙軌並行的方式:持續模組化,並移除或覆寫無法移除的部分。

  • 首先是模組化。

  • 如要避免程式碼進入建構作業,簡單的「如果這是「電話」應用程式」陳述式無法運作,因為這不會移除程式碼。

  • 因此,我們非常依賴 DI,尤其是選用和多重繫結。

  • 我們使用內部 DI,但與 Dagger 提供的功能非常相似。

  • 因此範例是以 Dagger 為主。

  • 使用選用繫結 (在 Dagger 中為 BindsOptionalOf) 時,您可以將依附元件宣告為選用,例如呼叫後端。

    @Module
    interface CallingModule {
        @BindsOptionalOf
        fun callingBackend(): CallingBackend
    }
    
    class Calls @Inject constructor(
        val backend: Optional<CallingBackend>,
    ) {
        fun start(): CallResult =
            backend.getOrNull()?.start()
                ?: CallResult.NotAllowed
    }
  • 如果手錶版本包含提供實作的模組,則會顯示插入的後端,且呼叫會正常運作。

  • 如果省略模組,模組會是空值,API 則會傳回 NotAllowed。

  • 多重繫結的作用相同,但適用於集合。

    @Module
    interface CoreModule {
        @Multibinds
        fun handlers(): Set<MessageHandler>
    }
    
    @Module
    interface ReactionsModule {
        @Binds @IntoSet
        fun reactions(impl: ReactionHandler):
     MessageHandler
    }
    
    class MessageDispatcher @Inject constructor(
          private val handlers: Set<MessageHandler>,
    )
  • 您可以使用 at multibinds 宣告集合,例如 MessageHandler,然後每個功能模組都可以使用 into set multibinding 新增自己的處理常式。

  • 如果您不想支援付款功能,可以將整個付款模組從建構作業中排除,這樣插入的集合就不會包含該處理常式。

  • 這樣一來,建構圖表就會決定是否支援該功能。

  • 不過,這種解耦方式有利有弊。

  • 如果使用直接參照,刪除模組會導致建構作業中斷。

  • 使用選用或多重繫結時,沒有任何項目會參照模組,因此建構作業會通過,功能會悄悄消失,或採用預設值。

  • 我們正好遇到這種情況。

  • 重構作業將功能移至專屬模組,而建構應用程式和手錶應用程式並未納入該模組。

  • 導致語音訊息顯示的長度為零秒。

  • 如何確保這種情況不會發生?

  • 您需要進行測試,才能重新加入檢查。

  • 在這個例子中,我們比較手機應用程式和手錶應用程式之間的多重繫結組合,找出重要的介面。

  • 這樣就能確保我們一律繫結正確的實作。

  • 第二個音軌則包含我們尚未移除的內容。

  • 重複使用模組可減少工作量,但模組宣告的所有內容都會合併到資訊清單中。

  • 手機啟動器活動會新增次要應用程式圖示,或不必要的內容供應器會與應用程式一起啟動。

  • 資訊清單合併工具提供實用的 tools:node="remove" 屬性,可讓您移除元件。

    <!-- remove unused Main activity node -->
    <activity android:name="com.whatsapp.Main"
              tools:node="remove" />
    
    <!-- redirect any calls to a new Activity -->
    <activity-alias
         android:name="com.whatsapp.Main"
         android:targetActivity=".MyNewActivity" />
    
    <provider
    android:name="…MigrationContentProvider"
            tools:node="remove" />
  • 一旦移除,縮減器也可以移除程式碼。

  • 如果仍有項目需要連到該活動,您可以使用 activity-alias 重新導向至新類別。

  • 不過,請注意,這些屬性只適用於優先順序較低的資訊清單,因此合併順序會決定是否要移除某些內容。

  • 資源也是如此。

  • Reuse 則會免費提供手機掛繩。

  • 不過,有些應用程式會做出手錶無法達成的承諾。

  • 我們來看看每則即時通訊開頭的加密通知結尾。

  • 在手機上,結尾會顯示「瞭解詳情」連結。

  • 不過,手錶上的連結不會導向任何內容。

  • 因此,我們在手錶上優先順序較高的模組中重新宣告相同的鍵,並將字串替換為「使用手機瞭解詳情」。

    <!-- phone -->
    <string name="…_disclosure">… Learn more</string>
    
    <!-- wearable override, same key -->
    <string name="…_disclosure">…Use your phone to learn more.</string>
  • 然後資源合併工具會選擇我們的資源。

  • 此外,您也需要確保不會意外覆寫字串。

  • 因此,我們建立了建構規則,確保覆寫項目在許可清單中,否則建構作業會失敗。

管理通知橋接

  • 接著來談談通知。

  • 根據預設,Wear OS 會橋接或鏡像手機通知。

  • 太棒了,但手機必須連線才能使用這項功能。

  • 我們希望 WhatsApp 獨立運作,因此應用程式會發布自己的通知。

  • 但如果橋接功能保持開啟,每則訊息都會震動兩次。

  • 關閉橋接功能即可修正重複問題。

  • 雖然有靜態資訊清單旗標,但這項設定會在使用者登入前,從安裝階段開始套用。

  • 因此,如果安裝後未登入,就會突然收不到任何通知,這不太好,對吧?

  • 因此,我們在執行階段使用 BridgingManager 設定。

    isRegistered.collect {
      when (it) {
             // watch posts its own
        true  -> bridging.setEnabled(false)
     // phone bridges again
        false -> bridging.setEnabled(true)
      }
    }
    
    fun setEnabled(bridgingEnabled){
       bridgingManager.setConfig(
    BridgingConfig.Builder(context,
    bridgingEnabled)
          .build())
    }
  • 你登入後,我們就會關閉橋接功能。

  • 登出後,系統會重新開啟這項功能。

  • 不過,請注意,沒有 API 可以讀取值,而且我們觀察到手錶會遺失值。

  • 因此,每當手錶從充電器上取下或應用程式更新時,我們都會重新寫入。

  • 最後一個主題是,如何登出?

  • 當你取消手錶與帳戶的連結時,使用者會預期訊息也會一併移除。

  • 因此我們會刪除所有內容,包括訊息、金鑰和設定。

  • 但該如何安全地執行呢?

  • 在背景執行緒仍寫入檔案時刪除檔案,會導致 SQLite 例外狀況,甚至更糟的是,寫入新建立的資料庫。

  • 而且如果不停止程序,就無法真正清除程序的狀態。

  • Android 提供 clearApplicationUserData API,可清除資料並停止應用程式,但也會重設權限。

  • 在某些原始設備製造商 (OEM) 的裝置上,通知橋接功能與通知權限相關聯,因此如果使用這項功能,手機鏡像功能也會突然失效。

  • 因此我們自行停止程序。

    clearActivityBackStack()
    
    // enumerate our own, kill each
    killOtherProcesses()
    
    // no threads alive — now safe
    deleteDataDirectories()
    
    // show logged out confirmation
    firePostCleanupIntent()
  • 我們在次要程序中建構小型服務,沒有 DI,也沒有 WorkManager,因此不會啟動應用程式的其他部分。

  • 登出後,這項服務會終止應用程式中的所有其他程序,並在沒有任何程序競爭時刪除所有資料。

  • 由於我們不使用 clearApplicationUserData,因此權限會保留,且可以開啟通知橋接功能,讓手機通知恢復。

  • 第一部分到此結束,接下來由 Subhag 介紹 UI 更新。

現代化 Wear OS UI

  • 謝謝 Martin。

  • 有了共用基礎架構,我們就能專注於讓 WhatsApp 在手腕上完美運作。

  • 這段旅程的第一部分是改用 Material 3,但首先,我們來探討原因。

  • 在圓形螢幕上,每個像素都很重要,而 Material 3 提供合適的元件和設計基礎,讓我們能為手錶打造現代化的 WhatsApp。

  • 如果注意到視覺效果,表面上看起來可能只是 UI 更新。

  • 我們從 Material 2 遷移到 Material 3,這並非一蹴可幾。

  • 新舊程式碼必須並存,並透過切換開關控制。

  • 因此,雖然畫面在視覺上與 Figma 相符,但仍會出現一些異常行為。

  • 舉例來說,轉動錶冠捲動畫面、向右滑動返回等基本手勢都無法使用。

  • 總而言之,遷移設計系統不只是遷移視覺效果,也是遷移行為。

  • 說到行為,如果你注意到靠近邊緣的資料列,會發現它們較窄,且嵌入曲線中。

  • 這就是 TransformingLazyColumn。

  • 資料列會轉換,因此圓形錶圈不會截斷資料列。

  • 動態畫面可能會讓事情變得棘手。

  • 新訊息送達、資料列移動,以及各種形狀的泡泡。

  • 來看看如何正確執行。

  • 左側的泡泡縮得太快。

  • 每則訊息在內部都是一個包在資訊卡中的對話框。

  • 我們指示父項卡片套用轉換,但相同的轉換也會傳遞至子項泡泡。

  • 因此,子項泡泡會獲得兩倍的預期轉換量。

  • 修正方式是確保我們在一個層級 (即父項資訊卡) 套用轉換。

  • 這也是一項重要原則。

  • 我們確保一個項目只有一個轉換。

  • 還有另一個問題:泡泡在螢幕中央應該是完整大小,但卻變小且褪色。

  • 大小取決於在畫面上的位置。

  • 在初始版本中,我們使用 Compose 的 remember 函式儲存大小調整,但最後也儲存了舊位置。

  • 因此,當新訊息送達並將舊訊息向上移動時,其轉換仍會以舊位置為基礎。

  • 進而導致錯誤。

  • 為修正這個問題,我們停止使用 remember 呼叫儲存大小,並移除該呼叫。

  • 這也是一種學習。

  • 資料列移動時,轉換也必須隨之移動。

  • 您可以在這裡看到程式碼中的兩項修正。

    // FIX #1: ONE OWNER
    CompositionLocalProvider(
      LocalSurfaceTransformation provides null
    ) {
      M3Card(transformation = t) {
        SharedChatEntry(...)
      }
    }
    
    // FIX #2: CURRENT ITEM SCOPE
    val t = SurfaceTransformation(spec)
    // BUG #1: DOUBLE MORPH
    M3Card(transformation = t) {
      CompositionLocalProvider(
        LocalSurfaceTransformation provides t
      ) { SharedChatEntry(...) }
    }
    
    // BUG #2: STALE ITEM SCOPE
    val t = remember(spec) {
      SurfaceTransformation(spec)
    }
  • 左上方的轉換套用至兩個層級,右上方的轉換則只套用至一個層級,也就是 M3 資訊卡。

  • 在左下方,我們使用 remember 呼叫來快取轉換,但它也會快取位置。

  • 右下角則會顯示每次考量泡泡最新位置時,系統計算出的轉換。

  • 如您所見,所有螢幕上的體驗都一致,主要內容會保持在中央附近,而資料列會自然地移到邊緣附近。

處理腕上通知快訊

  • 接著來談談通知。

  • 現在想像一下,手腕上的手錶震動,你查看手錶,看到一則訊息。

  • 但沒過多久,手腕又再次震動,而且震個不停。

  • 你收到多則通知,但每次查看時,看到的都是相同的內容。

  • 這真的很煩人。

  • 為瞭解決這個問題,我們問了自己兩個問題。

  • 什麼事件應觸發通知,提醒使用者?

  • 哪些事件應在不發出通知的情況下更新通知?

  • 這個錯誤發生的原因,在於個人資料相片更新、媒體下載和收據等背景作業,本應在不干擾使用者的情況下更新通知,卻反而向使用者發出通知。

  • 現在,收到新訊息時,手錶只會震動一次,而不需要提醒的事件則會默默更新通知。

  • 這也是一堂課。

  • 如果通知是核心使用者歷程之一,我們必須確保每則快訊都值得使用者注意。

  • 關於通知。

  • 先前,WhatsApp Wear OS 通知可讓你執行兩項操作。

  • 你可以閱讀訊息,也可以開啟應用程式。

  • 先前無法直接回覆通知。

  • 因此,我們使用 Android 的通知動作和 RemoteInput API 彌補了這項差距。

  • 使用者現在可以輸入或說出回覆內容,也可以輕觸快速回覆。

  • 但請記住,按下傳送按鈕並不代表工作結束。

  • 如果訊息傳送成功,系統會將即時通訊標示為已讀,並清除通知。

  • 如果訊息無法傳送,只要輕觸一下即可重試。

  • 這也是開發這項功能時的重要學習,也就是通知動作必須視為完整的端對端工作流程。

從資訊方塊遷移至小工具

  • 接著是「資訊方塊」和「小工具」。

  • 如你所見,資訊方塊會佔據整個螢幕,只要在錶面上滑動即可查看。

  • 小工具則是由多張較小的資訊卡組成,可在一個可捲動的畫面上顯示多個小工具。

  • WhatsApp 資訊方塊和小工具都採用相同的基礎資料。

  • 這也是一個轉折。

  • 您可能會認為這個 UI 是由應用程式繪製,但實際上並非如此。

  • 我們只需描述 UI 和資料,系統就會繪製出來。

  • 此外,輕觸任何圖示都無法執行應用程式的程式碼。

  • 你只能要求開啟畫面。

  • 以上就是本堂課程。

  • 如果系統正在繪製 UI,則該 UI 是產品和測試指標的一部分。

  • Frank 和 Yuri 將深入探討小工具。

透過更新提醒,避免活躍使用者人數驟降

  • 最後,我們來退一步思考,我們最終都希望有越來越多使用者使用我們的應用程式。

  • 現在,想像一下您是開發人員,某天登入資訊主頁時,發現活躍使用者人數大幅下滑。

  • 首先,這會非常令人擔憂。

  • 然後開始偵錯。

  • 您可能會認為發生了當機,或是有不好的版本正在進行。

  • 但對我們來說,這些都不是問題。

  • 原來,每個 WhatsApp 應用程式版本都有 90 天的內建到期日。

  • 因此,使用者可能會卡在舊版本,而新版本需要一段時間才能推送到手錶。

  • 因此,使用者並未離開應用程式,但應用程式仍停止運作。

  • 因此,我們將更新應用程式納入產品的一部分。

  • 如畫面所示,應用程式到期前 15 天,畫面上會顯示這項資訊。

  • 並在剩餘 7、5、3、2 和 1 天時再次顯示。

  • 使用者可以選擇關閉,或前往 Play 商店更新應用程式。

  • 這樣一來,使用者就會多次收到應用程式即將到期的提醒,也有多次機會更新應用程式。

  • 以上就是最後一項心得。

  • 即使更新內容已全面推出,開發人員的工作仍未完成。

  • 只有在使用者實際收到更新時,才算完成。

  • 以上就是全部內容。

  • 如果想繼續討論,歡迎在諮詢時間與我們交流。

  • 接下來,Frank 和 Yuri 將介紹 Wear 小工具。

  • 感謝您!

程式碼

程式碼片段

「Moving to a shared codebase」影片 02:27 處的 Kotlin 程式碼片段
@Module
interface CallingModule {
    @BindsOptionalOf
    fun callingBackend(): CallingBackend
}

class Calls @Inject constructor(
    val backend: Optional<CallingBackend>,
) {
    fun start(): CallResult =
        backend.getOrNull()?.start()
            ?: CallResult.NotAllowed
}
「Moving to a shared codebase」影片 02:47 處的 Kotlin 程式碼片段
@Module
interface CoreModule {
    @Multibinds
    fun handlers(): Set<MessageHandler>
}

@Module
interface ReactionsModule {
    @Binds @IntoSet
    fun reactions(impl: ReactionHandler):
 MessageHandler
}

class MessageDispatcher @Inject constructor(
      private val handlers: Set<MessageHandler>,
)
「改用共用程式碼集」影片 04:16 處的 XML 程式碼片段
<!-- remove unused Main activity node -->
<activity android:name="com.whatsapp.Main"
          tools:node="remove" />

<!-- redirect any calls to a new Activity -->
<activity-alias
     android:name="com.whatsapp.Main"
     android:targetActivity=".MyNewActivity" />

<provider
android:name="…MigrationContentProvider"
        tools:node="remove" />
「Moving to a shared codebase」影片 05:03 處的 XML 程式碼片段
<!-- phone -->
<string name="…_disclosure">… Learn more</string>

<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>
「管理通知橋接」影片 05:59 處的 Kotlin 程式碼片段
isRegistered.collect {
  when (it) {
         // watch posts its own
    true  -> bridging.setEnabled(false)
 // phone bridges again
    false -> bridging.setEnabled(true)
  }
}

fun setEnabled(bridgingEnabled){
   bridgingManager.setConfig(
BridgingConfig.Builder(context,
bridgingEnabled)
      .build())
}
「管理通知橋接」影片 07:06 處的 Kotlin 程式碼片段
clearActivityBackStack()

// enumerate our own, kill each
killOtherProcesses()

// no threads alive — now safe
deleteDataDirectories()

// show logged out confirmation
firePostCleanupIntent()
「Modernizing the Wear OS UI」影片 10:30 處的 Kotlin 程式碼片段
// FIX #1: ONE OWNER
CompositionLocalProvider(
  LocalSurfaceTransformation provides null
) {
  M3Card(transformation = t) {
    SharedChatEntry(...)
  }
}

// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)
「Modernizing the Wear OS UI」影片 10:30 處的 Kotlin 程式碼片段
// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
  CompositionLocalProvider(
    LocalSurfaceTransformation provides t
  ) { SharedChatEntry(...) }
}

// BUG #2: STALE ITEM SCOPE
val t = remember(spec) {
  SurfaceTransformation(spec)
}