Инженеры WhatsApp рассказывают, как они обновили приложение для Wear OS. На этом занятии рассматриваются архитектурные и UX-решения, лежащие в основе интеграции Material 3 и внедрения виджетов Wear.
Узнайте, какие уроки, шаблоны и инструменты помогли команде обновить приложение и сделать его удобным для пользователей.
TransformingLazyColumn, нужно применять преобразования только на одном уровне и не кешировать позиции с помощью remember в Compose.Расшифровка
Здравствуйте!
Меня зовут Мартин.
А я Субхаг.
Мы инженеры из команды 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, разрешения сохраняются, и мы можем включить переадресацию уведомлений, чтобы уведомления с телефона снова приходили.
На этом первая часть закончена. Теперь я передаю слово Субхагу, который расскажет об обновлении интерфейса.
Спасибо, Мартин.
После создания общей инфраструктуры мы сосредоточились на том, чтобы сделать 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.
Спасибо!
@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)
}