Das Android-Ökosystem ist riesig und Nutzer wechseln nahtlos zwischen Smartphones, Tablets, faltbaren Geräten und Desktop-Umgebungen. Da wir jetzt Compose First sind, wird in dieser Session erläutert, wie Sie Ihre App für anpassbare Fenster und verschiedene Seitenverhältnisse entwickeln können, bei denen herkömmliche Layoutannahmen nicht mehr funktionieren und zu gestreckten Benutzeroberflächen und verlorenem Status führen.
Hier erfahren Sie, wie Sie mit den neuesten Funktionen in Jetpack Compose, Navigation 3 und WindowManager dynamische Layouts erstellen können, die sich natürlich von kompakten Bildschirmen auf Splitscreen-Displays skalieren lassen. Sie erhalten Strategien, mit denen Sie Ihren Entwicklungs-Workflow mit Gemini und unseren Skills, wie dem adaptiven Skill, beschleunigen können, um Legacy-Codebasen automatisch für größere Bildschirme zu refaktorieren.
MediaQuery API verfolgt deklarativ aktive UI-Funktionen wie Fenstergröße und Faltpositionen. Es ersetzt das Zusammenfügen mehrerer Legacy-Plattformmanager in den Compose-Zustand.Grid und FlexBox bieten Bausteine für adaptive Layouts. Grid ermöglicht parametrisierte mehrdimensionale Anordnungen innerhalb eines einzelnen Composables, während FlexBox eine eindimensionale Raumverteilung und einen Zeilenumbruch bietet.Transkript
Francesco Romano
Hallo zusammen!
Heute sehen wir uns genauer an, wie Sie Ihre App mit Jetpack Compose für alle Bildschirme skalieren können.
Warum auf alle Bildschirme skalieren?
Das Android-Ökosystem hat sich auf viele verschiedene Formfaktoren ausgeweitet.
Ihre APK kann auf Smartphones, Foldables, Tablets, Chromebooks und Googlebooks, Fernsehern, Infotainmentsystemen in Fahrzeugen, XR-Geräten usw. ausgeführt werden.
Als Erstes sollten Sie Ihre Nutzer dort abholen, wo sie sind.
Zweitens ist die Umstellung der Android-Plattform auf adaptive Apps nicht nur vorübergehend.
Wir werden nicht zu einer Welt zurückkehren, in der es nur Smartphones gibt. KI ist gekommen, um zu bleiben.
Hier ist ein kurzer Zeitplan.
2022 haben wir damit begonnen, die Grundlagen zu schaffen.
Wir haben Android 12L und die erste Version von Jetpack WindowManager veröffentlicht, APIs wie Fenstergrößenklassen bereitgestellt und die erste Version der Qualitätsrichtlinien für adaptive Apps veröffentlicht.
Außerdem haben wir Google Play so aktualisiert, dass Bewertungen nach Gerätetyp aufgeteilt werden, und wir haben die Qualitätsrichtlinien für adaptive Apps in die Ranking-Entscheidungen einbezogen.
In Android 15 haben wir auch das Desktop-Freiform-Fenster im AOSP eingeführt und Compose Material 3 Adaptive ist jetzt stabil.
Ab Android 16, SDK 36, ignoriert das System auf größeren Bildschirmen einfach die Sperren für Ausrichtung und Seitenverhältnis sowie die Flags für die Größenänderung.
Zusammen mit der Veröffentlichung von verbundenen Displays kann Ihr APK jetzt in praktisch jeder Fenstergröße ausgeführt werden.
Und SDK 36 bot ein temporäres Manifest-Flag, mit dem Sie die Überschreibung von Ausrichtung und Seitenverhältnis deaktivieren konnten.
In Android 17 gibt es diese Option nicht mehr.
Adaptive Apps sind also eine grundlegende Anforderung an die Plattform.
In den letzten zehn Jahren haben wir eng mit Teams im gesamten Ökosystem zusammengearbeitet und aus erster Hand erfahren, warum die Entwicklung adaptiver Apps so schwierig sein kann.
Es gibt einige Statusverluste, fragmentierte Fenster-APIs, unklare Signale, wann das Layout gewechselt werden soll, und einige alte Annahmen, die tief in der Codebasis verankert sind.
Wir haben also alles, was wir in diesem Jahrzehnt der Entwicklerherausforderungen gelernt haben, direkt an zwei Stellen integriert: erstens in das UI-Toolkit selbst.
Heute sehen wir uns einige wichtige neue Funktionen in Jetpack Compose an: Medienabfragen, Raster, FlexBox und Navigation 3.
Aber auch in agentenbasierten Tools, die den Code mit Ihnen schreiben, über die offiziellen Android-Skills für Gemini in Android Studio und die Android CLI.
Wir sehen uns zum Beispiel die adaptiven Fähigkeiten von Compose an, die Anfang des Jahres auf der Google I/O vorgestellt wurden.
Es war bereits in der Lage, Smartphone-Layouts auf größere Fenstergrößen zu migrieren.
Außerdem kann sie den Verlust des Status beheben, faltbare Funktionen wie den Tischmodus implementieren und die Eingabeunterstützung verbessern, indem sie Tastenkombinationen, benutzerdefinierte Cursor, Kurzinfos usw. erkennt und implementiert.
Beginnen wir mit Jetpack Compose, der Grundlage für all das.
Jetpack Compose wurde 2021 stabil, feiert also seinen fünften Geburtstag.
In diesen fünf Jahren haben wir bei Tausenden von Produktions-Apps gesehen, wie viel einfacher und zuverlässiger es ist, mit Compose adaptive Apps zu entwickeln.
Die Android-Entwicklung ist jetzt offiziell Compose-first.
Das bedeutet, dass alle neuen modernen adaptiven Layouts, Stile und Navigationskomponenten für Compose entwickelt wurden.
Aus Sicht der Systemarchitektur ist der größte Vorteil von Compose für adaptive Apps gegenüber dem alten View-System die Interaktion zwischen Status und UI.
Zur Veranschaulichung möchte ich einen kurzen Vergleich mit dem View-System anstellen.
Denken Sie daran, wie das View-System funktioniert hat.
Die Benutzeroberfläche war ein Baum veränderlicher Objekte, die einmal aus statischen XML-Dateien aufgebläht wurden.
Sie hatten verschiedene Ressourcenqualifizierer. Beginnen wir mit dem Hauptproblem: Duplikate in verschiedenen Ressourcenqualifizierern.
Wenn Sie eine adaptive UI in XML erstellen, gibt es in der Layoutdatei keine bedingte Logik.
Stattdessen duplizieren Sie einfach die gesamte Hierarchie in Ressourcenordnern wie „layout“, „layout smallest window 600 dp“, „layout land“ usw.
Sehen Sie sich das Beispiel EditText auf der rechten Seite an.
<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>Das ist genau dieselbe logische Komponente, die die Texteingabe verarbeitet, und sie ist über verschiedene XML-Dateien verteilt.
Wenn Sie ein Attribut wie inputType oder maxLength im Smartphone-Layout aktualisieren, werden Sie vom Compiler nicht gezwungen, es auch im Tablet-Layout zu aktualisieren.
Da die Komponenten über verschiedene Dateien hinweg entkoppelt sind, ist eine strukturelle Abweichung im Laufe der Zeit fast unvermeidlich.
Diese Duplizierung ist auch mit einem anderen Problem verbunden, nämlich dem Verlust des Status bei Konfigurationsänderungen.
Denken Sie daran, dass UI-Komponenten im View-System zustandsorientiert sind.
Ein EditText besitzt einen internen bearbeitbaren Zwischenspeicher, eine benutzerdefinierte Cursorposition, einen Fokusstatus und einen Status.
Wenn ein Nutzer also beispielsweise das Gerät aufklappt oder die Fenstergröße über einen bestimmten Schwellenwert hinaus ändert, zerstört Android die gesamte Ansichtshierarchie und füllt die neuen XML-Dateien von Grund auf neu.
Zum Speichern der Nutzereingabe verwendet das Framework „onSaveInstanceState“, wodurch der mit der ID dieser Ansicht verknüpfte Status gepackt wird.
Das führt jedoch zu drei möglichen Fehlermodi.
Wenn jemand die ID in der anderen XML-Datei versehentlich text_input statt text_msg genannt hat, wird der eingegebene Text des Nutzers bei der Wiederherstellung des Status ohne Warnung verworfen.
Zweitens wird „onRestoreInstanceState“ nach „onViewCreated“ ausgeführt, was häufig zu Konflikten mit den ViewModel-Beobachtern und zu Callback-Schleifen führen kann.
Und drittens: Wenn Sie den Widget-Typ über Qualifizierer hinweg austauschen, z. B. auf einem kompakten Bildschirm einen Spinner und auf einem breiteren Bildschirm eine RadioGroup, und beide dieselbe ID haben, kommt es zur Laufzeit zu einem Absturz mit einer ClassCastException.
In Jetpack Compose ist die Benutzeroberfläche dagegen kein Objekt, das Sie einmal erstellen.
Ihre UI ist eine reine Funktion des Status.
Und das ändert die Definition einer Konfigurationsänderung komplett.
Was ist eine Konfigurationsänderung in Compose?
Ich habe einen Hinweis.
Francesco Romano
Ja.
In Compose ist eine Konfigurationsänderung nur eine weitere Neuzusammensetzung, und Neuzusammensetzungen finden ständig statt.
Wenn das Fenster breiter wird, weil der Nutzer das Gerät aufgeklappt hat, lesen die Composables die neue Größe und die Composables, deren Ausgabe davon abhängt, werden einfach noch einmal ausgeführt.
Das ist die gesamte Veranstaltung.
Sehen wir uns nun an, wie Sie mit den neuesten Compose-Tools und -Komponenten moderne adaptive Apps schreiben können.
In der XML-Welt mussten Sie sich Fragen stellen wie „Läuft meine App auf einem Smartphone, einem Tablet oder einem Computer?“ und dann die Benutzeroberfläche je nach Gerätekategorie verzweigen.
Es gibt jedoch einen grundlegenden Grund, dies zu vermeiden.
Selbst wenn die App auf demselben physischen Gerät ausgeführt wird, können sich der App-Kontext und die UI-Funktionen zur Laufzeit dynamisch ändern.
Die Botschaft, die ich vermitteln möchte, ist, dass UI-Entscheidungen von den aktuellen aktiven Funktionen bestimmt werden sollten: aktuelle Fenstergröße, Position, einige Hardwaremerkmale.
Sehen wir uns ein klassisches Beispiel an: Desktop-Freiform-Fenster auf einem externen Monitor.
Wenn Sie Ihr Pixel Smartphone an einen externen Monitor auf Ihrem Schreibtisch anschließen, wird der App-Prozess, der gerade auf einem 6-Zoll-Display im Hochformat ausgeführt wurde, jetzt auf einem 32-Zoll-Monitor mit angeschlossener physischer Tastatur und Maus ausgeführt.
Wenn Sie also prüfen, auf welchem Gerätetyp die App ausgeführt wird, sagt das nichts über den Bildschirm aus, auf dem sie tatsächlich angezeigt wird, oder über die Eingabemodalitäten.
Außerdem unterstützt Android den Mehrfenstermodus, sodass die Größe von Fenstern angepasst werden kann.
Auch wenn der Bildschirm eines Googlebooks größer ist als der eines Smartphones, wird Ihre App möglicherweise in einem 400 dp breiten Fenster zusammen mit anderen Apps ausgeführt.
Daher kann es auch sinnlos sein, die statischen Messwerte für die physische Anzeige abzufragen.
Wenn wir also Apps für Compose First entwickeln möchten, müssen wir wissen, wie wir die Umgebung abfragen.
Zuerst möchte ich das Konzept der UI-Funktionen formalisieren.
Zu den UI-Funktionen gehören nicht nur die Fensterbreite und -höhe oder die Position des faltbaren Geräts, sondern auch die derzeit aktiven Eingabe- und Ausgabemodalitäten.
Wir haben festgestellt, dass Nutzer in der Desktop-Umgebung nicht immer den Touchscreen verwenden.
Sie können Tastatur und Maus verwenden.
In einigen Autos gibt es einen Drehknopf oder eine andere Möglichkeit, Signale einzugeben.
Neben der Bildschirmgröße und der Eingabe ist auch die Entfernung des Nutzers zum Display, also der Betrachtungsabstand, oder ob eine Hardwarekamera oder ein Mikrofon angeschlossen ist, von Bedeutung.
Das sind alle Funktionen der Benutzeroberfläche.
Francesco Romano
Wenn Sie alle Signale abrufen wollten, mussten Sie fünf oder sechs verschiedene APIs aus unterschiedlichen Quellen zusammenführen.
Sie benötigten das Konfigurationsobjekt, den WindowMetricsCalculator, den WindowInfoTracker, den InputManager, den PackageManager und mussten dann jedes dieser Elemente manuell in einen Compose-Zustand konvertieren.
Hat es dir Spaß gemacht?
Nein, habe ich nicht.
Um dieses Problem zu beheben, haben wir in androidx.compose.ui die MediaQuery API eingeführt.
mediaQuery { ... }
derivedMediaQuery { ... }Sie sind von CSS-Medienabfragen inspiriert.
Und das Wort „Medien“ bedeutet nicht Audio- oder Videowiedergabe.
Es bezieht sich auf das Medium, die Umgebung, in der Ihre Benutzeroberfläche gerendert wird.
Und mit MediaQuery können Sie die UI-Funktionen auf eine einzige deklarative, Compose-native Weise abfragen.
Sehen wir uns die Attribute an, die in der UiMediaScope verfügbar sind.
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 und windowHeight in dp.
windowPosture, die derzeit für faltbare Geräte gedacht ist und „Flach“, „Auf dem Tisch“ oder „Buch“ sein kann.
pointerPrecision gibt die Genauigkeit des primären Zeigegeräts an: „Fine“ für Maus, Eingabestift oder Trackpad; „Coarse“ für Touch; „Blunt“ für Joystick, Controller oder „None“.
keyboardKind kann physisch, virtuell oder „none“ sein.
hasCamera und hasMicrophone verfolgen dynamisch, ob diese Hardware verfügbar ist.
Denke an eine externe Kamera, die angeschlossen werden kann.
viewingDistance meldet „Nah“, „Mittel“ oder „Weit“.
Ich denke aber, dass es einfacher ist, wenn wir uns ein echtes Beispiel ansehen, nämlich die Erkennung, ob sich das Gerät im Modus „Auf dem Tisch“ befindet.
Im Modus „Auf dem Tisch“ können Nutzer das Gerät halb zuklappen und es normalerweise auf einer Oberfläche ablegen.
Er eignet sich auch hervorragend für die längere Videowiedergabe, für Videoanrufe und für Zeitrafferaufnahmen.
Außerdem ist es sehr praktisch, wenn du einem Rezept folgen oder eine Diashow ansehen möchtest.
Wenn Sie die Tisch-Haltung vor MediaQuery erkennen wollten, mussten Sie diesen gesamten Code schreiben.
@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
}
}Sie mussten den Kontext abrufen, eine Instanz von WindowInfoTracker abrufen, den windowLayoutInfo erfassen, der ein Flow ist, einen lebenszyklusbewussten Compose-Status, und dann die displayFeatures durchlaufen, um zu prüfen, ob es einen gibt, der ein FoldingFeature ist, halb geöffnet und horizontal ist.
Und das ist nur für die Erkennung der Körperhaltung.
Das ist eine Menge Codebausteine.
Mit der neuen MediaQuery API schrumpft das alles auf nur wenige Zeilen Code.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}Die Funktion mediaQuery verwendet einen einzelnen Lambda-Parameter und stellt im Empfängerbereich dieses Lambdas die gerade gesehenen Eigenschaften bereit.
In diesem Beispiel können wir durch einfaches Prüfen von windowPosture in einem „when“-Ausdruck sofort die richtige zusammensetzbare Funktion zurückgeben.
Hier sind zwei weitere Beispiele für die Verwendung der 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")
}
}
}Erstens: Informationsdichte.
Wenn ein Nutzer also einen Laptop oder einen Desktop-Monitor mit einer Maus verwendet und die Zeigerpräzision hoch ist, können wir die Abstände der UI-Komponenten in diesem Beispiel von 8 Dp auf 2 Dp verringern, um mehr Inhalte anzuzeigen. Da die Nutzer nicht ihre Finger verwenden, können die Berührungszielbereiche kleiner sein.
Außerdem fähigkeitsgesteuerte Aktionen.
Angenommen, wir haben eine Chateingabeleiste. Wir können hasCamera und hasMicrophone prüfen, um den Aktionsbutton für Videoanrufe dynamisch ein- oder auszublenden. Oder wir können die Tastaturart „Physisch“ kombinieren, um Hinweise zu Tastenkombinationen wie „Drücke Strg + Eingabetaste zum Senden“ ein- oder auszublenden.
Diese werden alle zur Laufzeit abgerufen.
MediaQuery eignet sich auch hervorragend zum Testen und Erstellen von Vorschauen.
@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()
}
}Im Grunde löst MediaQuery seinen Empfänger über ein CompositionLocal namens LocalUiMediaScope auf.
Sie können also einfach einen gefälschten UiMediaScope über CompositionLocalProvider in Ihrer Vorschau bereitstellen. Sie müssen keine faltbaren Geräte starten und auf 90° einstellen oder eine Maus anschließen, nur um einen Compose-UI-Test auszuführen.
Francesco Romano
Wir wissen jetzt, wie wir die UI-Funktionen abfragen können.
Sehen wir uns nun an, wie das eigentliche Layout erstellt wird.
Dafür möchte ich Grid vorstellen, ein neues Layout-Primitiv in androidx.compose.foundation.layout, das jetzt stabil ist.
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...
}Mit Grid können Sie ein zweidimensionales Layout implementieren, indem Sie Elemente in Spalten und Zeilen anzeigen, die sich an die verfügbare Containergröße anpassen.
Sehen wir uns an, was Sie im Konfigurationsblock tun können.
Sie können neue Elemente oder Tracks anhängen, indem Sie „column“ oder „row“ aufrufen, und Sie können die standardmäßigen Kotlin-Schleifen wie „repeat“ verwenden.
Sie können Abstände zwischen den Spuren festlegen.
Sie können die Richtung der automatischen Platzierung über GridFlow.Row oder GridFlow.Column steuern.
Hier ist ein wichtiges Architekturdetail.
Der Konfigurationsblock wird während des Messdurchlaufs ausgeführt. Der Bereich der Rasterkonfiguration implementiert die Dichte und stellt die Einschränkungen bereit.
Sie können constraints.maxWidth.toDp() also direkt in der Konfiguration prüfen, um die Tracks ohne zusätzliche Neuzusammensetzungsdurchgänge zu berechnen.
Wenn Sie in Grid ein untergeordnetes Element ohne Modifikator hinzufügen, wird es automatisch in die nächste Zelle eingefügt.
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))
}Wenn Sie die explizite Kontrolle haben möchten, können Sie Modifier.gridItem verwenden. Sie haben dann einsbasierte Koordinaten.
Zeile 1, Spalte 1 ist also die Zelle oben links.
Es wird eine negative Indexierung unterstützt, d. h. Zeile -1 ist die letzte Zeile usw.
Mit rowSpan können Sie ein Element über einen 2D-Bereich erstrecken, und dann gibt es auch Überlappungen.
Wenn also zwei Elemente in sich überschneidenden Zellen platziert werden, stapelt Grid sie innerhalb dieser Grenzen übereinander. Die Z-Reihenfolge wird durch die Kompositionsreihenfolge bestimmt.
Spätere Elemente werden also wie bei Box über den anderen gezeichnet.
Jetzt kennen wir die Grundlagen von Grid.
Sehen wir uns das Beispiel mit dem Videoplayer an.
Je nachdem, ob das Gerät flach oder halb geöffnet ist, müssen wir zwischen dem Standardlayout und dem Layout „Auf dem Tisch“ wechseln.
Vor der Einführung von Grid mussten wir zwei verschiedene Composables erstellen, DefaultLayout und TabletopLayout, und dann den Code mit einem „when“-Ausdruck verzweigen.
@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)
}
}Sie fragen sich vielleicht: „Was ist daran falsch?“
Wenn Sie sich diese Layouts ansehen, ist ihr Inhalt identisch.
Auf beiden wird genau derselbe Player und genau derselbe Controller angezeigt.
Der einzige Unterschied zwischen ihnen besteht darin, wie diese beiden Elemente auf dem Bildschirm angeordnet sind.
Bisher hatten wir keine Möglichkeit, die Layoutanordnung selbst als Parameter zu übergeben, und mussten daher zwei separate Composables definieren.
Jetzt können wir die Layoutkonfiguration parametrisieren und alles in einer einzigen zusammensetzbaren Funktion verarbeiten.
Wir sehen uns an, wie das geht.
Mit der Grid API können wir benannte Bereiche als Enums definieren.
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()
}
}Anschließend können wir untergeordnete Composables, Player und Controller angeben, die im Raster platziert werden sollen. Wir können jedes untergeordnete Element mit einem benannten Bereich verknüpfen, indem wir Modifier.gridItem aufrufen und areaId übergeben.
Hier weisen wir Grid an, den Player im Bereich VideoPlayerArea.Player und den Controller im Bereich VideoPlayerArea.Controller zu platzieren.
Hier definieren wir die beiden Konfigurations-Lambdas in einem 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)
}
}Für das Standardlayout des Videoplayers konfigurieren wir ein Raster mit einer Zeile und einer Spalte. Beachten Sie, dass wir auch bei nur einer Zelle zwei Bereichsnamen derselben Zelle zuweisen können.
In diesem Fall wird der Controller wie zuvor über dem Player eingeblendet.
Für den Tabletop-Modus definieren wir einfach zwei Zeilen und eine Spalte und platzieren den Player in der oberen Hälfte und den Controller in der unteren Hälfte, etwa so.
Nachdem wir diese Konfigurations-Lambdas definiert haben, können wir MediaQuery direkt in der Konfiguration des Grids verwenden.
@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()
}
}
}Wir können jetzt also ein einzelnes Videoplayer-Layout-Composable mit der UI haben, das dynamisch zwischen zwei Konfigurationen wechselt.
Das nächste Primitive, über das ich sprechen möchte, ist 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) }
)
}
}
}Während Grid also 2D-Zeilen- und Spaltenlayouts verarbeitet, verarbeitet FlexBox, das jetzt ebenfalls stabil ist, eindimensionale Layouts, die dennoch eine intelligente Platzverteilung, Verkleinerung und mehrzeiligen Umbruch erfordern.
Es ist ein praktisches, responsives Layout, das Sie anstelle von zwei separaten Composables wie FlowRow und FlowColumn verwenden können, da es sich um einen einzelnen Container handelt, dessen Richtung sich dynamisch innerhalb seines Konfigurationsblocks ändern kann, ohne dass untergeordnete Composables neu erstellt werden müssen.
Außerdem wird der vollständige CSS-FlexBox-Algorithmus implementiert, sodass Sie „wrap“, „justify-content“, „align-items“ und „gap“ verwenden können.
Und für jedes untergeordnete Element gibt es Modifier.flex, mit dem Sie Basis, relatives Wachstum, Schrumpffaktoren, alignSelf usw. festlegen können.
Francesco Romano
Sehen wir uns nun die Layouts auf App-Ebene an, also wie wir die Navigation auf oberster Ebene und die Anordnung von Bildschirmen mit mehreren Bereichen in der gesamten Anwendung strukturieren, nicht nur auf einem einzelnen Bildschirm.
MediaQuery liefert Ihnen zwar die genaue Fensterbreite und -höhe in dp, für die Architektur auf App-Ebene benötigen Sie jedoch standardisierte, vordefinierte Darstellungsbereich-Breakpoints, also die Fenstergrößenklassen.
Grundsätzlich sollte jeder Bildschirm innerhalb dieser Gruppen responsiv sein. Zwischen den verschiedenen Gruppen kann es jedoch größere strukturelle Änderungen, Aktualisierungen und Unterschiede geben.
Beispielsweise kann auf einem kompakten Bildschirm nur eine Liste angezeigt werden, während in einem maximierten Fenster sowohl die Liste als auch die Details zu sehen sind.
Sie können Fenstergrößenklassen direkt auf UiMediaScope aufbauen, indem Sie ein Erweiterungsattribut für UiMediaScope definieren.
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...
}Beachten Sie, dass derivedMediaQuery und nicht die Standard-MediaQuery verwendet wird.
Warum?
Weil sich die Breite und Höhe des Fensters häufig ändern.
Wenn Nutzer die Größe Ihrer App ändern, z. B. auf einem Desktop, können sich diese Werte bei jedem Frame ändern.
Und Sie möchten wahrscheinlich nicht, dass das Scaffold auf App-Ebene bei jedem Frame neu zusammengesetzt wird, sondern nur, wenn das Fenster tatsächlich einen Haltepunkt überschreitet.
Und wie wechseln wir zwischen einem einspaltigen Ablauf und einem mehrspaltigen Layout, wenn wir mehr Platz haben?
Hier kommt Jetpack Navigation 3 ins Spiel, denn Nav 3 wurde von Grund auf für Compose-Zustände und ‑Szenen entwickelt.
Erstens gehört Ihnen der Back Stack, der einfach eine Liste von NavKeys ist. Er ist also sehr flexibel und lässt sich leicht als Zustand speichern.
Jeder Schlüssel im Stack wird in einen NavEntry aufgelöst, der das zugehörige Composable und die zugehörigen Metadaten enthält.
NavDisplay legt nicht hartcodiert fest, wie Einträge gezeichnet werden, sondern wertet eine Liste von Szenenstrategien aus.
In der Strategie können Sie die UI-Funktionen prüfen, z. B. die Fenstergrößenklassen.
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}Sie können den Back Stack prüfen und dann entscheiden, ob ein NavEntry in einem einzelnen Bereich, mehrere Elemente gleichzeitig oder übereinander gerendert werden sollen.
Es ist sehr flexibel.
Neben der Strategie für mehrere Bereiche bestimmen Fenstergrößenklassen auch, welche Navigationskomponenten Sie verwenden möchten, um die Ergonomie und die Nutzung des Bildschirmplatzes zu maximieren.
Beispiel: Bei kompakten Breiten ist eine untere Navigationsleiste sinnvoll, bei mittleren Breiten eine Navigationsleiste und bei erweiterten Breiten eine vollständige Navigationsleiste.
Wenn Sie Nav3 für Layoutentscheidungen auf App-Ebene und responsive Komponenten für die Definition auf Bildschirmebene kombinieren, erhalten Sie Apps, die so aussehen und auf praktisch jeder Fenstergröße und in jeder Umgebung gut funktionieren.
Wenn Sie mehr über die neuesten Updates für Nav 3 erfahren möchten, einschließlich der Unterstützung für Deeplinks, sehen Sie sich den Vortrag „Building Type-Safe Deep Links with Jetpack Navigation 3“ an.
Francesco Romano
Wir haben heute viele neue APIs behandelt: MediaQuery, Grid, FlexBox, Nav3.
Das war ein Überblick darüber, wie Sie mit Jetpack Compose adaptive Apps erstellen können.
Im Zeitalter der agentischen Entwicklung gibt es jedoch einen weiteren, extrem wichtigen Vorteil bei der Verwendung von Jetpack Compose: Es ist viel effektiver.
Wie Sie vielleicht schon in der Keynote gehört haben, haben wir festgestellt, dass Apps nach der Migration zu Compose über 30 % weniger Tokens verwenden. Wir arbeiten daran, Ihren Agentic Workflow für Compose-Apps noch weiter zu optimieren.
Heute möchte ich die wichtigsten Updates für die Compose Adaptive-Funktion vorstellen.
android skills list
android skills add adaptiveWie bereits erwähnt, werden Layouts nicht nur zu Grid, Flexbox und MediaQuery migriert, sondern es wird auch das End-to-End-Statusmanagement in der gesamten App explizit gehandhabt. Wir testen die Fähigkeit anhand von realen Fehlern, die zu Statusverlust führen: Beibehalten der Navigation, Scrollposition, aktiven Medienwiedergabe, halb eingegebenen Texteingaben usw.
Der Skill umfasst jetzt auch die Optimierung für faltbare Geräte, z. B. die automatische Umgestaltung von Medien- und Kamerabildschirmen in den Modus „Auf dem Tisch“. Wenn Ihre App jedoch auf einem Googlebook oder einem externen Monitor mit Maus und Tastatur ausgeführt wird, ist das Layout nur die halbe Miete.
Der Adaptive Skill kann jetzt Ihre Benutzeroberfläche prüfen, um Tastenkombinationen, benutzerdefinierte Mauszeiger, Hover-Zustände, Kurzinfos usw. zu finden und zu implementieren.
Sehen wir uns das einmal genauer an.
Ich habe eine Demo dazu aufgenommen.
Wir haben also unsere App.
Wir können uns unsere Haustiere ansehen.
Wir können ihnen Nachrichten senden.
Wir können uns ein Video von ihnen ansehen.
Ja, wir können die Top-Haustiere sehen.
Aber Sie sehen schon, dass das nicht optimal ist.
Ich möchte ein Bild mit etwas Drama und Katzen sehen.
Okay, ich möchte sie größer sehen.
Ah, das ist zurück zur Startseite.
Okay, ich erzähle meinem Kumpel davon.
„Buddy“, hm.
Die App ist nicht gut.
Außerdem ist es zu breit.
Sie sehen, dass es zu breit ist.
Versuchen wir, die Größe anzupassen.
Was?
Äh, ja, okay.
Der eingegebene Text ist verloren.
Okay, jetzt bitten wir Gemini, das zu korrigieren.
Dank der adaptiven Funktion kann der Prompt so einfach sein wie „Mach die App adaptiv“.
Wie Sie sehen, verwende ich Gemini 3.0 Flash, das zusammen mit dem Skill dazu in der Lage ist.
Die Fähigkeit wurde erkannt und die Änderungen werden jetzt ausgeführt.
Es werden Fragen gestellt wie: „Oh, MediaQuery ist experimentell.
Möchten Sie sie wirklich verwenden?“
Und mach weiter.
Ich musste die Demo beschleunigen, um sie zu kürzen, aber wenn Sie in den Demobereich kommen, können Sie die Funktionen selbst ausprobieren.
Und das ist nur eine der Fähigkeiten, die wir veröffentlicht haben.
Wir haben zum Beispiel auch eine neue Fähigkeit, mit der Sie die fantastischen Eingabestift-Funktionen entwickeln können.
Kommen Sie also vorbei und probieren Sie es aus.
Es ist also erledigt.
Es wurden 11 Dateien geändert.
Sehen wir uns das einmal genauer an.
Okay.
Das Stromnetz ist bereits im Einsatz.
Die Navigation wurde an die Seite verschoben.
Probieren wir die Filter aus.
Ich denke, sie werden jetzt funktionieren.
Ja, das tun sie.
Okay, ich schreibe meinem Freund, dass die App jetzt super ist.
Sehen wir uns an, wie das mit mehr Platz aussieht.
Ja, es wurde auf das Listen-Detailansicht-Layout umgestellt.
Dieser Durchlauf hat etwa 20 Minuten gedauert und etwa 200.000 Tokens verbraucht.
Okay.
Francesco Romano
Normalerweise beginne ich einen Vortrag mit Geschäftsmesswerten und Erfolgsgeschichten, aber bei ADS wollte ich direkt zum Code übergehen.
Ich möchte mir jedoch ein paar Minuten Zeit nehmen, um den Return on Investment (ROI) für die Entwicklung zu erläutern.
Heute kann Ihre Android-App potenziell auf mehr als 580 Millionen aktiven Geräten mit großem Bildschirm ausgeführt werden. Außerdem wissen wir, dass Nutzer von Geräten mit mehreren Geräten und faltbaren Geräten zu den am stärksten engagierten Segmenten unter Android gehören.
Sehen wir uns zwei konkrete Fallstudien an, die zeigen, was passiert, wenn Sie einen adaptiven Build veröffentlichen.
Die erste App ist „Gratitude“, eine beliebte App für Achtsamkeit und Tagebuchführung.
Durch die Einführung adaptiver Bildschirmdesigns konnte Gratitude Tablets und faltbare Smartphones als wachstumsstarken Akquisitionskanal etablieren.
Erstens wurde die Akquisitionsgeschwindigkeit auf großen Bildschirmen verdoppelt.
Die täglichen Installationen, die speziell von Tablets und faltbaren Geräten stammen, sind um 100 % gestiegen.
Der Anteil der Installationen auf Geräten mit großem Bildschirm an der Gesamtzahl der Installationen stieg von 7 % auf 10 %.
Die zweite App ist BoxBoxClub, eine Companion-App für die Formel 1 und den Motorsport.Nach der Aktualisierung des Builds für faltbare Geräte und größere Bildschirme zeigten die Telemetriedaten massive Zuwächse: 7,6 % mehr aktive Nutzer insgesamt und 54 % mehr Interaktionszeit.
Okay.
Wir sind am Ende des Vortrags angelangt und ich möchte Ihnen drei wichtige Punkte mit auf den Weg geben.
Wenn Sie Ihre App adaptiv gestalten, kann sie auf mehr Geräten und von mehr Nutzern verwendet werden. Außerdem ist sie dann für dieses wachsende Ökosystem zukunftssicher.
Die Android-Entwicklung ist Compose First.
Alle neuen Tools, Bibliotheken und Fähigkeiten sind für Compose konzipiert.
Compose eignet sich hervorragend für adaptive Layouts und die Entwicklung von KI-Agenten.
Codebasen sind kleiner und leichter verständlich, und KI-Agenten verbrauchen weniger Tokens, wenn sie mit Compose-Code arbeiten.
Probieren Sie also alle neuen Tools, Fähigkeiten und APIs aus und haben Sie Spaß dabei.
Vielen Dank.
<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