Apps foundation

Lecciones del recorrido de WhatsApp en Wear OS

Bocinas

  • Martin Pelant

    Ingeniero de software, Meta

  • Subhag Bhatia

    Ingeniero de software, Meta

Acerca de

Acerca de la charla

Escucha directamente a los ingenieros de WhatsApp sobre cómo actualizaron su app para Wear OS a gran escala. En esta sesión, se analizan las decisiones de arquitectura y UX detrás de la integración de Material 3 y la adopción de los widgets de Wear.

Obtén información directa sobre las lecciones, los patrones y las herramientas que usó el equipo para actualizar su app y ofrecer una experiencia del usuario coherente.

Resumen

  • Compartir el código de un teléfono Android con Wear OS reduce el trabajo duplicado, pero requiere la inserción de dependencias y la anulación de manifiestos. Las pruebas deben comparar conjuntos de vinculaciones múltiples para que las funciones modularizadas no desaparezcan de forma silenciosa.
  • La administración de la vinculación de notificaciones en el entorno de ejecución evita las alertas duplicadas y, al mismo tiempo, permite que el reloj funcione de forma independiente. Si desactivas la duplicación de notificaciones cuando accedes y la vuelves a activar cuando sales, evitarás que los teléfonos duplicados vibren dos veces.
  • Las migraciones de Material 3 en pantallas redondas requieren administrar los cambios de comportamiento junto con el estilo visual. Usar TransformingLazyColumn correctamente significa aplicar transformaciones en un solo nivel y evitar almacenar en caché las posiciones con remember de Compose.
  • Los límites de vencimiento de las apps pueden provocar disminuciones repentinas de usuarios activos si los relojes no se actualizan de inmediato. Los recordatorios de actualización programados dirigen a los usuarios a Google Play antes de que venza su compilación de noventa días.

Gemini Notebook

Temas

  • wear-os
  • material-3
  • jetpack-compose
  • modularización
  • notificaciones
  • tiles-and-widgets

Transcripción

Descargar transcripción

Introducción

  • Hola a todos.

  • Me llamo Martin.

  • Y yo soy Subhag.

  • Somos ingenieros de Meta y formamos parte del equipo que creó WhatsApp para Wear OS.

  • Hace poco, modernizamos nuestra app.

  • Por eso, hoy compartiremos las lecciones que aprendimos al migrar a una base de código compartida y, luego, crear nuevas experiencias sobre ella.

  • Lo interesante de WhatsApp en Wear OS es que funciona sin tu teléfono.

  • Una vez que se vincula, actúa como un dispositivo de WhatsApp independiente, con encriptación de extremo a extremo, al igual que tu teléfono, con su propia base de datos y conexión al servidor.

  • Por lo tanto, sigue funcionando incluso con Wi-Fi o LTE cuando el teléfono está fuera de alcance.

  • Lanzamos WhatsApp en Wear OS por primera vez en 2023.

  • Se ejecutaba con un cliente compacto y portátil escrito en Rust.

  • Nos permitió llegar a la producción rápidamente.

  • Sin embargo, era una segunda implementación independiente de WhatsApp, lo que significa que cada protocolo, encriptación y función se debía compilar dos veces.

  • Por lo tanto, la app de reloj siempre se estaba actualizando.

  • La portabilidad también dificultaba el manejo de las tareas en segundo plano.

  • El cliente controlaba sus propios subprocesos, temporizadores y sockets, que son elementos que Android quiere administrar por ti.

  • En Android, el segundo plano tiene reglas.

  • Cuando está en modo de Descanso, se pierde la red.

  • Una vez que se almacena en caché tu proceso, tienes un tiempo de ejecución limitado o nulo.

  • Necesitas un servicio, un trabajo o un impulso para superar eso.

  • Por lo tanto, nuestra app de reloj tuvo que conectarlos de forma manual.

  • Pero nuestra base de código de Android ya lo hace.

  • Por lo tanto, decidimos reutilizar nuestra base de código de Android y migrar a ella, ya que se probó en miles de millones de dispositivos y ya sigue las reglas de Android.

