WhatsApp-Entwickler berichten, wie sie ihre Wear OS-App im großen Stil aktualisiert haben. In dieser Session geht es um die Architektur- und UX-Entscheidungen, die hinter der Integration von Material 3 und der Einführung von Wear-Widgets stehen.
Sie erhalten einen direkten Einblick in die Lektionen, Muster und Tools, die das Team verwendet hat, um die App zu aktualisieren und eine einheitliche Nutzererfahrung zu bieten.
TransformingLazyColumn bedeutet, dass Transformationen nur auf einer Ebene angewendet werden und das Caching von Positionen mit remember von Compose vermieden wird.Transkript
Hallo zusammen!
Ich heiße Martin.
Und ich bin Subhag.
Wir sind Ingenieure bei Meta und gehören zum Team, das WhatsApp für Wear OS entwickelt hat.
Und wir haben unsere App vor Kurzem modernisiert.
Heute möchten wir Ihnen die Erkenntnisse vorstellen, die wir bei der Migration zu einer gemeinsamen Codebasis und der anschließenden Entwicklung neuer Funktionen gewonnen haben.
Das Besondere an WhatsApp für Wear OS ist, dass es ohne Smartphone funktioniert.
Nach der Verknüpfung fungiert es als eigenes WhatsApp-Gerät, das wie dein Smartphone Ende-zu-Ende-verschlüsselt ist und über eine eigene Datenbank und Verbindung zum Server verfügt.
Sie funktioniert also auch über WLAN oder LTE, wenn dein Smartphone nicht in der Nähe ist.
WhatsApp wurde 2023 erstmals für Wear OS eingeführt.
Sie wurde von einem kompakten, portablen Client unterstützt, der in Rust geschrieben wurde.
Wir konnten schnell in die Produktion gehen.
Es handelte sich jedoch um eine zweite, unabhängige Implementierung von WhatsApp. Das bedeutet, dass jedes Protokoll, jede Verschlüsselung und jede Funktion zweimal erstellt werden musste.
Die Smartwatch-App musste also immer aufholen.
Die Portabilität machte auch die Handhabung des Hintergrunds schwierig.
Der Client hat seine eigenen Threads, Timer und Sockets verarbeitet, die Android für Sie verwalten möchte.
Unter Android gelten für den Hintergrund Regeln.
Im Stromsparmodus wird die Netzwerkverbindung getrennt.
Sobald Ihr Prozess im Cache gespeichert ist, haben Sie nur noch eine begrenzte oder gar keine Ausführungszeit mehr.
Sie benötigen einen Dienst, einen Job oder einen Push, um das zu überwinden.
In unserer Smartwatch-App mussten wir diese also manuell verbinden.
Aber unsere Android-Codebasis tut das bereits.
Wir haben uns also entschieden, unsere Android-Codebasis wiederzuverwenden und zu migrieren. Sie hat sich auf Milliarden von Geräten bewährt und folgt bereits den Android-Regeln.
So müssen neue Funktionen nicht zweimal entwickelt werden.
Das Ziel war einfach: die Geschäftslogik beibehalten und die Benutzeroberfläche entfernen.
Übrig bleibt eine Headless-Bibliothek, die Kotlin-Flows als API verwendet.
Die Herausforderung besteht jedoch darin, dass Sie eine App dieser Größe nicht auf einmal entflechten können.
In einigen Modulen werden UI und Infrastruktur gemischt.
Andere hängen von Funktionen ab, die Sie in der Wear OS-App nicht benötigen.
Wir haben also auf zwei Ebenen gearbeitet: Wir haben weiter modularisiert und das, was wir noch nicht entfernen konnten, entfernt oder überschrieben.
Beginnen wir mit der Modularisierung.
Wenn Sie Code aus dem Build heraushalten möchten, reicht eine einfache „Wenn dies die Telefon App ist“-Anweisung nicht aus, da der Code dadurch nicht entfernt wird.
Wir verlassen uns also stark auf DI, insbesondere auf optionale und Multibindings.
Wir verwenden eine interne DI, die jedoch dem Angebot von Dagger sehr ähnlich ist.
Die Beispiele sind also in Dagger.
Mit der optionalen Bindung, in Dagger BindsOptionalOf, können Sie eine Abhängigkeit als optional deklarieren, z. B. den Aufruf des Back-Ends.
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}Wenn der Watch-Build dann ein Modul enthält, das die Implementierung bereitstellt, ist das eingefügte Backend vorhanden und der Aufruf funktioniert.
Wenn Sie das Modul weglassen, ist es null und Ihre API kann NotAllowed zurückgeben.
Multibinding funktioniert genauso, aber für Sets.
@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>,
)Sie können ein Set mit „@Multibinds“ deklarieren, z. B. MessageHandler. Jedes Funktionsmodul kann dann seinen eigenen Handler mit „@IntoSet“ hinzufügen.
Wenn Sie beispielsweise keine Zahlungen unterstützen möchten, können Sie das gesamte Zahlungsmodul aus dem Build weglassen. Der eingefügte Satz enthält dann diesen Handler nicht.
So ist die Frage, ob eine Funktion unterstützt wird, eine Frage für den Build-Graphen.
Diese Entkopplung hat jedoch zwei Seiten.
Bei direkten Verweisen würde das Löschen eines Moduls Ihren Build beschädigen.
Bei optionaler oder Mehrfachbindung gibt es nichts, was auf das Modul verweist. Der Build wird also erfolgreich ausgeführt und das Feature verschwindet unbemerkt oder es wird die Standardeinstellung verwendet.
Und genau das ist passiert.
Bei einem Refactoring wurde eine Funktion in ein eigenes Modul verschoben, das in unserer Build-App und unserer Smartwatch-App nicht enthalten war.
Dadurch wurde bei Sprachnachrichten eine Dauer von null Sekunden angezeigt.
Wie können Sie das verhindern?
Sie benötigen einen Test, um die Prüfung wieder einzufügen.
In unserem Fall vergleichen wir Multibinding-Sets zwischen der Smartphone-App und der Smartwatch-App für wichtige Schnittstellen.
So wird sichergestellt, dass immer die richtige Implementierung gebunden ist.
In Track 2 geht es um das, was wir noch nicht entfernen konnten.
Die Wiederverwendung von Modulen spart Arbeit, aber alles, was sie deklarieren, wird in Ihrem Manifest zusammengeführt.
Durch die Launcher-Aktivität des Smartphones würde ein zweites App-Symbol hinzugefügt oder es würden nicht benötigte Contentanbieter mit Ihrer App gestartet.
Die Manifestzusammenführung bietet ein nützliches tools:node="remove"-Attribut, mit dem Sie die Komponenten entfernen können.
<!-- 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" />Und sobald sie entfernt wurden, kann der Shrinker auch den Code entfernen.
Wenn etwas noch die Aktivität erreichen muss, können Sie activity-alias verwenden, um es an die neue Klasse weiterzuleiten.
Diese Attribute gelten jedoch nur für Manifeste mit niedrigerer Priorität. Die Zusammenführungsreihenfolge entscheidet also, ob etwas entfernt wird oder nicht.
Ressourcen funktionieren auf die gleiche Weise.
Bei der Wiederverwendung erhalten Sie Telefonstrings kostenlos.
Einige versprechen jedoch Dinge, die eine Smartwatch nicht leisten kann.
Sehen wir uns ein Beispiel am Ende des Verschlüsselungshinweises an, der am Anfang jedes Chats angezeigt wird.
Auf dem Smartphone endet sie mit einem Link zu weiteren Informationen.
Auf der Smartwatch ist hinter dem Link jedoch nichts zu finden.
Wir deklarieren also denselben Schlüssel in einem Modul mit höherer Priorität auf der Smartwatch neu und ersetzen den String durch „Weitere Informationen auf dem Smartphone“.
<!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>Und dann wählt der Ressourcen-Merger unsere aus.
Außerdem müssen Sie dafür sorgen, dass Sie den String nicht versehentlich überschreiben.
Deshalb haben wir eine Build-Regel erstellt, die dafür sorgt, dass der Build fehlschlägt, wenn die Überschreibung nicht auf der Zulassungsliste steht.
Kommen wir nun zu den Benachrichtigungen.
Standardmäßig werden Benachrichtigungen von Ihrem Smartphone auf Wear OS gespiegelt.
Das ist toll, funktioniert aber nur, wenn dein Smartphone verbunden ist.
Und wir möchten, dass WhatsApp unabhängig funktioniert, also postet unsere App eigene Benachrichtigungen.
Wenn die Überbrückung jedoch aktiviert bleibt, vibriert das Gerät bei jeder Nachricht zweimal.
Wenn Sie die Überbrückung deaktivieren, werden Duplikate entfernt.
Es gibt ein statisches Manifest-Flag, das jedoch ab der Installation gilt, bevor sich der Nutzer anmeldet.
Wenn Sie die App also installieren und sich nicht anmelden, erhalten Sie plötzlich keine Benachrichtigungen mehr. Das ist nicht gerade ideal, oder?
Daher legen wir sie zur Laufzeit mit einem BridgingManager fest.
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())
}Wenn du dich anmeldest, wird die Überbrückung deaktiviert.
Wenn du dich abmeldest, wird sie wieder aktiviert.
Beachte jedoch, dass es keine API zum Auslesen der Werte gibt und dass die Smartwatch sie verlieren kann.
Wir schreiben sie also jedes Mal neu, wenn die Smartwatch vom Ladegerät genommen wird oder die App aktualisiert wird.
Das letzte Thema, das ich besprechen möchte, ist, wie Sie sich abmelden.
Wenn du die Verknüpfung einer Smartwatch mit deinem Konto aufhebst, erwarten die Nutzer, dass die Nachrichten ebenfalls entfernt werden.
Wir löschen also alles – Nachrichten, Schlüssel, Einstellungen.
Aber wie geht das sicher?
Wenn Sie Dateien löschen, während Hintergrundthreads noch in sie schreiben, erhalten Sie SQLite-Ausnahmen oder, noch schlimmer, Schreibvorgänge in neu erstellte Datenbanken.
Und Sie können den Status eines Prozesses nicht wirklich löschen, ohne ihn zu beenden.
Android bietet die clearApplicationUserData API, mit der die Daten gelöscht und die App beendet werden, aber auch die Berechtigungen zurückgesetzt werden.
Bei einigen OEMs ist die Benachrichtigungsweiterleitung an die Berechtigungen zum Senden von Benachrichtigungen gebunden. Wenn Sie diese also verwenden, verlieren Sie plötzlich auch die Smartphone-Spiegelung.
Wir beenden den Prozess also selbst.
clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()Wir erstellen einen kleinen Dienst in einem sekundären Prozess, der keine DI und keinen WorkManager hat, sodass der Rest der App nicht gestartet wird.
Nachdem Sie sich abgemeldet haben, beendet dieser Dienst alle anderen Prozesse in unserer App und löscht erst dann alle Daten, wenn nichts mehr im Hintergrund läuft.
Da wir die clearApplicationUserData nicht verwenden, bleiben die Berechtigungen erhalten und wir können die Benachrichtigungsweiterleitung aktivieren, damit die Smartphone-Benachrichtigungen wieder angezeigt werden.
Das war der erste Teil. Jetzt übergebe ich an Subhag, der über die neue Benutzeroberfläche sprechen wird.
Danke, Martin.
Nachdem die gemeinsame Infrastruktur eingerichtet war, wollten wir WhatsApp so gestalten, dass es sich auf der Smartwatch wie zu Hause anfühlt.
Der erste Teil dieser Reise war der Wechsel zu Material 3. Aber warum?
Auf einem runden Display, auf dem jedes Pixel zählt, bietet Material 3 die richtigen Komponenten und die Designgrundlage, um WhatsApp für das Handgelenk zu modernisieren.
Auf den ersten Blick sieht es vielleicht nur wie ein Update der Benutzeroberfläche aus.
Wir haben von Material 2 auf Material 3 umgestellt, und das ging nicht von heute auf morgen.
Der alte und der neue Code mussten nebeneinander hinter einem Schalter existieren.
Infolgedessen zeigten die Bildschirme, die visuell mit Figma übereinstimmten, immer noch einige fehlerhafte Verhaltensweisen.
Einige der grundlegendsten Gesten funktionierten nicht, z. B. das Scrollen mit der Krone oder das Wischen nach rechts, um zurückzugehen.
Eine Migration des Designsystems ist also nicht nur eine visuelle Migration, sondern auch eine Migration des Verhaltens.
Apropos Verhalten: Wenn Sie sich die Zeilen am Rand ansehen, werden Sie feststellen, dass sie schmaler sind und sich in die Kurve einfügen.
Das ist „TransformingLazyColumn“.
Zeilen werden so transformiert, dass sie nicht durch die runde Lünette abgeschnitten werden.
Ein dynamischer Bildschirm kann die Sache erschweren.
Neue Nachrichten kommen an, Zeilen verschieben sich und Bubbles gibt es in allen Formen.
Sehen wir uns an, was es bedeutet, es richtig zu machen.
Links sehen Sie, dass die Blasen viel zu schnell schrumpfen.
Jede Nachricht ist intern eine Bubble, die in einer Karte verpackt ist.
Wir haben die übergeordnete Karte angewiesen, eine Transformation anzuwenden, aber dieselbe Transformation wurde auch an die untergeordnete Bubble weitergegeben.
Dadurch wird die untergeordnete Blase doppelt so stark transformiert wie beabsichtigt.
Die Lösung bestand darin, eine Transformation auf einer Ebene anzuwenden, und zwar auf der übergeordneten Karte.
Das ist auch ein wichtiges Prinzip.
Wir haben dafür gesorgt, dass für jedes Element eine Transformation durchgeführt wird.
Und hier ist ein weiteres Problem: Blasen, die klein und verblasst sind, obwohl sie in der Mitte des Bildschirms in voller Größe angezeigt werden sollten.
Die Größe hängt von der Position auf dem Bildschirm ab.
In unserer ersten Version haben wir die Größenanpassung mit der Compose-Funktion „remember“ gespeichert, aber dadurch wurde auch die ältere Position gespeichert.
Wenn jetzt eine neue Nachricht eingeht und die älteren Bubbles nach oben verschoben werden, basiert ihre Transformation immer noch auf ihrer älteren Position.
Und das führt zu diesem Fehler.
Um dieses Problem zu beheben, haben wir die Speicherung der Größe mit dem „remember“-Aufruf beendet und den Aufruf entfernt.
Und das ist auch eine Erkenntnis.
Wenn eine Zeile verschoben wird, muss die Transformation mit verschoben werden.
Hier sehen Sie beide Korrekturen im Code.
// 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)
}Oben links wird die Transformation auf zwei Ebenen angewendet, oben rechts nur auf einer Ebene, nämlich der M3-Karte.
Unten links verwenden wir den „remember“-Aufruf, um die Transformation im Cache zu speichern, aber auch die Position wird im Cache gespeichert.
Und unten rechts wird die Transformation jedes Mal unter Berücksichtigung der neuesten Position der Blase berechnet.
Wie Sie sehen, ist das Ergebnis eine einheitliche Darstellung auf allen Bildschirmen, bei der wichtige Inhalte in der Mitte bleiben und sich die Zeilen natürlich am Rand bewegen.
Als Nächstes sprechen wir über Benachrichtigungen.
Stell dir vor, dein Handgelenk vibriert, du schaust auf deine Smartwatch und siehst eine Nachricht.
Aber kurz darauf vibriert dein Handgelenk wieder und wieder.
Sie erhalten mehrere Benachrichtigungen und sehen jedes Mal dasselbe.
Das ist wirklich ärgerlich.
Um dieses Problem zu beheben, haben wir uns zwei Fragen gestellt.
Bei welchem Ereignis soll der Nutzer benachrichtigt werden?
Und welches Ereignis sollte die Benachrichtigungen im Hintergrund aktualisieren?
Dieser Fehler trat auf, weil im Hintergrund Profilbildaktualisierungen, Mediendownloads und Belege, die die Benachrichtigung im Hintergrund aktualisieren sollten, stattdessen den Nutzer alarmierten.
Wenn eine neue Nachricht eingeht, vibriert das Gerät am Handgelenk genau einmal. Bei Ereignissen, die keine Benachrichtigung erfordern, wird die Benachrichtigung lautlos aktualisiert.
Und das ist auch eine Lektion.
Wenn Benachrichtigungen zu den wichtigsten User Journeys gehören, müssen wir dafür sorgen, dass jede Meldung die Aufmerksamkeit der Nutzer auch wirklich verdient.
Thema Benachrichtigungen:
Bisher konntest du mit WhatsApp-Benachrichtigungen auf Wear OS zwei Dinge tun.
Sie können die Nachricht lesen oder die App öffnen.
Wenn Sie direkt auf eine Benachrichtigung antworten wollten, war das bisher nicht möglich.
Wir haben diese Lücke mit der Benachrichtigungsaktion von Android mit der RemoteInput API geschlossen.
Der Nutzer kann die Antwort jetzt eingeben, sagen oder auf eine Kurzantwort tippen.
Aber denken Sie daran: Mit dem Senden ist es nicht getan.
Wenn eine Nachricht erfolgreich gesendet wurde, wird der Chat als gelesen markiert und die Benachrichtigung gelöscht.
Wenn eine Nachricht nicht zugestellt werden kann, bieten wir die Möglichkeit, es mit nur einem Tippen noch einmal zu versuchen.
Eine wichtige Erkenntnis bei der Entwicklung war, dass eine Benachrichtigungsaktion als vollständiger End-to-End-Workflow behandelt werden muss.
Als Nächstes sehen wir uns Kacheln und Widgets an.
Eine Kachel ist ein Vollbild, das du durch Wischen auf dem Zifferblatt aufrufen kannst.
Widgets hingegen sind mehrere kleinere Karten, von denen mehrere auf einen scrollbaren Bildschirm passen.
Sowohl WhatsApp-Kacheln als auch ‑Widgets basieren auf denselben zugrunde liegenden Daten.
Und das ist auch eine Wendung.
Sie denken vielleicht, dass diese Benutzeroberfläche von unserer App gezeichnet wird, aber das ist nicht der Fall.
Wir beschreiben nur die Benutzeroberfläche und die Daten, und das System zeichnet sie.
Außerdem kann durch Tippen auf eines der Symbole kein Code Ihrer App ausgeführt werden.
Du kannst ihn nur bitten, einen Bildschirm zu öffnen.
Das war unsere Lektion.
Wenn das System die Benutzeroberfläche zeichnet, ist sie Teil Ihres Produkts und Ihrer Testmesswerte.
Frank und Yuri werden in ihrem Vortrag zu Widgets näher darauf eingehen.
Damit kommen wir zur letzten Folie. Lassen Sie uns einen Schritt zurückgehen und uns fragen, was wir am Ende des Tages wollen: Wir wollen, dass immer mehr Nutzer unsere Apps verwenden.
Stellen Sie sich vor, Sie melden sich eines Tages in Ihren Dashboards an und sehen einen erheblichen Rückgang der aktiven Nutzer.
Nun, zuerst einmal wäre das sehr besorgniserregend.
Und dann können Sie mit dem Debugging beginnen.
Und Sie könnten das Gefühl haben, dass es einen Absturz gab oder dass es ein fehlerhaftes Release gibt.
Aber bei uns war das nicht der Fall.
Es hat sich herausgestellt, dass jede WhatsApp-Version ein integriertes Ablaufdatum von 90 Tagen hat.
Stell dir vor, Nutzer hängen bei einem veralteten Build fest, während es dauert, bis die neueren Builds auf ihren Smartwatches ankommen.
Die Nutzer verlassen die App also nicht, aber die App funktioniert nicht mehr.
Deshalb haben wir die Aktualisierung der App zu einem Teil des Produkts gemacht.
Wie Sie auf dem Bildschirm sehen können, wird 15 Tage vor Ablauf der App diese Meldung angezeigt.
Danach wird sie wieder angezeigt, wenn noch 7, 5, 3, 2 und 1 Tag verbleiben.
Nutzer können die Meldung schließen oder im Google Play Store ihre App aktualisieren.
So werden Nutzer mehrmals daran erinnert, dass ihre App bald abläuft, und sie haben mehrere Möglichkeiten, ihre App zu aktualisieren.
Das ist unsere letzte Erkenntnis.
Unsere Arbeit als Entwickler ist nicht abgeschlossen, wenn wir ein Update zu 100 % bereitgestellt haben.
Sie ist erst abgeschlossen, wenn die Nutzer unsere Updates tatsächlich erhalten.
Damit sind wir am Ende angekommen.
Wenn Sie das Gespräch fortsetzen möchten, können Sie sich gerne während der Sprechstunden melden.
Als Nächstes sprechen Frank und Yuri über Wear-Widgets.
Vielen Dank.
@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)
}