Основы работы с приложениями

Уроки, извлеченные из опыта WhatsApp с Wear OS

Колонки

  • Мартин Пелант (Martin Pelant)

    Инженер-программист, Meta

  • Субхаг Бхатия (Subhag Bhatia)

    Инженер-программист, Meta

О приложении

О выступлении

Инженеры WhatsApp рассказывают, как они обновили приложение для Wear OS. На этом занятии рассматриваются архитектурные и UX-решения, лежащие в основе интеграции Material 3 и внедрения виджетов Wear.

Узнайте, какие уроки, шаблоны и инструменты помогли команде обновить приложение и сделать его удобным для пользователей.

Сводка

  • Общий код для Android и Wear OS позволяет избежать дублирования работы, но требует внедрения зависимостей и переопределения манифеста. Тесты должны сравнивать наборы мультисвязывания, чтобы модульные функции не исчезали без уведомления.
  • Управление передачей уведомлений во время выполнения предотвращает дублирование оповещений, позволяя часам работать независимо. Чтобы телефон не вибрировал дважды, отключите мост при входе в систему и включите его при выходе.
  • При переходе на Material 3 на круглых экранах необходимо учитывать изменения в поведении и визуальном стиле. Чтобы правильно использовать TransformingLazyColumn, нужно применять преобразования только на одном уровне и не кешировать позиции с помощью remember в Compose.
  • Если часы не обновляются вовремя, ограничения на срок действия приложения могут привести к внезапному падению числа активных пользователей. Напоминания о запланированных обновлениях направляют пользователей в Google Play до истечения 90-дневного срока действия сборки.

Gemini Notebook

Темы

  • wear-os
  • material-3
  • jetpack-compose
  • модуляризация
  • уведомления
  • tiles-and-widgets

Расшифровка

Введение

  • Здравствуйте!

  • Меня зовут Мартин.

  • А я Субхаг.

  • Мы инженеры из команды Meta, которая разработала WhatsApp для Wear OS.

  • Недавно мы обновили наше приложение.

  • Сегодня мы расскажем, чему научились, когда переходили на общую базу кода и создавали на ее основе новые функции.

  • WhatsApp на Wear OS работает без телефона.

  • После этого оно будет работать как отдельное устройство WhatsApp со сквозным шифрованием, собственной базой данных и подключением к серверу.

  • Поэтому он продолжает работать даже по Wi-Fi или LTE, когда телефон находится вне зоны действия.

  • WhatsApp для Wear OS был запущен в 2023 году.

  • Он работал на базе компактного портативного клиента, написанного на Rust.

  • Это позволило нам быстро запустить рабочую версию.

  • Однако это была вторая, независимая реализация WhatsApp, а значит, каждый протокол, шифрование и функцию приходилось создавать дважды.

  • Поэтому приложение для часов всегда отставало.

  • Из-за портативности консоли было сложно работать с фоном.

  • Клиент обрабатывал собственные потоки, таймеры и сокеты, которыми Android хочет управлять за вас.

  • На устройствах Android для фонового режима действуют правила.

  • В спящем режиме устройство отключается от сети.

  • После кеширования процесса время его выполнения ограничено или отсутствует.

  • Чтобы преодолеть это, вам нужен сервис, задание или push-уведомление.

  • Поэтому в приложении для часов нам пришлось настраивать их вручную.

  • Но наша база кода Android уже делает это.

  • Поэтому мы решили перейти на базу кода Android, которая уже проверена на миллиардах устройств и соответствует правилам Android.

