Inżynier oprogramowania
Jetpack Compose ułatwia projektowanie wysokiej jakości interfejsów użytkownika, ale wykrywanie problemów, które wpływają na wydajność interfejsu, może być trudne. Tego rodzaju problemy mogą prowadzić do problemów, które zauważą użytkownicy, takich jak opóźnienie uruchamiania i pomijanie klatek, które objawia się jako zacinanie się podczas przewijania lub przerywane animacje.
W tym wystąpieniu przedstawimy narzędzia, które pomogą Ci odkryć, co wpływa na wydajność interfejsu, skupiając się na Perfetto i śledzeniu (ponownego) komponowania. Następnie omówimy konkretne antywzorce, których możesz używać i które mogą Cię niepostrzeżenie kosztować, a także sposoby ich znajdowania w kodzie i pozytywne wzorce, którymi możesz je zastąpić. Po zakończeniu tej prezentacji będziesz wiedzieć, jak myśleć w Compose z nastawieniem na wydajność oraz jak profilować, analizować i weryfikować optymalizacje wydajności w aplikacji.
Transkrypcja
Cześć,
Cześć!
Mamy nadzieję, że udało Ci się odpocząć.
Mam nadzieję, że jesteś po kawie, masz nawilżoną skórę i tak dalej.
Dzisiaj przyjrzymy się temu bliżej.
No dobrze, zaczynamy.
Dzisiaj przyjrzymy się temu bliżej.
Nazywam się Andrei Shikov, a na scenie towarzyszy mi Andrew Bailey.
Obaj pracujemy w zespole zajmującym się środowiskiem wykonawczym kompilatora Compose, ale zajmujemy się też wieloma innymi rzeczami, w tym wydajnością.
Tematem dzisiejszej rozmowy jest odpowiedź na Twoje pytania dotyczące wydajności, ponieważ Jetpack Compose stał się domyślnym zestawem narzędzi UI do tworzenia aplikacji na Androida.
Wiele osób pyta teraz, jak zapewnić jak największą wydajność interfejsu Compose.
Dziś, jak już wspominałem, szczegółowo omówimy ten temat.
Przedstawimy też kilka narzędzi, które pomogą Ci osiągnąć jeszcze lepsze wyniki w Compose.
Na początek chciałbym omówić, dlaczego wydajność jest dla nas ważna.
Gdy myślisz o jakości i tworzeniu wysokiej jakości aplikacji, zwykle myślisz o animacjach i bogatym zestawie funkcji, a czasem zapominasz o wydajności. Jednak to, jak użytkownicy postrzegają aplikację, naprawdę zależy od jej wydajności.
Jeśli animacje się zacinają, wrażenia natychmiast się pogarszają.
Wszystkie dane i liczby, o których dzisiaj wspomnimy, to wskaźniki zastępcze, które wybraliśmy, aby pokazać, co użytkownicy myślą o aplikacji, o animacjach i o wszystkim, co dzieje się na ekranie.
Zasadniczo optymalizacja aplikacji polega na poprawie wrażeń użytkowników.
Chcę, żebyście o tym pamiętali, gdy będziemy dziś szczegółowo omawiać różne tematy.
Gdy zaczynasz analizować skuteczność aplikacji, wykonaj 3 szybkie czynności.
Po pierwsze, jeśli mierzysz wydajność, mierzysz ją w trybie wersji, więc nie mamy wielu informacji o możliwości debugowania, a to wszystko spowalnia naszą aplikację.
Po drugie, włącz R8 lub inne minifikatory.
W tym roku wprowadziliśmy optymalizację, która dwukrotnie zwiększa wydajność współprogramów, a to nie wszystko.
Podczas optymalizacji Compose w dużym stopniu polegamy na R8.
Ostatnia rzecz, szczególnie w przypadku danych dotyczących uruchamiania: upewnij się, że używasz profilu podstawowego.
Dzięki temu klasy, które wczytujesz podczas uruchamiania, są wstępnie skompilowane i wstępnie wczytane, gdy aplikacja się uruchamia.
Jak widać, jest to około 6 razy więcej niż w przypadku zwykłej kompilacji aplikacji z możliwością debugowania.
A gdy zmierzysz wszystkie te elementy, połączą się one z innymi korzyściami, które uzyskasz dzięki kodowi.
Jeśli po wykonaniu tych czynności aplikacja nadal działa wolno lub występują inne problemy z wydajnością, warto skorzystać z pętli wydajności.
Chcesz zacząć analizować dane dotyczące konkretnych scenariuszy.
Załóżmy na przykład, że aplikacja ładuje się zbyt długo. Możesz ją zresetować, uruchomić w zwykły sposób i sprawdzić, ile czasu zajmuje jej wyświetlenie pierwszego ekranu z danymi.
Może aplikacja trochę się zacina, więc przewijasz ją i upewniasz się, że przewijanie jest powtarzalne i stabilne.
Stabilne środowisko na tym slajdzie jest bardzo ważne, ponieważ podczas późniejszej optymalizacji chcesz mieć pewność, że zauważysz poprawę danych i będziesz w stanie potwierdzić, że optymalizacje faktycznie działają zgodnie z oczekiwaniami.
Testy porównawcze są do tego bardzo przydatne, ponieważ rejestrują już wiele danych, wykonują wiele iteracji, aby upewnić się, że na urządzeniu nie ma zbyt wielu zakłóceń, a także przeprowadzają wiele konfiguracji urządzenia, które mogą Ci pomóc.
Testy porównawcze są też domyślnie powtarzalnymi scenariuszami i to właśnie staramy się osiągnąć.
Możesz też jednak użyć wielu skryptów.
Na koniec profilujesz, ulepszasz coś w aplikacji, analizujesz dane i powtarzasz te czynności, być może z innymi danymi dotyczącymi scenariuszy, w których aplikacja ma problemy.
Pierwszym narzędziem, które dziś przedstawiamy i które warto dobrze poznać, są ślady Perfetto.
Rekordy śledzenia Perfetto rejestrują wszystko, co dzieje się na urządzeniu podczas nagrywania śladu, np. podczas uruchamiania testu porównawczego makro lub skryptu, który nagrywasz.
To ogromna pomoc dla każdego inżyniera, który pracuje nad wydajnością Androida.
W moim przypadku większość dnia pracy spędzam obecnie na analizowaniu logów Perfetto.
Wyglądają one mniej więcej tak:
To bardzo dużo danych.
Jak już wspominałem, rejestrują one wszystko, co dzieje się na urządzeniu.
Na początku możesz mieć trudności ze znalezieniem swojej aplikacji, dlatego zalecam skorzystanie z tego przydatnego paska wyszukiwania.
Wyszukaj Compose, pakiet aplikacji i inne elementy.
Skup się na znanych Ci rzeczach.
Gdy już się oswoisz z tym narzędziem i znajdziesz proces aplikacji, zobaczysz linie czasu renderowania klatek i czas trwania wielu operacji w aplikacji.
Jeśli wyizolujemy jeden z nich, zobaczymy coś takiego.
Pierwszy to praca wątku głównego.
To tutaj Compose wykonuje większość swoich zadań.
Następnie mamy RenderThread.
W tym miejscu przetwarzamy polecenia dotyczące obszaru roboczego i kodujemy je na potrzeby GPU.
Następnie GPU wykonuje swoje zadanie, a system tworzy kompozycję.
Dzisiaj skupimy się tylko na głównym wątku.
Ponieważ to właśnie tam Compose wykonuje swoją pracę.
Mówimy o Compose, więc warto skupić się tylko na tym.
Gdy wyizolujemy tę jedną klatkę w głównym wątku, zaczniemy widzieć wycinki Compose.
Wybrałem dla Ciebie kilka z nich.
To jak kompozycja, pomiar, układ, rysowanie.
Jeśli znasz fazy Compose, prawdopodobnie zobaczysz je, gdy będziesz analizować wydajność.
Dzięki temu mają one osobne wycinki w śladzie Perfetto i bardzo łatwo można sprawdzić, ile czasu zajmuje każda faza i które fazy są uruchamiane w poszczególnych klatkach.
Dużo i szczegółowo rozmawialiśmy o optymalizacji niektórych z tych działań. Chcesz mieć pewność, że wykonujesz odpowiednie działania na właściwym etapie.
Jeśli na przykład masz animację, która aktualizuje kolor pola, chcesz unieważnić tylko fazę rysowania, a nie kompozycję, układ itp.
To podstawy skuteczności, a dziś omówimy niektóre kwestie bardziej szczegółowo, skupiając się zwłaszcza na kompozycji.
Teraz Andrew opowie nam o tym.
Dziękuję, Aron.
Jak wspomniał Andrei, dużo czasu poświęciliśmy wcześniej na rozmowy w kontekście wydajności o optymalizacjach, takich jak unikanie odczytów wstecznych, odraczanie odczytów stanu do właściwej fazy w cyklu życia kompozycji i optymalizowanie oraz upewnianie się, że elementy kompozycyjne są unieważniane zgodnie z oczekiwaniami, aby nie wykonywać zbyt wielu zadań w każdej klatce.
To bardzo dobry punkt wyjścia, ale mamy już sporo materiałów na ten temat i chcemy pójść o krok dalej.
Dziś zajmiemy się tym, co pozostaje w kompozycji po przeprowadzeniu tych optymalizacji, oraz tym, co robią same funkcje kompozycyjne.
Niestety wszystko, co znajduje się w kodzie Compose, i wszystko, co dostarczasz z aplikacją, ma wpływ na wydajność i wiąże się z kosztami.
Żaden kod, który dostarczasz w aplikacji, nie jest bezpłatny. Nawet tak błahe rzeczy jak odczytywanie stanu i tworzenie elementów takich jak efekt uboczny w kompozycji wiążą się z pewnymi kosztami. Warto o tym pamiętać, zwłaszcza gdy zaczynasz analizować, co właściwie robią Twoje funkcje kompozycyjne, i chcesz zwiększyć wydajność aplikacji.
Aby pokazać, jak to wygląda w najmniejszej skali, oto niektóre z najmniejszych operacji, które można wykonać w dowolnym miejscu w kodzie, a zwłaszcza w funkcjach kompozycyjnych. W tej rozdzielczości mówimy o skali nanosekundowej, jeśli chodzi o to, ile te rzeczy mniej więcej kosztują.
Jeśli chcesz poprawić skuteczność, masz 2 opcje.
Możesz usunąć bardzo dużą liczbę niedrogich operacji lub mniejszą liczbę droższych operacji.
Na tym wykresie korutyny są po droższej stronie, jeśli chodzi o te operacje, i jest to dobry przykład na to, od czego zacząć, ponieważ jeśli usuniemy kilka z tych instancji, możemy szybciej zacząć dostrzegać bardziej wymierne korzyści w naszej aplikacji.
Warto też pamiętać, że uruchomienie współprogramu wiąże się z kosztem, ale jego anulowanie również.
Koszt anulowania jest szczególnie istotny, ponieważ występuje głównie wtedy, gdy usuwasz treści i być może przechodzisz do innych obszarów aplikacji.
Opuszczenie strony to bardzo kosztowny przypadek w wielu aplikacjach, w których trzeba maksymalnie wykorzystać czas renderowania klatki i dostępne zasoby.
Dlatego ważne jest, aby pamiętać o obu tych kosztach.
Wszystkie te elementy, niezależnie od tego, na co patrzymy, będą się sumować w skali około 16 milionów nanosekund.
Mamy więc budżet, który musimy zmniejszyć, a korutyny będą dobrym przykładem na początek.
W Compose współprogramy pojawiają się najczęściej w interfejsach API efektów.
Istnieje wiele interfejsów API, których można używać z efektami ubocznymi, od wysokiej funkcjonalności po krótki czas oczekiwania.
Na najbardziej funkcjonalnym końcu tego spektrum mamy LaunchedEffect, który zawiera wszystko, co jest potrzebne do pracy asynchronicznej, zwłaszcza animacje i wszystko, co wymaga zakresu współprogramu.
Jeśli jednak nie potrzebujesz tych funkcji i możesz wykonać zadanie za pomocą tańszego interfejsu API, warto rozważyć jego użycie, jeśli spełnia on Twoje potrzeby.
Aby pokazać, jak to wygląda w praktyce, mam tutaj element kompozycyjny odtwarzacza multimedialnego, który pokazuje wszystkie różne efekty uboczne i to, kiedy mogą się pojawić.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}Możemy zacząć od LaunchedEffect.
W tym przykładzie jest on używany do asynchronicznego wczytywania danych w odtwarzaczu multimedialnym.
Jeśli potrzebujesz zakresu współprogramu, współprogramów ogólnie lub chcesz wykonać wywołanie zawieszające, użyj LaunchedEffect.
Nawet jeśli jest to nadal droższe rozwiązanie i wiąże się z kosztami, dopóki wykorzystujesz je i uzasadniasz, używaj odpowiedniego interfejsu API do danego zadania.
Jeśli jednak znajdziesz się w sytuacji, w której używasz LaunchedEffect, ale nie korzystasz ze współprogramu, warto rozważyć inne interfejsy API, które mogą robić to samo, ale mniejszym kosztem.
Jeśli przesuniemy się w dół spektrum, zobaczymy inne przykłady efektów ubocznych, takie jak DisposableEffect.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}DisposableEffect jest przydatny, gdy z efektem jest powiązany proces konfiguracji i demontażu.
W tym samym przykładzie odtwarzacza multimedialnego mam przykład, który blokuje orientację aktywności w trybie poziomym, gdy element kompozycyjny jest obecny, a następnie zwalnia tę blokadę, gdy element znika.
Jeśli chcesz uzyskać jednorazowy efekt LaunchedEffect i DisposableEffect, ale nie potrzebujesz współprogramów i nie masz efektu dispose, możesz też użyć nowego interfejsu API SideEffect z kluczami, który wprowadziliśmy w Compose 1.12.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}To świetna alternatywa dla pozostałych 2 interfejsów API, jeśli korzystasz z nich LaunchedEffect dla wygody lub DisposableEffect z konieczności, aby uzyskać jednorazowy efekt. Przejście na interfejsy API SideEffect z kluczami może przynieść ten sam efekt przy mniejszym obciążeniu i lepszej wydajności, zwłaszcza jeśli migracja jest przeprowadzana wielokrotnie w całej aplikacji.
Najtańszy interfejs API to podstawowy interfejs SideEffect.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}Jest to najbardziej odpowiednie, gdy chcesz zsynchronizować stan zewnętrzny świata z tym, co dzieje się w kompozycji.
W tym przykładzie odtwarzacza multimedialnego pokazuję synchronizację stanu odtwarzania z parametrem odbieranym przez kompozycję.
Jeśli zdecydujesz się sprawdzić użycie efektów ubocznych i przejść na tańszy, pamiętaj, że oprócz innych zachowań i przypadków użycia, o których mówiliśmy, istnieje też różnica w gwarancji kolejności efektów ubocznych.
Najpierw są wykonywane wszystkie efekty jednorazowe, a następnie zwykłe efekty uboczne, z kluczem lub bez. Po zakończeniu ramki uruchamiane są wszystkie uruchomione efekty, ponieważ wiąże się to z wysyłaniem.
Jeśli uruchomione efekty zależą od tego czasu, weź to pod uwagę, gdy rozważasz migrację.
W przypadku każdej migracji przetestuj kod i upewnij się, że nie polegasz na żadnych gwarancjach dotyczących czasu oraz że kod nadal działa.
Jeśli nie możesz zmniejszyć złożoności samego efektu ubocznego, możesz też zmniejszyć liczbę efektów ubocznych, łącząc je ze sobą.
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}Warto to rozważyć, zwłaszcza jeśli masz kilka efektów, które używają tego samego klucza.
Warto rozważyć ich scalenie, ponieważ mogą być wykonywane jednocześnie.
W tym przykładzie mam kilka uruchomionych efektów, z których każdy wykonuje asynchroniczne wywołanie do modelu widoku.
Jeśli zastąpię je pojedynczym LaunchedEffect, a następnie innymi wywołaniami uruchamiania, mimo że nadal będę mieć tę samą liczbę współprogramów, które są tu uruchomione, będzie to wymagało mniejszego nakładu pracy, ponieważ praca zostanie przeniesiona z kompozycji do współprogramów.
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}W takich sytuacjach warto rozważyć migrację, aby w miarę możliwości zmniejszyć liczbę efektów.
Efekty i współprogramy to tylko jeden z przykładów miejsca, które można sprawdzić w aplikacji, ale każda funkcja kompozycyjna i każda aplikacja będzie inna i będzie miała inne możliwości.
Aby zrozumieć, co jest istotne dla Twojej aplikacji, musimy porozmawiać o następnym narzędziu, które przedstawi Andrei, czyli o śledzeniu kompozycji.
OK.
Jeśli spojrzysz na tę wierną rekonstrukcję ramki Perfetto, którą widzieliśmy wcześniej na innym slajdzie, zobaczysz, że śledzenie kompozycji dostarcza nam informacji o czasie w różnych fazach, ale nie podaje zbyt wielu szczegółów na temat tego, co się dzieje.
Co?
Tak, co dzieje się w kompozycji, w której są uruchamiane funkcje typu „composable”.
Pomaga nam w tym śledzenie kompozycji.
Dlatego wprowadzamy w kompilatorze Compose znaczniki, które zasadniczo dodają adnotacje do każdej funkcji i sprawiają, że jest ona widoczna w śladzie Perfetto, dzięki czemu można sprawdzić, ile czasu zajmuje każda funkcja.
Możesz użyć tej funkcji, aby dowiedzieć się, które funkcje są rekomponowane.
Wiąże się to jednak z pewnym narzutem, więc jeśli chcesz uzyskać jak najdokładniejsze dane o skuteczności, użyj zwykłego Perfetto lub uruchom aplikację bez przechwytywania Perfetto i użyj innych wskaźników.
Śledzenie kompozycji jest jednak bardzo przydatne, jeśli chcesz dowiedzieć się, ile czasu zajmuje każda funkcja kompozycyjna.
Czasami chcesz też dowiedzieć się, dlaczego elementy są rekomponowane. To właśnie próbujemy rozwiązać w następnej wersji śledzenia Compose, czyli 1.13.
Wprowadzamy teraz śledzenie rekompozycji, które dodaje adnotacje do każdego odczytu i zapisu stanu, który spowodował rekompozycję.
W śladzie Perfetto zobaczysz coś takiego: każdy zapis stanu i stosy wywołań, w których następuje zapis i odczyt. Możesz je dopasować do rzeczywistych wierszy w kodzie, w których te stany były odczytywane lub modyfikowane.
Jeśli chcesz dowiedzieć się więcej, kliknij ten link.
Znajdziesz tam całą dokumentację dotyczącą śledzenia kompozycji i rekompozycji.
Teraz Andrew opowie, jak możemy efektywniej wykorzystać tę funkcję, aby uniknąć zapisów stanu.
Dziękuję, Aron.
Na podstawie wyników tego badania możesz odkryć, że w kompozycji występują unieważnienia, które nie są przydatne dla aplikacji i których można było uniknąć.
Najczęstszym przykładem jest przycisk przewijania do góry, którego widoczność zależy od widoczności lub postępu przewijania.
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}W tym przypadku odczytuję wartość stanu przewijania wewnątrz kompozycji. Jest to stan, który zmienia się bardzo często, nawet raz na klatkę. Spowoduje to wiele rekompozycji, których można by uniknąć, ponieważ mimo że stan przewijania się zmienia, rzeczywisty warunek, który nas interesuje, nie zmienia się tak często.
W takim przypadku zalecamy użycie np. stanu obliczonego, który pozwala uniknąć unieważnień w przypadku zmian stanu, które nie mają znaczenia dla aplikacji.
W tym przykładzie obliczenia zostały umieszczone w stanie obliczonym. Gdy odczytujemy ten stan, zamiast unieważniać na podstawie samej pozycji przewijania, unieważniamy warunkowo ten element kompozycyjny na podstawie całego warunku i wywodzimy go z innego stanu.
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}Jeśli ten wzorzec jest Ci już znany, prawdopodobnie widziałeś(-aś) go w połączeniu ze stanem pochodnym.
ComputedState to nowość w Compose 1.13. Chociaż ComputedState i DerivedState mają podobny cel, jakim jest warunkowe unieważnianie, gdy zmienia się ich obliczenie, różnią się subtelnie sposobem, w jaki to robią.
DerivedState, który jest dostępny od początku istnienia Compose, zapisuje w pamięci podręcznej obliczoną wartość i używa jej ponownie, dopóki nie zmienią się zależności.
W przypadku ComputedState wartość jest obliczana przy każdym odczycie.
Buforowanie może wydawać się przydatne, ale w wielu sytuacjach jest droższe niż samo odczytanie wyniku.
Aby zrozumieć, jak to działa, możemy przyjrzeć się przykładowi, jak te stany mogą być używane w aplikacji, i sprawdzić, co każdy z nich robi.
Tutaj mam ten sam stan, który odzwierciedla mniej więcej to, co robiliśmy w przycisku przewijania do góry, gdzie jest jakiś warunek oparty na innym stanie, i mam go zarówno jako DerivedState, jak i ComputedState.
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valueOdczytuję też stan dwukrotnie i za każdym razem otrzymuję tę samą wartość, ale pod maską dzieje się coś innego.
Aby uprościć śledzenie tego, co faktycznie pojawiłoby się tutaj w narzędziu takim jak Perfetto, możemy sprawdzić, co się dzieje, gdy odczytujesz oba te stany.
Zaczynamy od wywołania computedState.value.
Wykonuje funkcję lambda obliczeń, która następnie odczytuje wartość stanu. Jest też trochę pracy związanej z powiadomieniem o odczycie.
Dotyczy to wszystkich stanów migawek w Compose.
Drugi odczyt zachowuje się dokładnie tak samo jak pierwszy i ma takie same parametry wydajności.
Dla porównania, pierwszy odczyt DerivedState działa podobnie – nadal musi wywołać funkcję lambda obliczeń i wysłać powiadomienie, ale wiąże się z niewielkim narzutem na zarządzanie pamięcią podręczną, który jest nieco skrócony przy pierwszym uruchomieniu, ponieważ nie ma pamięci podręcznej do sprawdzenia.
Wymaga to więc trochę więcej pracy związanej z pamięcią podręczną, ale pozwala osiągnąć ten sam efekt w niewiele dłuższym czasie.
Drugi odczyt DerivedState jest jednak szybszy niż drugi odczyt ComputedState, mimo że oba te stany są dość proste.
Jak wspomnieliśmy na poprzednich slajdach, koszt odczytu stanu może być w niektórych przypadkach znaczny, a odczyt z pamięci podręcznej jest szybszy niż sam odczyt, dlatego w przypadku DerivedState drugi odczyt jest nieco szybszy.
But the actual interesting scenario is what happens when the state needs to be invalidated.
Jest to tak zwany scenariusz odczytu po zapisie, w którym aktualizujesz jeden ze stanów używanych w obliczeniach, a obliczenia muszą zostać wykonane ponownie.
W kodzie wygląda to tak, że modyfikujemy „a”, na przykład zwiększamy jego wartość, a następnie ponownie odczytujemy wartość lub rekomponujemy.
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.valueW przypadku ComputedState diagram będzie wyglądał dokładnie tak samo.
Nie ma pamięci podręcznej do zarządzania, więc wygląda to dokładnie tak samo, niezależnie od tego, czy jest to pierwszy, drugi czy trzeci odczyt.
DerivedState ma jednak trudne zadanie.
Przechodzi przez bardzo kosztowną ścieżkę, aby sprawdzić, czy pamięć podręczna jest nieprawidłowa, zanim jeszcze zaktualizuje i wywoła samo obliczenie. Następnie musi śledzić zależności i zarządzać wieloma zależnymi obserwacjami, aby zapisać wartość w pamięci podręcznej i zrozumieć, że wszystkie te wartości faktycznie się zmieniły, oraz zarządzać samym utrzymaniem.
Wszystko to się sumuje i sprawia, że koszt unieważnienia stanu DerivedState jest bardzo wysoki w porównaniu ze stanem ComputedState.
Aby poprzeć te twierdzenia liczbami, ComputedState jest zwykle szybszy przy pierwszym odczycie o około 200 ns.
ComputedState traci około 350 ns na Pixelu 6, zwłaszcza w określonych warunkach testu porównawczego.
Dlatego przy drugim odczycie ComputedState będzie nieco wolniejszy niż DerivedState.
Ale w przypadku, który nas interesuje, czyli gdy unieważniamy i prawdopodobnie musimy wykonać pracę nad ramką, ComputedState jest prawie 2 razy szybszy niż DerivedState.
W zależności od tego, do czego używasz stanów, ComputedState może być bardzo dobrym pierwszym wyborem.
Ogólnie rzecz biorąc, podczas korzystania z DerivedState i ComputedState nadal trzeba podjąć decyzję.
We're not deprecating ComputedState, there are still usages for both.
Jeśli nie masz pewności, którą opcję wybrać, stan obliczony jest zwykle dobrym wyborem.
Stan pochodny jest najbardziej przydatny, gdy faktycznie korzystasz z buforowania, czyli robisz coś nietrywialnego, np. skomplikowany algorytm, dużą alokację, dużo odczytów stanu, które faktycznie korzystają z buforowania, lub cokolwiek nietrywialnego albo zależnego od takich rzeczy jak równość obiektów i równość tożsamości obiektów, gdzie nadrabiasz różnicę i korzystasz z buforowania, które oferuje stan pochodny.
Inną kwestią, o której chcę wspomnieć, jest to, że zarówno stan pochodny, jak i obliczony próbują uniknąć unieważnienia samej funkcji kompozycyjnej.
Różni się to od pomijania funkcji kompozycyjnej, o którym zwykle mówimy, gdy myślimy o warunkowym unieważnianiu lub niewykonywaniu pracy w funkcji kompozycyjnej.
Chcę tutaj ujawnić, że chociaż dużo mówi się o stabilności i pomijaniu w Compose, nie jest to najważniejsza kwestia i nie zawsze warto optymalizować pod tym kątem.
Jak już wspomnieliśmy, wszystko w funkcji kompozycyjnej wiąże się z pewnymi kosztami, w tym pomijanie.
// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}Pominięcie nie musi być proste.
Aby w ogóle móc ją udostępnić, musimy wykonać wiele prac konserwacyjnych.
Musimy wykonać takie czynności jak przechowywanie wszystkich parametrów i znalezienie dla nich miejsca w początkowej kompozycji, a następnie zaktualizowanie tego rekordu podczas ponownego tworzenia kompozycji.
Musimy też porównać Twoje wartości z poprzednimi, aby sprawdzić, czy funkcja kompozycyjna powinna zostać pominięta.
Następnie wszystko to jest tylko kwestią sprawdzenia warunku, który pomija kod lub nie.
Może to być kosztowne, w zależności od tego, co się dzieje.
Kompilator ma wbudowaną optymalizację, dzięki której, jeśli przekazujesz ten sam parametr, możemy ponownie użyć kontroli równości, ale nie jest to rozwiązanie uniwersalne i dość łatwo je obejść.
Aby zobaczyć przykład, w którym pomijanie trochę nas zawodzi, możemy przyjrzeć się klasycznemu przykładowi przewijanej listy, która zawiera duży obiekt ze wszystkimi danymi do skomponowania na ekranie.
data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }W tym przypadku model ekranu zawiera coś w rodzaju ekranu nagłówka, a następnie całą listę kontaktów, którą przekazuje do kilku funkcji kompozycyjnych, które ostatecznie wywołują listę leniwą, a następnie wyświetlają je na ekranie z kilkoma komórkami elementów.
Hierarchia kompozycji tego kodu wygląda mniej więcej tak: zaczynamy od ekranu kontaktu, trochę go rozwijamy, a następnie mamy inne funkcje kompozycyjne, które wywołują i prezentują nasze elementy.
Jeśli spojrzymy na to, jak przekazywane są nasze dane, zaczynamy od modelu na górze.
Destrukturyzujemy go na ciąg znaków, a następnie na listę, która jest konwertowana na funkcję lambda elementów dla listy leniwej, a następnie lista leniwa tworzy wszystkie elementy, które pojawiają się na ekranie.
Zobaczmy, co się stanie, gdy zmodyfikujemy ostatni kontakt na tej liście.
Chcemy unieważnić tylko ostatni element na liście, ale tak się nie stanie.
Jeśli spojrzymy na ślad kompozycji dla tej rekompozycji, będzie on wyglądał mniej więcej tak: zaczynamy od ekranu kontaktu, a następnie przechodzimy do tego elementu listy.
To dość kosztowny sposób na dotarcie tutaj.
Widzimy też, że są sekcje, w których nic się nie dzieje.
Chociaż nie jest to tutaj jasne, to właśnie w tym miejscu elementy kompozycyjne decydują, czy zostaną pominięte.
W tym przykładzie pomijane są tylko 2 komponenty, ponieważ lista LazyList jest rekomponowana w określony sposób.
Gdy podasz nową funkcję lambda, unieważni ona wszystkie elementy, które zawiera.
Spowoduje to unieważnienie 2 kontaktów, które nie uległy zmianie, oprócz tego, który uległ zmianie. Te 2 kontakty to jedyne elementy, które zostaną pominięte.
Wszystkie inne elementy wykonują to porównanie równości, aby stwierdzić, że faktycznie muszą zostać wykonane.
Jeśli obiekty są stabilne, to porównanie może być dość kosztowne.
Jeśli zmusisz nas do przejrzenia wszystkich struktur danych, może to spowodować znaczne koszty tylko po to, aby sprawdzić, czy 2 obiekty są równe.
Co gorsza, ponieważ przeprowadziliśmy dekonstrukcję, musimy wykonać to samo porównanie na liście dwukrotnie.
Jeśli zamiast tego pozwolisz kompilatorowi działać domyślnie i użyjesz silnego pomijania, nadal możesz pomijać i sprawdzać, czy funkcje kompozycyjne mają być pomijane warunkowo, ale będą one działać znacznie szybciej, ponieważ będą używać równości tożsamości obiektów zamiast równości strukturalnej.
Obliczenie tego jest znacznie szybsze. Chociaż ponownie tworzymy te same elementy w tej ramce, w której faktycznie ważne jest, aby szybko się tam dostać, nie marnujemy zbyt dużo czasu na ustalanie, że nie możemy pominąć tych funkcji kompozycyjnych.
Dlatego też kładziemy nacisk na korzystanie z narzędzi takich jak testy porównawcze i ślady, aby zrozumieć, co dzieje się w aplikacji, zamiast bezpośrednio liczyć rekompozycje.
Mogą być przydatnym wskaźnikiem, ale nie należy się na nich skupiać ani nadmiernie ich analizować podczas optymalizacji aplikacji.
Pamiętaj, że zamiast próbować pominąć funkcję kompozycyjną, unikaj jej unieważniania.
W tym przykładzie widzieliśmy, że ślady kompozycji mogą dostarczyć wielu informacji o tym, co się dzieje, ale niekoniecznie pokazują cały obraz.
Andrei opowie nam o śladach metod.
OK.
Jak już wspomniał Andrew, śledzenie kompozycji nie zawsze pokazuje wszystko, co się dzieje, ponieważ dodaje adnotacje tylko do funkcji typu „composable”. Mogą się zdarzyć przypadki, gdy w śladzie kompozycji, który już rozszerza ślad Perfetto, widać tylko puste miejsce, ale nadal nie widać wszystkiego, co się tam dzieje.
Czasami może to być coś, co robi Compose, a czasami może to być coś w Twoim kodzie. Trudno to stwierdzić, patrząc tylko na ślad kompozycji.
Teraz chcesz pobrać ślad metody.
W profilerze w Android Studio jest taka funkcja, ale można też użyć poleceń ADB, aby pobrać ślad, który zawiera każde wywołanie funkcji w środowisku Java.
Obejmuje wszystko, co koduje kompilator Compose, wszystko, co robi środowisko wykonawcze Compose, i wszystko, co robi Twój kod. Jest to bardzo przydatne do śledzenia i sprawdzania, co dokładnie jest wykonywane i które funkcje zostały wywołane w tym obszarze.
Chcę tylko… zobaczyć ten element pośrodku.
Chcę podkreślić, że śledzenie metod wiąże się z bardzo dużym obciążeniem.
Narzut jest od 10 do 20 razy większy w zależności od tego, jak złożone jest drzewo funkcji, ponieważ każde wywołanie funkcji dodaje trochę narzutu.
Aby to zilustrować, możemy spojrzeć na zrzut ekranu.
Tak zwykle wygląda ślad metody podczas uruchamiania aplikacji. Możesz się zastanawiać, jak to odczytać.
Zwykle zaczynasz od śladu kompozycji, patrzysz na funkcje, które masz tutaj.
To jest uproszczony ślad kompozycji, który zawiera ScreenContent, przycisk i inne funkcje.
Załóżmy, że chcemy dowiedzieć się, co dzieje się w ScreenContent.
Perfetto ma pasek wyszukiwania.
Wczytujesz ślad metody w przeglądarce Perfetto i szukasz tej samej funkcji, którą masz tutaj, ScreenContent. Otrzymasz coś takiego, czyli ślad metody dla ekranu.
Jak widać, są tu Composer.startGroup i rozmowy grupowe.
To właśnie środowiska wykonawcze i kompilator robią w tle, aby kompozycja działała prawidłowo, ale interesujące jest wywołanie Composer.changed.
W ten sposób sprawdzamy, czy parametry zmieniły się między poprzednimi wywołaniami a bieżącym wywołaniem funkcji kompozycyjnej.
Większość tych Composer.changed jest zdominowana przez ScreenModel.equals.
Być może ScreenModel to jakaś klasa big data, którą oznaczyliśmy jako stabilną, aby pomóc w zliczaniu rekompozycji, więc może to być coś, co pojawiło się w raporcie kompilatora.
Usunięcie adnotacji o stabilności może w tym przypadku poprawić wydajność.
Warto więc zwrócić na to uwagę, a ślady metod kompozycji są najlepszym narzędziem do znalezienia takich problemów.
OK.
Omówiliśmy dziś wiele kwestii, a teraz Andrew podsumuje nasze spotkanie.
Podsumowując wszystko, o czym dzisiaj mówiliśmy, warto pamiętać o kilku kwestiach podczas projektowania funkcji kompozycyjnych i optymalizowania istniejących aplikacji.
Przede wszystkim pamiętaj, że tańsze i nieunieważniane funkcje kompozycyjne są lepsze niż te, które pomijasz.
Pamiętaj, że podczas projektowania funkcji kompozycyjnych lepiej jest rozważyć argumenty, które są mniejsze i łatwiejsze do porównania, oraz ewentualnie ponownie wykorzystać porównania z innymi wywołaniami funkcji, aby skorzystać z optymalizacji.
Gdy używasz interfejsów API w funkcjach kompozycyjnych, pamiętaj, że wydajność lub czas renderowania klatki będą zależeć od kosztu samych interfejsów API i częstotliwości ich używania.
Zwracaj uwagę na to, jak te koszty się sumują, i pamiętaj, że mogą się z nimi wiązać koszty wycofania, które mogą pojawić się w najmniej odpowiednim momencie dla Twojej aplikacji.
Pamiętaj też, że podczas optymalizacji w aplikacji ważne jest, aby postępować zgodnie z zasadami.
Zacznij od przeprowadzenia testów porównawczych, aby podejmować świadome decyzje, profiluj aplikację, aby mieć pewność, że kierujesz działania na właściwe obszary kodu, i sprawdzaj, czy wprowadzasz zmiany i czy widzisz oczekiwane wyniki modyfikacji, które wprowadzasz na podstawie testów porównawczych i profilowania.
Pamiętaj też, że łatwość konserwacji i wydajność aplikacji mogą być ze sobą sprzeczne.
Zrób więc to, co jest najlepsze dla Ciebie i Twojego zespołu, aby nie przesadzić z optymalizacją i nie utrudnić sobie konserwacji.
Jeśli potrzebujesz dodatkowej pomocy, masz do dyspozycji jeszcze jedno narzędzie – agenta AI, którego już masz.
W ramach interfejsu wiersza poleceń Androida i umiejętności, które tam udostępniamy, wprowadziliśmy umiejętność dotyczącą wydajności Jetpack Compose, która zawiera wiele informacji, o których dzisiaj mówiliśmy, i może przeprowadzić audyt aplikacji oraz zasugerować tego rodzaju optymalizacje.
Możesz go zainstalować w istniejącym agencie za pomocą interfejsu wiersza poleceń Androida.
android skills add jetpack-compose-performanceWięcej informacji o interfejsie wiersza poleceń Androida znajdziesz na stronie developer.android.com.
Więcej informacji o naszych umiejętnościach znajdziesz w naszym repozytorium na GitHubie.
Na tym kończymy nasze spotkanie. Życzymy bezpiecznego testowania i udanej pracy z Compose.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valuevar a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.value// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }android skills add jetpack-compose-performance