Cómo migrar a una base de código compartida

  • De esa manera, no es necesario compilar las funciones nuevas dos veces.

  • El objetivo era simple: mantener la lógica empresarial y quitar la IU.

  • Lo que queda es una biblioteca sin interfaz gráfica que usa flujos de Kotlin como su API.

  • Sin embargo, el desafío es que no puedes desenredar una app de este tamaño de una sola vez.

  • Algunos módulos mezclan la IU con la infraestructura.

  • Otras dependen de funciones que no necesitas en la app para Wear OS.

  • Por lo tanto, trabajamos en dos frentes: seguimos modularizando y quitamos o reemplazamos lo que aún no podíamos quitar.

  • Comencemos con la modularización.

  • Para mantener el código fuera de la compilación, una simple sentencia "if this is phone app" no funcionará porque no quita el código.

  • Por lo tanto, dependemos en gran medida de la DI, en especial de la opcional y la de vinculación múltiple.

  • Usamos la DI interna, pero se asigna de forma similar a lo que ofrece Dagger.

  • Por lo tanto, los ejemplos están en Dagger.

  • Con la vinculación opcional, que en Dagger es BindsOptionalOf, puedes declarar una dependencia como opcional, por ejemplo, llamando al backend.

    @Module
    interface CallingModule {
        @BindsOptionalOf
        fun callingBackend(): CallingBackend
    }
    
    class Calls @Inject constructor(
        val backend: Optional<CallingBackend>,
    ) {
        fun start(): CallResult =
            backend.getOrNull()?.start()
                ?: CallResult.NotAllowed
    }
  • Luego, si la compilación de reloj incluye un módulo que proporciona la implementación, el backend insertado estará presente y la llamada funcionará.

  • Si omites el módulo, será nulo y tu API podrá devolver NotAllowed.

  • La vinculación múltiple funciona de la misma manera, pero 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>,
    )
  • Puedes declarar un conjunto con multibinds, por ejemplo, MessageHandler, y, luego, cada módulo de funciones puede agregar su propio controlador con multibinding de conjunto.

  • Si no quieres admitir pagos, por ejemplo, puedes dejar todo el módulo de pagos fuera de la compilación, y el conjunto insertado no incluirá ese controlador.

  • De esa manera, si se admite la función, es una pregunta para el gráfico de compilación.

  • Sin embargo, esta desvinculación funciona en ambos sentidos.

  • Con las referencias directas, borrar un módulo interrumpiría la compilación.

  • Con la vinculación opcional o múltiple, no hay nada que haga referencia al módulo, por lo que la compilación se realiza correctamente y la función desaparece sin problemas, o bien se produce la acción predeterminada.

  • Y llegamos exactamente a esto.

  • Una refactorización movió una función a su propio módulo, y nuestra app de compilación y nuestra app de reloj no la incluyeron.

  • Esto provocaba que los mensajes de voz mostraran una duración de cero segundos.

  • ¿Cómo puedes asegurarte de que esto no suceda?

  • Bueno, necesitas una prueba para volver a colocar la verificación.

  • En nuestro caso, lo que hacemos es comparar los conjuntos de vinculación múltiple entre la app de Teléfono y la app de reloj para las interfaces que importan.

  • De esa manera, nos aseguramos de que siempre esté vinculada la implementación correcta.

  • Ahora, la pista dos abarca lo que aún no pudimos quitar.

  • Reutilizar módulos te ahorrará trabajo, pero todo lo que declaren se combinará en tu manifiesto.

  • La actividad del selector del teléfono agregaría un ícono de la app secundario, o bien se iniciarían proveedores de contenido innecesarios con tu app.

  • La combinación de manifiestos ofrece un atributo tools:node="remove" útil que te permite quitar los 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" />
  • Y, una vez que se quitan, el reductor también puede quitar el código.

  • Si algo aún necesita llegar a la actividad, puedes usar activity-alias para redireccionar a la clase nueva.

  • Sin embargo, ten en cuenta que esos atributos solo se aplican a los manifiestos de menor prioridad, por lo que el orden de combinación decide si se quita algo o no.

  • Los recursos funcionan de la misma manera.

  • La reutilización te brinda cadenas de teléfono sin costo.

  • Sin embargo, algunas prometen cosas que un reloj no puede hacer.

  • Veamos un ejemplo al final del aviso de encriptación que aparece al principio de cada chat.

  • En el teléfono, termina con un vínculo para obtener más información.

  • Sin embargo, no hay nada detrás del vínculo en el reloj.

  • Por lo tanto, lo que hacemos es volver a declarar la misma clave en el módulo de mayor prioridad en el reloj y reemplazar la cadena por "usa tu teléfono para obtener más información".

    <!-- phone -->
    <string name="…_disclosure">… Learn more</string>
    
    <!-- wearable override, same key -->
    <string name="…_disclosure">…Use your phone to learn more.</string>
  • Luego, la combinación de recursos elige el nuestro.

  • También necesitas algo que garantice que no anules la cadena por accidente.

  • Por lo tanto, creamos una regla de compilación que garantiza y falla si la anulación no está incluida en la lista de entidades permitidas.