Переход на общую базу кода

  • Это позволяет не создавать новые функции дважды.

  • Цель была проста: сохранить бизнес-логику и удалить пользовательский интерфейс.

  • Осталась только библиотека без интерфейса, которая использует потоки Kotlin в качестве API.

  • Однако проблема в том, что вы не можете распутать приложение такого размера за один раз.

  • В некоторых модулях интерфейс смешан с инфраструктурой.

  • Другие зависят от функций, которые не нужны в приложении для Wear OS.

  • Поэтому мы работали в двух направлениях: продолжали модулировать и убирали или перезаписывали то, что пока не могли удалить.

  • Начнем с модульности.

  • Чтобы исключить код из сборки, простого оператора "если это приложение"Телефон"" недостаточно, поскольку он не удаляет код.

  • Поэтому мы активно используем внедрение зависимостей, особенно необязательное и множественное.

  • Мы используем собственную систему внедрения зависимостей, но она очень похожа на Dagger.

  • Поэтому примеры приведены в Dagger.

  • С помощью необязательной привязки (в Dagger это BindsOptionalOf) можно объявить зависимость как необязательную, например вызов бэкенда.

    @Module
    interface CallingModule {
        @BindsOptionalOf
        fun callingBackend(): CallingBackend
    }
    
    class Calls @Inject constructor(
        val backend: Optional<CallingBackend>,
    ) {
        fun start(): CallResult =
            backend.getOrNull()?.start()
                ?: CallResult.NotAllowed
    }
  • Если сборка для часов включает модуль, который предоставляет реализацию, то внедренный бэкенд будет присутствовать и вызов сработает.

  • Если вы не укажете модуль, он будет иметь значение null, и API может вернуть 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>,
    )
  • Вы можете объявить набор с помощью multibinds, например MessageHandler, а затем каждый модуль функции может добавить собственный обработчик, используя multibinding.

  • Если вы не хотите поддерживать платежи, то можете не включать в сборку модуль платежей, и внедренный набор не будет содержать этот обработчик.

  • Таким образом, вопрос о том, поддерживается ли функция, решается на уровне графа сборки.

  • Однако у этого разделения есть и обратная сторона.

  • При прямых ссылках удаление модуля нарушит сборку.

  • При использовании необязательной или множественной привязки на модуль ничего не ссылается, поэтому сборка проходит успешно, а функция просто исчезает или используется вариант по умолчанию.

  • И мы попали именно в эту точку.

  • В результате рефакторинга функция была перенесена в отдельный модуль, который не был включен в сборку приложения и приложение для часов.

  • В результате длительность голосовых сообщений отображалась как нулевая.

  • Как этого избежать?

  • Ну, вам нужен тест, чтобы вернуть чек.

  • В нашем случае мы сравниваем наборы мультисвязывания между приложением "Телефон" и приложением для часов для важных интерфейсов.

  • Это гарантирует, что у нас всегда будет правильная реализация.

  • Теперь перейдем к тому, что мы пока не можем удалить.

  • Повторное использование модулей позволяет сэкономить время, но все объявленные в них элементы объединяются в манифесте.

  • Например, в лаунчере телефона может появиться дополнительный значок приложения, или вместе с приложением будут запускаться ненужные поставщики контента.

  • В инструменте объединения манифестов есть полезный атрибут tools:node="remove", который позволяет удалять компоненты.

    <!-- 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" />
  • А когда они будут удалены, оптимизатор сможет удалить и код.

  • Если вам все же нужно передать данные в действие, используйте activity-alias, чтобы перенаправить их в новый класс.

  • Однако следует учитывать, что эти атрибуты применяются только к манифестам с более низким приоритетом, поэтому порядок объединения определяет, будет ли что-то удалено или нет.

  • Ресурсы работают так же.

  • Повторное использование строк для телефонов бесплатно.

  • Однако некоторые обещают то, что часы сделать не могут.

  • Давайте рассмотрим пример в конце уведомления о шифровании, которое находится в начале каждого чата.

  • На телефоне в конце есть ссылка "Подробнее".

  • Однако на часах по ссылке ничего нет.

  • Поэтому мы повторно объявляем тот же ключ в модуле с более высоким приоритетом на часах и заменяем строку на "Используйте телефон, чтобы узнать больше".

    <!-- phone -->
    <string name="…_disclosure">… Learn more</string>
    
    <!-- wearable override, same key -->
    <string name="…_disclosure">…Use your phone to learn more.</string>
  • Затем объединитель ресурсов выбирает наш.

  • Вам также нужно что-то, что гарантирует, что вы случайно не переопределите строку.

  • Поэтому мы создали правило сборки, которое проверяет, разрешено ли переопределение. Если нет, сборка завершается ошибкой.

