Inżynierowie WhatsAppa opowiedzą o tym, jak ulepszyli aplikację na Wear OS na dużą skalę. Podczas tej sesji omówimy decyzje dotyczące architektury i UX, które doprowadziły do integracji Material 3 i wdrożenia widżetów na Wear.
Poznaj wnioski, wzorce i narzędzia, które zespół wykorzystał do zaktualizowania aplikacji i zapewnienia spójnych wrażeń użytkowników.
TransformingLazyColumn oznacza stosowanie przekształceń tylko na jednym poziomie i unikanie buforowania pozycji za pomocą remember w Compose.Transkrypcja
Cześć wszystkim.
Nazywam się Martin.
A ja Subhag.
Jesteśmy inżynierami w firmie Meta i pracujemy nad aplikacją WhatsApp na Wear OS.
Niedawno zmodernizowaliśmy naszą aplikację.
Dziś podzielimy się wnioskami, które wyciągnęliśmy z migracji do wspólnej bazy kodu i tworzenia na jej podstawie nowych funkcji.
WhatsApp na Wear OS działa bez telefonu.
Po połączeniu działa ono jako osobne urządzenie WhatsApp, w pełni zaszyfrowane, tak jak telefon, z własną bazą danych i połączeniem z serwerem.
Dzięki temu działa nawet przez Wi-Fi lub LTE, gdy telefon jest poza zasięgiem.
Aplikację WhatsApp na Wear OS wprowadziliśmy w 2023 r.
Był on obsługiwany przez kompaktowego, przenośnego klienta napisanego w języku Rust.
Dzięki temu szybko przeszliśmy do produkcji.
Była to jednak druga, niezależna implementacja WhatsAppa, co oznacza, że każdy protokół, szyfrowanie i funkcję trzeba było utworzyć dwukrotnie.
Aplikacja na zegarek zawsze była więc w tyle.
Przenośność utrudniała też obsługę tła.
Klient obsługiwał własne wątki, liczniki czasu i gniazda, czyli elementy, którymi Android chce zarządzać za Ciebie.
Na Androidzie działanie w tle podlega regułom.
W trybie drzemki tracisz połączenie z siecią.
Gdy proces zostanie zapisany w pamięci podręcznej, czas jego wykonywania będzie ograniczony lub nie będzie go wcale.
Aby to zrobić, potrzebujesz usługi, zadania lub pusha.
Nasza aplikacja na zegarek musiała więc łączyć je ręcznie.
Ale nasza baza kodu na Androida już to robi.
Dlatego zdecydowaliśmy się na ponowne wykorzystanie i migrację do naszej bazy kodu Androida, która została przetestowana na miliardach urządzeń i jest już zgodna z zasadami Androida.
Dzięki temu nie trzeba tworzyć nowych funkcji dwukrotnie.
Cel był prosty: zachować logikę biznesową i usunąć interfejs.
Pozostaje biblioteka bez interfejsu, która używa przepływów Kotlin jako interfejsu API.
Problem polega jednak na tym, że nie da się rozplątać aplikacji o takim rozmiarze za jednym razem.
Niektóre moduły łączą interfejs z infrastrukturą.
Inne zależą od funkcji, których nie potrzebujesz w aplikacji na Wear OS.
Pracowaliśmy więc na 2 frontach: kontynuowaliśmy modularyzację i usuwaliśmy lub nadpisywaliśmy to, czego nie mogliśmy jeszcze usunąć.
Zacznijmy od modularyzacji.
Aby nie uwzględniać kodu w kompilacji, nie wystarczy prosta instrukcja „jeśli to jest aplikacja na telefon”, ponieważ nie usuwa ona kodu.
Dlatego w dużym stopniu polegamy na wstrzykiwaniu zależności, zwłaszcza opcjonalnym i z wieloma powiązaniami.
Korzystamy z własnego wstrzykiwania zależności, ale jest ono bardzo podobne do tego, co oferuje Dagger.
Przykłady są więc w Daggerze.
Dzięki opcjonalnemu wiązaniu (w Daggerze jest to BindsOptionalOf) możesz zadeklarować zależność jako opcjonalną, na przykład wywołując backend.
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}Jeśli kompilacja zegarka zawiera moduł, który zapewnia implementację, wstrzyknięty backend będzie obecny, a wywołanie będzie działać.
Jeśli pominiesz moduł, będzie on miał wartość null, a interfejs API może zwrócić błąd NotAllowed.
Wiązanie wielokrotne działa tak samo, ale w przypadku zbiorów.
@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>,
)Możesz zadeklarować zbiór za pomocą multibinds, na przykład MessageHandler, a następnie każdy moduł funkcji może dodać własny moduł obsługi za pomocą multibindingu into set.
Jeśli nie chcesz obsługiwać płatności, możesz na przykład pominąć cały moduł płatności w kompilacji, a wstrzyknięty zestaw nie będzie zawierał tego modułu obsługi.
W ten sposób pytanie o to, czy funkcja jest obsługiwana, jest pytaniem o graf kompilacji.
Jednak to rozdzielenie działa w obie strony.
W przypadku odwołań bezpośrednich usunięcie modułu spowodowałoby błąd kompilacji.
W przypadku opcjonalnego lub wielokrotnego wiązania nie ma niczego, co odwołuje się do modułu, więc kompilacja się udaje, a funkcja po cichu znika lub zostaje zastosowana domyślna opcja.
I właśnie to osiągnęliśmy.
Refaktoryzacja przeniosła funkcję do osobnego modułu, a nasza aplikacja do kompilacji i aplikacja na zegarek jej nie uwzględniły.
W efekcie wiadomości głosowe miały czas trwania równy 0 sekund.
Jak temu zapobiec?
Musisz przeprowadzić test, aby ponownie uzyskać plakietkę.
W naszym przypadku porównujemy zestawy wielowiązań między aplikacją na telefon i aplikacją na zegarek pod kątem interfejsów, które mają znaczenie.
Dzięki temu mamy pewność, że zawsze mamy powiązaną właściwą implementację.
Ścieżka 2 obejmuje to, czego nie udało nam się jeszcze usunąć.
Ponowne używanie modułów pozwoli Ci zaoszczędzić czas, ale wszystko, co deklarują, zostanie scalone z manifestem.
Aktywność programu uruchamiającego na telefonie dodałaby dodatkową ikonę aplikacji lub niepotrzebne mechanizmy dostarczania treści uruchamiałyby się wraz z aplikacją.
Narzędzie do scalania plików manifestu udostępnia przydatny atrybut tools:node="remove", który umożliwia wyodrębnienie komponentów.
<!-- 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" />Gdy zostaną usunięte, narzędzie do zmniejszania rozmiaru może usunąć również kod.
Jeśli coś nadal musi dotrzeć do aktywności, możesz użyć activity-alias, aby przekierować do nowej klasy.
Pamiętaj jednak, że te atrybuty dotyczą tylko plików manifestu o niższym priorytecie, więc kolejność scalania decyduje o tym, czy coś zostanie usunięte.
Zasoby działają w ten sam sposób.
Dzięki ponownemu wykorzystaniu otrzymujesz ciągi tekstowe na telefon bezpłatnie.
Niektóre z nich obiecują jednak rzeczy, których zegarek nie jest w stanie zrobić.
Przyjrzyjmy się przykładowi na końcu powiadomienia o szyfrowaniu, które znajduje się na początku każdego czatu.
Na telefonie kończy się linkiem „Więcej informacji”.
Jednak po kliknięciu linku na zegarku nic się nie dzieje.
W takim przypadku ponownie deklarujemy ten sam klucz w module o wyższym priorytecie na zegarku i zastępujemy ciąg tekstem „użyj telefonu, aby dowiedzieć się więcej”.
<!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>Następnie scalanie zasobów wybiera nasze.
Potrzebujesz też czegoś, co zapobiegnie przypadkowemu zastąpieniu ciągu znaków.
Dlatego utworzyliśmy regułę kompilacji, która sprawdza, czy zastąpienie jest na liście dozwolonych, i jeśli nie jest, powoduje błąd.
Porozmawiajmy teraz o powiadomieniach.
Domyślnie Wear OS przekazuje lub powiela powiadomienia z telefonu.
To świetnie, ale działa tylko wtedy, gdy telefon jest połączony.
Chcemy, aby WhatsApp działał niezależnie, więc nasza aplikacja publikuje własne powiadomienia.
Jeśli jednak mostkowanie pozostanie włączone, każda wiadomość będzie powodować podwójne powiadomienie.
Wyłączenie mostkowania rozwiązuje problem z duplikatami.
Istnieje statyczna flaga manifestu, ale obowiązuje ona od momentu instalacji, zanim użytkownik się zaloguje.
Jeśli więc zainstalujesz aplikację i nie zalogujesz się, nagle stracisz wszystkie powiadomienia, co nie jest zbyt dobre, prawda?
Dlatego ustawiamy go w czasie działania za pomocą 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())
}Gdy się zalogujesz, wyłączymy mostkowanie.
Gdy się wylogujesz, włączymy ją z powrotem.
Pamiętaj jednak, że nie ma interfejsu API do odczytywania wartości, a zegarek może je utracić.
Dlatego zapisujemy je ponownie, gdy zegarek zostanie odłączony od ładowarki lub gdy aplikacja zostanie zaktualizowana.
Ostatni temat, który chcę omówić, to wylogowywanie się.
Gdy odłączysz zegarek od konta, użytkownicy będą oczekiwać, że wiadomości zostaną usunięte.
Usuwamy więc wszystko – wiadomości, klucze, ustawienia.
Ale jak to zrobić bezpiecznie?
Jeśli usuniesz pliki, gdy wątki w tle nadal będą w nich zapisywać, wystąpią wyjątki SQLite lub, co gorsza, zapisy w nowo utworzonych bazach danych.
Nie można wyczyścić stanu procesu bez jego zatrzymania.
Android udostępnia interfejs API clearApplicationUserData, który usuwa dane i zatrzymuje aplikację, ale też resetuje uprawnienia.
W przypadku niektórych producentów OEM mostkowanie powiadomień jest powiązane ze zgodą na wyświetlanie powiadomień, więc jeśli używasz tej funkcji, możesz nagle utracić też możliwość wyświetlania powiadomień z telefonu.
Dlatego sami zatrzymujemy ten proces.
clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()Tworzymy niewielką usługę w procesie pomocniczym, który nie ma DI ani menedżera zadań, więc nie uruchamia reszty aplikacji.
Po wylogowaniu się ta usługa kończy wszystkie inne procesy w naszej aplikacji i dopiero wtedy usuwa wszystkie dane, gdy nic nie zakłóca jej działania.
Ponieważ nie używamy clearApplicationUserData, uprawnienia pozostają i możemy włączyć mostkowanie powiadomień, aby powiadomienia z telefonu wróciły.
To wszystko, jeśli chodzi o część 1. Teraz przekażę głos Subhagowi, który opowie o odświeżonym interfejsie.
Dzięki, Martin.
Po utworzeniu wspólnej infrastruktury skupiliśmy się na tym, aby aplikacja WhatsApp była jak najlepiej dostosowana do zegarka.
Pierwszym etapem tej podróży było przejście na Material 3, ale najpierw zapytajmy, dlaczego.
Na okrągłym wyświetlaczu, gdzie liczy się każdy piksel, Material 3 zapewnił nam odpowiednie komponenty i podstawy projektowania, dzięki którym mogliśmy zmodernizować WhatsAppa na zegarki.
Jeśli spojrzysz na elementy wizualne, na pierwszy rzut oka może się wydawać, że to tylko aktualizacja interfejsu.
Przechodziliśmy z Material 2 na Material 3, a to nie mogło się wydarzyć z dnia na dzień.
Stary i nowy kod musiały działać obok siebie, a przełączanie między nimi odbywało się za pomocą przełącznika.
W rezultacie ekrany, które wizualnie pasowały do Figmy, nadal wykazywały pewne nieprawidłowe zachowania.
Na przykład przewijanie za pomocą pokrętła, przesunięcie w prawo, aby wrócić, czy niektóre z najbardziej podstawowych gestów nie działały.
Wszystko to oznacza, że migracja systemu projektowania to nie tylko migracja wizualna, ale też migracja zachowań.
Jeśli chodzi o zachowanie, rzędy przy krawędziach są węższe i zakrzywione.
That's TransformingLazyColumn.
Wiersze są przekształcane, aby okrągła ramka ich nie odcinała.
Dynamiczny ekran może utrudniać sprawę.
Pojawiają się nowe wiadomości, wiersze się przesuwają, a dymki mają różne kształty.
Zobaczmy, co to oznaczało w praktyce.
Jeśli spojrzysz na lewą stronę, zobaczysz, że bąbelki kurczą się zbyt agresywnie.
Każda wiadomość jest wewnętrznie dymkiem zawartym w karcie.
Kazaliśmy karcie nadrzędnej zastosować przekształcenie, ale to samo przekształcenie zostało przekazane do dymka podrzędnego.
W efekcie dymek podrzędny otrzymuje dwukrotnie większą wartość przekształcenia.
Rozwiązaniem było zastosowanie przekształcenia na jednym poziomie, czyli na karcie nadrzędnej.
To również kluczowa zasada.
We ensured trans- one transform for one item.
Kolejny problem: bąbelki, które są małe i wyblakłe na środku ekranu, gdzie powinny być pełnowymiarowe.
Rozmiar jest funkcją ich pozycji na ekranie.
W naszej początkowej wersji zapisaliśmy dopasowanie rozmiaru za pomocą funkcji zapamiętywania w Compose, ale spowodowało to też zapisanie starszej pozycji.
W rezultacie, gdy pojawia się nowa wiadomość i przesuwa starsze dymki w górę, ich przekształcenie nadal opiera się na ich starszej pozycji.
To powoduje błąd.
Aby rozwiązać ten problem, przestaliśmy zapisywać rozmiar za pomocą wywołania zapamiętywania i usunęliśmy to wywołanie.
To też jest nauka.
Gdy wiersz jest przenoszony, jego przekształcenie musi zostać przeniesione razem z nim.
Tutaj możesz zobaczyć obie poprawki w kodzie.
// 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)
}W lewym górnym rogu widać, że przekształcenie jest stosowane na 2 poziomach, a w prawym górnym rogu – że jest stosowane tylko na 1 poziomie, czyli na karcie M3.
W lewym dolnym rogu używamy wywołania remember, aby zapisać przekształcenie w pamięci podręcznej, ale zapisuje ono również pozycję.
W prawym dolnym rogu obliczamy przekształcenie, za każdym razem biorąc pod uwagę najnowszą pozycję bąbelka.
Jak widać, efekt jest spójny na wszystkich ekranach, kluczowe treści pozostają blisko środka, a wiersze naturalnie przesuwają się w pobliżu krawędzi.
Teraz omówimy powiadomienia.
Teraz wyobraź sobie, że czujesz wibracje na nadgarstku, sprawdzasz zegarek i widzisz wiadomość.
Jednak chwilę później zegarek na nadgarstku wibruje ponownie i ponownie.
Otrzymujesz wiele powiadomień, a gdy je sprawdzasz, za każdym razem widzisz to samo.
To bardzo irytujące.
Aby rozwiązać ten problem, zadaliśmy sobie 2 pytania.
Jakie zdarzenie powinno wywołać powiadomienie użytkownika?
Które zdarzenie powinno aktualizować powiadomienia bez powiadamiania użytkownika?
Ten błąd występował, ponieważ za kulisami aktualizacje zdjęcia profilowego, pobieranie multimediów i potwierdzenia, które powinny aktualizować powiadomienie w tle, zamiast tego wysyłały alert do użytkownika.
Teraz, gdy nadejdzie nowa wiadomość, zegarek zawibruje dokładnie raz, a zdarzenia, które nie wymagają alertu, odświeżą powiadomienie bez dźwięku.
To też jest lekcja.
Jeśli powiadomienia są jednym z głównych elementów ścieżki użytkownika, musimy zadbać o to, aby każdy alert naprawdę zasługiwał na jego uwagę.
Jeśli chodzi o powiadomienia,
Wcześniej powiadomienia z WhatsAppa na Wear OS umożliwiały wykonywanie 2 czynności.
Możesz przeczytać wiadomość lub otworzyć aplikację.
Jeśli chcesz odpowiedzieć bezpośrednio na powiadomienie, wcześniej nie było to możliwe.
Wypełniliśmy tę lukę, używając działania powiadomienia na Androidzie z interfejsem RemoteInput API.
Użytkownik może teraz wpisać lub podyktować odpowiedź albo kliknąć szybką odpowiedź.
Pamiętaj jednak, że wysłanie wiadomości to nie koniec.
Jeśli wiadomość zostanie wysłana, oznaczymy czat jako przeczytany i usuniemy powiadomienie.
Jeśli wiadomość nie zostanie dostarczona, możesz spróbować ponownie jednym kliknięciem.
To również kluczowa kwestia, którą odkryliśmy podczas tworzenia tej funkcji: działanie powiadomienia musi być traktowane jako kompletny, kompleksowy proces.
Następnie karty i widżety.
Karta, jak widać, zajmuje cały ekran, który można otworzyć, przesuwając palcem po tarczy zegarka.
Widżety to mniejsze karty, których kilka może zmieścić się na jednym ekranie z możliwością przewijania.
Zarówno kafelki, jak i widżety WhatsApp korzystają z tych samych danych.
To też jest zwrot akcji.
Możesz pomyśleć, że ten interfejs jest rysowany przez naszą aplikację, ale tak nie jest.
Opisujemy tylko interfejs i dane, a system je rysuje.
Kliknięcie dowolnej ikony nie może uruchamiać kodu aplikacji.
Możesz tylko poprosić o otwarcie ekranu.
To już koniec lekcji.
Jeśli system renderuje interfejs, jest on częścią produktu i wskaźników testowania.
Frank i Yuri będą kontynuować rozmowę o widżetach.
To prowadzi nas do ostatniego slajdu. Cofnijmy się o krok i zadajmy sobie pytanie: czy ostatecznie nie chodzi nam o to, żeby coraz więcej użytkowników korzystało z naszych aplikacji?
Teraz wyobraź sobie, że jako deweloper logujesz się pewnego dnia do panelu i widzisz znaczny spadek liczby aktywnych użytkowników.
Przede wszystkim byłoby to bardzo niepokojące.
Następnie możesz zacząć debugować.
Możesz mieć wrażenie, że doszło do awarii lub że coś jest nie tak z nową wersją.
Jednak w naszym przypadku nie było to prawdą.
Okazuje się, że każda wersja aplikacji WhatsApp ma wbudowany 90-dniowy okres ważności.
Wyobraź sobie, że użytkownicy utknęli na nieaktualnej kompilacji, a nowe kompilacje nie docierają na ich zegarki.
W rezultacie użytkownicy nie opuszczają aplikacji, ale przestaje ona działać.
Dlatego aktualizowanie aplikacji stało się częścią produktu.
Jak widać na ekranie, 15 dni przed wygaśnięciem aplikacji na ekranie pojawi się ten komunikat.
Następnie pojawia się ponownie, gdy do końca okresu pozostaje 7, 5, 3, 2 i 1 dzień.
Użytkownik może odrzucić powiadomienie lub przejść do Sklepu Play, aby zaktualizować aplikację.
Dzięki temu użytkownicy otrzymują kilka przypomnień o zbliżającym się wygaśnięciu aplikacji i mają kilka okazji do jej zaktualizowania.
To już ostatni wniosek.
Nasza praca jako deweloperów nie kończy się, gdy udostępnimy aktualizację wszystkim użytkownikom.
Proces jest zakończony dopiero wtedy, gdy użytkownicy otrzymają nasze aktualizacje.
To już koniec.
Jeśli chcesz kontynuować rozmowę, zapraszamy na konsultacje.
Teraz Frank i Yuri opowiedzą o widżetach na Wear.
Dziękujemy.
@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)
}