Ouça os engenheiros do WhatsApp sobre como eles atualizaram o app para Wear OS em grande escala. Esta sessão aborda as decisões de arquitetura e UX por trás da integração do Material 3 e da adoção de widgets do Wear.
Conheça as lições, os padrões e as ferramentas que a equipe usou para atualizar o app e oferecer uma experiência do usuário coesa.
TransformingLazyColumn corretamente significa aplicar transformações em apenas um nível e evitar o armazenamento em cache de posições com o remember do Compose.Transcrição
Olá, pessoal.
Meu nome é Martin.
E eu sou o Subhag.
Somos engenheiros da Meta na equipe responsável pelo WhatsApp no Wear OS.
E recentemente modernizamos nosso app.
Por isso, hoje vamos compartilhar as lições que aprendemos ao migrar para uma base de código compartilhada e criar novas experiências com base nela.
O que torna o WhatsApp no Wear OS interessante é que ele funciona sem o smartphone.
Depois de vinculado, ele funciona como um dispositivo WhatsApp próprio, com criptografia de ponta a ponta, assim como seu smartphone, com um banco de dados e uma conexão com o servidor próprios.
Assim, ele continua funcionando mesmo em Wi-Fi ou LTE quando o smartphone está fora de alcance.
Lançamos o WhatsApp no Wear OS em 2023.
Ele era alimentado por um cliente compacto e portátil escrito em Rust.
Ele nos levou à produção rapidamente.
No entanto, era uma segunda implementação independente do WhatsApp, o que significa que cada protocolo, criptografia e recurso precisava ser criado duas vezes.
Por isso, o app para smartwatch estava sempre se atualizando.
Por ser portátil, também era difícil lidar com o segundo plano.
O cliente lidava com as próprias linhas de execução, timers e soquetes, coisas que o Android quer gerenciar para você.
No Android, o segundo plano tem regras.
No modo de soneca, você perde a rede.
Depois que o processo é armazenado em cache, o tempo de execução é limitado ou nulo.
Você precisa de um serviço, job ou push para superar isso.
Por isso, nosso app para smartwatch precisava conectar esses elementos manualmente.
Mas nossa base de código do Android já faz isso.
Por isso, decidimos reutilizar e migrar para nossa base de código do Android, que foi testada em bilhões de dispositivos e já segue as regras do Android.
Dessa forma, os novos recursos não precisam ser criados duas vezes.
O objetivo era simples: manter a lógica de negócios e remover a interface.
O que resta é uma biblioteca headless que usa fluxos do Kotlin como API.
No entanto, o desafio é que não é possível separar um app desse tamanho de uma só vez.
Alguns módulos misturam a interface com a infraestrutura.
Outras dependem de recursos que você não precisa no app para Wear OS.
Por isso, trabalhamos em duas frentes: continuamos a modularizar e removemos ou substituímos o que ainda não podíamos remover.
Vamos começar com a modularização.
Para manter o código fora do build, uma simples instrução "se este for o app Telefone" não vai funcionar porque não remove o código.
Por isso, dependemos muito da DI, especialmente a opcional e a multibinding.
Usamos DI interna, mas ela é muito parecida com o que o Dagger oferece.
Por isso, os exemplos estão em Dagger.
Com a vinculação opcional, que no Dagger é BindsOptionalOf, é possível declarar uma dependência como opcional, por exemplo, chamando o back-end.
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}Se o build do relógio incluir um módulo que forneça a implementação, o back-end injetado estará presente e a chamada vai funcionar.
Se você omitir o módulo, ele será nulo, e a API poderá retornar NotAllowed.
A vinculação múltipla funciona da mesma forma, mas para conjuntos.
@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>,
)É possível declarar um conjunto com multibinds, por exemplo, MessageHandler, e cada módulo de recurso pode adicionar o próprio gerenciador usando multibinding em conjunto.
Se você não quiser oferecer suporte a pagamentos, por exemplo, poderá deixar todo o módulo de pagamentos fora do build, e o conjunto injetado não vai incluir esse gerenciador.
Dessa forma, se o recurso é compatível ou não é uma questão para o gráfico de build.
No entanto, essa dissociação tem dois lados.
Com referências diretas, a exclusão de um módulo corromperia o build.
Com a vinculação opcional ou múltipla, não há nada que faça referência ao módulo. Portanto, o build é aprovado, e o recurso desaparece silenciosamente ou o padrão é usado.
E foi exatamente isso que aconteceu.
Uma refatoração moveu um recurso para o próprio módulo, e o app de build e o app para smartwatch não o incluíram.
Isso fazia com que as mensagens de voz mostrassem zero segundos como duração.
Como garantir que isso não aconteça?
Bem, você precisa de um teste para colocar a verificação de volta.
No nosso caso, comparamos conjuntos de multibinding entre o app Telefone e o app para smartwatch para interfaces importantes.
Dessa forma, garantimos que a implementação correta esteja sempre vinculada.
A segunda faixa cobre o que ainda não conseguimos remover.
Reutilizar módulos poupa trabalho, mas tudo o que eles declaram é mesclado ao seu manifesto.
A atividade de inicialização do smartphone adicionaria um ícone de app secundário ou provedores de conteúdo desnecessários seriam iniciados com o app.
A mesclagem de manifestos oferece um atributo tools:node="remove" útil que permite remover os componentes.
<!-- 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" />E, quando eles estiverem fora, o shrinker também poderá remover o código.
Se algo ainda precisar chegar à atividade, use activity-alias para redirecionar para a nova classe.
No entanto, esses atributos se aplicam apenas a manifestos de menor prioridade. Portanto, a ordem de combinação decide se algo é removido ou não.
Os recursos funcionam da mesma forma.
A reutilização oferece strings de smartphone sem custo financeiro.
No entanto, alguns prometem coisas que um relógio não pode fazer.
Vamos dar um exemplo no final do aviso de criptografia que aparece no início de cada conversa.
No smartphone, ela termina com um link "Saiba mais".
No entanto, não há nada por trás do link no relógio.
Então, o que fazemos é redeclarar a mesma chave em um módulo de maior prioridade no relógio e substituir a string por "use seu smartphone para saber mais".
<!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>E, em seguida, a mesclagem de recursos escolhe o nosso.
Você também precisa de algo para garantir que não vai substituir a string por acidente.
Por isso, criamos uma regra de build que garante e falha se a substituição não estiver na lista de permissões.
Agora vamos falar sobre as notificações.
Por padrão, o Wear OS espelha as notificações do smartphone.
Isso é ótimo, mas só funciona se o smartphone estiver conectado.
E queremos que o WhatsApp funcione de forma independente, para que nosso app poste as próprias notificações.
Mas se a ponte permanecer ativada, cada mensagem vai vibrar duas vezes.
Desativar a ponte corrige as duplicações.
Há uma flag estática do manifesto, mas ela é aplicada na instalação antes do login do usuário.
Se você instalar e não fizer login, vai perder todas as notificações, o que não é bom, certo?
Por isso, definimos isso no ambiente de execução usando um 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 você faz login, desativamos a ponte.
Quando você sai, ele é reativado.
No entanto, não há uma API para ler os valores de volta, e observamos que o relógio os perde.
Por isso, o que fazemos é gravar novamente sempre que o relógio sai do carregador ou se o app é atualizado.
O último tópico que quero discutir é: como sair?
Quando você desvincula um relógio da sua conta, as pessoas esperam que as mensagens sejam removidas com ele.
Por isso, excluímos tudo: mensagens, chaves e configurações.
Mas como fazer isso com segurança?
Excluir arquivos enquanto as linhas de execução em segundo plano ainda estão gravando neles, o que gera exceções do SQLite ou, pior ainda, gravações em bancos de dados recém-recriados.
E não é possível limpar o estado de um processo sem interrompê-lo.
O Android oferece a API clearApplicationUserData, que limpa os dados e interrompe o app, mas também redefine as permissões.
Em alguns OEMs, a ponte de notificações está vinculada às permissões de notificação. Portanto, se você usar esse recurso, também vai perder o espelhamento do smartphone.
Por isso, interrompemos o processo.
clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()Criamos um serviço pequeno em um processo secundário, que não tem DI nem gerenciador de trabalho, para que ele não inicie o restante do app.
Depois que você sai da conta, esse serviço encerra todos os outros processos no app e só exclui os dados quando não há mais nada em execução.
Como não usamos o clearApplicationUserData, as permissões são mantidas e podemos ativar a ponte de notificações para que as notificações do smartphone voltem.
Essa é a primeira parte. Agora, vou passar a palavra para Subhag, que vai falar sobre a atualização da interface.
Obrigado, Martin.
Com a infraestrutura compartilhada, nosso foco foi fazer com que o WhatsApp parecesse realmente em casa no seu pulso.
A primeira parte dessa jornada foi a mudança para o Material 3, mas primeiro vamos entender o motivo.
Em uma tela redonda, em que cada pixel é importante, o Material 3 nos deu os componentes certos e a base de design para modernizar o WhatsApp para o pulso.
Se você notar os recursos visuais, superficialmente, pode parecer apenas uma atualização da interface.
Estávamos migrando do Material 2 para o 3, e isso não acontece de um dia para o outro.
O código antigo e o novo precisavam coexistir por trás de uma chave.
Como resultado, as telas que correspondiam visualmente ao Figma ainda mostravam alguns comportamentos corrompidos.
Por exemplo, a rolagem da coroa, o gesto de deslizar para a direita para voltar e alguns dos gestos mais básicos não funcionavam.
Ou seja, uma migração de sistema de design não é apenas uma migração visual, mas também uma migração de comportamento.
Falando em comportamentos, se você notar as linhas perto das bordas, elas são mais estreitas e estão inseridas na curva.
Essa é a TransformingLazyColumn.
As linhas se transformam para que a moldura circular não as corte.
Uma tela dinâmica pode complicar as coisas.
Novas mensagens chegam, linhas se movem e balões aparecem em todos os formatos.
Vamos ver o que significa acertar.
Se você notar à esquerda, as bolhas estão diminuindo de forma muito agressiva.
Cada mensagem é, internamente, um balão dentro de um card.
Pedimos ao card pai para aplicar uma transformação, mas ela também estava sendo transmitida para o balão filho.
Como resultado, o balão filho está recebendo o dobro da transformação pretendida.
A correção foi garantir que aplicamos uma transformação em um nível, que foi no card pai.
Esse também é um princípio fundamental.
Garantimos uma transformação para cada item.
E aqui está outro problema: balões pequenos e esmaecidos bem no meio da tela, onde deveriam estar em tamanho normal.
O tamanho é uma função da posição deles na tela.
Na versão inicial, salvamos o ajuste de tamanho usando a função remember do Compose, mas isso também acabou salvando a posição anterior.
Como resultado, quando uma nova mensagem chega e desloca os balões mais antigos para cima, a transformação deles ainda se baseia na posição anterior.
Isso resulta no bug.
Para corrigir isso, paramos de salvar o tamanho usando a chamada remember e nos livramos dela.
E isso também é um aprendizado.
Quando uma linha é movida, a transformação dela precisa ser movida junto.
Aqui você pode ver as duas correções no código.
// 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)
}No canto superior esquerdo, a transformação está sendo aplicada em dois níveis, e no canto superior direito, apenas em um nível, que é o card M3.
No canto inferior esquerdo, usamos a chamada remember para armazenar em cache a transformação, mas ela também armazena em cache a posição.
No canto inferior direito, calculamos a transformação considerando a posição mais recente do balão.
Como você pode ver, o resultado é uma experiência consistente em todas as telas, em que o conteúdo principal fica perto do centro e as linhas se movem naturalmente perto da borda.
Agora, vamos falar sobre as notificações.
Agora imagine que seu pulso vibra, você olha para o relógio e vê uma mensagem.
Mas, momentos depois, seu pulso vibra de novo e de novo.
Você recebe várias notificações e, quando verifica, é sempre a mesma coisa.
Isso é muito chato.
Para corrigir isso, fizemos duas perguntas.
Qual evento deve acionar a notificação para o usuário?
E qual evento deve atualizar as notificações silenciosamente?
Esse bug estava acontecendo porque, em segundo plano, as atualizações de foto do perfil, os downloads de mídia e os recibos, que deveriam atualizar a notificação silenciosamente, estavam alertando o usuário.
Agora, quando uma nova mensagem chega, o pulso vibra apenas uma vez, enquanto eventos que não exigem alerta atualizam a notificação silenciosamente.
E isso também é uma lição.
Quando as notificações são uma das principais jornadas do usuário, é preciso garantir que cada alerta realmente mereça a atenção dele.
Sobre as notificações.
Antes, as notificações do WhatsApp no Wear OS permitiam que você fizesse duas coisas.
Você pode ler a mensagem ou abrir o app.
Antes, não era possível responder diretamente a uma notificação.
Por isso, preenchemos essa lacuna usando a ação de notificação do Android com a API RemoteInput.
Agora o usuário pode digitar, falar ou tocar em uma resposta rápida.
Mas lembre-se: o envio não é o fim.
Se uma mensagem for enviada, vamos marcar o chat como lido e limpar a notificação.
Se uma mensagem não for entregue, oferecemos uma nova tentativa com um único toque.
E esse também é um aprendizado importante ao desenvolver isso: uma ação de notificação precisa ser tratada como um fluxo de trabalho completo de ponta a ponta.
Em seguida, "Blocos e widgets".
Um bloco, como você pode ver, é uma tela cheia que pode ser acessada deslizando o mostrador do relógio.
Já os widgets são vários cards menores, e muitos deles podem caber em uma única tela rolável.
Os blocos e widgets do WhatsApp usam os mesmos dados.
E isso também é uma reviravolta.
Você pode achar que essa interface é desenhada pelo nosso app, mas não é o caso.
Apenas descrevemos a interface e os dados, e o sistema os desenha.
Além disso, tocar em qualquer um dos ícones não executa o código do app.
Você só pode pedir para abrir uma tela.
E essa é a nossa lição.
Se o sistema estiver renderizando sua IU, ele faz parte do produto e das métricas de teste.
Frank e Yuri vão se aprofundar na conversa sobre widgets.
E isso me leva ao último slide, que é dar um passo para trás e nos perguntar: no fim das contas, queremos que mais e mais usuários usem nossos apps.
Agora imagine que, como desenvolvedor, você faz login nos seus painéis um dia e vê uma queda significativa de usuários ativos.
Bem, antes de mais nada, seria muito preocupante.
Em seguida, você pode começar a depurá-lo.
Você pode achar que houve uma falha ou que há uma versão ruim em andamento.
Mas, para nós, nenhuma dessas opções era o caso.
Acontece que todas as versões do app WhatsApp têm uma validade integrada de 90 dias.
Imagine que os usuários estão presos em uma versão desatualizada, enquanto as versões mais recentes estão demorando para chegar aos relógios.
Como resultado, os usuários não saem do app, mas ele para de funcionar.
Por isso, incluímos a atualização do app como parte do produto.
Como você pode ver na tela, 15 dias antes da expiração do app, isso aparece na tela.
Depois, ele aparece novamente quando faltarem 7, 5, 3, 2 e 1 dia.
O usuário pode dispensar a mensagem ou acessar a Google Play Store para atualizar o app.
Isso ajuda a lembrar os usuários várias vezes que o app está prestes a expirar e dá a eles várias chances de atualizar o app.
E esse é o último aprendizado.
Nosso trabalho como desenvolvedores não termina quando lançamos uma atualização para 100% dos usuários.
Ele só é concluído quando os usuários recebem as atualizações.
Chegamos ao fim.
Se quiser continuar a conversa, venha falar com a gente durante o horário de atendimento.
Em seguida, Frank e Yuri vão falar sobre os widgets do Wear.
Nossa equipe agradece.
@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)
}