Starszy inżynier ds. relacji z deweloperami
Ekosystem Androida jest ogromny, a użytkownicy płynnie przełączają się między telefonami, tabletami, urządzeniami składanymi i komputerami. Teraz, gdy Compose jest naszym głównym narzędziem, podczas tej sesji dowiesz się, jak zaprojektować aplikację pod kątem okien o zmiennym rozmiarze i różnych formatów obrazu, w przypadku których tradycyjne założenia dotyczące układu nie sprawdzają się, co prowadzi do rozciągnięcia interfejsu i utraty stanu.
Dowiedz się, jak najnowsze funkcje Jetpack Compose, Navigation 3 i WindowManager mogą pomóc Ci tworzyć dynamiczne układy, które naturalnie skalują się od kompaktowych ekranów do paneli podwójnych. Poznasz strategie przyspieszania procesu programowania za pomocą Gemini i naszych umiejętności, takich jak umiejętność adaptacyjna, które pozwalają automatycznie refaktoryzować starsze bazy kodu pod kątem większych ekranów.
MediaQuery deklaratywnie śledzi aktywne funkcje interfejsu, takie jak rozmiar okna i pozycje składania. Zastępuje łączenie wielu starszych menedżerów platformy w stan Compose.Grid i FlexBox zapewniają elementy składowe układu adaptacyjnego. Grid umożliwia parametryzowane wielowymiarowe układy w ramach jednego elementu kompozycyjnego, a FlexBox zapewnia jednowymiarowe rozmieszczenie i zawijanie.Transkrypcja
Francesco Romano
Cześć wszystkim.
Dziś szczegółowo omówimy, jak skalować aplikację na każdy ekran za pomocą Jetpack Compose.
Dlaczego warto skalować na każdy ekran?
Ekosystem Androida rozszerzył się na wiele różnych formatów.
Plik APK może działać na telefonach, urządzeniach składanych, tabletach, Chromebookach i Googlebookach, telewizorach, systemach multimedialnych w samochodach, urządzeniach z XR itp.
Po pierwsze, wychodź naprzeciw potrzebom użytkowników.
Po drugie, przejście platformy Android na aplikacje adaptacyjne nie jest tymczasowe.
Nie wrócimy do świata, w którym były tylko telefony. To już z nami zostanie.
Mam tu krótką oś czasu.
W 2022 roku zaczęliśmy kłaść pod nie fundamenty.
Wprowadziliśmy Androida 12L, pierwszą wersję Jetpack WindowManager, udostępniliśmy interfejsy API, takie jak klasy rozmiaru okna, i opublikowaliśmy pierwszą wersję wskazówek dotyczących jakości aplikacji adaptacyjnych.
Zaktualizowaliśmy też Google Play, aby oceny były podzielone według typu urządzenia, i zaczęliśmy uwzględniać wskazówki dotyczące jakości aplikacji adaptacyjnych w decyzjach o rankingu.
W Androidzie 15 wprowadziliśmy też tryb okien na pulpicie w AOSP i stabilną wersję Compose Material 3 Adaptive.
Od Androida 16 (SDK 36) system po prostu ignoruje blokady orientacji, blokady formatu obrazu i flagi możliwości zmiany rozmiaru na większych ekranach.
Wraz z wprowadzeniem połączonych wyświetlaczy plik APK może teraz działać w praktycznie dowolnym rozmiarze okna.
W pakiecie SDK 36 wprowadziliśmy tymczasową flagę manifestu, która pozwalała zrezygnować z zastępowania orientacji i formatu obrazu.
W Androidzie 17 ta opcja rezygnacji została usunięta.
Dlatego tworzenie adaptacyjnych aplikacji jest podstawowym wymaganiem platformy.
W ciągu ostatniej dekady ściśle współpracowaliśmy z zespołami z całego ekosystemu i dowiedzieliśmy się, dlaczego tworzenie aplikacji adaptacyjnych może być trudne.
Możesz utracić stan, interfejsy API okien są rozdrobnione, sygnały dotyczące tego, kiedy przełączyć układ, są niejasne, a w bazie kodu są głęboko zakorzenione stare założenia.
Wykorzystaliśmy więc wszystko, czego nauczyliśmy się przez tę dekadę wyzwań dla deweloperów, i wdrożyliśmy to bezpośrednio w 2 miejscach: po pierwsze w samym zestawie narzędzi UI.
Dzisiaj przyjrzymy się kilku ważnym nowym funkcjom w Jetpack Compose: zapytaniu o media, siatce, FlexBox i Nawigacji 3.
Obejmuje to też narzędzia oparte na agentach, które piszą kod razem z Tobą, dzięki oficjalnym umiejętnościom Androida w Gemini w Android Studio i interfejsie wiersza poleceń Androida.
Na przykład zobaczymy funkcje adaptacyjne Compose, które zostały udostępnione podczas konferencji Google I/O na początku tego roku.
Już wcześniej umożliwiała ona przenoszenie układów telefonów do większych rozmiarów okien.
Teraz może też rozwiązać problem z utratą stanu, wdrożyć funkcje unikalne dla urządzeń składanych, takie jak tryb stołowy, oraz ulepszyć obsługę danych wejściowych przez wykrywanie i wdrażanie skrótów klawiszowych, niestandardowych kursorów, etykietek itp.
Zacznijmy od Jetpack Compose, który jest podstawą tego wszystkiego.
Wersja stabilna Jetpack Compose została wydana w 2021 r., więc w tym roku obchodzimy 5 urodziny.
W ciągu tych 5 lat zaobserwowaliśmy w tysiącach aplikacji produkcyjnych, o ile łatwiejsze i bardziej niezawodne jest tworzenie aplikacji adaptacyjnych za pomocą Compose.
Programowanie na Androida jest teraz oficjalnie oparte na Compose.
Oznacza to, że wszystkie nowe, nowoczesne układy adaptacyjne, style i komponenty nawigacyjne są tworzone pod kątem Compose.
Z perspektywy architektury systemu największą zaletą Compose w przypadku aplikacji adaptacyjnych w porównaniu ze starszym systemem widoków jest sposób interakcji stanu i interfejsu.
Aby to wyjaśnić, chcę szybko porównać go z systemem widoków.
Zastanówmy się, jak działał system widoków.
Interfejs był drzewem zmiennych obiektów, które były jednorazowo rozwijane ze statycznych plików XML.
Masz różne kwalifikatory zasobów. Zacznijmy od głównego problemu: duplikacji w różnych kwalifikatorach zasobów.
Gdy tworzysz adaptacyjny interfejs użytkownika w XML, w pliku układu nie ma żadnej logiki warunkowej.
Zamiast tego wystarczy powielić całą hierarchię w folderach zasobów, takich jak układ, układ najmniejszego okna 600 dp, układ poziomy itd.
Spójrz na przykład EditText po prawej stronie.
<LinearLayout android:orientation="vertical">
<EditText
android:id="@+id/text_msg"
android:inputType="textCapCharacters"
android:maxLength="12" />
</LinearLayout><ConstraintLayout>
<TextInputEditText
android:id="@+id/text_input"
android:inputType="text" />
</ConstraintLayout>To dokładnie ten sam komponent logiczny, który obsługuje wprowadzanie tekstu, i jest on rozproszony w różnych plikach XML.
Jeśli zaktualizujesz atrybut taki jak inputType lub maxLength w układzie telefonu, kompilator nie wymusi na Tobie zaktualizowania go również w układzie tabletu.
Ponieważ komponenty są rozdzielone w różnych plikach, z czasem niemal na pewno dojdzie do dryfu strukturalnego.
To powielanie jest też związane z innym problemem, jakim jest utrata stanu podczas zmian konfiguracji.
Pamiętaj, że w systemie widoków komponenty interfejsu są stanowe.
EditText jest właścicielem wewnętrznego bufora Editable, niestandardowej pozycji kursora, stanu aktywności i niektórych stanów.
Dlatego gdy użytkownik na przykład rozłoży urządzenie lub zmieni rozmiar okna powyżej określonego progu, Android niszczy całą hierarchię widoków i od nowa wypełnia nowe pliki XML.
Aby zapisać dane wejściowe użytkownika, platforma korzysta z metody onSaveInstanceState, która pakuje stan powiązany z identyfikatorem tego widoku.
To jednak stwarza 3 możliwe rodzaje błędów.
Po pierwsze, jeśli ktoś przypadkowo nazwał identyfikator text_input zamiast text_msg w innym pliku XML, przywracanie stanu po cichu odrzuci tekst wpisany przez użytkownika bez żadnych ostrzeżeń.
Po drugie, onRestoreInstanceState jest uruchamiane po onViewCreated, co może często kolidować z obserwatorami ViewModel i powodować pętle wywołań zwrotnych.
Po trzecie, jeśli zmienisz typ widżetu w różnych kwalifikatorach, na przykład na mniejszym ekranie chcesz mieć spinner, a na szerszym RadioGroup, to jeśli mają ten sam identyfikator, spowoduje to błąd ClassCastException w czasie działania.
W Jetpack Compose interfejs nie jest obiektem, który tworzysz raz.
Interfejs jest czystą funkcją stanu.
To całkowicie zmienia definicję zmiany konfiguracji.
Czym jest zmiana konfiguracji w Compose?
Mam podpowiedź.
Francesco Romano
Tak.
W Compose zmiana konfiguracji to po prostu kolejna rekompozycja, a rekompozycje zdarzają się cały czas.
Gdy okno się poszerzy, na przykład po rozłożeniu urządzenia przez użytkownika, funkcje kompozycyjne odczytają nowy rozmiar, a te, których dane wyjściowe od niego zależą, zostaną ponownie uruchomione.
To całe wydarzenie.
Teraz zobaczmy, co oznacza tworzenie nowoczesnych aplikacji adaptacyjnych przy użyciu najnowszych narzędzi i komponentów Compose.
W świecie XML trzeba było zadawać pytania typu „Czy aplikacja działa na telefonie, tablecie czy komputerze?”, a następnie rozgałęziać interfejs na podstawie kategorii urządzenia.
Istnieje jednak ważny powód, dla którego nie należy tego robić.
Nawet w przypadku tego samego urządzenia fizycznego kontekst aplikacji i możliwości interfejsu mogą się dynamicznie zmieniać w czasie działania.
Chcę przekazać, że decyzje dotyczące interfejsu powinny być podejmowane na podstawie aktualnie aktywnych funkcji: bieżącego rozmiaru okna, pozycji i niektórych cech sprzętu.
Przyjrzyjmy się klasycznemu przykładowi, czyli trybowi okien na pulpicie na zewnętrznym monitorze.
Jeśli podłączysz telefon Pixel do zewnętrznego monitora na biurku, dokładnie ten sam proces aplikacji, który właśnie działał na 6-calowym ekranie w orientacji pionowej, będzie teraz działać na 32-calowym monitorze panoramicznym z podłączoną klawiaturą fizyczną i myszą.
Dlatego sprawdzenie typu urządzenia, na którym działa aplikacja, nie gwarantuje niczego w kwestii ekranu, na którym jest ona wyświetlana, ani sposobu wprowadzania danych.
Co więcej, Android obsługuje wiele okien, więc można zmieniać ich rozmiar.
Nawet jeśli ekran Googlebooka jest większy niż ekran telefonu, aplikacja może działać w oknie o szerokości 400 dp razem z innymi aplikacjami.
Dlatego wysyłanie zapytań o statyczne dane dotyczące wyświetlacza fizycznego może być bezcelowe.
Aby więc projektować aplikacje z myślą o Compose, musimy najpierw zrozumieć, jak wysyłać zapytania do środowiska.
Zacznijmy od próby sformalizowania pojęcia możliwości interfejsu.
Możliwości interfejsu obejmują nie tylko szerokość i wysokość okna czy pozycję składanego urządzenia, ale też aktualnie aktywne tryby wejścia i wyjścia.
Zauważyliśmy, że w środowisku komputerowym użytkownicy nie zawsze korzystają z ekranu dotykowego.
Można używać klawiatury i myszy.
W systemie samochodowym niektóre samochody mają pokrętło lub inne wejście, inny sposób wprowadzania sygnałów.
Oprócz rozmiaru ekranu i metody wprowadzania danych uwzględnia też odległość użytkownika od wyświetlacza, czyli odległość oglądania, oraz to, czy kamera lub mikrofon są obecnie podłączone.
To wszystkie funkcje interfejsu.
Francesco Romano
Do tej pory, aby pobrać wszystkie sygnały, trzeba było połączyć 5–6 różnych interfejsów API z różnych źródeł.
Potrzebny był obiekt konfiguracji, WindowMetricsCalculator, WindowInfoTracker, InputManager, PackageManager, a następnie trzeba było ręcznie przekonwertować każdy z nich na stan, stan Compose.
Czy podobało Ci się to?
Nie.
Aby rozwiązać ten problem, w androidx.compose.ui wprowadziliśmy interfejs MediaQuery API.
mediaQuery { ... }
derivedMediaQuery { ... }Inspiracją dla nich były zapytania o media CSS.
Słowo „multimedia” nie oznacza odtwarzania dźwięku ani obrazu.
Odnosi się do medium, czyli środowiska, w którym renderowany jest interfejs.
MediaQuery zapewnia pojedynczy, deklaratywny, natywny dla Compose sposób wysyłania zapytań o możliwości interfejsu.
Przyjrzyjmy się właściwościom udostępnianym w obiekcie UiMediaScope.
sealed interface UiMediaScope {
@get:FrequentlyChangingValue val windowWidth: Dp
@get:FrequentlyChangingValue val windowHeight: Dp
val windowPosture: Posture // Flat, Tabletop, Book
val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
val keyboardKind: KeyboardKind // Physical, Virtual, None
val hasCamera: Boolean
val hasMicrophone: Boolean
val viewingDistance: ViewingDistance // Near, Medium, Far
}windowWidth i windowHeight w Dp.
windowPosture, który jest obecnie przeznaczony dla urządzeń składanych i może być płaski, na stole lub w trybie książki.
pointerPrecision informuje o precyzji głównego urządzenia wskazującego: Fine (dokładne) w przypadku myszy, rysika lub trackpada; Coarse (zgrubne) w przypadku ekranu dotykowego; Blunt (niedokładne) w przypadku joysticka lub kontrolerów; None (brak).
keyboardKind może być fizyczny, wirtualny lub żaden.
hasCamera i hasMicrophone dynamicznie śledzą, czy ten sprzęt jest dostępny.
Zastanów się nad kamerą zewnętrzną, którą można podłączyć.
viewingDistance zgłasza, że jest blisko, średnio daleko lub daleko.
Myślę jednak, że łatwiej będzie to zrozumieć na prawdziwym przykładzie, czyli wykrywaniu, czy urządzenie jest w trybie stołu.
Tryb stołu to pozycja, w której użytkownicy mogą złożyć urządzenie do połowy i zwykle położyć je na powierzchni.
Świetnie sprawdza się podczas dłuższego odtwarzania filmów, rozmów wideo czy nagrywania filmów w trybie poklatkowym.
Jest też bardzo przydatny, gdy chcesz na przykład postępować zgodnie z przepisem lub obejrzeć pokaz slajdów.
Do tej pory, aby wykryć pozycję stołową przed MediaQuery, trzeba było napisać cały ten kod.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
val context = LocalContext.current
val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
.collectAsStateWithLifecycle(null)
val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
displayFeature is FoldingFeature &&
displayFeature.state == FoldingFeature.State.HALF_OPENED &&
displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
} ?: false
return if (isTabletopPosture) {
VideoPlayerLayout.Tabletop
} else {
VideoPlayerLayout.Default
}
}Trzeba było uzyskać kontekst, pobrać instancję WindowInfoTracker, zebrać windowLayoutInfo, czyli przepływ, stan Compose uwzględniający cykl życia, a następnie iterować po displayFeatures, aby sprawdzić, czy istnieje FoldingFeature, który jest częściowo otwarty i poziomy.
A to tylko po to, aby wykryć postawę.
To dużo schematycznych zadań.
Dzięki nowemu interfejsowi MediaQuery API wszystko to można skrócić do kilku wierszy kodu.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}Funkcja mediaQuery przyjmuje pojedynczy parametr lambda, a w zakresie odbiornika tej funkcji lambda udostępnia zestaw właściwości, które właśnie widzieliśmy.
W tym przykładzie, sprawdzając windowPosture w wyrażeniu when, możemy natychmiast zwrócić odpowiedni element kompozycyjny.
Oto 2 inne przykłady użycia interfejsu API.
@Composable
fun AdaptiveChatControls(
onStartVideoCall: () -> Unit,
onSendMessage: () -> Unit,
) {
// 1. Return a Dp value directly based on pointer precision
val chipPadding = mediaQuery {
if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
}
// 2. Return a Boolean combining hardware capabilities
val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
val hasPhysicalKeyboard = mediaQuery {
keyboardKind == KeyboardKind.Physical
}
Row(modifier = Modifier.padding(chipPadding)) {
if (canVideoCall) {
IconButton(onClick = onStartVideoCall) {
Icon(Icons.Default.Videocam, contentDescription = "Video call")
}
}
Button(onClick = onSendMessage) {
Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
}
}
}Pierwszą z nich jest gęstość informacji.
Gdy użytkownik korzysta z laptopa lub komputera stacjonarnego z myszą, a precyzja wskaźnika jest wysoka, możemy zmniejszyć dopełnienie komponentów interfejsu, na przykład z 8 dp do 2 dp, aby wygodnie wyświetlać więcej treści. Ponieważ użytkownicy nie używają palców, docelowe elementy dotykowe mogą być mniejsze.
Działania oparte na możliwościach.
Załóżmy, że mamy pasek wprowadzania czatu. Możemy sprawdzić hasCamera i hasMicrophone, aby dynamicznie wyświetlać lub ukrywać przycisk polecenia rozmowy wideo, albo możemy połączyć typ klawiatury fizycznej, aby wyświetlać lub nie wyświetlać wskazówek dotyczących skrótów klawiszowych, takich jak naciśnięcie Ctrl + Enter, aby wysłać.
Wszystkie te informacje są pobierane w czasie działania.
MediaQuery świetnie sprawdza się też podczas testowania i generowania podglądu.
@Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
@Composable
fun TabletopPlayerPreview() {
val simulatedMediaScope = object : UiMediaScope {
override val windowWidth = 840.dp
override val windowHeight = 700.dp
override val windowPosture = UiMediaScope.Posture.Tabletop
override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
override val keyboardKind = UiMediaScope.KeyboardKind.Physical
override val hasCamera = true
override val hasMicrophone = true
override val viewingDistance = UiMediaScope.ViewingDistance.Medium
}
CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
VideoPlayerScreen()
}
}MediaQuery rozwiązuje odbiorcę za pomocą lokalnej kompozycji o nazwie LocalUiMediaScope.
Możesz więc po prostu podać fałszywy obiekt UiMediaScope za pomocą CompositionLocalProvider w podglądzie i nie musisz uruchamiać urządzeń składanych i ustawiać ich pod kątem 90° ani podłączać myszy, aby przeprowadzić test interfejsu Compose.
Francesco Romano
Wiemy już, jak wysyłać zapytania o możliwości interfejsu.
Teraz zobaczmy, jak utworzyć rzeczywisty układ.
W tym celu chcę przedstawić siatkę, która jest nowym elementem podstawowym układu w androidx.compose.foundation.layout, który jest teraz stabilny.
Grid(
modifier = Modifier.fillMaxSize().padding(16.dp),
config = {
val maxWidthDp = constraints.maxWidth.toDp()
val columnCount = if (maxWidthDp < 600.dp) 2 else 4
repeat(columnCount) {
column(1.fr)
}
row(GridTrackSize.Auto)
row(1.fr)
gap(row = 16.dp, column = 8.dp)
flow = GridFlow.Row
}
) {
// GridScope children...
}Siatka umożliwia wdrożenie układu dwuwymiarowego przez wyświetlanie elementów w kolumnach i wierszach, które dostosowują się do dostępnego rozmiaru kontenera.
Zobaczmy, co można zrobić w bloku konfiguracji.
Możesz dołączać nowe elementy lub ścieżki, wywołując kolumnę lub wiersz, i używać standardowych pętli Kotlin, takich jak repeat.
Możesz ustawić odstępy między ścieżkami.
Kierunek automatycznego rozmieszczania możesz kontrolować za pomocą właściwości flow-direction: GridFlow.Row lub GridFlow.Column.
Oto kluczowy szczegół architektury.
Blok konfiguracji jest uruchamiany podczas etapu pomiaru, a zakres konfiguracji siatki implementuje gęstość i udostępnia ograniczenia.
Oznacza to, że możesz sprawdzić constraints.maxWidth.toDp() bezpośrednio w konfiguracji, aby obliczyć ścieżki bez dodatkowych etapów rekompozycji.
W siatce, gdy dodasz element podrzędny bez modyfikatora, automatycznie przejdzie on do następnej komórki.
Grid(
config = {
repeat(3) { column(1.fr) }
repeat(3) { row(100.dp) }
gap(8.dp)
}
) {
// Spans 2x2 area (rows 1..2, columns 1..2)
HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))
// Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
LiveBadge(
modifier = Modifier.gridItem(
row = 1,
column = 1,
alignment = Alignment.TopStart
)
)
// Negative index: placed in last row (-1), spanning all 3 columns
FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
}Jeśli chcesz mieć pełną kontrolę, możesz użyć Modifier.gridItem i współrzędnych zaczynających się od 1.
Wiersz 1, kolumna 1 to komórka w lewym górnym rogu.
Obsługuje indeksowanie ujemne, więc wiersz -1 jest ostatnim wierszem itd.
Możesz użyć właściwości rowSpan, aby rozciągnąć element na obszar dwuwymiarowy, a następnie nałożyć na niego inny element.
Jeśli więc 2 elementy zostaną umieszczone w nakładających się komórkach, Grid ułoży je jeden na drugim w tych granicach, a kolejność osi Z zostanie określona przez kolejność kompozycji.
Późniejsze elementy będą więc rysowane na innych, tak jak w przypadku Box.
Znamy już podstawy siatki.
Przeanalizujmy przykład odtwarzacza wideo.
W zależności od tego, czy urządzenie jest rozłożone na płasko czy do połowy, musimy przełączać się między układem domyślnym a układem stołu.
Przed wprowadzeniem siatki musieliśmy utworzyć 2 różne funkcje kompozycyjne, DefaultLayout i TabletopLayout, a następnie rozgałęzić kod w wyrażeniu when.
@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
when(currentVideoPlayerLayout()) {
VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
}
}Możesz pomyśleć: „Co w tym złego?”.
Jeśli przyjrzysz się tym układom, zobaczysz, że ich zawartość jest identyczna.
Na obu wyświetla się dokładnie ten sam odtwarzacz i dokładnie ten sam kontroler.
Jedyną różnicą między nimi jest sposób rozmieszczenia tych 2 elementów na ekranie.
Do tej pory nie mieliśmy możliwości przekazania samego układu jako parametru, więc musieliśmy zdefiniować 2 osobne funkcje kompozycyjne.
Teraz możemy sparametryzować konfigurację układu i obsłużyć wszystko w ramach jednej funkcji typu „composable”.
Zobaczmy, jak to zrobić.
Dzięki interfejsowi Grid API możemy definiować nazwane obszary jako typy wyliczeniowe.
enum class VideoPlayerArea { Player, Controller }
Grid(
config = { ... },
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}Następnie możemy określić elementy kompozycyjne podrzędne, odtwarzacz i kontroler, które mają zostać umieszczone w siatce, i powiązać każdy element podrzędny z nazwanym obszarem, wywołując Modifier.gridItem i przekazując areaId.
W tym miejscu informujemy Grid, że odtwarzacz ma zostać umieszczony w obszarze o nazwie VideoPlayerArea.Player, a kontroler – w obszarze VideoPlayerArea.Controller.
Tutaj definiujemy 2 lambdy konfiguracji w VideoPlayerGridConfiguration.
object VideoPlayerGridConfiguration {
val Default: GridConfigurationScope.() -> Unit = {
row(1f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
}
val Tabletop: GridConfigurationScope.() -> Unit = {
row(0.5f)
row(0.5f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
}
}W przypadku domyślnego układu odtwarzacza wideo konfigurujemy siatkę z 1 wierszem i 1 kolumną, ale zauważ, że nawet jeśli jest tylko 1 komórka, możemy przypisać do niej 2 nazwy obszarów.
W tym przypadku, jak już widzieliśmy, kontroler jest nałożony na odtwarzacz.
W przypadku trybu stołowego definiujemy tylko 2 wiersze i 1 kolumnę, umieszczając odtwarzacz w górnej połowie, a kontroler w dolnej, mniej więcej tak.
Po zdefiniowaniu tych funkcji lambda konfiguracji możemy użyć MediaQuery bezpośrednio w konfiguracji siatki.
@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Grid(
config = { mediaQuery {
if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
else { VideoPlayerGridConfiguration.Default }
}
}
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}
}Możemy teraz mieć jeden układ odtwarzacza, który można komponować z interfejsem i który dynamicznie przełącza się między 2 konfiguracjami.
Kolejny element podstawowy, o którym chcę opowiedzieć, to FlexBox.
@Composable
fun AdaptiveActionHeader(tags: List<String>) {
FlexBox(
config = {
direction( if (isCompact)
FlexDirection.Column else FlexDirection.Row )
wrap(FlexWrap.Wrap)
alignItems(FlexAlignItems.Center)
gap(row = 8.dp, column = 12.dp)
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Home",
modifier = Modifier.flex {
basis(FlexBasis.Auto)
grow(1f)
shrink(1f)
}
)
tags.forEach { tag ->
FilterChipItem(
label = tag,
modifier = Modifier.flex { shrink(0f) }
)
}
}
}Podczas gdy Grid obsługuje dwuwymiarowe układy wierszy i kolumn, FlexBox, który jest teraz stabilny, obsługuje układy jednowymiarowe, które nadal wymagają inteligentnego rozmieszczania, zmniejszania i zawijania wierszy.
Jest to wygodny, elastyczny układ, którego można używać zamiast 2 osobnych funkcji kompozycyjnych, takich jak FlowRow i FlowColumn, ponieważ jest to pojedynczy kontener, którego kierunek może zmieniać się dynamicznie w bloku konfiguracji bez konieczności ponownego tworzenia podrzędnych funkcji kompozycyjnych.
Implementuje też pełny algorytm CSS FlexBox, dzięki czemu masz do dyspozycji wrap, justify-content, align-items i gap.
Każdy element podrzędny ma właściwość Modifier.flex, która określa podstawę, względny wzrost, współczynniki zmniejszania, alignSelf itd.
Francesco Romano
Teraz przejdźmy od definicji na poziomie ekranu do układów na poziomie aplikacji, czyli sposobu, w jaki strukturyzujemy nawigację najwyższego poziomu i układy ekranu z wieloma panelami w całej aplikacji, a nie tylko na jednym ekranie.
MediaQuery podaje dokładną szerokość i wysokość okna w dp, ale w przypadku architektury na poziomie aplikacji potrzebne są ustandaryzowane, ujednolicone punkty przerwania widocznego obszaru, czyli klasy rozmiaru okna.
Zasadniczo chcesz, aby każdy ekran był elastyczny w ramach każdego z tych przedziałów, ale możesz wprowadzać więcej zmian strukturalnych i aktualizacji oraz różnic między różnymi przedziałami.
Może na przykład wyświetlać tylko listę na kompaktowym ekranie lub listę i szczegóły w rozwiniętym oknie.
Możesz tworzyć klasy wielkości okna bezpośrednio na podstawie UiMediaScope w bardzo przejrzysty sposób, czyli definiując właściwość rozszerzenia w UiMediaScope.
val UiMediaScope.windowSizeClass: WindowSizeClass
@Composable get() = derivedMediaQuery {
WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
widthDp = windowWidth.value,
heightDp = windowHeight.value
)
}
@Composable
fun AppRoot() {
val sizeClass = mediaQuery { windowSizeClass }
val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
)
// Drive app-level navigation and scene strategies...
}Zwróć uwagę, że używa on derivedMediaQuery, a nie standardowego MediaQuery.
Dlaczego?
Ponieważ szerokość i wysokość okna to wartości, które często się zmieniają.
Gdy użytkownicy zmieniają rozmiar aplikacji, np. na komputerze, te wartości mogą się zmieniać w każdej klatce.
Prawdopodobnie nie chcesz rekomponować rusztowania na poziomie aplikacji w każdej klatce, ale tylko wtedy, gdy okno faktycznie przekroczy punkt przerwania.
Gdy mamy już klasy rozmiaru okna, jak możemy przejść z przepływu z jednym panelem do układów z wieloma panelami, gdy mamy więcej miejsca?
W tym przypadku świetnie sprawdza się Jetpack Navigation 3, ponieważ ten komponent został od podstaw zaprojektowany pod kątem stanu i scen Compose.
Po pierwsze, masz stos wsteczny, który jest po prostu listą kluczy nawigacyjnych, dzięki czemu jest bardzo elastyczny i łatwy do zapisania jako stan.
Każdy klucz na stosie jest powiązany z obiektem NavEntry zawierającym funkcję kompozycyjną i metadane.
NavDisplay nie ma zakodowanego na stałe sposobu rysowania wpisów. Zamiast tego ocenia listę strategii scen.
W ramach strategii możesz sprawdzić możliwości interfejsu, np. klasy rozmiaru okna.
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}Możesz sprawdzić stos wsteczny, a następnie zdecydować, czy chcesz renderować jeden element NavEntry w jednym panelu, czy kilka elementów jednocześnie lub jeden na drugim.
To bardzo elastyczne rozwiązanie.
Oprócz strategii wielopanelowej klasy rozmiaru okna określają też, jakich komponentów nawigacyjnych chcesz używać, aby zmaksymalizować ergonomię i wykorzystanie miejsca na ekranie.
Na przykład na urządzeniach o szerokości kompaktowej chcesz mieć dolny pasek nawigacyjny, na urządzeniach o szerokości średniej – kolumnę nawigacji, a na urządzeniach o szerokości rozszerzonej – pełny panel nawigacji.
Połączenie Nav3 do podejmowania decyzji dotyczących układu na poziomie aplikacji i komponentów elastycznych do definiowania na poziomie ekranu pozwala uzyskać aplikacje, które wyglądają tak i świetnie prezentują się w praktycznie każdym rozmiarze okna i środowisku.
Jeśli chcesz dowiedzieć się więcej o najnowszych aktualizacjach Nav 3, w tym o obsłudze precyzyjnych linków, obejrzyj prezentację Tworzenie bezpiecznych precyzyjnych linków za pomocą Jetpack Navigation 3.
Francesco Romano
Omówiliśmy dziś wiele nowych interfejsów API: MediaQuery, Grid, FlexBox, Nav3.
To już wszystkie informacje o tym, jak Jetpack Compose pomaga tworzyć aplikacje adaptacyjne.
Jednak w erze rozwoju opartego na agentach Jetpack Compose ma jeszcze jedną niezwykle ważną zaletę: jest znacznie skuteczniejszy.
Prawdopodobnie słyszeliście podczas prezentacji, że po przejściu na Compose aplikacje używają o ponad 30% mniej tokenów. Pracujemy nad jeszcze większą optymalizacją Agentic Workflows w aplikacjach Compose.
Dzisiaj chcę wyróżnić duże aktualizacje umiejętności Compose Adaptive.
android skills list
android skills add adaptiveJak już wspominałem, oprócz migracji układów do Grid, Flexbox i MediaQuery, teraz wyraźnie obsługuje on kompleksowe zarządzanie stanem w całej aplikacji. Testujemy tę umiejętność pod kątem rzeczywistych błędów związanych z utratą stanu: zachowywanie nawigacji, pozycji przewijania, aktywnego odtwarzania multimediów, częściowo wpisanego tekstu itd.
Umiejętność obejmuje teraz również optymalizację pozycji urządzenia składanego, np. automatyczne refaktoryzowanie ekranów multimediów i aparatu do trybu stołu, ale gdy aplikacja działa na Googlebooku lub zewnętrznym monitorze z myszą i klawiaturą, układ to tylko połowa sukcesu.
Umiejętność adaptacyjna może teraz sprawdzać interfejs użytkownika, aby znajdować i wdrażać skróty klawiszowe, niestandardowe kursory, stany najechania, etykietki itp.
Zobaczmy, jak to działa.
Nagrałem wersję demonstracyjną.
Mamy już aplikację.
Możemy popatrzeć na nasze zwierzęta.
Możemy wysyłać im wiadomości.
Możemy obejrzeć film o nich.
Tak, widzimy najpopularniejsze zwierzęta.
Ale już widzisz, że nie jest to najlepsze rozwiązanie.
Teraz zobaczmy, czy uda mi się znaleźć zdjęcie z kotami i odrobiną dramatyzmu.
Chcę zobaczyć je w większym rozmiarze.
Dah, to powrót do domu.
OK, powiem o tym koledze.
No tak, „Buddy”, hm.
Aplikacja nie jest zbyt dobra.
Jest też za szeroki.
Widzisz, że jest za szeroki.
Spróbujmy zmienić jego rozmiar.
Co?
No dobrze.
Wpisany tekst został utracony.
Teraz poprosimy Gemini o naprawienie tego błędu.
Dzięki umiejętności adaptacyjnej prompt może być tak prosty jak „dostosuj aplikację”.
Jak widzisz, używam modelu Gemini 3.0 Flash, który wraz z umiejętnością jest w stanie to zrobić.
Została ona wykryta i teraz zaczyna wprowadzać zmiany.
Zada kilka pytań, np. „MediaQuery jest eksperymentalny.
Czy naprawdę chcesz go użyć?”
I kontynuuj.
Musiałem przyspieszyć, żeby coś wyciąć, ale jeśli przyjdziesz do obszaru demonstracyjnego, możesz samodzielnie wypróbować umiejętności.
A to tylko jedna z umiejętności, które udostępniliśmy.
Mamy też na przykład nową umiejętność, która pomoże Ci tworzyć niesamowite funkcje rysika.
Przyjdź na miejsce, aby wypróbować tę funkcję.
Wszystko gotowe.
Zmodyfikowano 11 plików.
Zobaczmy, jak to działa.
OK.
Grid już działa.
Nawigacja została przeniesiona na bok.
Wypróbujmy filtry.
Myślę, że teraz będą działać.
Tak, mają.
OK, wyślę wiadomość do znajomego i powiem mu, że aplikacja jest teraz świetna.
Zobaczmy, jak to wygląda, gdy mamy więcej miejsca.
Tak, został przeniesiony do układu szczegółowej listy.
To uruchomienie trwało około 20 minut i wykorzystało około 200 tys. tokenów.
OK.
Francesco Romano
Zwykle zaczynam od wskaźników biznesowych i przypadków sukcesu, ale w przypadku ADS chciałem od razu przejść do kodu.
Chcę jednak poświęcić kilka minut na omówienie zwrotu z inwestycji w inżynierię.
Obecnie aplikacja na Androida może działać na ponad 580 milionach aktywnych urządzeń z dużym ekranem. Wiemy też, że użytkownicy urządzeń składanych i użytkownicy korzystający z wielu urządzeń należą do najbardziej zaangażowanych segmentów na Androidzie.
Przyjrzyjmy się 2 konkretnym studiom przypadków, które pokazują, co się dzieje, gdy udostępniasz kompilację adaptacyjną.
Pierwsza to Gratitude, popularna aplikacja do dbania o siebie i prowadzenia dziennika.
Dzięki zastosowaniu adaptacyjnych projektów ekranów firma Gratitude uczyniła z tabletów i urządzeń składanych kanał pozyskiwania klientów o wysokim potencjale wzrostu.
Po pierwsze, podwoili tempo pozyskiwania użytkowników na dużych ekranach.
Dzienna liczba instalacji na tabletach i urządzeniach składanych wzrosła o 100%.
Zwiększyli udział dużych ekranów w łącznej liczbie użytkowników z 7% do 10%.
Drugą aplikacją jest BoxBoxClub, która jest aplikacją towarzyszącą dla Formuły 1 i sportów motorowych.Po zaktualizowaniu jej pod kątem urządzeń składanych i większych ekranów dane telemetryczne wykazały ogromne wzrosty: o 7,6% w przypadku ogólnej liczby aktywnych użytkowników i o 54% w przypadku czasu zaangażowania.
OK.
Zbliżamy się do końca prezentacji. Chcę, abyście zapamiętali 3 ważne rzeczy.
Dzięki temu aplikacja może obsługiwać więcej urządzeń i użytkowników, a jednocześnie jest przygotowana na przyszłość w tym rozwijającym się ekosystemie.
Programowanie na Androida opiera się na Compose.
Wszystkie nowe narzędzia, biblioteki i umiejętności są tworzone z myślą o Compose.
Compose doskonale nadaje się do układów adaptacyjnych i programowania agentowego.
Bazy kodu są mniejsze i łatwiejsze do zrozumienia, a agenty używają mniej tokenów podczas pracy z kodem Compose.
Wypróbuj wszystkie nowe narzędzia, umiejętności i interfejsy API.
Dziękujemy.
<LinearLayout android:orientation="vertical">
<EditText
android:id="@+id/text_msg"
android:inputType="textCapCharacters"
android:maxLength="12" />
</LinearLayout><ConstraintLayout>
<TextInputEditText
android:id="@+id/text_input"
android:inputType="text" />
</ConstraintLayout>mediaQuery { ... }
derivedMediaQuery { ... }sealed interface UiMediaScope {
@get:FrequentlyChangingValue val windowWidth: Dp
@get:FrequentlyChangingValue val windowHeight: Dp
val windowPosture: Posture // Flat, Tabletop, Book
val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
val keyboardKind: KeyboardKind // Physical, Virtual, None
val hasCamera: Boolean
val hasMicrophone: Boolean
val viewingDistance: ViewingDistance // Near, Medium, Far
}@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
val context = LocalContext.current
val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
.collectAsStateWithLifecycle(null)
val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
displayFeature is FoldingFeature &&
displayFeature.state == FoldingFeature.State.HALF_OPENED &&
displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
} ?: false
return if (isTabletopPosture) {
VideoPlayerLayout.Tabletop
} else {
VideoPlayerLayout.Default
}
}@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}@Composable
fun AdaptiveChatControls(
onStartVideoCall: () -> Unit,
onSendMessage: () -> Unit,
) {
// 1. Return a Dp value directly based on pointer precision
val chipPadding = mediaQuery {
if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
}
// 2. Return a Boolean combining hardware capabilities
val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
val hasPhysicalKeyboard = mediaQuery {
keyboardKind == KeyboardKind.Physical
}
Row(modifier = Modifier.padding(chipPadding)) {
if (canVideoCall) {
IconButton(onClick = onStartVideoCall) {
Icon(Icons.Default.Videocam, contentDescription = "Video call")
}
}
Button(onClick = onSendMessage) {
Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
}
}
}@Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
@Composable
fun TabletopPlayerPreview() {
val simulatedMediaScope = object : UiMediaScope {
override val windowWidth = 840.dp
override val windowHeight = 700.dp
override val windowPosture = UiMediaScope.Posture.Tabletop
override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
override val keyboardKind = UiMediaScope.KeyboardKind.Physical
override val hasCamera = true
override val hasMicrophone = true
override val viewingDistance = UiMediaScope.ViewingDistance.Medium
}
CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
VideoPlayerScreen()
}
}Grid(
modifier = Modifier.fillMaxSize().padding(16.dp),
config = {
val maxWidthDp = constraints.maxWidth.toDp()
val columnCount = if (maxWidthDp < 600.dp) 2 else 4
repeat(columnCount) {
column(1.fr)
}
row(GridTrackSize.Auto)
row(1.fr)
gap(row = 16.dp, column = 8.dp)
flow = GridFlow.Row
}
) {
// GridScope children...
}Grid(
config = {
repeat(3) { column(1.fr) }
repeat(3) { row(100.dp) }
gap(8.dp)
}
) {
// Spans 2x2 area (rows 1..2, columns 1..2)
HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))
// Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
LiveBadge(
modifier = Modifier.gridItem(
row = 1,
column = 1,
alignment = Alignment.TopStart
)
)
// Negative index: placed in last row (-1), spanning all 3 columns
FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
}@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
when(currentVideoPlayerLayout()) {
VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
}
}enum class VideoPlayerArea { Player, Controller }
Grid(
config = { ... },
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}object VideoPlayerGridConfiguration {
val Default: GridConfigurationScope.() -> Unit = {
row(1f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
}
val Tabletop: GridConfigurationScope.() -> Unit = {
row(0.5f)
row(0.5f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
}
}@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Grid(
config = { mediaQuery {
if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
else { VideoPlayerGridConfiguration.Default }
}
}
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}
}@Composable
fun AdaptiveActionHeader(tags: List<String>) {
FlexBox(
config = {
direction( if (isCompact)
FlexDirection.Column else FlexDirection.Row )
wrap(FlexWrap.Wrap)
alignItems(FlexAlignItems.Center)
gap(row = 8.dp, column = 12.dp)
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Home",
modifier = Modifier.flex {
basis(FlexBasis.Auto)
grow(1f)
shrink(1f)
}
)
tags.forEach { tag ->
FilterChipItem(
label = tag,
modifier = Modifier.flex { shrink(0f) }
)
}
}
}val UiMediaScope.windowSizeClass: WindowSizeClass
@Composable get() = derivedMediaQuery {
WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
widthDp = windowWidth.value,
heightDp = windowHeight.value
)
}
@Composable
fun AppRoot() {
val sizeClass = mediaQuery { windowSizeClass }
val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
)
// Drive app-level navigation and scene strategies...
}if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}android skills list
android skills add adaptive