Wstęp
Dotykasz ikony na telefonie. Ułamek sekundy później pojawia się aplikacja. Wydaje się, że dzieje się to natychmiast — a przynajmniej tak powinno być. Czy zastanawiało cię kiedyś, co właściwie dzieje się w tym ułamku sekundy? Bo to wcale nie jest proste. Między chwilą, gdy palec dotyka szkła, a momentem, gdy na ekranie pojawiają się piksele, telefon wykonuje niezwykłą sekwencję operacji. Procesy są forkowane. Pamięć jest alokowana. Klasy są ładowane. Layouty są inflatowane. Piksele są rysowane. I wszystko to musi się wydarzyć na tyle szybko, żeby nikt tego nie zauważył. Dziś prześledzimy całą tę drogę — od chwili, gdy palec odrywa się od ikony, do momentu, gdy na ekranie pojawia się pierwsza klatka. A kiedy zobaczysz wszystkie kroki, zrozumiesz dokładnie, dlaczego jedne aplikacje startują w 200 milisekund, a inne potrzebują pełnych dwóch sekund.
Co się dzieje, gdy dotykasz ikony
Dotykasz więc ikony aplikacji. Co dzieje się najpierw? Dotyk przechwytuje systemowy input dispatcher, który ustala, co dokładnie zostało dotknięte. Widzi, że to ikona w launcherze, więc wysyła intent do system servera — a konkretnie do ActivityManagerService. ActivityManagerService to mózg cyklu życia aplikacji w Androidzie. Analizuje intent i zadaje pytanie: czy dla tej aplikacji działa już jakiś proces? Jeśli tak, świetnie — być może da się pójść na skróty. Jeśli nie, trzeba zacząć od zera. A start od zera oznacza, że potrzebny jest zupełnie nowy proces Linuksa. Android nie tworzy jednak procesów tak jak zwykły system linuksowy. Ma na to swój sposób. Bardzo sprytny sposób, który nazywa się Zygote.
Zygote: proces-matka
Zygote to specjalny proces, który startuje podczas uruchamiania telefonu. Jego jedynym zadaniem jest być w gotowości do powoływania na świat nowych procesów aplikacji. A istnieje z powodów wydajnościowych. Utworzenie procesu od zera — załadowanie całego środowiska uruchomieniowego Androida, wstępne załadowanie tysięcy klas frameworka, przygotowanie systemu graficznego — zajmuje dużo czasu. Dlatego Android robi to wszystko raz, podczas bootowania, właśnie w Zygote. Zygote z góry ładuje każdą popularną klasę, każdy popularny zasób, wszystko, czego aplikacje zwykle potrzebują. Kiedy potem dotykasz ikony i system potrzebuje nowego procesu, Zygote nie zaczyna od zera. Forkuje samego siebie. Fork to wywołanie systemowe Linuksa, które tworzy dokładną kopię bieżącego procesu. Jest niezwykle szybkie dzięki pamięci copy-on-write — nowy proces współdzieli strony pamięci Zygote, dopóki czegoś faktycznie nie zmodyfikuje. Nowy proces aplikacji zaczyna więc życie jako klon Zygote, z całym frameworkiem Androida już załadowanym i gotowym do pracy. To jedna z najważniejszych optymalizacji wydajności w całym Androidzie.
Od forka do Application.onCreate
Gdy Zygote sforkuje już nowy proces dla twojej aplikacji, runtime musi przygotować właśnie twoją aplikację. Ładuje APK, odnajduje klasę Application i wywołuje jej konstruktor, a następnie onCreate. To pierwszy fragment twojego kodu, który się wykonuje. I tu jest rzecz kluczowa — wszystko, co dzieje się w Application.onCreate, dzieje się, zanim użytkownik cokolwiek zobaczy. Każda biblioteka, którą inicjalizujesz, każde SDK, które konfigurujesz, każdy framework analityczny, który podpinasz — wszystko to bezpośrednio wydłuża czas startu. Widziałem produkcyjne aplikacje, w których Application.onCreate trwało 800 milisekund. Prawie pełna sekunda, zanim w ogóle zacznie się jakakolwiek praca nad UI. I to wyłącznie inicjalizacja bibliotek. Dagger konfigurujący wstrzykiwanie zależności. Inicjalizacja Firebase. Podpinające się crash reportery. SDK reklamowe łączące się ze swoimi serwerami. Każda z tych milisekund to czas, w którym użytkownik wpatruje się w pusty ekran.
Tworzenie Activity i pierwszy layout
Kiedy Application.onCreate się zakończy, system tworzy twoją startową Activity (tę z launchera) i wywołuje jej onCreate. To tutaj zwykle wywołujesz setContentView albo, w Compose, setContent. Gdy wywołujesz setContentView, system inflatuje twój layout XML — parsuje XML, tworzy obiekty View i składa je w drzewo. Każdy View w tym drzewie trzeba skonstruować, odczytać jego atrybuty i podpiąć go do rodzica. Przy złożonym layoucie z dziesiątkami widoków to realna praca. Następnie system musi wykonać przebieg measure — ustalić, jak duży powinien być każdy View. Potem przebieg layout — ustalić, gdzie każdy View ma się znaleźć. I na koniec przebieg draw — faktyczne rysowanie pikseli. Dopiero gdy pierwsze rysowanie się zakończy, użytkownik widzi twoją aplikację. Ten moment — pojawienie się pierwszej klatki na ekranie — nazywamy Time to Initial Display, czyli TTID. Od dotyku do pierwszej klatki. I to właśnie ta liczba ma znaczenie.
Cold start, warm start i hot start
Nie każde uruchomienie aplikacji przechodzi jednak przez wszystkie te kroki. Android wyróżnia trzy rodzaje startu. Cold start to pełna droga — proces nie istnieje, Zygote wykonuje fork, Application.onCreate się wykonuje, Activity powstaje od zera. To najwolniejsza ścieżka. Warm start oznacza, że proces wciąż żyje, ale Activity została zniszczona. Pomijamy więc tworzenie procesu i Application.onCreate, ale nadal musimy utworzyć Activity i ją wyrenderować. Hot start jest najszybszy — proces żyje, a Activity wciąż jest w pamięci. System po prostu przenosi ją na pierwszy plan. Może wywoła onResume, ale pracy jest minimalnie. Zrozumienie tych trzech trybów jest kluczowe, bo mierząc czas startu, musisz wiedzieć, który tryb właściwie mierzysz. Hot start trwający 100 milisekund nic ci nie mówi o cold starcie, który może trwać 1500 milisekund.
Co spowalnia start
Porozmawiajmy o najczęstszych zabójcach startu. Winowajca numer jeden, którego widzę w prawdziwych aplikacjach, to ciężkie Application.onCreate. Zbyt wiele bibliotek inicjalizowanych zachłannie (eagerly) na main threadzie. Rozwiązaniem jest leniwa inicjalizacja — nie inicjalizuj niczego, dopóki faktycznie tego nie potrzebujesz. Użyj biblioteki App Startup albo napisz własne leniwe providery. Drugi winowajca to operacje dyskowe (disk IO) na main threadzie podczas startu. Odczyt SharedPreferences, ładowanie plików konfiguracyjnych, odczyt z bazy danych. Każdy odczyt z dysku blokuje main thread i bezpośrednio wydłuża czas startu. SharedPreferences są wyjątkowo podstępne, bo przy pierwszym dostępie ładują cały plik do pamięci, a jeśli sięgasz po nie na main threadzie w trakcie startu, blokujesz go. Trzeci winowajca to rozbudowane hierarchie widoków. Głęboko zagnieżdżony layout z wieloma widokami dłużej się inflatuje, mierzy, rozmieszcza i rysuje. Każdy poziom zagnieżdżenia może wywołać kilka przebiegów measure. Płaskie layouty z ConstraintLayout albo Compose są wyraźnie szybsze.
Jak mierzyć czas startu
Jak to właściwie zmierzyć? Najprostszym narzędziem jest adb shell am start z flagą -W (myślnik i wielka litera W). To polecenie uruchamia aplikację i wypisuje trzy wartości czasu: ThisTime, TotalTime i WaitTime. Zwykle interesuje cię TotalTime — mierzy czas od chwili, gdy system otrzymał intent uruchomienia, do momentu, gdy pierwsza klatka skończyła się rysować. Uruchom to polecenie kilka razy i weź medianę, bo czasy startu się wahają. Do bardziej szczegółowej analizy możesz użyć profilera w Android Studio. Zacznij nagrywać trace przed uruchomieniem aplikacji, a zobaczysz dokładnie, na co idzie każda milisekunda. Zobaczysz Application.onCreate, Activity.onCreate, przebieg measure, przebieg layout, rysowanie — wszystko rozłożone na osi czasu. W ten sposób znajdujesz konkretne wąskie gardło. Inicjalizacja bibliotek? Disk IO? Inflacja layoutu? Profiler ci to powie.
Baseline Profiles
A teraz coś, co po wprowadzeniu zmieniło zasady gry. Baseline Profiles. Pamiętasz z wcześniejszych odcinków kursu, że ART korzysta z kompilacji JIT — najpierw interpretuje bajtkod, a z czasem kompiluje gorące metody do kodu natywnego? Otóż podczas cold startu żaden fragment twojego kodu nie jest jeszcze skompilowany przez JIT. Runtime interpretuje bajtkod, a to jest znacznie wolniejsze niż wykonywanie skompilowanego kodu natywnego. Baseline Profiles rozwiązują ten problem. Baseline profile to lista klas i metod, które mają zostać skompilowane do kodu natywnego już przy instalacji, z użyciem kompilacji AOT. Profil generujesz, przeprowadzając aplikację przez jej kluczowe ścieżki użytkownika — start, nawigację, najważniejsze ekrany — a biblioteka Macrobenchmark zapisuje, które metody zostały wykonane. Ten profil trafia potem do użytkownika razem z APK, a gdy użytkownik instaluje aplikację, ART z góry kompiluje wszystkie te metody. Efekt? Przy kolejnym cold starcie gorące ścieżki są już kodem natywnym. Nie trzeba rozgrzewać JIT. Google podaje, że Baseline Profiles mogą skrócić czas startu nawet o 30 procent. To ogromna różnica.
Oś czasu startu krok po kroku
Złóżmy całą oś czasu w jedną sekwencję. Czas zero: użytkownik dotyka ikony. System rozwiązuje intent i widzi, że proces nie istnieje. Prosi Zygote o fork. Ten fork trwa mniej więcej od 5 do 10 milisekund. Potem wykonuje się Application.onCreate — w dobrze zoptymalizowanej aplikacji to może być 50–100 milisekund. Następnie odpala Activity.onCreate. Inflatujesz layout i konfigurujesz widoki. Potem system wykonuje measure, layout i draw. I wreszcie pierwsza klatka trafia na wyświetlacz. W dobrze zoptymalizowanej aplikacji cały cold start powinien trwać poniżej 500 milisekund. W złożonej aplikacji rozsądnym celem jest czas poniżej sekundy. Panel Android Vitals oznaczy twoją aplikację, jeśli cold start przekroczy 5 sekund, ale szczerze mówiąc, jeśli jesteś choćby blisko 5 sekund, masz poważny problem. Opóźnienie powyżej sekundy użytkownicy odbierają jako ślamazarne działanie aplikacji.
Praktyczne wskazówki
Na koniec kilka konkretnych wskazówek, które możesz zastosować od razu. Po pierwsze: zrób audyt Application.onCreate. Spisz każdą bibliotekę, którą inicjalizujesz, i zapytaj — czy to musi się wydarzyć przed pierwszą klatką? Jeśli nie, odłóż to na później. Po drugie: przenieś disk IO poza main thread. Jeśli czytasz SharedPreferences podczas startu, przejdź na DataStore albo przynajmniej sięgaj po preferencje w wątku w tle. Po trzecie: uprość początkowy layout. Pierwszy ekran nie musi od razu pokazywać wszystkiego. Załaduj szkielet, pokaż go użytkownikowi, a szczegóły uzupełniaj asynchronicznie. Po czwarte: używaj Baseline Profiles. Skonfiguruj bibliotekę Macrobenchmark, nagraj profil startu i dołącz go do builda. Po piąte: mierz, mierz i jeszcze raz mierz. Włącz adb shell am start -W do swojego codziennego workflow. Znaj czas swojego cold startu tak dobrze, jak znasz rozmiar swojego APK.
Podsumowanie i co dalej
Podsumujmy. Gdy dotykasz ikony, system prosi Zygote o sforkowanie nowego procesu. Wykonuje się Application.onCreate, potem powstaje Activity, a layout jest inflatowany, mierzony, rozmieszczany i rysowany. Pierwsza klatka pojawia się na ekranie i dopiero wtedy użytkownik widzi aplikację. Cold start przechodzi przez każdy krok. Warm start pomija tworzenie procesu. Hot start pomija prawie wszystko. Największymi zabójcami startu są ciężka inicjalizacja, disk IO na main threadzie i złożone hierarchie widoków. Baseline Profiles z góry kompilują gorące ścieżki, eliminując rozgrzewkę JIT. W następnym filmie przyjrzymy się z bliska samemu main threadowi — pętli komunikatów, która napędza wszystko, co widzisz na ekranie. Poznasz Looper, Handler i MessageQueue oraz dowiesz się, dlaczego zablokowanie main threada zamraża całą aplikację. Do zobaczenia.