Ekran startowy aplikacji mobilnej
Od dotknięcia ikony na pulpicie do pierwszego ekranu aplikacji mija chwila: Android musi uruchomić proces, otworzyć przeglądarkę w trybie aplikacji i pobrać stronę z serwera. Ekran startowy (splash) wypełnia ten czas logo AcePark na ciemnym tle, żeby oczekiwanie nie wyglądało jak awaria.
👤 Co widzi użytkownik
- Dotknięcie ikony — od razu ciemne tło z logo AcePark.
- Logo zostaje na ekranie przez czas ładowania (zwykle ułamek sekundy, przy słabym zasięgu dłużej).
- Pojawia się właściwy ekran: pulpit dla zalogowanych, ekran logowania dla pozostałych.
Na całej tej ścieżce nie powinno być białego mignięcia.
🔧 Jak to działa
Ekran startowy nie jest jednym elementem — to cztery warstwy, które przekazują sobie kontrolę:
| Warstwa | Kiedy widoczna | Gdzie zdefiniowana |
|---|---|---|
Tło okna LauncherActivity | Od startu procesu do narysowania splashu | app/src/main/res/drawable/launch_splash.xml + app/src/main/res/values/styles.xml |
| Splash TWA | Podczas otwierania karty przeglądarki | backgroundColor w twa-manifest.json i app/build.gradle |
| Strona startowa | Przez cały czas ładowania pulpitu | public/app-start.html |
| Splash webowy | Od pierwszego renderu HTML do wczytania uprawnień do tras | .splash-screen w app/globals.css |
Wszystkie używają tego samego odcienia #1a1a1a i tego samego logo, żeby
przejścia między nimi były niewidoczne.
⚠️ Dlaczego istnieje strona startowa
To jest sedno całego mechanizmu i najłatwiejsza rzecz do przypadkowego zepsucia, więc warto rozumieć, skąd się wzięła.
Biblioteka TWA potrafi przekazać splash przeglądarce-gospodarzowi, żeby ta
trzymała go na ekranie przez cały czas ładowania strony. Wymaga to jednak, by
przeglądarka deklarowała kategorię
androidx.browser.trusted.category.TrustedWebActivitySplashScreensV1.
Chrome ją deklaruje. Samsung Internet nie. A na telefonach Samsunga to
właśnie Samsung Internet jest domyślną przeglądarką, więc to on hostuje
aplikację.
Bez przekazania splashu logo znika razem z LauncherActivity, a użytkownik
patrzy na pustą kartę przeglądarki, dopóki pulpit się nie wyrenderuje.
Pomiar na Galaxy S21 (Android 15) przed poprawką:
| Czas | Co na ekranie |
|---|---|
| 0–600 ms | logo (splash natywny) |
| 600 ms – 2,5 s | biały ekran |
| 3,2 s | pulpit |
Kluczowe: w tej dziurze nie ma jeszcze żadnego dokumentu, więc nie da się
jej zamalować ze strony webowej. Ani background_color z manifestu, ani reguły
CSS nie mają czego dotyczyć. Skrócenie łańcucha też nie pomaga — puste
przekierowanie z / kosztuje około 190 ms z tych dwóch sekund, resztę zjada
renderowanie pulpitu po stronie serwera.
Rozwiązaniem jest danie przeglądarce czegokolwiek do namalowania, byle
szybko. Adres startowy TWA prowadzi więc na public/app-start.html:
statyczny plik bez sprawdzania sesji i bez zapytań do bazy, z logo wklejonym
jako data URI, przez co całość to jedno żądanie bez zależnych pobrań.
Przychodzi w 150–250 ms i od razu przekierowuje na /.
Działa to dlatego, że przeglądarka trzyma poprzedni dokument na ekranie,
dopóki następny się nie wyrenderuje. Ciemna strona zostaje więc widoczna
przez cały czas, w którym / sprawdza sesję, a pulpit się renderuje. Dziura
nie została skrócona — została zapełniona.
Splash natywny pokazuje ten sam napis co warstwy webowe, a nie ikonę
aplikacji. Bubblewrap generuje splash.png z iconUrl, czyli z ikony — przez co
start zaczynał się od żółtej piłki, a zaraz potem przeskakiwał na napis AcePark.
Pliki w app/src/main/res/drawable-*/splash.png są więc podmienione ręcznie:
napis o szerokości 180dp z przezroczystym marginesem 72dp pod spodem, który
odwzorowuje odstęp i loader ze strony startowej, dzięki czemu wyśrodkowany
obrazek stawia napis dokładnie tam, gdzie pojawi się chwilę później.
bubblewrap update nadpisze te pliki ikoną — trzeba je wtedy wygenerować
ponownie.
Napis musi mieć tę samą geometrię we wszystkich trzech warstwach: 180dp
szerokości i wysokość wynikającą z proporcji pliku (522×117, czyli 40dp). Splash
w aplikacji miał zaszyte height={60} bez reguły CSS, która by to nadpisała —
logo było rozciągnięte w pionie o połowę i przy przejściu ze strony startowej
skakało. Logo nie ma też animacji pulsowania: przy zmianie dokumentu startowała
ona od nowa, więc napis skakał jasnością i skalą dokładnie w tym miejscu, które
miało być niewidoczne.
Logo musi też pochodzić z tego samego adresu w obu warstwach. Splash w
aplikacji pobierał je przez next/image, czyli spod /_next/image?url=…, a
strona startowa ma je wklejone jako data URI — dla przeglądarki to dwa różne
zasoby, więc w momencie przejścia napisu przez chwilę nie było i widać było
mignięcie. Dlatego splash ma unoptimized (zwykły /logo/logo-white.png), a
strona startowa wczytuje ten plik z wyprzedzeniem przez rel="preload".
Dwie rzeczy, które przy edycji tego pliku łatwo zepsuć:
- Przekierowanie musi nastąpić po pierwszym wyrenderowaniu. Wywołanie
location.replacesynchronicznie w<head>sprawi, że przeglądarka podmieni dokument, nigdy go nie pokazawszy — i biel wróci. Stąd podwójnerequestAnimationFrame. - Żadnych zależnych zasobów. Logo jest wklejone w plik celowo. Odwołanie do zewnętrznego obrazka czy arkusza stylów dokłada podróż w obie strony dokładnie w tym momencie, który mieliśmy uratować.
Strona jest wykluczona z matchera w middleware.ts — przejście przez
sprawdzanie sesji przekreślałoby jej sens.
Adres startowy to /app-start, bez rozszerzenia. Cloudflare Workers Assets
domyślnie obcina .html i przekierowuje, więc wskazanie na /app-start.html
dokładałoby jedną podróż w obie strony przy każdym starcie — czyli dokładnie to,
co ta strona ma eliminować. Sam plik nazywa się app-start.html, bo tak wymaga
katalog public/; wskazuje się na niego bez rozszerzenia.
Dodatkowo app/globals.css ustawia ciemne tło dokumentu w trybie
display-mode: standalone, czyli wyłącznie wewnątrz zainstalowanej aplikacji —
w zwykłej przeglądarce strony zachowują swoje jasne tło.
Android (TWA)
Aplikacja na Androida to Trusted Web Activity generowana przez Bubblewrap.
Motyw Theme.AceParkLauncher dokłada do przezroczystego motywu Bubblewrapa
windowBackground z logo na ciemnym tle, dzięki czemu pierwsza klatka po
dotknięciu ikony jest już splashem, a nie pustym oknem.
Zmiany w app/src/main/AndroidManifest.xml, app/src/main/res/values/styles.xml
i app/src/main/res/drawable/launch_splash.xml są nadpisywane przez
bubblewrap update — po każdej aktualizacji projektu natywnego trzeba je
przywrócić.
Weryfikacja, że zasoby się kompilują, bez pełnego wydania:
JAVA_HOME=$HOME/.bubblewrap/jdk/jdk-17.0.11+9/Contents/Home ANDROID_HOME=$HOME/.bubblewrap/android_sdk ./gradlew :app:processDebugResources
iOS (Capacitor)
iOS pokazuje LaunchScreen.storyboard natychmiast po dotknięciu ikony — tło
jest tam ustawione na ten sam odcień #1a1a1a, podobnie jak backgroundColor
wtyczki SplashScreen w capacitor.config.ts. Dlatego na iOS problem białego
mignięcia nie występuje i te pliki nie wymagały zmian.
Zmiana kolorystyki
Przy zmianie koloru ekranu startowego trzeba zaktualizować komplet miejsc, inaczej wróci przebłysk:
public/manifest.json→background_colortwa-manifest.jsonorazapp/build.gradle→backgroundColorpublic/app-start.html→ tłobodyoraz kolor piłkiapp/globals.css→ tłohtmlwdisplay-mode: standaloneoraz gradient.splash-screencapacitor.config.ts→plugins.SplashScreen.backgroundColorios/App/App/Base.lproj/LaunchScreen.storyboard→backgroundColorwidoku