Android 生态系统非常庞大,用户可以在手机、平板电脑、可折叠设备和桌面环境之间无缝切换。现在,我们已进入 Compose 优先时代,本演讲将探讨如何针对可调整大小的窗口和各种宽高比来构建应用架构,因为在这些情况下,传统的布局假设会失效,从而导致界面拉伸和状态丢失。
探索 Jetpack Compose、Navigation 3 和 WindowManager 的最新功能如何帮助您构建动态布局,使其能够自然地从小屏幕扩展到双窗格显示屏。您将掌握如何使用 Gemini 和我们的技能(例如自适应技能)来加速开发工作流,从而自动重构旧代码库以适应更大的屏幕。
MediaQuery API 会以声明方式跟踪活跃的界面功能,例如窗口大小和折叠状态。它取代了将多个旧版平台管理器拼接成 Compose 状态的做法。Grid 和 FlexBox 基元提供了自适应布局构建块。Grid 允许在单个可组合项中进行参数化多维排列,而 FlexBox 则提供一维空间分布和换行。转写文稿
Francesco Romano
大家好,
今天,我们将深入探讨如何使用 Jetpack Compose 将应用扩展到各种屏幕。
为何要适配各种屏幕?
因为 Android 生态系统已扩展到众多不同的设备规格。
您的 APK 可以在手机、可折叠设备、平板电脑、Chromebook 和 Googlebook、电视、车载信息娱乐系统、XR 设备等设备上运行。
首先,要随时随地触达用户。
其次,Android 平台向自适应应用的转变并非一时之举。
我们不可能回到只有手机的世界,它会一直存在。
这里有一个简要的时间轴。
2022 年,我们开始奠定基础。
我们推出了 Android 12L、Jetpack WindowManager 的首个版本,以及窗口大小类别等 API,并发布了首个版本的自适应应用质量指南。
然后,我们还更新了 Google Play,以便按设备类型对评分进行细分,并开始将自适应应用质量指南纳入排名决策。
在 Android 15 中,我们还在 AOSP 中引入了窗口化模式,并发布了 Compose Material 3 Adaptive 的稳定版。
从 Android 16(SDK 36)开始,系统会直接忽略大屏设备上的屏幕方向锁定、宽高比锁定和尺寸可调整性标志。
随着外接显示屏的发布,您的 APK 现在几乎可以在任何窗口大小下运行。
SDK 36 提供了一个临时清单标志,用于停用方向和宽高比替换。
在 Android 17 中,该选择退出选项已被移除。
因此,构建自适应应用是平台的基本要求。
在过去十年中,我们与整个生态系统中的团队密切合作,亲身体会到构建自适应应用为何如此困难。
您会遇到一些状态丢失、窗口 API 碎片化、何时切换布局的信号不明确等问题,并且代码库深处还存在一些旧假设。
因此,我们汲取了这十年来从开发者挑战中获得的经验,并将其直接融入两个方面:首先是界面工具包本身。
今天,我们将探索 Jetpack Compose 中的一些重要新功能:媒体查询、网格、FlexBox 和 Navigation 3。
还体现在智能体工具中,这些工具通过 Android Studio 中的 Gemini 官方 Android 技能和 Android CLI 与您一起编写代码。
例如,我们将看到今年早些时候在 Google I/O 大会上发布的 Compose 自适应技能。
它已经能够将手机布局迁移到更大的窗口尺寸。
现在,它还可以修复状态丢失问题,实现可折叠设备特有的体验(例如桌面模式),并通过发现和实现键盘快捷键、自定义光标、提示等来改进输入支持。
好的,我们先从 Jetpack Compose 开始,它是所有这些功能的基础。
Jetpack Compose 于 2021 年发布了稳定版,因此今年是其五周年。
在这五年中,我们通过数千个正式版应用见证了使用 Compose 构建自适应应用是多么轻松和可靠。
Android 开发现在正式采用 Compose 优先。
这意味着,所有新的现代自适应布局、样式和导航组件都是为 Compose 构建的。
从系统架构的角度来看,与旧版视图系统相比,Compose 在自适应应用方面的最大优势在于状态和界面的交互方式。
为了解释这一点,我想与 View 系统做一个快速比较。
那么,请思考一下视图系统的工作原理。
界面是一个可变对象树,您只需从静态 XML 文件中膨胀一次。
您有不同的资源限定符,我们先从这里的主要问题开始:不同资源限定符之间的重复。
在 XML 中构建自适应界面时,布局文件中没有任何条件逻辑。
而是需要在各个资源文件夹(例如 layout、layout-sw600dp、layout-land 等)中复制整个层次结构。
好的,请看右侧的 EditText 示例。
<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>这是处理文本输入的完全相同的逻辑组件,它分散在不同的 XML 文件中。
如果您在手机布局中更新了 inputType 或 maxLength 等属性,编译器不会强制您在平板电脑布局中也更新该属性。
由于组件在不同文件中解耦,随着时间的推移,结构漂移几乎不可避免。
这种重复还与另一个问题有关,即配置更改期间的状态丢失。
请注意,在 View 系统中,界面组件是有状态的。
EditText 拥有其内部 Editable 缓冲区、自定义光标位置、焦点状态和一些状态。
因此,当用户(比方说)展开设备或调整窗口大小超过特定阈值时,Android 会销毁整个视图层次结构,并从头开始膨胀新的 XML 文件。
为了保存用户输入,框架会依赖 onSaveInstanceState,后者会将与该视图 ID 相关联的状态打包。
但这会产生三种可能的故障模式。
首先,如果有人在另一个 XML 中不小心将 ID 命名为 text_input 而不是 text_msg,状态恢复功能会静默丢弃用户的输入文本,而不会发出任何警告。
其次,onRestoreInstanceState 在 onViewCreated 之后运行,这可能会经常与 ViewModel 观察器发生冲突,从而导致回调循环。
第三,如果您在限定符之间切换 widget 类型,例如在紧凑屏幕上您可能需要一个微调框,而在较宽的屏幕上您需要一个 RadioGroup,如果它们共享相同的 ID,则会在运行时发生 ClassCastException 崩溃。
而在 Jetpack Compose 中,界面不是您一次性构建的对象。
界面是状态的纯函数。
这彻底改变了配置变更的定义。
Compose 中的配置变更是什么?
我有个提示。
Francesco Romano
没错
在 Compose 中,配置变更只是另一种重组,而重组随时都在发生。
当窗口变宽时(可能是因为用户展开了设备),可组合项会读取新尺寸,而输出取决于该尺寸的可组合项会重新运行。
这就是整个活动。
现在,我们来看看如何使用最新的 Compose 工具和组件编写现代自适应应用。
因此,在 XML 世界中,您需要提出诸如“我是在手机、平板电脑还是桌面设备上运行?”之类的问题,然后根据设备类别分支界面。
但有一个根本原因,让我们不建议这样做。
即使在完全相同的实体设备上运行,应用上下文和界面功能也可能会在运行时动态变化。
因此,我想传达的信息是,界面决策应由当前活跃的功能驱动:当前窗口大小、姿态、一些硬件特性。
我们来看一个经典示例,即外接显示器上的窗口化模式。
如果您将 Pixel 手机连接到办公桌上的外接显示器,那么刚才在 6 英寸纵向屏幕上运行的应用进程现在会在连接了实体键盘和鼠标的 32 英寸宽屏显示器上运行。
因此,检查应用在何种设备类型上运行并不能保证其在何种屏幕上显示,也不能保证其采用何种输入模式。
此外,Android 还支持多窗口模式,因此窗口大小可以调整。
即使 Googlebook 的屏幕比手机大,您的应用也可能在 400 dp 宽的窗口中与其他应用一起运行。
因此,查询静态物理显示屏指标也可能毫无意义。
因此,若要首先针对 Compose 设计应用架构,我们需要了解如何查询环境。
首先,我想尝试将界面功能的概念形式化。
界面功能不仅包括窗口宽度和高度或可折叠状态,还包括当前处于活跃状态的输入和输出模态。
我们发现,在桌面环境中,用户并不总是使用触摸屏。
可以使用键盘和鼠标。
在汽车系统中,有些汽车有旋钮或其他输入方式,即不同的信号输入方式。
除了屏幕尺寸和输入方式之外,还包括用户与显示屏的距离(例如观看距离),或者当前是否连接了硬件摄像头或麦克风。
以上就是所有界面功能。
Francesco Romano
现在,如果您想检索所有信号,就必须将来自不同来源的 5、6 个不同的 API 拼接在一起。
您需要配置对象、WindowMetricsCalculator、WindowInfoTracker、InputManager、PackageManager,然后手动将每个对象转换为 Compose 状态。
您喜欢这样做吗?
我没有。
为了解决此问题,我们在 androidx.compose.ui 中引入了 MediaQuery API。
mediaQuery { ... }
derivedMediaQuery { ... }其设计灵感源自 CSS 媒体查询。
这里的“媒体”一词并非指音频或视频播放。
它指的是媒介,即呈现界面的环境。
MediaQuery 则提供了一种声明式的 Compose 原生方式,让您能够查询界面功能。
我们来看看 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和 windowHeight(以 dp 为单位)。
windowPosture,目前适用于可折叠设备,可以是 Flat、Tabletop 或 Book。
pointerPrecision 会告知您主要指控设备的精确度:鼠标、触控笔或触控板为“Fine”;触控为“Coarse”;操纵杆、控制器或无为“Blunt”。
keyboardKind 可以是物理、虚拟或无。
hasCamera 和 hasMicrophone 会动态跟踪此硬件是否可用。
考虑可以插入的外接摄像头。
viewingDistance 会报告“近”“中”或“远”。
不过,我认为通过一个实际示例来理解会更容易,即检测设备是否处于桌上模式。
桌上模式是一种让用户将设备半折叠并通常放置在平面上的姿态。
非常适合长时间播放视频、进行视频通话和延时摄影。
它还非常实用,比如跟着食谱做菜或观看幻灯片。
现在,如果您想在 MediaQuery 之前检测 Tabletop 姿态,则必须编写所有这些代码。
@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
}
}您必须获取上下文,获取 WindowInfoTracker 的实例,收集 windowLayoutInfo(这是一个流,一个生命周期感知型 Compose 状态),然后遍历 displayFeatures,检查是否存在一个 FoldingFeature、半开且水平的折叠。
而这仅仅是为了检测姿势。
这需要编写大量样板代码。
借助新的 MediaQuery API,所有这些代码都可以缩减为这几行代码。
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}mediaQuery 函数接受单个 lambda 参数,并在该 lambda 的接收器范围内公开我们刚刚看到的一组属性。
在此示例中,只需在 when 表达式中检查 windowPosture,我们就可以立即返回正确的组合项。
下面是另外两个使用该 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")
}
}
}首先是信息密度。
因此,当用户使用笔记本电脑、带鼠标的桌面显示器,且指针精度为“精细”时,我们可以将界面组件的内边距从本例中的 8 Dp 缩减到 2 Dp,以便舒适地显示更多内容,因为用户不使用手指,触摸目标可以更小。
此外,还有功能驱动的操作。
假设我们有一个聊天输入栏,我们可以检查 hasCamera 和 hasMicrophone,以动态显示或隐藏视频通话操作按钮,也可以结合键盘类型是否为实体键盘,来显示或不显示某些键盘快捷键提示,例如“按 Ctrl+Enter 即可发送”。
这些都是在运行时检索的。
MediaQuery 也非常适合用于测试和生成预览。
@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 基本上是通过名为 LocalUiMediaScope 的 composition local 来解析其接收器的。
因此,您只需在预览中通过 CompositionLocalProvider 提供虚假的 UiMediaScope,而无需启动可折叠设备并将其折叠成 90° 或连接鼠标,即可运行 Compose 界面测试。
Francesco Romano
现在,我们已经知道如何查询界面功能。
现在,我们来看看如何构建实际布局。
为此,我想介绍一下 Grid,它是 androidx.compose.foundation.layout 中的一种新布局基元,现已稳定。
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...
}借助网格,您可以通过在列和行中显示项来实现二维布局,这些列和行会根据可用的容器大小进行调整。
我们来看看您可以在 config 代码块中执行哪些操作。
您可以通过调用 column 或 row 来附加新元素或轨道,也可以使用标准 Kotlin 循环(例如 repeat)。
您可以在轨道之间设置间距。
您可以通过 flow-- GridFlow.Row 或 GridFlow.Column 控制自动放置方向。
以下是关键架构细节。
配置块在测量阶段运行,网格配置作用域会实现密度并公开约束条件。
这意味着,您可以直接在配置中检查 constraints.maxWidth.toDp(),以计算轨道,而无需额外的重组过程。
在 Grid 中,当您添加子项时,如果您添加的子项没有修饰符,它会自动流入下一个单元格。
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))
}如果您想进行显式控制,可以使用 Modifier.gridItem,并且您拥有从 1 开始的坐标。
因此,第 1 行第 1 列是左上角的单元格。
它支持负索引,因此行 -1 是最后一行,依此类推。
您可以使用 rowSpan 将一个项跨越一个二维区域,然后也会出现重叠。
因此,如果将两个项放置在重叠的单元格中,Grid 会在这些边界内将它们堆叠在一起,并且 Z 轴顺序由组合顺序决定。
因此,后面的项会像 Box 一样绘制在其他项之上。
现在,我们已经了解了网格的基础知识。
我们来看一下视频播放器示例。
我们需要根据设备是平放还是半开,在默认布局或桌面模式布局之间切换。
在 Grid 出现之前,我们必须创建两个不同的可组合项(DefaultLayout 和 TabletopLayout),然后使用 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)
}
}您可能会想:“这有什么问题?”
如果您查看这些布局,会发现它们的内容完全相同。
两者显示的播放器和控制器完全相同。
它们之间的唯一区别在于这两个元素在屏幕上的排列方式。
到目前为止,由于我们无法将布局排列本身作为参数传递,因此不得不定义两个单独的可组合项。
现在,我们可以将布局配置参数化,并在单个可组合函数中处理所有内容。
我们来看看具体如何实现。
因此,借助 Grid API,我们可以将已命名的区域定义为枚举。
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()
}
}然后,我们可以指定要放置在网格内的子级可组合项(播放器和控制器),并通过调用 Modifier.gridItem 将每个子级与命名区域相关联,并传递 areaId。
在这里,我们指示 Grid 将播放器放置在名为 VideoPlayerArea.Player 的区域中,将控制器放置在 VideoPlayerArea.Controller 中。
在这里,我们在 VideoPlayerGridConfiguration 中定义了两个配置 lambda。
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)
}
}对于默认视频播放器布局,我们配置了一个一行一列的网格,但请注意,即使只有一个单元格,我们仍然可以为同一个单元格分配两个区域名称。
在这种情况下,如前所述,控制器会叠加在播放器之上。
对于桌面模式,我们只需定义两行一列,将播放器放在上半部分,将控制器放在下半部分,基本上就像这样。
定义这些配置 lambda 后,我们就可以直接在网格配置中使用 MediaQuery。
@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()
}
}
}现在,我们可以创建一个视频播放器布局可组合项,其界面可在两种配置之间动态切换。
接下来我要介绍的原语是 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) }
)
}
}
}因此,虽然 Grid 负责处理二维行和列布局,但 FlexBox(现在也已稳定)负责处理仍需要智能空间分布、收缩、多行换行的一维布局。
这是一种便捷的自适应布局,可用于替代两个单独的可组合项(例如 FlowRow 和 FlowColumn),因为它是一个单一容器,其方向可以在其配置块内动态更改,而无需重新创建子可组合项。
它实现了完整的 CSS FlexBox 算法,因此您可以使用 wrap、justify-content、align-items、gap。
在每个子项上,您都有 Modifier.flex,它为您提供基准、相对增长、收缩因子、alignSelf 等。
Francesco Romano
现在,我们从屏幕级定义放大到应用级布局,看看如何在整个应用(而不仅仅是单个屏幕)中构建顶级导航和多窗格屏幕排列。
虽然 MediaQuery 会为您提供以 Dp 为单位的确切窗口宽度和高度,但对于应用级架构,您需要标准化、有主见的视口断点,即窗口大小类别。
基本上,您希望每个屏幕在每个分桶中都能自适应,同时您可以在不同分桶之间进行更多结构性更改、更新和差异化设计。
例如,在较小屏幕上仅显示列表,或在展开窗口中同时显示列表和详情。
您可以采用非常简洁的方式直接在 UiMediaScope 之上构建窗口大小类别,即在 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...
}需要注意的一点是,它使用的是 derivedMediaQuery,而不是标准的 MediaQuery。
为什么?
因为窗口宽度和高度是频繁变化的值。
当用户调整应用大小时(例如在桌面上),这些值可能会在每一帧发生变化。
您可能不希望在每一帧都重组应用级 Scaffold,而只希望在窗口实际跨越断点时重组。
确定窗口大小类别后,如果空间变大,我们该如何将应用从单窗格流程过渡到多窗格布局?
这正是 Jetpack Navigation 3 的优势所在,因为 Nav 3 是围绕 Compose 状态和场景从头开始构建的。
首先,您拥有返回堆栈,它只是一个 NavKey 列表,因此非常灵活,并且可以轻松保存为状态。
堆栈中的每个键都会解析为一个 NavEntry,其中包含其可组合项和元数据。
NavDisplay 不会硬编码条目的绘制方式,而是会评估一系列场景策略。
在策略中,您可以检查界面功能,例如窗口大小类别。
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}您可以检查返回堆栈,然后决定是在单个窗格中呈现一个 NavEntry,还是同时呈现多个项,或者一个项叠加在另一个项之上。
非常灵活。
除了多窗格策略之外,窗口大小类别还决定了您要使用哪些导航组件,以最大限度地提高工效学设计和屏幕空间利用率。
例如,在较小宽度下,您可能需要底部导航栏;在中等宽度下,您可能需要侧边导航栏;在较大宽度下,您可能需要完整的抽屉式导航栏。
将 Nav3 用于应用级布局决策,并将自适应组件用于屏幕级定义,您就能打造出像这样在几乎所有窗口大小和环境中都表现出色的应用。
具体到 Nav 3,如果您想详细了解最新更新(包括对深层链接的支持),请观看“使用 Jetpack Navigation 3 构建类型安全的深层链接”演讲。
Francesco Romano
好了,今天我们介绍了很多新 API:MediaQuery、Grid、FlexBox、Nav3。
以上就是 Jetpack Compose 如何帮助您构建自适应应用的全部内容。
但在智能体开发时代,使用 Jetpack Compose 还有一个极其重要的优势:效率更高。
您可能在主旨演讲中听到了,我们发现应用在迁移到 Compose 后,使用的 token 减少了 30% 以上,我们正在努力进一步优化 Compose 应用的 Agentic Workflows。
今天,我想重点介绍 Compose Adaptive 技能的重大更新。
android skills list
android skills add adaptive正如我所说,除了将布局迁移到 Grid、Flexbox 和 MediaQuery 之外,它现在还明确处理整个应用的端到端状态管理,并且我们针对实际状态丢失 bug 测试了该技能:保留导航、滚动位置、活动媒体播放、半输入文本输入等。
现在,这项技能还包括可折叠设备折叠状态优化,例如自动将媒体和相机屏幕重构为桌上模式,但当您的应用在 Googlebook 或外接显示器上运行,并使用鼠标和键盘时,布局只是其中一部分。
现在,自适应技能可以审核您的界面,以查找并实现键盘快捷键、自定义指针光标、悬停状态、提示等。
我们来看看实际用例。
我录制了一个演示视频。
现在,我们有了应用。
我们可以查看宠物。
我们可以与他们互发消息。
我们可以观看一段相关视频。
是的,我们可以看到最受欢迎的宠物。
但您已经看到,这并不理想。
现在,假设我只想看一张有戏剧效果和猫咪的图片。
好的,我想看大一点的。
呃,那是返回首页。
好的,我这就告诉我的朋友。
是的,所以,“Buddy”,嗯。
应用不太好。
而且太宽了。
您会发现它太宽了。
我们来尝试调整其大小。
什么?
呃,好吧。
我输入的文字也丢失了。
好了,现在该问问 Gemini 来解决这个问题了。
因此,得益于自适应技能,提示可以非常简单,例如“让应用自适应”。
如你所见,我使用的是 Gemini 3.0 Flash,它与技能结合使用,能够完成这项任务。
它已掌握该技能,现在开始执行更改。
它会提出一些问题,例如“MediaQuery 处于实验阶段。
您确定要使用吗?”
继续加油。
为了节省时间,我不得不加快了视频播放速度,但如果您来到演示区,可以亲自体验这些技能。
而这只是我们发布的一项技能。
例如,我们还推出了一项新技能,可帮助您打造出色的触控笔体验。
所以,快来这个区域试试吧。
所以,已经完成了。
它修改了 11 个文件。
我们来看看实际用例。
好的。
Grid 已经在发挥作用。
导航栏已移至侧边。
我们来试试滤镜。
我想现在应该可以正常使用了。
是的,确实如此。
好,我们来给我的好友发消息,告诉他这款应用现在很好用。
我们来看看空间更大时的效果。
是的,它已迁移到列表-详情布局。
这次运行大约耗时 20 分钟,消耗了大约 20 万个 token。
好的。
Francesco Romano
通常,我会在演讲开始时先介绍业务指标和成功案例,但对于 ADS,我想直接进入代码部分。
不过,我想花几分钟时间分享一下工程投资回报率。
如今,您的 Android 应用有望在超过 5.8 亿台活跃的大屏设备上运行。我们还了解到,多设备用户和可折叠设备用户是 Android 平台上互动度最高的群体。
我们来看两个具体的案例研究,它们证明了发布自适应 build 会带来怎样的效果。
首先是 Gratitude,这是一款热门的自我关爱和每日日志应用。
通过采用自适应屏幕设计,Gratitude 将平板电脑和可折叠设备打造成了高增长的获客渠道。
首先,他们在大屏幕设备上的获客速度翻了一番。
具体而言,来自平板电脑和可折叠设备的每日安装量增长了 100%。
大屏设备用户在整体用户群中的占比从 7% 提升到了 10%(按总安装量计算)。
第二个应用是 BoxBoxClub,这是一款一级方程式赛车和赛车运动配套应用。在针对可折叠设备和大屏设备更新其 build 后,其遥测数据显示出巨大收益:整体活跃用户数增加了 7.6%,互动时长增加了 54%。
好的。
演讲即将结束,我想给您三个重要的要点。
打造自适应应用可让您的应用服务于更多设备和更多用户,同时让您的应用能够适应这个不断扩展的生态系统。
Android 开发中优先采用 Compose。
所有新工具、库和技能都是为 Compose 构建的。
Compose 非常适合自适应布局和智能体开发。
代码库更小,更易于理解,智能体在处理 Compose 代码时使用的 token 更少。
所以,快去尝试所有新工具、技能和 API,尽情探索吧!
谢谢。
<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