WhatsApp 工程師將親自分享如何大規模升級 Wear OS 應用程式。本課程將介紹整合 Material 3 和採用 Wear 小工具時,在架構和使用者體驗方面的決策。
直接瞭解團隊在更新應用程式和提供一致使用者體驗時,所學到的經驗、採用的模式和工具。
TransformingLazyColumn 的方式是只在一個層級套用轉換,並避免使用 Compose 的 remember 快取位置。轉錄稿
大家好!
我是 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 更新。
謝謝 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 應用程式版本都有 90 天的內建到期日。
因此,使用者可能會卡在舊版本,而新版本需要一段時間才能推送到手錶。
因此,使用者並未離開應用程式,但應用程式仍停止運作。
因此,我們將更新應用程式納入產品的一部分。
如畫面所示,應用程式到期前 15 天,畫面上會顯示這項資訊。
並在剩餘 7、5、3、2 和 1 天時再次顯示。
使用者可以選擇關閉,或前往 Play 商店更新應用程式。
這樣一來,使用者就會多次收到應用程式即將到期的提醒,也有多次機會更新應用程式。
以上就是最後一項心得。
即使更新內容已全面推出,開發人員的工作仍未完成。
只有在使用者實際收到更新時,才算完成。
以上就是全部內容。
如果想繼續討論,歡迎在諮詢時間與我們交流。
接下來,Frank 和 Yuri 將介紹 Wear 小工具。
感謝您!
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.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>,
)<!-- 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" /><!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>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())
}clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()// 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)
}