Cómo administrar la sincronización de notificaciones

  • Ahora, hablemos de las notificaciones.

  • De forma predeterminada, Wear OS vincula o duplica las notificaciones de tu teléfono.

  • Eso es genial, pero solo funciona si tu teléfono está conectado.

  • Y queremos que WhatsApp funcione de forma independiente, por lo que nuestra app publica nuestras propias notificaciones.

  • Sin embargo, si la vinculación permanece activada, cada mensaje vibra dos veces.

  • Si desactivas la conexión, se corregirán los duplicados.

  • Hay una marca estática del manifiesto, pero se aplica desde la instalación antes de que el usuario esté conectado.

  • Por lo tanto, si instalas la app y no te conectas, de repente perderás todas las notificaciones, lo que no es muy bueno, ¿verdad?

  • Por lo tanto, lo configuramos en el entorno de ejecución con 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())
    }
  • Cuando te conectas, desactivamos el puente.

  • Cuando sales de tu cuenta, volvemos a activarla.

  • Sin embargo, ten en cuenta que no hay una API para volver a leer los valores, y observamos que el reloj los pierde.

  • Por lo tanto, lo que hacemos es volver a escribirlo cada vez que el reloj se desconecta del cargador o si se actualiza la app.

  • El último tema que quería tratar es cómo salir de la cuenta.

  • Cuando desvinculas un reloj de tu cuenta, las personas esperan que los mensajes se vayan con él.

  • Por lo tanto, borramos todo: mensajes, claves y parámetros de configuración.

  • Pero ¿cómo hacerlo de forma segura?

  • Si borras archivos mientras los subprocesos en segundo plano aún escriben en ellos, obtendrás excepciones de SQLite o, lo que es peor, escrituras en bases de datos recién recreadas.

  • Y no puedes borrar el estado de un proceso sin detenerlo.

  • Android te ofrece la API de clearApplicationUserData, que borra los datos y detiene la app, pero también restablece los permisos.

  • En algunos OEM, la vinculación de notificaciones se relaciona con los permisos de notificaciones, por lo que, si usas esta opción, también perderás la duplicación del teléfono de forma repentina.

  • Por lo tanto, detenemos el proceso nosotros mismos.

    clearActivityBackStack()
    
    // enumerate our own, kill each
    killOtherProcesses()
    
    // no threads alive — now safe
    deleteDataDirectories()
    
    // show logged out confirmation
    firePostCleanupIntent()
  • Compilamos un servicio pequeño en un proceso secundario, que no tiene DI ni WorkManager, por lo que no inicia el resto de la app.

  • Después de que sales de tu cuenta, este servicio finaliza todos los demás procesos de nuestra app y, solo entonces, borra todos los datos cuando no hay nada que compita con él.

  • Como no usamos clearApplicationUserData, los permisos se conservan y podemos activar la vinculación de notificaciones para que vuelvan a aparecer las notificaciones del teléfono.

  • Eso es todo para la primera parte. Ahora, le daré la palabra a Subhag para que hable sobre la actualización de la IU.

