Android 生態系統十分龐大,使用者可在手機、平板電腦、摺疊式裝置和電腦環境之間流暢切換。我們現在以 Compose 為優先,因此本課程將探討如何為可調整大小的視窗和各種顯示比例建構應用程式架構,因為傳統版面配置假設會失效,導致 UI 延展和狀態遺失。
瞭解如何運用 Jetpack Compose、Navigation 3 和 WindowManager 的最新功能,建構動態版面配置,自然地從小螢幕縮放到雙窗格螢幕。您將學到如何運用 Gemini 和我們的技能 (例如自動調整技能),加速開發工作流程,自動重構舊版程式碼集,以適應更大的螢幕。
MediaQuery API 會以宣告方式追蹤有效的 UI 功能,例如視窗大小和摺疊姿勢。可取代將多個舊版平台管理工具拼接成 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 零碎、切換版面配置的信號不明確,以及程式碼集深處有一些舊假設。
因此,我們將這十年來從開發人員挑戰中學到的所有經驗,直接融入兩個地方:首先是 UI 工具包本身。
今天,我們將探索 Jetpack Compose 的幾項重大新功能:媒體查詢、格線、FlexBox 和 Navigation 3。
此外,Android Studio 內建 Gemini 和 Android CLI 的官方 Android 技能,也提供代理工具,可與您一起編寫程式碼。
舉例來說,我們將介紹今年稍早在 Google I/O 大會上發布的 Compose 自動調整技能。
它已經能夠將手機版面配置遷移到更大的視窗大小。
現在,這項工具還能修正狀態遺失問題、實作摺疊式裝置獨有的體驗 (例如桌面模式),以及透過探索和實作鍵盤快速鍵、自訂游標、工具提示等,改善輸入支援。
首先來談談 Jetpack Compose,這是所有內容的基礎。
Jetpack Compose 於 2021 年推出穩定版,因此今年是第五週年。
在這五年中,我們看到數千個正式版應用程式,使用 Compose 建構自動調整式應用程式,變得更輕鬆、更可靠。
Android 開發作業現在正式以 Compose 為優先。
也就是說,所有新的現代化自動調整版面配置、樣式、導覽元件都是專為 Compose 建構。
從系統架構的角度來看,Compose 適用於自動調整式應用程式,相較於舊版檢視系統,Compose 的最大優勢在於狀態和 UI 的互動方式。
為了說明這一點,我想快速與檢視系統進行比較。
請回想一下檢視區塊系統的運作方式。
UI 是可變動物件的樹狀結構,您曾從靜態 XML 檔案加載過一次。
您有不同的資源限定詞,我們先從主要問題開始:不同資源限定詞之間的重複。
在 XML 中建構自動調整 UI 時,版面配置檔案中不會有任何條件邏輯。
而是只要在資源資料夾中複製整個階層,例如版面配置、版面配置最小視窗 600 dp、版面配置橫向等。
請看右側的 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 系統中,UI 元件是有狀態的。
EditText 擁有內部可編輯緩衝區、自訂游標位置、焦點狀態和部分狀態。
因此,當使用者展開裝置或調整視窗大小超過特定門檻時,Android 會刪除整個檢視區塊階層,並從頭開始加載新的 XML 檔案。
為了儲存使用者輸入內容,架構會依賴 onSaveInstanceState,將與該檢視區塊 ID 連結的狀態打包。
但這會產生三種可能的故障模式。
首先,如果有人不小心在其他 XML 中將 ID 命名為 text_input,而不是 text_msg,狀態還原功能會自動捨棄使用者的輸入文字,且不會顯示任何警告。
其次,onRestoreInstanceState 會在 onViewCreated 之後執行,這通常會與 ViewModel 觀察器發生衝突,可能導致回呼迴圈。
第三,如果您在限定條件之間切換小工具類型,例如在精簡螢幕上使用微調工具,在較寬的螢幕上使用 RadioGroup,如果兩者共用相同的 ID,就會在執行階段發生 ClassCastException 錯誤。
但在 Jetpack Compose 中,UI 並非您建構一次的物件。
UI 是狀態的純函式。
這項異動完全顛覆了設定變更的定義。
Compose 中的設定變更是什麼?
我有個提示。
Francesco Romano
是啊
在 Compose 中,設定變更只是另一種重組,而且重組隨時都會發生。
當視窗變寬時 (可能是因為使用者展開裝置),可組合項會讀取新的大小,而輸出內容取決於大小的可組合項則會重新執行。
這就是整個活動。
現在,我們來看看如何使用最新的 Compose 工具和元件,編寫現代化自動調整應用程式。
因此,在 XML 的世界中,您必須詢問「我是在手機、平板電腦還是電腦上執行?」,然後根據裝置類別分支 UI。
但有一個根本原因,讓我們不建議這麼做。
即使在完全相同的實體裝置上執行,應用程式情境和 UI 功能也可能在執行階段動態變更。
因此,我想傳達的訊息是,UI 決策應由目前的使用中功能決定:目前的視窗大小、姿勢、部分硬體特性。
我們來看一個經典範例:在外接螢幕上使用電腦分割視窗。
如果您將 Pixel 手機插入桌上的外接螢幕,原本在 6 吋直向螢幕上執行的應用程式程序,現在會在 32 吋寬螢幕上執行,並連接實體鍵盤和滑鼠。
因此,檢查應用程式執行的裝置類型,無法保證實際顯示的螢幕或輸入模式。
此外,Android 支援多視窗模式,因此可以調整視窗大小。
即使 Googlebook 的螢幕比手機大,應用程式仍可能在 400 dp 寬的視窗中執行,並與其他應用程式並列。
因此,查詢靜態實體螢幕指標可能也毫無意義。
因此,如要先為 Compose 建構應用程式,我們需要瞭解如何查詢環境。
首先,我想先試著將 UI 功能的概念公式化。
UI 功能不僅包括視窗寬度和高度或摺疊式裝置的摺疊狀態,還包括目前啟用的輸入和輸出模式。
我們發現,使用者在電腦環境中不一定會使用觸控螢幕。
他們可以使用鍵盤和滑鼠。
在汽車系統中,有些車輛有旋轉鈕或其他輸入方式,可輸入不同信號。
除了螢幕大小和輸入方式外,還包括使用者與螢幕的距離 (例如觀看距離),或目前是否連接硬體相機或麥克風。
以上是所有 UI 功能。
Francesco Romano
現在,如要擷取所有信號,您必須將來自不同來源的 5、6 個 API 拼接在一起。
您需要設定物件、WindowMetricsCalculator、WindowInfoTracker、InputManager、PackageManager,然後手動將每個物件轉換為狀態,也就是 Compose 狀態。
你喜歡這樣做嗎?
我沒有。
為解決這個問題,我們在 androidx.compose.ui 中推出了 MediaQuery API。
mediaQuery { ... }
derivedMediaQuery { ... }這個概念源自 CSS 媒體查詢。
這裡的「媒體」並非指音訊或影片播放。
這是指呈現 UI 的媒介和環境。
MediaQuery 則提供單一宣告式 Compose 原生方式,可查詢 UI 功能。
我們來看看 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 之前偵測桌面模式,就必須編寫所有這些程式碼。
@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")
}
}
}首先是資訊密度。
因此,當使用者使用筆電、桌上型電腦螢幕和滑鼠,且指標精確度為「精細」時,我們可以將 UI 元件的邊框間距從本例中的 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 的組合本機解析接收器。
因此,您只需在預覽中透過 CompositionLocalProvider 提供假 UiMediaScope,不必啟動摺疊式裝置並設定 90° 或連接滑鼠,即可執行 Compose UI 測試。
Francesco Romano
現在我們知道如何查詢 UI 功能了。
現在,我們來看看如何建構實際的版面配置。
因此,我想介紹 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...
}格線可讓您在欄和列中顯示項目,並根據可用的容器大小調整,藉此實作二維版面配置。
來看看您可以在設定區塊中執行哪些操作。
您可以呼叫資料欄或資料列來附加新元素或軌道,也可以使用標準 Kotlin 迴圈,例如重複。
您可以在軌道之間設定間隔。
您可以透過 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 將項目跨越 2D 區域,然後也會有重疊。
因此,如果將兩個項目放入重疊的儲存格中,Grid 會在這些邊界內將項目堆疊在另一個項目上方,而 Z 排序則由組合順序決定。
因此,後續項目會像 Box 一樣,繪製在其他項目上方。
現在,我們已經瞭解 Grid 的基本概念。
我們來看看影片播放器的例子。
視裝置是平放或半開,我們需要在預設版面配置或桌面版面配置之間切換。
在 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()
}
}
}因此,我們現在可以建立單一影片播放器版面配置可組合函式,並在 UI 中動態切換兩種設定。
接下來要介紹的基本類型是 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 會處理 2D 列和欄版面配置,但 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 不會硬式編碼項目的繪製方式,而是評估場景策略清單。
在策略中,您可以檢查 UI 功能,例如視窗大小類別。
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}您可以檢查返回堆疊,然後決定要在單一窗格中算繪一個 NavEntry,還是同時算繪多個項目,或是一個項目疊在另一個項目上。
使用上非常靈活。
除了多窗格策略外,視窗大小類別也會決定要使用哪些導覽元件,盡可能提升人體工學和螢幕空間的使用率。
舉例來說,在精簡寬度上,您可能需要底部導覽列;在中等寬度上,您可能需要 NavRail;在展開寬度上,您可能需要完整的導覽匣。
結合 Nav3 的應用程式層級版面配置決策,以及回應式元件的畫面層級定義,就能打造出像這樣在各種視窗大小和環境中都能完美呈現的應用程式。
如要進一步瞭解 Nav 3 的最新更新 (包括深層連結支援),請觀看「使用 Jetpack Navigation 3 建構類型安全的深層連結」講座。
Francesco Romano
好的,我們今天介紹了許多新的 API:MediaQuery、Grid、FlexBox、Nav3。
以上就是 Jetpack Compose 如何協助您建構自動調整式應用程式。
不過,在代理式開發時代,使用 Jetpack Compose 還有另一個極為重要的優勢:效率更高。
您可能在主題演講中聽過,我們發現應用程式遷移至 Compose 後,使用的權杖減少了 30% 以上,我們也正在努力進一步最佳化 Compose 應用程式的代理工作流程。
今天,我想重點介紹 Compose Adaptive 技能的重大更新。
android skills list
android skills add adaptive如前所述,除了將版面配置遷移至 Grid、Flexbox 和 MediaQuery 之外,現在還能明確處理應用程式的端對端狀態管理,我們也針對實際的狀態遺失錯誤測試這項技能:保留導覽、捲動位置、進行中的媒體播放、輸入到一半的文字等。
這項技能現在也包含摺疊式裝置型態最佳化,例如自動將媒體和相機畫面重構為免手持模式,但如果應用程式是在 Googlebook 或外接螢幕上執行,並搭配滑鼠和鍵盤,版面配置就只是其中一部分。
Adaptive 技能現在可以稽核 UI,找出並實作鍵盤快速鍵、自訂指標游標、懸停狀態、工具提示等。
一起來看看實際的運作方式吧。
我錄製了這項功能的示範影片。
現在我們有應用程式了。
我們可以看看寵物。
我們可以傳訊息給他們。
我們可以看看他們的影片。
可以,我們能看到最受歡迎的寵物。
但如您所見,這並非理想做法。
現在,假設我想看一張有貓咪和戲劇效果的圖片。
好,我想放大。
Dah,返回首頁。
好,我會告訴朋友。
「Buddy」啊,嗯。
應用程式不太好用。
而且太寬了。
您會發現它太寬了。
我們來試著調整大小。
什麼?
呃,好吧。
我輸入的文字也消失了。
現在可以請 Gemini 修正這個問題了。
因此,只要使用 Adaptive 技能,提示詞就能簡單到像「讓應用程式自動調整」。
如您所見,我使用的是 Gemini 3.0 Flash,搭配技能就能完成這項工作。
它已取得技能,現在開始執行變更。
它會問一些問題,例如「MediaQuery 仍在實驗階段,
確定要使用嗎?」
繼續努力。
為了節省時間,我必須加快速度,但如果你來到展示區,可以親自試試這些技能。
這只是我們推出的其中一項技能。
舉例來說,我們也推出一項新技能,可協助您打造出色的觸控筆體驗。
歡迎到這個區域試用。
所以,這件事已經完成了。
修改了 11 個檔案。
一起來看看實際的運作方式吧。
好的。
Grid 已經開始運作。
導覽列已移至側邊。
我們來試試篩選器。
我想現在應該可以正常運作了。
沒錯。
好,我要傳訊息給朋友,告訴他這個應用程式現在很好用。
我們來看看空間更大時的效果。
沒錯,它已遷移至清單/詳細資料版面配置。
這次執行作業耗時約 20 分鐘,使用了約 20 萬個詞元。
好的。
Francesco Romano
我通常會先介紹業務指標和成功案例,但這次我想直接切入 ADS 的程式碼。
不過,我想花幾分鐘時間分享工程投資報酬率。
目前,您的 Android 應用程式可能在超過 5.8 億部使用中的大螢幕裝置上執行,而且我們也知道,支援多種裝置和摺疊式裝置使用者是 Android 上參與度最高的客群之一。
接下來,我們將透過兩個具體個案研究,說明推出自動調整版本後的成效。
首先是 Gratitude,這是一款熱門的自我照顧和每日日記應用程式。
Gratitude 採用自動調整式螢幕設計,讓平板電腦和摺疊式裝置成為高成長的獲客管道。
首先,他們在大螢幕上的獲客速度翻倍。
平板電腦和摺疊式裝置的每日安裝量成長 100%。
大螢幕使用者在整體使用者族群中的占比,從總安裝量的 7% 提高到 10%。
第二個是 BoxBoxClub,這是一款一級方程式賽車和賽車運動隨附應用程式,在更新折疊式裝置和大螢幕版本後,遙測資料顯示大幅成長:整體活躍使用者增加 7.6%,參與時間增加 54%。
好的。
這場講座即將結束,我想提供三個重要重點。
建構自動調整功能可讓應用程式支援更多裝置和使用者,同時確保應用程式能因應這個不斷擴大的生態系統。
Android 開發作業以 Compose 優先。
所有新工具、程式庫和技能都是為 Compose 建構。
Compose 非常適合用於自動調整版面配置和代理式開發。
程式碼集較小且更容易理解,代理在處理 Compose 程式碼時使用的權杖較少。
歡迎試用所有新工具、技能和 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