WhatsApp のエンジニアが、Wear OS アプリを大規模にアップグレードした方法について直接説明します。このセッションでは、マテリアル 3 の統合と Wear ウィジェットの採用の背景にあるアーキテクチャと UX の決定について説明します。
チームがアプリを更新し、一貫したユーザー エクスペリエンスを提供するために使用したレッスン、パターン、ツールに関する分析情報を直接得ることができます。
TransformingLazyColumn を適切に使用するということは、1 つのレベルでのみ変換を適用し、Compose の remember で位置をキャッシュに保存しないようにすることを意味します。文字起こし
こんにちは。
Martin と申します。
Subhag です。
私たちは Meta のエンジニアで、Wear OS 版 WhatsApp の開発チームに所属しています。
最近、アプリをモダナイズしました。
そこで本日は、共有コードベースに移行し、その上で新しいエクスペリエンスを構築したことから得られた教訓を共有します。
Wear OS 版 WhatsApp の興味深い点は、スマートフォンがなくても動作することです。
リンクされると、独自のデータベースとサーバーへの接続を備えた、スマートフォンと同様にエンドツーエンドで暗号化された独自の WhatsApp デバイスとして機能します。
そのため、スマートフォンが通信圏外でも、Wi-Fi または LTE で動作し続けます。
Wear OS 向け WhatsApp は 2023 年に初めてリリースされました。
この攻撃は、Rust で記述されたコンパクトでポータブルなクライアントによって実行されました。
本番環境への移行を迅速に行うことができました。
しかし、これは WhatsApp の 2 つ目の独立した実装であったため、すべてのプロトコル、暗号化、機能を 2 回構築する必要がありました。
そのため、スマートウォッチ アプリは常に遅れを取っていました。
ポータブルであるため、背景の処理も難しくなりました。
クライアントは、Android が管理するスレッド、タイマー、ソケットを独自に処理していました。
Android では、バックグラウンドにルールがあります。
Doze モードでは、ネットワーク接続が失われます。
プロセスがキャッシュに保存されると、実行時間が制限されるか、実行時間がなくなります。
これを克服するには、サービス、ジョブ、またはプッシュが必要です。
そのため、スマートウォッチ アプリでは、それらを手動で接続する必要がありました。
しかし、Android コードベースではすでにそれを行っています。
そこで、何十億台ものデバイスで実証済みで、すでに Android のルールに準拠している Android コードベースを再利用して移行することにしました。
これにより、新機能を 2 回構築する必要がなくなります。
目標はシンプルでした。ビジネス ロジックを維持し、UI を削除することです。
残っているのは、Kotlin フローを API として使用するヘッドレス ライブラリです。
しかし、この規模のアプリを一度に解きほぐすことはできません。
一部のモジュールでは、UI とインフラストラクチャが混在しています。
Wear OS アプリに不要な機能に依存している。
そこで、2 つのトラックで作業しました。モジュール化を継続し、まだ削除できないものを削除または上書きしました。
まずはモジュール化から始めましょう。
コードをビルドから除外するには、単純な「電話アプリの場合」というステートメントではコードが削除されないため、機能しません。
そのため、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
}スマートウォッチのビルドに実装を提供するモジュールが含まれている場合、挿入されたバックエンドが存在し、呼び出しが機能します。
モジュールを省略すると null になり、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>,
)たとえば、MessageHandler のように、マルチバインドでセットを宣言できます。その後、各機能モジュールは、セット マルチバインドを使用して独自のハンドラを追加できます。
たとえば、支払いをサポートしない場合は、支払いモジュール全体をビルドから除外できます。挿入されたセットには、そのハンドラは含まれません。
そうすれば、機能がサポートされているかどうかはビルドグラフの問題になります。
ただし、この分離は両刃の剣です。
直接参照を使用すると、モジュールを削除するとビルドが破損します。
オプション バインディングまたはマルチバインディングを使用すると、モジュールを参照するものがなくなるため、ビルドは成功し、機能は静かに消えるか、デフォルトが使用されます。
そして、まさにこの問題に直面しました。
リファクタリングによって機能が独自のモジュールに移動され、ビルドアプリとスマートウォッチ アプリにその機能が含まれていませんでした。
この問題により、音声メッセージの長さが 0 秒と表示されていました。
では、これを防ぐにはどうすればよいでしょうか。
チェックを戻すにはテストが必要です。
この例では、重要なインターフェースについて、電話アプリとスマートウォッチ アプリのマルチバインディング セットを比較します。
これにより、常に適切な実装がバインドされるようになります。
トラック 2 では、まだ削除できていないものについて説明します。
モジュールを再利用すると作業を省くことができますが、モジュールで宣言されたものはすべてマニフェストにマージされます。
スマートフォンのランチャー アクティビティによって、2 つ目のアプリアイコンが追加されたり、不要なコンテンツ プロバイダがアプリとともに起動されたりします。
マニフェスト マージャーには、コンポーネントを取り出すことができる便利な 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 を使用して新しいクラスにリダイレクトできます。
ただし、これらの属性は優先度の低いマニフェストにのみ適用されるため、削除されるかどうかはマージの順序によって決まります。
リソースも同様に機能します。
再利用すると、電話番号文字列を無料で取得できます。
しかし、スマートウォッチではできないことを約束しているものもあります。
最後に、すべてのチャットの冒頭に表示される暗号化に関する通知を例に見てみましょう。
スマートフォンでは、最後に「詳細」リンクが表示されます。
ただし、スマートウォッチのリンク先には何もありません。
そこで、スマートウォッチの優先度の高いモジュールで同じキーを再宣言し、文字列を「スマートフォンで詳細を確認」に置き換えます。
<!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>そして、リソース マージャーがアプリのリソースを選択します。
また、文字列を誤ってオーバーライドしないようにする必要もあります。
そのため、オーバーライドが許可リストに登録されていない場合に失敗するビルドルールを構築しました。
次に、通知について説明します。
デフォルトでは、Wear OS はスマートフォンの通知をブリッジまたはミラーリングします。
それは素晴らしいことですが、スマートフォンが接続されている場合にのみ機能します。
また、WhatsApp は独立して動作するようにしたいので、アプリが独自の通知を投稿します。
しかし、ブリッジがオンのままだと、すべてのメッセージで 2 回バイブレーションが鳴ります。
ブリッジをオフにすると、重複が修正されます。
静的なマニフェスト フラグがありますが、これはユーザーがログインする前のインストールから適用されます。
そのため、インストールしてログインしないと、突然すべての通知が届かなくなります。これはあまり良くないですよね?
そのため、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 が用意されていますが、この API は権限もリセットします。
また、一部の OEM では、通知ブリッジが通知権限に関連付けられているため、これを使用すると、スマートフォンのミラーリングも突然失われます。
そのため、プロセスを自分で停止します。
clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()DI も WorkManager もないセカンダリ プロセスに小さなサービスを構築し、アプリの残りの部分が起動しないようにします。
ログアウトすると、このサービスはアプリ内の他のすべてのプロセスを強制終了し、競合するプロセスがなくなってからすべてのデータを削除します。
clearApplicationUserData を使用しないため、権限は保持され、通知ブリッジをオンにできるため、スマートフォンの通知が戻ります。
パート 1 は以上です。ここからは Subhag にバトンタッチして、UI の更新について説明してもらいます。
Martin さん、ありがとうございます。
共有インフラストラクチャが整ったことで、WhatsApp をスマートウォッチで快適に利用できるようにすることに注力しました。
その取り組みの最初のステップは、Material 3 への移行でした。まずは、その理由を説明しましょう。
すべてのピクセルが重要となる円形ディスプレイにおいて、Material 3 は、スマートウォッチ向けに WhatsApp を刷新するための適切なコンポーネントとデザイン基盤を提供してくれました。
ビジュアルに注目すると、表面的には UI の更新のように見えるかもしれません。
Material 2 から 3 への移行は、一朝一夕にはできませんでした。
古いコードと新しいコードは、スイッチの背後で共存する必要がありました。
その結果、Figma と視覚的に一致する画面でも、一部の動作が破損していました。
たとえば、リューズのスクロールや、右にスワイプして戻るなど、最も基本的なジェスチャーの一部が機能しませんでした。
つまり、デザイン システムの移行は、ビジュアルの移行だけでなく、動作の移行でもあるということです。
動作について言えば、端に近い列は幅が狭く、カーブに沿って配置されています。
それが TransformingLazyColumn です。
円形のベゼルで切り取られないように、行が変換されます。
動的な画面では、操作が複雑になることがあります。
新しいメッセージが届き、行が移動し、さまざまな形のバブルが表示されます。
正しく行うとはどういうことか見てみましょう。
左側を見ると、バブルが急激に縮小しています。
各メッセージは、内部的にはカード内にラップされたバブルです。
親カードに変換を適用するように指示しましたが、同じ変換が子バブルにも渡されていました。
その結果、子バブルは意図した変換の 2 倍の量を取得しています。
修正方法は、1 つのレベル(親カード)で変換を適用することでした。
これも重要な原則です。
1 つのアイテムに対して 1 つの変換を確実に実行しました。
もう 1 つの問題は、画面の中央に表示されるバブルが小さく、色が薄いことです。本来はフルサイズで表示されるはずです。
サイズは画面上の位置によって決まります。
最初のバージョンでは、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)
}左上では、変換が 2 つのレベルで適用されていることがわかります。右上では、1 つのレベル(M3 カード)でのみ適用されています。
左下では、remember 呼び出しを使用して変換をキャッシュしていますが、位置もキャッシュしています。
右下では、バブルの最新の位置を毎回考慮して変換を計算しています。
ご覧のとおり、すべての画面で一貫したエクスペリエンスが実現され、重要なコンテンツは中央付近に表示され、行は端の近くに自然に移動します。
次に、通知について説明します。
ここで、手首が振動し、スマートウォッチを確認すると、メッセージが表示されているとします。
しかし、数分後に手首が再び振動します。
複数の通知が届き、毎回確認しても同じ内容が表示される。
それは本当に困りますね。
この問題を解決するために、私たちは 2 つの質問を自問しました。
ユーザーに通知するイベント
また、どのイベントで通知をサイレントに更新する必要があるか。
このバグは、バックグラウンドでプロフィール写真の更新、メディアのダウンロード、領収書が通知をサイレント更新するはずが、ユーザーにアラートを送信していたために発生していました。
新しいメッセージが届くと、スマートウォッチが 1 回だけ振動します。アラートに値しないイベントの場合は、通知が静かに更新されます。
これも教訓です。
通知がコア ユーザー ジャーニーの 1 つである場合、各アラートがユーザーの注意を引くに値するものであることを確認する必要があります。
通知に関するトピック。
以前の WhatsApp Wear OS 通知では、2 つの操作を行うことができました。
メッセージを読んだり、アプリを開いたりできます。
通知に直接返信したくても、以前はできませんでした。
そこで、Android の通知アクションと RemoteInput API を使用して、このギャップを埋めました。
ユーザーは、入力、音声入力、またはクイック返信のタップで返信できるようになりました。
ただし、送信ボタンを押したら終わりではありません。
メッセージが正常に送信されると、チャットは既読としてマークされ、通知は消去されます。
メッセージの配信に失敗した場合は、1 回タップするだけで再試行できます。
これは、この機能を開発するうえで重要な学びでもありました。つまり、通知アクションは、エンドツーエンドの完全なワークフローとして扱う必要があるということです。
次は、タイルとウィジェットです。
タイルは、ご覧のとおり、ウォッチフェイスからスワイプしてアクセスできる全画面表示です。
一方、ウィジェットは複数の小さなカードで、スクロール可能な 1 つの画面に複数収まります。
WhatsApp のタイルとウィジェットはどちらも、同じ基盤データを使用しています。
これもまた、ひねりです。
この UI はアプリによって描画されているように見えるかもしれませんが、実際はそうではありません。
UI とデータのみを記述し、システムがそれを描画します。
また、アイコンをタップしてもアプリのコードは実行されません。
画面を開くようにリクエストすることのみ可能です。
このレッスンは以上です。
システムが UI を描画している場合、それはプロダクトとテストの指標の一部です。
Frank と Yuri がウィジェットについて詳しく説明します。
最後のスライドでは、一歩引いて、アプリを使用するユーザーを増やすにはどうすればよいかを考えてみましょう。
ある日、デベロッパーがダッシュボードにログインすると、アクティブ ユーザー数が急激に減少していることに気づきます。
まず、非常に懸念されることです。
その後、デバッグを開始します。
クラッシュが発生した、または不適切なリリースが行われていると感じるかもしれません。
しかし、私たちの場合、それらのいずれも当てはまりませんでした。
WhatsApp アプリの各バージョンには、90 日間の有効期限が組み込まれていることがわかりました。
そのため、新しいビルドがスマートウォッチに届くまでに時間がかかっている間、ユーザーは古いビルドから抜け出せなくなります。
その結果、ユーザーはアプリを終了していないにもかかわらず、アプリが動作を停止します。
そこで、アプリの更新をプロダクトの一部にしました。
画面に表示されているように、アプリの有効期限の 15 日前に、このメッセージが画面に表示されます。
その後、残り 7 日、5 日、3 日、2 日、1 日の時点で再度表示されます。
ユーザーは、このメッセージを閉じるか、Google Play ストアに移動してアプリを更新するかを選択できます。
これにより、アプリの有効期限が近づいていることをユーザーに複数回通知し、アプリを更新する機会を複数回提供できます。
これが最後の学びです。
デベロッパーの仕事は、アップデートを 100% リリースしたら終わりではありません。
ユーザーが実際にアップデートを受け取って初めて完了します。
これで、本コースは終了です。
この件についてさらにご質問がある場合は、オフィスアワーにお気軽にお問い合わせください。
次は、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)
}