Ascolta direttamente i tecnici di WhatsApp su come hanno eseguito l'upgrade della loro app per Wear OS su larga scala. Questa sessione illustra le decisioni relative all'architettura e alla UX alla base dell'integrazione di Material 3 e dell'adozione dei widget per Wear.
Scopri direttamente gli insight, i pattern e gli strumenti utilizzati dal team per aggiornare l'app e offrire un'esperienza utente coerente.
TransformingLazyColumn correttamente significa applicare le trasformazioni a un solo livello ed evitare di memorizzare nella cache le posizioni con remember di Compose.Trascrizione
Ciao,
Mi chiamo Martin.
E io sono Subhag.
Siamo ingegneri di Meta e facciamo parte del team che ha sviluppato WhatsApp su Wear OS.
Di recente abbiamo modernizzato la nostra app.
Oggi condivideremo gli insegnamenti che abbiamo appreso dalla migrazione a un codebase condiviso e dalla creazione di nuove esperienze su questa base.
La particolarità di WhatsApp su Wear OS è che funziona senza lo smartphone.
Una volta collegato, funge da dispositivo WhatsApp autonomo, con crittografia end-to-end proprio come lo smartphone, con il proprio database e la propria connessione al server.
In questo modo, continua a funzionare anche tramite Wi-Fi o LTE quando lo smartphone è fuori portata.
Abbiamo lanciato WhatsApp su Wear OS nel 2023.
Era basato su un client compatto e portatile scritto in Rust.
Ci ha permesso di entrare in produzione rapidamente.
Tuttavia, si trattava di una seconda implementazione indipendente di WhatsApp, il che significa che ogni protocollo, crittografia e funzionalità doveva essere creato due volte.
Di conseguenza, l'app dell'orologio era sempre in ritardo.
La portabilità ha anche reso difficile la gestione dello sfondo.
Il client gestiva i propri thread, timer e socket, cose che Android vuole gestire per te.
Su Android, il background ha delle regole.
In modalità Sospensione, la rete viene persa.
Una volta memorizzato nella cache, il processo ha un tempo di esecuzione limitato o nullo.
Per superare questo limite, devi utilizzare un servizio, un job o un push.
Quindi la nostra app dell'orologio ha dovuto collegarli manualmente.
Ma il nostro codebase Android lo fa già.
Abbiamo quindi deciso di riutilizzare ed eseguire la migrazione alla nostra codebase Android, che è stata testata su miliardi di dispositivi e segue già le regole di Android.
In questo modo, non è necessario creare due volte le nuove funzionalità.
L'obiettivo era semplice: mantenere la logica di business e rimuovere l'interfaccia utente.
Ciò che rimane è una libreria headless che utilizza i flussi Kotlin come API.
La sfida, tuttavia, è che non puoi districare un'app di queste dimensioni in un'unica soluzione.
Alcuni moduli combinano la UI con l'infrastruttura.
Altre dipendono da funzionalità non necessarie nell'app per Wear OS.
Quindi abbiamo lavorato su due binari: abbiamo continuato a modularizzare e abbiamo rimosso o sovrascritto ciò che non potevamo ancora rimuovere.
Iniziamo con la modularizzazione.
Per escludere il codice dalla build, una semplice istruzione "se si tratta di un'app Telefono" non funzionerà perché non rimuove il codice.
Per questo motivo, facciamo molto affidamento sulla DI, in particolare su quella facoltativa e sul multibinding.
Utilizziamo la DI interna, ma è molto simile a quella offerta da Dagger.
Quindi gli esempi sono in Dagger.
Con l'associazione facoltativa, in Dagger BindsOptionalOf, puoi dichiarare una dipendenza come facoltativa, ad esempio chiamando il backend.
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}Se la build dello smartwatch include un modulo che fornisce l'implementazione, il backend inserito sarà presente e la chiamata funzionerà.
Se ometti il modulo, il valore sarà null e l'API potrà restituire NotAllowed.
Il multibinding funziona allo stesso modo, ma per gli insiemi.
@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>,
)Puoi dichiarare un set con un multibinding, ad esempio MessageHandler, e poi ogni modulo della funzionalità può aggiungere il proprio gestore utilizzando il multibinding nel set.
Se non vuoi supportare i pagamenti, ad esempio puoi escludere l'intero modulo dei pagamenti dalla build e l'insieme inserito non includerà quel gestore.
In questo modo, la questione se la funzionalità è supportata o meno riguarda il grafico di build.
Tuttavia, questo disaccoppiamento ha un duplice effetto.
Con i riferimenti diretti, l'eliminazione di un modulo interromperebbe la build.
Con l'associazione facoltativa o multipla, non esiste alcun riferimento al modulo, quindi la build viene superata e la funzionalità scompare silenziosamente o viene applicata l'impostazione predefinita.
Ed è esattamente quello che abbiamo fatto.
Un refactoring ha spostato una funzionalità nel proprio modulo e la nostra app di build e la nostra app dell'orologio non l'hanno inclusa.
Di conseguenza, i messaggi vocali mostravano una durata di zero secondi.
Come fare per evitare che ciò accada?
Beh, ti serve un test per rimettere il controllo.
Nel nostro caso, confrontiamo i set di multi-binding tra l'app Telefono e l'app dell'orologio per le interfacce importanti.
In questo modo ci assicuriamo di avere sempre l'implementazione corretta.
Il secondo percorso riguarda ciò che non abbiamo ancora potuto rimuovere.
Il riutilizzo dei moduli ti farà risparmiare lavoro, ma tutto ciò che dichiarano viene unito nel manifest.
L'attività di avvio dello smartphone aggiungerebbe un'icona dell'app secondaria oppure i provider di contenuti non necessari verrebbero avviati con la tua app.
L'unione dei manifest offre un utile attributo tools:node="remove" che consente di rimuovere i componenti.
<!-- 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" />Una volta che non sono più presenti, lo shrinker può rimuovere anche il codice.
Se qualcosa deve ancora raggiungere l'attività, puoi utilizzare activity-alias per reindirizzare alla nuova classe.
Tuttavia, tieni presente che questi attributi si applicano solo ai manifest con priorità inferiore, quindi l'ordine di unione decide se un elemento viene rimosso o meno.
Le risorse funzionano allo stesso modo.
Reuse ti offre stringhe di telefono senza costi.
Tuttavia, alcuni promettono cose che uno smartwatch non può fare.
Facciamo un esempio alla fine dell'avviso sulla crittografia che si trova all'inizio di ogni chat.
Sullo smartphone, termina con un link "Scopri di più".
Tuttavia, non c'è nulla dietro il link sullo smartwatch.
Quindi, dichiariamo nuovamente la stessa chiave nel modulo con priorità più alta sullo smartwatch e sostituiamo la stringa con "Usa lo smartphone per saperne di più".
<!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>Poi, il gestore delle risorse sceglie la nostra.
Inoltre, devi assicurarti di non eseguire l'override della stringa per errore.
Per questo motivo, abbiamo creato una regola di build che garantisce e non va a buon fine se l'override non è incluso nella lista consentita.
Ora parliamo di notifiche.
Per impostazione predefinita, Wear OS esegue il bridging o il mirroring delle notifiche dello smartphone.
È fantastico, ma funziona solo se lo smartphone è connesso.
Inoltre, vogliamo che WhatsApp funzioni in modo indipendente, quindi la nostra app pubblica le proprie notifiche.
Tuttavia, se il bridging rimane attivo, ogni messaggio vibra due volte.
La disattivazione del bridging corregge i duplicati.
Esiste un flag statico del file manifest, ma si applica dall'installazione prima che l'utente si connetta.
Quindi, se installi l'app e non accedi, perdi improvvisamente tutte le notifiche, il che non è il massimo, vero?
Quindi lo impostiamo in fase di runtime utilizzando 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())
}Quando accedi, il bridging viene disattivato.
Quando esci, la riattiviamo.
Tuttavia, tieni presente che non esiste un'API per leggere i valori e abbiamo osservato che lo smartwatch li perde.
Quindi, lo riscriviamo ogni volta che lo smartwatch viene rimosso dal caricabatterie o se l'app viene aggiornata.
L'ultimo argomento di cui volevo parlare è come uscire.
Quando scolleghi uno smartwatch dal tuo account, gli utenti si aspettano che i messaggi vengano eliminati.
Quindi eliminiamo tutto: messaggi, chiavi, impostazioni.
Ma come farlo in sicurezza?
Se elimini i file mentre i thread in background continuano a scriverci, ricevi eccezioni SQLite o, peggio ancora, scritture su database appena ricreati.
Inoltre, non è possibile cancellare lo stato di un processo senza interromperlo.
Android offre l'API clearApplicationUserData, che cancella i dati e arresta l'app, ma reimposta anche le autorizzazioni.
Inoltre, su alcuni OEM, il bridging delle notifiche è legato alle autorizzazioni alle notifiche, quindi se lo utilizzi, perderesti improvvisamente anche il mirroring dello smartphone.
Quindi interrompiamo il processo noi stessi.
clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()Creiamo un piccolo servizio in un processo secondario, che non ha DI, né WorkManager, quindi non avvia il resto dell'app.
Dopo la disconnessione, questo servizio interrompe tutti gli altri processi nella nostra app e solo allora elimina tutti i dati quando non ci sono conflitti.
Poiché non utilizziamo clearApplicationUserData, le autorizzazioni rimangono e possiamo attivare il bridging delle notifiche, in modo che le notifiche dello smartphone vengano ripristinate.
Questo è tutto per la prima parte. Ora lascio la parola a Subhag, che parlerà dell'aggiornamento della UI.
Grazie, Martin.
Con l'infrastruttura condivisa, il nostro obiettivo era far sì che WhatsApp fosse davvero a suo agio sul tuo polso.
La prima parte di questo percorso è stata il passaggio a Material 3, ma prima chiediamoci il perché.
Su un display rotondo in cui ogni pixel è importante, Material 3 ci ha fornito i componenti giusti e le basi di progettazione per modernizzare WhatsApp per il polso.
Se noti gli elementi visivi, in apparenza potrebbe sembrare solo un aggiornamento della UI.
Stavamo passando da Material 2 a 3 e non è stato possibile farlo da un giorno all'altro.
Il codice precedente e quello nuovo dovevano coesistere dietro un interruttore.
Di conseguenza, le schermate che corrispondevano visivamente a Figma mostravano ancora alcuni comportamenti non corretti.
Per fare alcuni esempi, lo scorrimento della corona, lo scorrimento verso destra per tornare indietro, alcuni dei gesti più basilari non funzionavano.
In altre parole, la migrazione di un sistema di progettazione non è solo una migrazione visiva, ma anche una migrazione del comportamento.
A proposito di comportamenti, se noti le righe vicino ai bordi, sono più strette e inserite nella curva.
Questo è TransformingLazyColumn.
Le righe si trasformano in modo che la lunetta circolare non le tagli.
Uno schermo dinamico può complicare le cose.
Arrivano nuovi messaggi, le righe si spostano e le bolle assumono ogni forma.
Vediamo cosa significava fare le cose per bene.
Se noti, a sinistra, i fumetti si stanno riducendo in modo troppo aggressivo.
Ogni messaggio è internamente una bolla racchiusa in una scheda.
Abbiamo indicato alla scheda principale di applicare una trasformazione, ma la stessa trasformazione è stata trasmessa anche al fumetto secondario.
Di conseguenza, il fumetto secondario riceve il doppio della trasformazione prevista.
La correzione è stata quella di applicare una trasformazione a un livello, ovvero alla scheda principale.
E questo è anche un principio chiave.
Abbiamo garantito una trasformazione per un elemento.
Ecco un altro problema: bolle piccole e sbiadite proprio al centro dello schermo, dove dovrebbero essere di dimensioni normali.
Le dimensioni sono una funzione della loro posizione sullo schermo.
Nella nostra versione iniziale, abbiamo salvato l'adattamento delle dimensioni utilizzando la funzione remember di Compose, ma in questo modo è stata salvata anche la posizione precedente.
Di conseguenza, quando arriva un nuovo messaggio e i fumetti precedenti vengono spostati verso l'alto, la loro trasformazione si basa ancora sulla loro posizione precedente.
E questo genera il bug.
Per risolvere il problema, abbiamo interrotto il salvataggio delle dimensioni utilizzando la chiamata remember e abbiamo eliminato la chiamata.
E anche questo è un insegnamento.
Quando una riga si sposta, la sua trasformazione deve spostarsi con essa.
Qui puoi vedere entrambe le correzioni nel codice.
// 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)
}In alto a sinistra, puoi vedere che la trasformazione viene applicata a due livelli, mentre in alto a destra viene applicata a un solo livello, ovvero la scheda M3.
In basso a sinistra, utilizziamo la chiamata remember per memorizzare nella cache la trasformazione, ma memorizza nella cache anche la posizione.
In basso a destra, calcoliamo la trasformazione considerando ogni volta l'ultima posizione della bolla.
Come puoi vedere, il risultato è un'esperienza coerente su tutti gli schermi, in cui i contenuti principali rimangono vicino al centro e le righe si spostano naturalmente vicino al bordo.
Ora parliamo delle notifiche.
Ora immagina che il tuo polso vibri, controlli lo smartwatch e vedi un messaggio.
Ma poco dopo, il polso vibra di nuovo e di nuovo.
Ricevi più notifiche e, ogni volta che controlli, vedi la stessa cosa.
Beh, è davvero fastidioso.
Per risolvere il problema, ci siamo posti due domande.
Quale evento deve attivare la notifica per l'utente?
E quale evento dovrebbe aggiornare le notifiche in modalità silenziosa?
Questo bug si verificava perché, dietro le quinte, gli aggiornamenti delle foto del profilo, i download di contenuti multimediali e le ricevute, che dovrebbero aggiornare la notifica in modo silenzioso, avvisavano invece l'utente.
Ora, quando arriva un nuovo messaggio, il polso vibra esattamente una volta, mentre gli eventi che non richiedono un avviso aggiornano la notifica in modo silenzioso.
E anche questa è una lezione.
Quando le notifiche sono uno dei percorsi dell'utente principali, dobbiamo assicurarci che ogni avviso meriti davvero l'attenzione dell'utente.
A proposito di notifiche.
In precedenza, le notifiche di WhatsApp su Wear OS consentivano di eseguire due azioni.
Puoi leggere il messaggio o aprire l'app.
Se volevi rispondere direttamente a una notifica, prima non potevi farlo.
Abbiamo quindi colmato questa lacuna utilizzando l'azione di notifica di Android con l'API RemoteInput.
Ora l'utente può digitare, pronunciare o toccare una risposta rapida.
Ma ricorda che l'invio non è la fine.
Se un messaggio viene inviato correttamente, contrassegniamo la chat come letta e cancelliamo la notifica.
Se un messaggio non viene recapitato, offriamo la possibilità di riprovare con un solo tocco.
Un altro aspetto fondamentale che abbiamo imparato durante lo sviluppo è che un'azione di notifica deve essere trattata come un flusso di lavoro end-to-end completo.
Poi, Schede e widget.
Un riquadro, come puoi vedere, è una schermata intera, che puoi raggiungere scorrendo dal quadrante.
I widget, invece, sono più schede di dimensioni ridotte, che possono essere inserite in un'unica schermata scorrevole.
Sia i riquadri sia i widget di WhatsApp si basano sugli stessi dati sottostanti.
E anche questo è un colpo di scena.
Potresti pensare che questa UI venga disegnata dalla nostra app, ma in realtà non è così.
Descriviamo solo la UI e i dati, mentre il sistema li disegna.
Inoltre, toccando una delle icone non è possibile eseguire il codice dell'app.
Puoi solo chiedere di aprire una schermata.
E con questo si conclude la nostra lezione.
Se il sistema sta disegnando la tua UI, questa fa parte delle metriche del prodotto e dei test.
Frank e Yuri approfondiranno l'argomento nel loro intervento sui widget.
E questo mi porta all'ultima slide, che ci invita a fare un passo indietro e a chiederci: alla fine della fiera, vogliamo che sempre più utenti utilizzino le nostre app.
Ora immagina di essere uno sviluppatore, di accedere alle tue dashboard un giorno e di vedere un calo significativo degli utenti attivi.
Beh, prima di tutto, sarebbe molto preoccupante.
e poi iniziare a eseguirne il debug.
Potresti pensare che si sia verificato un arresto anomalo o che sia in corso una release non riuscita.
Ma per noi non è stato così.
Si scopre che ogni versione dell'app WhatsApp ha una scadenza integrata di 90 giorni.
Immagina che gli utenti siano bloccati su una build obsoleta, mentre le build più recenti impiegano tempo per raggiungere i loro smartwatch.
Di conseguenza, gli utenti non abbandonano l'app, ma l'app smette di funzionare.
Per questo motivo, abbiamo reso l'aggiornamento dell'app parte del prodotto.
Come puoi vedere sullo schermo, 15 giorni prima della scadenza dell'app, viene visualizzato questo messaggio.
Viene visualizzato di nuovo quando mancano 7, 5, 3, 2 e 1 giorno.
L'utente può scegliere di ignorarlo o di accedere al Play Store per aggiornare l'app.
In questo modo, l'utente riceve più promemoria che lo informano che la scadenza dell'app si sta avvicinando e ha più opportunità di aggiornarla.
E questo è il nostro ultimo insegnamento.
Il nostro lavoro di sviluppatori non si conclude quando rilasciamo un aggiornamento al 100%.
È completo solo quando gli utenti ricevono effettivamente i nostri aggiornamenti.
Siamo arrivati alla fine.
Se vuoi continuare la conversazione, non esitare a passare a salutarci durante l'orario di consulenza.
Ora Frank e Yuri parleranno dei widget di Wear.
Grazie.
@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)
}