Как управлять дублированием уведомлений

  • Теперь поговорим об уведомлениях.

  • По умолчанию Wear OS дублирует уведомления с телефона.

  • Это здорово, но работает только при подключенном телефоне.

  • Мы хотим, чтобы WhatsApp работал независимо, поэтому наше приложение отправляет собственные уведомления.

  • Но если мост останется включенным, каждое сообщение будет жужжать дважды.

  • Если отключить мост, дубликаты будут устранены.

  • Есть статический флаг манифеста, но он применяется с момента установки до входа пользователя.

  • Если вы установите приложение и не войдете в аккаунт, то перестанете получать уведомления. Это не очень удобно, правда?

  • Поэтому мы задаем его во время выполнения с помощью 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())
    }
  • Когда вы вошли в аккаунт, мы отключили мост.

  • Когда вы выходите из аккаунта, мы снова включаем ее.

  • Однако имейте в виду, что API для чтения значений нет, и мы заметили, что часы их теряют.

  • Поэтому мы записываем его снова, когда часы снимаются с зарядного устройства или когда приложение обновляется.

  • Последняя тема, которую я хотел бы обсудить, – как выйти из встроенной версии.

  • Когда вы отменяете связь часов с аккаунтом, пользователи ожидают, что сообщения будут удалены.

  • Поэтому мы удаляем все: сообщения, ключи, настройки.

  • Но как сделать это безопасно?

  • Если вы удалите файлы, пока фоновые потоки все ещё записывают в них данные, вы получите исключения SQLite или, что ещё хуже, записи в заново созданные базы данных.

  • А очистить состояние процесса, не остановив его, невозможно.

  • В Android есть API clearApplicationUserData, который удаляет данные и останавливает приложение, но при этом сбрасывает разрешения.

  • На устройствах некоторых производителей дублирование уведомлений связано с разрешениями на уведомления, поэтому, если вы используете эту функцию, вы также можете потерять возможность дублирования экрана телефона.

  • Поэтому мы останавливаем процесс сами.

    clearActivityBackStack()
    
    // enumerate our own, kill each
    killOtherProcesses()
    
    // no threads alive — now safe
    deleteDataDirectories()
    
    // show logged out confirmation
    firePostCleanupIntent()
  • Мы создаем небольшой сервис во вторичном процессе, в котором нет ни DI, ни WorkManager, поэтому он не запускает остальную часть приложения.

  • После выхода из аккаунта этот сервис завершает все остальные процессы в приложении и только потом удаляет данные, когда ему ничего не мешает.

  • Поскольку мы не используем clearApplicationUserData, разрешения сохраняются, и мы можем включить переадресацию уведомлений, чтобы уведомления с телефона снова приходили.

  • На этом первая часть закончена. Теперь я передаю слово Субхагу, который расскажет об обновлении интерфейса.

