Écoutez les ingénieurs de WhatsApp expliquer comment ils ont amélioré leur application Wear OS à grande échelle. Cette session aborde les décisions d'architecture et d'expérience utilisateur qui ont conduit à l'intégration de Material 3 et à l'adoption des widgets Wear.
Découvrez directement les leçons, les modèles et les outils que l'équipe a utilisés pour mettre à jour son application et offrir une expérience utilisateur cohérente.
TransformingLazyColumn correctement, vous devez appliquer des transformations à un seul niveau et éviter de mettre en cache les positions avec remember de Compose.Transcription
Bonjour,
Je m'appelle Martin.
Et moi, c'est Subhag.
Nous sommes ingénieurs chez Meta, dans l'équipe qui a développé WhatsApp sur Wear OS.
Nous avons récemment modernisé notre application.
Aujourd'hui, nous allons partager les leçons que nous avons tirées de la migration vers une codebase partagée et de la création de nouvelles expériences sur cette base.
Ce qui rend WhatsApp sur Wear OS intéressant, c'est qu'il fonctionne sans votre téléphone.
Une fois l'association effectuée, il agit comme un appareil WhatsApp à part entière, chiffré de bout en bout comme votre téléphone, avec sa propre base de données et sa propre connexion au serveur.
Elle continue donc de fonctionner même en Wi-Fi ou LTE lorsque votre téléphone est hors de portée.
Nous avons lancé WhatsApp sur Wear OS en 2023.
Il était alimenté par un client compact et portable écrit en Rust.
Nous avons pu passer rapidement en production.
Cependant, il s'agissait d'une deuxième implémentation indépendante de WhatsApp, ce qui signifie que chaque protocole, chiffrement et fonctionnalité devait être créé deux fois.
L'application de la montre était donc toujours en retard.
La portabilité rendait également la gestion de l'arrière-plan délicate.
Le client gérait ses propres threads, minuteurs et sockets, des éléments qu'Android souhaite gérer pour vous.
Sur Android, l'arrière-plan est soumis à des règles.
En mode Sommeil, vous perdez la connexion réseau.
Une fois votre processus mis en cache, vous disposez d'un temps d'exécution limité, voire nul.
Pour résoudre ce problème, vous avez besoin d'un service, d'un job ou d'un push.
Notre application de montre devait donc les connecter manuellement.
Mais notre codebase Android le fait déjà.
Nous avons donc décidé de réutiliser notre codebase Android et d'y migrer. Il a été testé sur des milliards d'appareils et respecte déjà les règles Android.
Ainsi, les nouvelles fonctionnalités n'ont pas besoin d'être créées deux fois.
L'objectif était simple : conserver la logique métier et supprimer l'UI.
Il ne reste qu'une bibliothèque sans interface graphique qui utilise les flux Kotlin comme API.
Cependant, le problème est que vous ne pouvez pas démêler une application de cette taille en une seule fois.
Certains modules mélangent l'UI et l'infrastructure.
D'autres dépendent de fonctionnalités dont vous n'avez pas besoin dans l'application Wear OS.
Nous avons donc travaillé sur deux fronts : nous avons continué à modulariser, et nous avons supprimé ou écrasé ce que nous ne pouvions pas encore supprimer.
Commençons par la modularisation.
Pour exclure du code de la compilation, une simple instruction "if this is phone app" (si l'application est destinée à un téléphone) ne fonctionnera pas, car elle ne supprime pas le code.
Nous nous appuyons donc fortement sur l'injection de dépendances, en particulier sur les liaisons facultatives et multiples.
Nous utilisons l'injection de dépendances en interne, mais elle est très proche de ce que propose Dagger.
Les exemples sont donc en Dagger.
Avec la liaison facultative, dans Dagger, il s'agit de BindsOptionalOf. Vous pouvez déclarer une dépendance comme facultative, par exemple en appelant le backend.
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}Ensuite, si la compilation de la montre inclut un module qui fournit l'implémentation, le backend injecté sera présent et l'appel fonctionnera.
Si vous omettez le module, il sera nul et votre API pourra renvoyer NotAllowed.
La liaison multiple fonctionne de la même manière, mais pour les ensembles.
@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>,
)Vous pouvez déclarer un ensemble avec @Multibinds, par exemple MessageHandler, puis chaque module de fonctionnalité peut ajouter son propre gestionnaire à l'aide de la liaison multiple IntoSet.
Si vous ne souhaitez pas accepter les paiements, par exemple, vous pouvez exclure l'intégralité du module de paiement de la compilation, et l'ensemble injecté n'inclura pas ce gestionnaire.
De cette façon, la compatibilité d'une fonctionnalité est une question qui concerne le graphe de compilation.
Cependant, ce découplage présente des avantages et des inconvénients.
Avec les références directes, la suppression d'un module interromprait votre compilation.
Avec la liaison facultative ou la liaison multiple, rien ne fait référence au module. La compilation réussit donc, et la fonctionnalité disparaît discrètement ou l'action par défaut est effectuée.
Et c'est exactement ce que nous avons fait.
Une refactorisation a déplacé une fonctionnalité dans son propre module, et notre application de compilation et notre application de la montre ne l'incluaient pas.
Les messages vocaux affichaient alors une durée de zéro seconde.
Comment s'assurer que cela ne se produira pas ?
Eh bien, vous avez besoin d'un test pour remettre la vérification.
Dans notre cas, nous comparons les ensembles de liaisons multiples entre l'application Téléphone et l'application de la montre pour les interfaces qui nous intéressent.
De cette façon, nous nous assurons que la bonne implémentation est toujours liée.
La piste 2 couvre ce que nous n'avons pas encore pu supprimer.
La réutilisation de modules vous évite de devoir les recréer, mais tout ce qu'ils déclarent est fusionné dans votre fichier manifeste.
L'activité du lanceur d'applications du téléphone ajouterait une icône d'application secondaire, ou des fournisseurs de contenu inutiles démarreraient avec votre application.
L'outil de fusion de fichiers manifestes propose un attribut tools:node="remove" utile qui vous permet de supprimer les composants.
<!-- 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" />Une fois qu'ils sont supprimés, l'outil de réduction peut également supprimer le code.
Si un élément doit toujours atteindre l'activité, vous pouvez utiliser activity-alias pour rediriger vers la nouvelle classe.
Notez toutefois que ces attributs ne s'appliquent qu'aux fichiers manifestes de priorité inférieure. L'ordre de fusion détermine donc si un élément est supprimé ou non.
Les ressources fonctionnent de la même manière.
Reuse vous offre des chaînes de téléphone.
Cependant, certaines promettent des choses qu'une montre ne peut pas faire.
Prenons un exemple à la fin de la notification de chiffrement qui s'affiche au début de chaque discussion.
Sur téléphone, il se termine par un lien "En savoir plus".
Cependant, il n'y a rien derrière le lien sur la montre.
Nous redéclarons donc la même clé dans un module de priorité plus élevée sur la montre et remplaçons la chaîne par "Utilisez votre téléphone pour en savoir plus".
<!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>Le fusionneur de ressources choisit alors la nôtre.
Vous devez également vous assurer de ne pas remplacer accidentellement une chaîne.
Nous avons donc créé une règle de compilation qui vérifie que le remplacement est autorisé et qui échoue si ce n'est pas le cas.
Parlons maintenant des notifications.
Par défaut, Wear OS ponte ou duplique les notifications de votre téléphone.
C'est formidable, mais cela ne fonctionne que si votre téléphone est connecté.
Et nous voulons que WhatsApp fonctionne de manière indépendante, afin que notre application publie ses propres notifications.
Mais si le pontage reste activé, chaque message vibre deux fois.
La désactivation de la mise en pont corrige les doublons.
Il existe un flag de fichier manifeste statique, mais il s'applique dès l'installation, avant que l'utilisateur ne se connecte.
Si vous installez l'application sans vous connecter, vous perdez soudainement toutes les notifications, ce qui n'est pas idéal, n'est-ce pas ?
Nous le définissons donc au moment de l'exécution à l'aide d'un 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())
}Lorsque vous vous connectez, nous désactivons la mise en correspondance.
Lorsque vous vous déconnectez, nous la réactivons.
Toutefois, n'oubliez pas qu'il n'existe pas d'API pour lire les valeurs, et nous avons constaté que la montre les perdait.
Nous la réécrivons donc chaque fois que la montre est retirée du chargeur ou que l'application est mise à jour.
Le dernier sujet que je voulais aborder est le suivant : comment se déconnecter ?
Lorsque vous dissociez une montre de votre compte, les utilisateurs s'attendent à ce que les messages soient également supprimés.
Nous supprimons donc tout : messages, clés, paramètres.
Mais comment le faire en toute sécurité ?
Si vous supprimez des fichiers alors que des threads d'arrière-plan continuent d'y écrire, vous obtiendrez des exceptions SQLite ou, pire encore, des écritures dans des bases de données récemment recréées.
Et vous ne pouvez pas vraiment effacer l'état d'un processus sans l'arrêter.
Android propose l'API clearApplicationUserData, qui efface les données et arrête l'application, mais réinitialise également les autorisations.
De plus, sur certains OEM, la mise en pont des notifications est liée aux autorisations de notification. Par conséquent, si vous l'utilisez, vous perdrez également la mise en miroir du téléphone.
Nous arrêtons donc le processus nous-mêmes.
clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()Nous créons un petit service dans un processus secondaire, qui n'a pas d'injection de dépendances ni de gestionnaire de tâches, de sorte qu'il ne démarre pas le reste de l'application.
Une fois que vous vous êtes déconnecté, ce service arrête tous les autres processus de notre application, puis supprime toutes les données lorsqu'aucun processus n'est en cours d'exécution.
Comme nous n'utilisons pas clearApplicationUserData, les autorisations sont conservées et nous pouvons activer le relais des notifications pour que les notifications du téléphone s'affichent à nouveau.
Voilà pour la première partie. Je vais maintenant laisser la parole à Subhag, qui va vous parler de la refonte de notre interface utilisateur.
Merci, Martin.
Une fois l'infrastructure partagée en place, notre objectif était de faire en sorte que WhatsApp soit parfaitement intégré à votre poignet.
La première partie de ce parcours a été le passage à Material 3, mais d'abord, demandons-nous pourquoi.
Sur un écran rond où chaque pixel compte, Material 3 nous a fourni les composants et les bases de conception nécessaires pour moderniser WhatsApp pour le poignet.
Si vous regardez les visuels, cela peut ressembler à une simple mise à jour de l'interface utilisateur.
Nous étions en train de passer de Material 2 à Material 3, et cela ne pouvait pas se faire du jour au lendemain.
L'ancien et le nouveau code devaient coexister derrière un commutateur.
Par conséquent, les écrans qui correspondaient visuellement à Figma présentaient encore des comportements défectueux.
Par exemple, le défilement avec la couronne, le balayage vers la droite pour revenir en arrière, certains des gestes les plus basiques ne fonctionnaient pas.
En d'autres termes, la migration d'un système de conception n'est pas seulement une migration visuelle, mais aussi une migration de comportement.
En parlant de comportements, si vous regardez les lignes près des bords, elles sont plus étroites et s'insèrent dans la courbe.
C'est TransformingLazyColumn.
Les lignes se transforment pour que la lunette circulaire ne les coupe pas.
Un écran dynamique peut compliquer les choses.
De nouveaux messages arrivent, les lignes se déplacent et les bulles prennent toutes les formes.
Voyons ce que cela signifie.
Si vous regardez à gauche, les bulles rétrécissent de manière beaucoup trop agressive.
En interne, chaque message est une bulle encapsulée dans une fiche.
Nous avons demandé à la carte parente d'appliquer une transformation, mais la même transformation a également été transmise à la bulle enfant.
Par conséquent, la bulle enfant reçoit deux fois plus de transformations que prévu.
La solution consistait à s'assurer que nous appliquions une transformation à un niveau, à savoir au niveau de la carte parente.
C'est aussi un principe clé.
Nous avons veillé à ce qu'il n'y ait qu'une seule transformation par élément.
Autre problème : les bulles sont petites et estompées au milieu de l'écran, alors qu'elles devraient être en taille réelle.
La taille dépend de leur position sur l'écran.
Dans notre version initiale, nous avons enregistré l'ajustement de la taille à l'aide de la fonction "remember" de Compose, mais cela a également enregistré son ancienne position.
Par conséquent, lorsqu'un nouveau message arrive et déplace les anciennes bulles vers le haut, leur transformation est toujours basée sur leur ancienne position.
ce qui entraîne ce bug.
Pour résoudre ce problème, nous avons arrêté d'enregistrer la taille à l'aide de l'appel remember et nous avons supprimé l'appel.
C'est aussi une leçon.
Lorsqu'une ligne est déplacée, sa transformation doit l'être également.
Ici, vous pouvez voir les deux corrections dans le 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)
}En haut à gauche, vous pouvez voir que la transformation est appliquée à deux niveaux, et en haut à droite, elle n'est appliquée qu'à un seul niveau, qui est la carte M3.
En bas à gauche, nous utilisons l'appel "remember" pour mettre en cache la transformation, mais il met également en cache la position.
En bas à droite, nous calculons la transformation en tenant compte de la dernière position de la bulle à chaque fois.
Comme vous pouvez le voir, le résultat est une expérience cohérente sur tous les écrans, où le contenu clé reste près du centre et les lignes se déplacent naturellement près du bord.
Parlons maintenant des notifications.
Imaginez maintenant que votre poignet vibre, que vous regardez votre montre et que vous voyez un message.
Mais quelques instants plus tard, votre poignet vibre à nouveau, et encore une fois.
Vous recevez plusieurs notifications, et chaque fois que vous les consultez, vous voyez la même chose.
C'est vraiment agaçant.
Pour résoudre ce problème, nous nous sommes posé deux questions.
Quel événement doit déclencher l'envoi d'une notification à l'utilisateur ?
Et quel événement doit mettre à jour les notifications de manière silencieuse ?
Ce bug se produisait, car en coulisses, les mises à jour de la photo de profil, les téléchargements de contenus multimédias et les reçus, qui auraient dû mettre à jour la notification de manière silencieuse, alertaient l'utilisateur à la place.
Désormais, lorsqu'un nouveau message arrive, le poignet vibre une seule fois, tandis que les événements qui ne nécessitent pas d'alerte actualisent la notification de manière silencieuse.
C'est aussi une leçon.
Lorsque les notifications font partie de vos principaux parcours utilisateur, vous devez vous assurer que chaque alerte mérite vraiment l'attention de l'utilisateur.
En parlant de notifications,
Auparavant, les notifications WhatsApp sur Wear OS vous permettaient d'effectuer deux actions.
Vous pouvez lire le message ou ouvrir l'application.
Si vous vouliez répondre directement à une notification, vous ne pouviez pas le faire auparavant.
Nous avons donc comblé cette lacune en utilisant l'action de notification d'Android avec l'API RemoteInput.
L'utilisateur peut désormais saisir sa réponse, la prononcer ou appuyer sur une réponse rapide.
Mais n'oubliez pas que l'envoi n'est pas la fin du processus.
Si un message est envoyé, nous marquons le chat comme lu et effaçons la notification.
Si un message n'est pas distribué, nous proposons de le renvoyer d'un simple geste.
C'est aussi un enseignement clé que nous avons tiré lors du développement : une action de notification doit être traitée comme un workflow complet de bout en bout.
Passons maintenant aux cartes et aux widgets.
Comme vous pouvez le voir, une carte est un écran complet auquel vous pouvez accéder en balayant l'écran depuis le cadran de votre montre.
Les widgets, quant à eux, sont plusieurs fiches plus petites, dont plusieurs peuvent tenir sur un seul écran à faire défiler.
Les cartes et les widgets WhatsApp sont alimentés par les mêmes données sous-jacentes.
Et ça aussi, c'est un rebondissement.
Vous pourriez penser que cette UI est dessinée par notre application, mais ce n'est pas le cas.
Nous décrivons uniquement l'UI et les données, et le système les dessine.
De plus, appuyer sur l'une des icônes ne permet pas d'exécuter le code de votre application.
Vous pouvez uniquement lui demander d'ouvrir un écran.
C'est tout pour cette leçon.
Si le système dessine votre UI, il fait partie de vos métriques de produit et de test.
Frank et Yuri approfondiront le sujet dans leur présentation sur les widgets.
Cela m'amène à la dernière diapositive. Prenons un peu de recul et demandons-nous ce que nous voulons. En fin de compte, nous voulons que de plus en plus d'utilisateurs utilisent nos applications.
Imaginez maintenant qu'en tant que développeur, vous vous connectez un jour à vos tableaux de bord et que vous constatez une baisse significative du nombre d'utilisateurs actifs.
Tout d'abord, ce serait très préoccupant.
Vous pouvez ensuite commencer à le déboguer.
Vous pourriez penser qu'il y a eu un plantage ou qu'une mauvaise version est en cours de déploiement.
Mais pour nous, aucune de ces situations ne s'appliquait.
Il s'avère que chaque version de l'application WhatsApp a une date d'expiration intégrée de 90 jours.
Imaginez que les utilisateurs soient bloqués sur une version obsolète, tandis que les versions plus récentes mettent du temps à arriver sur leurs montres.
Par conséquent, les utilisateurs ne quittent pas l'application, mais celle-ci cesse de fonctionner.
Nous avons donc intégré la mise à jour de l'application au produit.
Comme vous pouvez le voir, 15 jours avant l'expiration de l'application, ce message s'affiche à l'écran.
Elle s'affiche ensuite de nouveau lorsqu'il reste 7, 5, 3, 2 et 1 jour.
L'utilisateur peut choisir de l'ignorer ou d'accéder au Play Store pour mettre à jour son application.
Cela permet de rappeler plusieurs fois à l'utilisateur que l'expiration de son application approche et de lui donner plusieurs occasions de la mettre à jour.
C'est notre dernier enseignement.
Notre travail de développeur ne s'arrête pas lorsque nous déployons une mise à jour à 100 %.
Il n'est terminé que lorsque les utilisateurs reçoivent effectivement nos mises à jour.
Nous voici arrivés au terme de ce module.
Si vous souhaitez poursuivre la conversation, n'hésitez pas à venir me voir pendant les heures de bureau.
Ensuite, Frank et Yuri vont nous parler des widgets Wear.
Merci.
@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)
}