Moderniza la IU de Wear OS

  • Gracias, Martin.

  • Con la infraestructura compartida implementada, nuestro objetivo era hacer que WhatsApp se sintiera como en casa en tu muñeca.

  • La primera parte de ese recorrido fue un cambio a Material 3, pero primero, preguntémonos por qué.

  • En una pantalla redonda en la que cada píxel importa, Material 3 nos brindó los componentes adecuados y la base de diseño para modernizar WhatsApp para la muñeca.

  • Si observas los elementos visuales, a simple vista, podría parecer solo una actualización de la IU.

  • Bueno, estábamos pasando de Material 2 a 3, y no podía suceder de la noche a la mañana.

  • El código antiguo y el nuevo debían coexistir detrás de un interruptor.

  • Como resultado, las pantallas que coincidían visualmente con Figma aún mostraban algunos comportamientos incorrectos.

  • Por ejemplo, el desplazamiento con la corona, el deslizamiento hacia la derecha para volver atrás y algunos de los gestos más básicos no funcionaban.

  • En resumen, la migración de un sistema de diseño no solo es una migración visual, sino también una migración de comportamiento.

  • Hablando de comportamientos, si observas las filas cerca de los bordes, son más estrechas y están metidas en la curva.

  • Ese es TransformingLazyColumn.

  • Las filas se transforman para que el bisel circular no las corte.

  • Una pantalla dinámica puede complicar las cosas.

  • Llegan mensajes nuevos, se mueven las filas y aparecen burbujas de todas las formas.

  • Veamos qué significaba hacerlo bien.

  • Si observas a la izquierda, las burbujas se están reduciendo de forma demasiado agresiva.

  • Cada mensaje es, internamente, una burbuja dentro de una tarjeta.

  • Le indicamos a la tarjeta superior que aplicara una transformación, pero la misma transformación también se pasaba al globo secundario.

  • Como resultado, la burbuja secundaria recibe el doble de la transformación prevista.

  • La solución fue asegurarnos de aplicar una transformación en un nivel, y eso fue en la tarjeta superior.

  • Y ese también es un principio clave.

  • Nos aseguramos de que haya una transformación para un elemento.

  • Y aquí hay otro problema: las burbujas son pequeñas y se desvanecen justo en el medio de la pantalla, donde deberían tener el tamaño completo.

  • Bueno, el tamaño es una función de su posición en la pantalla.

  • En nuestra versión inicial, guardamos el ajuste de tamaño con la función remember de Compose, pero eso también terminó guardando su posición anterior.

  • Como resultado, cuando llega un mensaje nuevo y desplaza los globos más antiguos hacia arriba, su transformación sigue basándose en su posición anterior.

  • Y eso genera ese error.

  • Para solucionar este problema, dejamos de guardar el tamaño con la llamada remember y nos deshicimos de ella.

  • Y eso también es un aprendizaje.

  • Cuando se mueve una fila, su transformación debe moverse con ella.

  • Aquí puedes ver ambas correcciones en el 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)
    }
  • En la parte superior izquierda, puedes ver que la transformación se aplica en dos niveles, y en la parte superior derecha, solo se aplica en un nivel, que es la tarjeta de M3.

  • En la parte inferior izquierda, usamos la llamada remember para almacenar en caché la transformación, pero también almacena en caché la posición.

  • Y en la parte inferior derecha, calculamos la transformación considerando la última posición de la burbuja cada vez.

  • Como puedes ver, el resultado es una experiencia coherente en todas las pantallas, en la que el contenido clave permanece cerca del centro y las filas se mueven de forma natural cerca del borde.

Cómo controlar las alertas de notificación en la muñeca

  • Ahora, hablemos de las notificaciones.

  • Ahora imagina que tu muñeca vibra, miras el reloj y ves un mensaje.

  • Pero, momentos después, tu muñeca vuelve a vibrar una y otra vez.

  • Recibes varias notificaciones y, cuando las revisas, ves lo mismo.

  • Bueno, eso es muy molesto.

  • Para solucionar este problema, nos hicimos dos preguntas.

  • ¿Qué evento debería activar la notificación para alertar al usuario?

  • ¿Y qué evento debería actualizar las notificaciones de forma silenciosa?

  • Este error se producía porque, tras bambalinas, las actualizaciones de las fotos de perfil, las descargas de contenido multimedia y los recibos, que deberían actualizar la notificación de forma silenciosa, alertaban al usuario.

  • Ahora, cuando llega un mensaje nuevo, la muñeca vibra exactamente una vez, mientras que los eventos que no requieren alertas actualizan la notificación de forma silenciosa.

  • Y eso también es una lección.

  • Cuando las notificaciones son uno de los recorridos del usuario principales, debemos asegurarnos de que cada alerta realmente merezca la atención del usuario.

  • En lo que respecta a las notificaciones,

  • Anteriormente, las notificaciones de WhatsApp para Wear OS te permitían hacer dos cosas.

  • Podrías leer el mensaje o abrir la app.

  • Antes, no podías responder directamente a una notificación.

  • Por lo tanto, cerramos esa brecha usando la acción de notificación de Android con la API de RemoteInput.

  • Ahora, el usuario puede escribirla, decirla o presionar una respuesta rápida.

  • Pero recuerda que presionar enviar no es el final.

  • Si un mensaje se envía correctamente, marcamos el chat como leído y borramos la notificación.

  • Si un mensaje no se entrega, ofrecemos una opción para volver a enviarlo con un solo toque.

  • Y ese también es un aprendizaje clave mientras se desarrolla esto: una acción de notificación debe tratarse como un flujo de trabajo completo de extremo a extremo.