Модернизация интерфейса Wear OS

  • Спасибо, Мартин.

  • После создания общей инфраструктуры мы сосредоточились на том, чтобы сделать WhatsApp по-настоящему удобным для использования на часах.

  • Первым шагом на этом пути стал переход на Material 3. Но сначала давайте разберемся, зачем это нужно.

  • На круглом экране, где важен каждый пиксель, Material 3 предоставил нам нужные компоненты и основу дизайна для модернизации WhatsApp для запястья.

  • Если вы посмотрите на визуальные эффекты, на первый взгляд это может показаться просто обновлением пользовательского интерфейса.

  • Мы переходили с Material 2 на Material 3, и это не могло произойти в одночасье.

  • Старый и новый код должны были сосуществовать за переключателем.

  • В результате экраны, которые визуально соответствовали Figma, все равно работали с ошибками.

  • Например, прокрутка с помощью заводной головки, жест "Назад" (пролистывание вправо) и некоторые другие базовые жесты не работали.

  • Все это говорит о том, что переход на новую систему проектирования – это не только визуальный переход, но и переход на новое поведение.

  • Кстати, о поведении: если вы посмотрите на ряды у краев, то заметите, что они уже и загнуты.

  • Это TransformingLazyColumn.

  • Строки преобразуются так, чтобы круглый безель не обрезал их.

  • Динамический экран может усложнить задачу.

  • Новые сообщения, перемещающиеся строки и пузыри всех форм.

  • Давайте посмотрим, что значит сделать это правильно.

  • Если вы посмотрите на левую часть экрана, то увидите, что пузыри уменьшаются слишком быстро.

  • Каждое сообщение представляет собой всплывающее окно, обернутое внутри карточки.

  • Мы указали, что преобразование должно применяться к родительской карточке, но оно также передавалось дочернему элементу.

  • В результате дочерний элемент bubble получает двойное преобразование.

  • Решение заключалось в том, чтобы применить преобразование на одном уровне, а именно на родительской карточке.

  • И это тоже ключевой принцип.

  • Мы обеспечили преобразование одного элемента за раз.

  • И вот ещё одна проблема: пузыри, которые маленькие и блеклые прямо в середине экрана, где они должны быть полноразмерными.

  • Размер зависит от положения на экране.

  • В первой версии мы сохраняли размер с помощью функции remember в Compose, но в итоге сохранялось и старое положение.

  • В результате, когда приходит новое сообщение и сдвигает старые пузыри вверх, их преобразование по-прежнему основано на их старом положении.

  • И это приводит к ошибке.

  • Чтобы исправить это, мы перестали сохранять размер с помощью вызова remember и избавились от вызова.

  • И это тоже урок.

  • При перемещении строки ее преобразование должно перемещаться вместе с ней.

  • Здесь вы можете увидеть оба исправления в коде.

    // 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)
    }
  • В левом верхнем углу вы можете видеть, что преобразование применяется на двух уровнях, а в правом верхнем углу оно применяется только на одном уровне, то есть на карточке M3.

  • В левом нижнем углу мы используем вызов remember для кэширования преобразования, но он также кэширует позицию.

  • А в правом нижнем углу мы каждый раз вычисляем преобразование с учетом последнего положения пузыря.

  • Как видите, в результате мы получаем единообразный интерфейс на всех экранах, где ключевой контент остается ближе к центру, а строки естественным образом перемещаются ближе к краю.

Как управлять уведомлениями на часах

  • Теперь поговорим об уведомлениях.

  • Теперь представьте, что вы почувствовали вибрацию на запястье, посмотрели на часы и увидели сообщение.

  • Но через несколько мгновений ваше запястье снова и снова вибрирует.

  • Вы получаете несколько уведомлений, но каждый раз видите одно и то же.

  • Это очень раздражает.

  • Чтобы решить эту проблему, мы задали себе два вопроса.

  • О каком событии должно сообщать уведомление?

  • и какое событие должно обновлять уведомления без звука?

  • Это происходило потому, что обновление фото профиля, скачивание медиафайлов и получение квитанций, которые должны были происходить незаметно, оповещали пользователя.

  • Теперь при получении нового сообщения часы вибрируют ровно один раз, а уведомления о событиях, не требующих внимания, обновляются беззвучно.

  • И это тоже урок.

  • Если уведомления – это один из основных путей пользователя, нужно убедиться, что каждое оповещение действительно заслуживает внимания.

  • Об уведомлениях.

  • Раньше уведомления WhatsApp на Wear OS позволяли делать две вещи:

  • Вы можете прочитать сообщение или открыть приложение.

  • Раньше вы не могли отвечать на уведомления напрямую.

  • Мы решили эту проблему, используя действие уведомления Android с RemoteInput API.

  • Теперь пользователь может набрать ответ на клавиатуре, продиктовать его или выбрать один из готовых вариантов.

  • Но помните, что нажатие кнопки "Отправить" – это не конец.

  • Если сообщение отправлено успешно, мы помечаем чат как прочитанный и удаляем уведомление.

  • Если сообщение не доставлено, вы можете повторить попытку одним нажатием.

  • И это также ключевой урок, извлеченный при разработке этого, заключается в том, что действие уведомления должно рассматриваться как полный сквозной рабочий процесс.

Переход с карточек на виджеты

  • Далее – карточки и виджеты.

  • Карточка – это полноэкранный элемент, который можно открыть, проведя по циферблату.

  • Виджеты – это несколько карточек меньшего размера, которые помещаются на одном экране с прокруткой.

  • И в карточках, и в виджетах WhatsApp используются одни и те же данные.

  • И это тоже поворот.

  • Вы можете подумать, что этот интерфейс отрисовывается нашим приложением, но на самом деле это не так.

  • Мы только описываем интерфейс и данные, а система их отрисовывает.

  • Кроме того, нажатие на значок не может запустить код приложения.

  • Вы можете только попросить его открыть экран.

  • Вот и всё на сегодня.

  • Если система отрисовывает ваш интерфейс, он является частью вашего продукта и показателей тестирования.

  • Фрэнк и Юрий подробно расскажут о виджетах.

Как предотвратить резкое снижение числа активных пользователей с помощью напоминаний об обновлении

  • И это подводит меня к последнему слайду. Давайте сделаем шаг назад и спросим себя: в конце концов, мы хотим, чтобы все больше и больше пользователей использовали наши приложения.

  • Теперь представьте, что вы разработчик. Однажды вы входите в систему и видите, что число активных пользователей резко упало.

  • Ну, во-первых, это было бы очень тревожно.

  • А затем вы можете начать его отладку.

  • И вы можете подумать, что произошел сбой или что-то не так с выпуском.

  • Но в нашем случае ни один из этих вариантов не подходил.

  • Оказывается, у каждой версии WhatsApp есть встроенный срок действия – 90 дней.

  • Представьте, что пользователи застряли на устаревшей сборке, а новые сборки не успевают дойти до их часов.

  • В результате пользователи не покидают приложение, но оно перестает работать.

  • Поэтому мы сделали обновление приложения частью продукта.

  • Как вы видите на экране, за 15 дней до истечения срока действия приложения на экране появляется это сообщение.

  • Затем оно появляется снова, когда до окончания срока действия остается 7, 5, 3, 2 и 1 день.

  • Пользователь может закрыть его или перейти в Google Play, чтобы обновить приложение.

  • Это позволяет пользователям несколько раз напомнить о том, что срок действия приложения истекает, и дает им несколько возможностей обновить его.

  • И это наш последний вывод.

  • Работа разработчика не заканчивается, когда обновление выпущено для всех пользователей.

  • Она завершается, только когда пользователи получают обновления.

  • На этом всё.

  • Если вы хотите продолжить разговор, приходите на наши консультации.

  • А теперь Фрэнк и Юрий расскажут о виджетах для Wear OS.

  • Спасибо!

Код

Фрагменты кода

Фрагмент кода на Kotlin в видео "Переход на общую базу кода"
@Module
interface CallingModule {
    @BindsOptionalOf
    fun callingBackend(): CallingBackend
}

class Calls @Inject constructor(
    val backend: Optional<CallingBackend>,
) {
    fun start(): CallResult =
        backend.getOrNull()?.start()
            ?: CallResult.NotAllowed
}
Фрагмент кода Kotlin на 02:47 в видео "Переход на общую базу кода"
@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>,
)
Фрагмент XML-кода на 04:16 в видео "Переход на общую базу кода"
<!-- 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" />
Фрагмент XML на 05:03 в видео "Переход на общую базу кода"
<!-- phone -->
<string name="…_disclosure">… Learn more</string>

<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>
Фрагмент кода на Kotlin в разделе "Управление дублированием уведомлений"
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())
}
Фрагмент кода на Kotlin в разделе "Управление передачей уведомлений" (07:06)
clearActivityBackStack()

// enumerate our own, kill each
killOtherProcesses()

// no threads alive — now safe
deleteDataDirectories()

// show logged out confirmation
firePostCleanupIntent()
Фрагмент кода на Kotlin в видео "Модернизация интерфейса Wear OS"
// FIX #1: ONE OWNER
CompositionLocalProvider(
  LocalSurfaceTransformation provides null
) {
  M3Card(transformation = t) {
    SharedChatEntry(...)
  }
}

// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)
Фрагмент кода на Kotlin в видео "Модернизация интерфейса Wear OS"
// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
  CompositionLocalProvider(
    LocalSurfaceTransformation provides t
  ) { SharedChatEntry(...) }
}

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