Migra de tarjetas a widgets

  • A continuación, Tarjetas y widgets.

  • Como puedes ver, una tarjeta es una pantalla completa a la que puedes acceder deslizando el dedo desde la cara de reloj.

  • Por otro lado, los widgets son varias tarjetas más pequeñas, de las cuales pueden caber varias en una pantalla desplazable.

  • Tanto las tarjetas como los widgets de WhatsApp se basan en los mismos datos subyacentes.

  • Y eso también es un giro.

  • Podrías pensar que nuestra app dibuja esta IU, pero en realidad no es así.

  • Solo describimos la IU y los datos, y el sistema los dibuja.

  • Además, presionar cualquiera de los íconos no puede ejecutar el código de tu app.

  • Solo puedes pedirle que abra una pantalla.

  • Y esa es nuestra lección.

  • Si el sistema dibuja tu IU, forma parte de tu producto y de las métricas de prueba.

  • Frank y Yuri profundizarán en su charla sobre los widgets.

Cómo evitar la caída de usuarios activos con recordatorios de actualización

  • Y eso me lleva a la última diapositiva, que es dar un paso atrás y preguntarnos, al final del día, queremos que cada vez más usuarios usen nuestras apps.

  • Ahora imagina que, como desarrollador, un día accedes a tus paneles y ves una caída significativa de usuarios activos.

  • Bueno, en primer lugar, sería muy preocupante.

  • Y, luego, podrías comenzar a depurarlo.

  • Y podrías sentir que hubo una falla o que hay una mala versión en curso.

  • Pero, en nuestro caso, no fue así.

  • Resulta que cada versión de la app de WhatsApp tiene un vencimiento integrado de 90 días.

  • Imagina que los usuarios se quedan con una compilación obsoleta, mientras que las compilaciones más recientes tardan en llegar a sus relojes.

  • Como resultado, los usuarios no salen de la app, pero esta deja de funcionar.

  • Por lo tanto, hicimos que la actualización de la app fuera parte del producto.

  • Como puedes ver en la pantalla, 15 días antes de que venza la app, aparece esto en la pantalla.

  • Luego, vuelve a aparecer cuando quedan 7, 5, 3, 2 y 1 día.

  • El usuario puede descartar el mensaje o ir a Play Store para actualizar la app.

  • Esto ayuda a que los usuarios reciban varios recordatorios de que se acerca el vencimiento de la app y, además, les da varias oportunidades para actualizarla.

  • Y ese es nuestro aprendizaje final.

  • Nuestro trabajo como desarrolladores no termina cuando enviamos una actualización al 100%.

  • Solo se completa cuando los usuarios reciben nuestras actualizaciones.

  • Esto nos lleva al final.

  • Si quieres continuar la conversación, no dudes en pasar a saludarnos durante el horario de atención.

  • A continuación, Frank y Yuri hablarán sobre los widgets de Wear.

  • Gracias.

Código

Fragmentos de código

Fragmento de Kotlin en el minuto 2:27 de "Moving to a shared codebase"
@Module
interface CallingModule {
    @BindsOptionalOf
    fun callingBackend(): CallingBackend
}

class Calls @Inject constructor(
    val backend: Optional<CallingBackend>,
) {
    fun start(): CallResult =
        backend.getOrNull()?.start()
            ?: CallResult.NotAllowed
}
Fragmento de Kotlin en el minuto 2:47 de "Moving to a shared codebase"
@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>,
)
Fragmento XML en el minuto 4:16 de "Cómo pasar a una base de código compartida"
<!-- 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" />
Fragmento XML en el minuto 05:03 de "Moving to a shared codebase"
<!-- phone -->
<string name="…_disclosure">… Learn more</string>

<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>
Fragmento de Kotlin en el minuto 05:59 de "Administra la vinculación de notificaciones"
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())
}
Fragmento de Kotlin en el minuto 7:06 de "Cómo administrar la vinculación de notificaciones"
clearActivityBackStack()

// enumerate our own, kill each
killOtherProcesses()

// no threads alive — now safe
deleteDataDirectories()

// show logged out confirmation
firePostCleanupIntent()
Fragmento de Kotlin en el minuto 10:30 de "Modernizing the Wear OS UI"
// FIX #1: ONE OWNER
CompositionLocalProvider(
  LocalSurfaceTransformation provides null
) {
  M3Card(transformation = t) {
    SharedChatEntry(...)
  }
}

// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)
Fragmento de Kotlin en el minuto 10:30 de "Modernizing the Wear OS UI"
// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
  CompositionLocalProvider(
    LocalSurfaceTransformation provides t
  ) { SharedChatEntry(...) }
}

// BUG #2: STALE ITEM SCOPE
val t = remember(spec) {
  SurfaceTransformation(spec)
}