Старший инженер по связям с разработчиками
Экосистема Android огромна, и пользователи легко переключаются между телефонами, планшетами, складными устройствами и компьютерами. Теперь, когда мы перешли на Compose First, на этом занятии мы рассмотрим, как спроектировать приложение для окон с изменяемым размером и различными соотношениями сторон, где традиционные предположения о макете не работают, что приводит к растянутому пользовательскому интерфейсу и потере состояния.
Узнайте, как новейшие возможности Jetpack Compose, Navigation 3 и WindowManager помогают создавать динамические макеты, которые легко масштабируются от компактных экранов до двухпанельного режима. Вы узнаете, как ускорить разработку с помощью Gemini и наших навыков, например адаптивного, чтобы автоматически проводить рефакторинг устаревших баз кода для больших экранов.
MediaQuery декларативно отслеживает активные возможности пользовательского интерфейса, такие как размер окна и положение сгиба. Она заменяет собой несколько устаревших менеджеров платформ, которые раньше приходилось объединять, чтобы получить состояние Compose.Grid и FlexBox предоставляют строительные блоки адаптивного макета. Grid позволяет создавать параметризованные многомерные макеты в рамках одной composable-функции, а 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 для Gemini в Android Studio и Android CLI.
Например, мы рассмотрим адаптивные навыки Compose, которые были представлены на конференции Google I/O ранее в этом году.
Оно уже могло адаптировать макеты для телефонов к большим экранам.
Теперь он также может исправить потерю состояния, реализовать уникальные возможности складных устройств, такие как настольный режим, и улучшить поддержку ввода, обнаруживая и реализуя сочетания клавиш, настраиваемые курсоры, всплывающие подсказки и так далее.
Давайте начнем с Jetpack Compose, который является основой для всего этого.
Стабильная версия Jetpack Compose вышла в 2021 году, так что в этом году ему исполняется пять лет.
За эти пять лет мы увидели на примере тысяч приложений, насколько проще и надежнее создавать адаптивные интерфейсы с помощью Compose.
Теперь Compose – основной инструмент для разработки приложений Android.
Это значит, что все новые современные адаптивные макеты, стили и компоненты навигации созданы для Compose.
С точки зрения системной архитектуры, самое большое преимущество Compose для адаптивных приложений по сравнению с устаревшей системой представлений – это то, как взаимодействуют состояние и пользовательский интерфейс.
Чтобы объяснить это, я хочу провести быстрое сравнение с системой представлений.
Итак, подумайте о том, как работала система представлений.
Пользовательский интерфейс представлял собой дерево изменяемых объектов, которые вы один раз создавали из статических XML-файлов.
У вас были разные квалификаторы ресурсов, и давайте начнем с основной проблемы: дублирования в разных квалификаторах ресурсов.
При создании адаптивного интерфейса в XML в файле макета нет условной логики.
Вместо этого вы просто дублируете всю иерархию в папках ресурсов, таких как макет, макет наименьшего окна 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 компоненты пользовательского интерфейса имеют состояние.
EditText владеет своим внутренним редактируемым буфером, настраиваемой позицией курсора, состоянием фокуса и некоторыми другими состояниями.
Поэтому, когда пользователь, скажем, раскладывает устройство или изменяет размер окна за пределы определенного порога, Android уничтожает всю иерархию View и наполняет новые XML-файлы с нуля.
Чтобы сохранить пользовательский ввод, фреймворк использует метод onSaveInstanceState, который упаковывает состояние, связанное с идентификатором этого представления.
Но это создает три возможных режима отказа.
Во-первых, если кто-то случайно назвал идентификатор text_input вместо text_msg в другом XML, восстановление состояния автоматически удаляет введенный пользователем текст без каких-либо предупреждений.
Во-вторых, onRestoreInstanceState запускается после onViewCreated, что может часто конфликтовать с наблюдателями ViewModel и вызывать циклы обратного вызова.
И в-третьих, если вы меняете тип виджета в зависимости от квалификаторов, например на компактном экране вы хотите использовать Spinner, а на более широком – RadioGroup, и у них один и тот же идентификатор, то во время выполнения произойдет сбой с ошибкой ClassCastException.
В Jetpack Compose пользовательский интерфейс не является объектом, который вы создаете один раз.
Интерфейс – это чистая функция состояния.
И это полностью меняет то, что вообще представляет собой изменение конфигурации.
Что такое изменение конфигурации в Compose?
У меня есть подсказка.
Франческо Романо (Francesco Romano)
Да.
В Compose изменение конфигурации — это просто ещё одна рекомпозиция, а рекомпозиции происходят постоянно.
Когда окно становится шире, например если пользователь развернул устройство, composable-функции считывают новый размер, а те, чей вывод зависит от него, просто перезапускаются.
Вот и все.
А теперь давайте посмотрим, как писать современные адаптивные приложения, используя новейшие инструменты и компоненты Compose.
Поэтому в мире XML вам приходилось задавать вопросы вроде: "Я работаю на телефоне, планшете или компьютере?", а затем разветвлять пользовательский интерфейс в зависимости от категории устройства.
Но есть фундаментальная причина, по которой этого делать не стоит.
Даже при запуске на одном и том же физическом устройстве контекст приложения и возможности пользовательского интерфейса могут динамически меняться во время выполнения.
Итак, сообщение, которое я хочу оставить, заключается в том, что решения пользовательского интерфейса должны определяться текущими активными возможностями: текущим размером окна, положением, некоторыми характеристиками оборудования.
Рассмотрим классический пример – многооконный режим на внешнем мониторе.
Если вы подключите телефон Pixel к внешнему монитору на рабочем столе, то приложение, которое только что работало на 6-дюймовом вертикальном экране, будет работать на 32-дюймовом широком мониторе с подключенной физической клавиатурой и мышью.
Поэтому проверка типа устройства, на котором запущено приложение, ничего не гарантирует в отношении экрана, на котором оно фактически отображается, или способов ввода.
Кроме того, Android поддерживает многооконный режим, поэтому размер окон можно изменять.
Даже если экран Googlebook больше, чем у телефона, ваше приложение может работать в окне шириной 400 dp вместе с другими приложениями.
Поэтому запрашивать статические показатели физического дисплея также может быть бессмысленно.
Поэтому, чтобы создавать приложения для Compose, сначала нужно понять, как запрашивать среду.
И я хочу начать с попытки формализовать концепцию возможностей пользовательского интерфейса.
Возможности пользовательского интерфейса включают не только ширину и высоту окна или складную позу, но также активные в данный момент модальности ввода и вывода.
Мы заметили, что пользователи компьютеров не всегда используют сенсорный экран.
Они могут использовать клавиатуру и мышь.
В автомобильной системе некоторые автомобили имеют поворотную ручку или другой вход, другой способ ввода сигналов.
Помимо размера экрана и способа ввода, учитывается расстояние до экрана и наличие подключенной камеры или микрофона.
Это все возможности интерфейса.
Франческо Романо (Francesco Romano)
Теперь, если вы хотите получить все сигналы, вам нужно объединить 5 или 6 разных API из разных источников.
Вам нужен был объект конфигурации, WindowMetricsCalculator, WindowInfoTracker, InputManager, PackageManager, а затем вручную преобразовать каждый из них в состояние, состояние Compose.
Вам понравилось?
Нет.
Чтобы решить эту проблему, мы добавили в androidx.compose.ui API MediaQuery.
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 для джойстика, контроллеров или None.
keyboardKind может быть физическим, виртуальным или отсутствовать.
hasCamera и hasMicrophone динамически отслеживают, доступно ли это оборудование.
Подумайте о внешней камере, которую можно подключить.
а viewingDistance – "Близко", "Средне" или "Далеко".
Но, думаю, будет проще, если мы рассмотрим реальный пример, а именно определение того, находится ли устройство в настольном режиме.
Настольный режим – это положение, в котором устройство сложено наполовину и обычно установлено на поверхности.
Это очень удобно для длительного просмотра видео, видеозвонков и интервальной съемки.
Это также очень полезно, например, для того, чтобы следовать рецепту или смотреть слайд-шоу.
Раньше, чтобы определить положение Tabletop до 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 принимает один лямбда-параметр, а в области получателя этой лямбда-функции она предоставляет набор свойств, которые мы только что видели.
В этом примере, просто проверяя windowPosture в выражении when, мы можем сразу вернуть правильную composable-функцию.
Вот ещё два примера использования 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 до 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.
Таким образом, вы можете просто предоставить поддельный UiMediaScope через CompositionLocalProvider в предварительном просмотре, и вам не нужно раскручивать складные устройства и ставить 90° или подключать мышь только для запуска теста Compose UI.
Франческо Романо (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...
}Сетка позволяет реализовать двухмерный макет, отображая элементы в столбцах и строках, которые адаптируются к доступному размеру контейнера.
Давайте посмотрим, что можно сделать в блоке конфигурации.
Вы можете добавлять новые элементы или дорожки, вызывая столбец или строку, и использовать стандартные циклы Kotlin, такие как повторение.
Вы можете задать промежутки между дорожками.
Вы можете управлять направлением автоматического размещения с помощью 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 является последней строкой и т. д.
Вы можете использовать rowSpan, чтобы растянуть элемент на двумерную область, а также перекрывать элементы.
Поэтому, если два элемента помещены в перекрывающиеся ячейки, Grid укладывает их друг на друга в этих границах, а порядок по оси Z определяется порядком композиции.
Поэтому последующие элементы будут отрисовываться поверх других, как и в Box.
Теперь мы знаем основы Grid.
Рассмотрим пример с видеопроигрывателем.
В зависимости от того, разложено устройство или полуоткрыто, нам нужно переключаться между макетом по умолчанию и макетом для настольного режима.
До появления Grid нам приходилось создавать две разные composable-функции, 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)
}
}И вы можете подумать: «Что в этом плохого?»
Если вы посмотрите на эти макеты, их содержимое идентично.
На обоих экранах отображается один и тот же проигрыватель и один и тот же контроллер.
Единственное различие между ними заключается в том, как эти два элемента расположены на экране.
До сих пор, поскольку у нас не было возможности передать само расположение макета в качестве параметра, мы были вынуждены определить две отдельные composable-функции.
Теперь мы можем параметризовать конфигурацию макета и обрабатывать все в рамках одной composable-функции.
И мы увидим, как это сделать.
Итак, с помощью 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()
}
}Затем мы можем указать дочерние composable-функции, player и controller, которые будут размещены внутри сетки, и связать каждую дочернюю функцию с именованной областью, вызвав Modifier.gridItem и передав areaId.
Здесь мы указываем Grid, что проигрыватель нужно разместить в области VideoPlayerArea.Player, а контроллер – в области VideoPlayerArea.Controller.
А здесь мы определяем две лямбда-функции конфигурации в 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)
}
}Для макета видеопроигрывателя по умолчанию мы настраиваем сетку из одной строки и одного столбца, но обратите внимание, что даже если есть только одна ячейка, мы все равно можем назначить два имени области одной и той же ячейке.
В этом случае, как мы видели ранее, контроллер накладывается поверх проигрывателя.
Для настольного режима мы просто определяем две строки и один столбец, помещая проигрыватель в верхнюю половину, а контроллер – в нижнюю, в основном, вот так.
Определив эти лямбда-выражения конфигурации, мы можем использовать 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, который теперь также стабилен, обрабатывает одномерные макеты, которые по-прежнему нуждаются в интеллектуальном распределении пространства, сжатии и многострочном переносе.
Это удобный адаптивный макет, который можно использовать вместо двух отдельных composable-функций, таких как FlowRow и FlowColumn, поскольку это один контейнер, направление которого может динамически меняться внутри его блока конфигурации без повторного создания дочерних composable-функций.
И он реализует полный алгоритм 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.
Почему?
Поскольку ширина и высота окна часто меняются.
Когда пользователи изменяют размер окна приложения, например на компьютере, эти значения могут меняться в каждом кадре.
И вы, вероятно, не захотите перекомпоновывать каркас на уровне приложения в каждом кадре, а только тогда, когда окно действительно пересекает контрольную точку.
И как только у нас появятся классы размеров окон, как нам перевести наше приложение из однопанельного режима в многопанельный, когда у нас будет больше места?
Именно здесь Jetpack Navigation 3 проявляет себя во всей красе, поскольку Nav 3 была разработана с нуля на основе состояния и сцен Compose.
Во-первых, вы владеете обратным стеком, который представляет собой просто список NavKeys, поэтому он очень гибкий и его легко сохранить как состояние.
Каждый ключ в стеке разрешается в NavEntry, содержащий его composable-функцию и метаданные.
NavDisplay не задает в коде, как отрисовываются записи, а оценивает список стратегий сцены.
В рамках стратегии вы можете проверить возможности пользовательского интерфейса, например классы размеров окон.
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}Вы можете проверить обратный стек, а затем решить, следует ли отображать один NavEntry на одной панели, несколько элементов одновременно или один поверх другого.
Это очень гибкий инструмент.
Помимо стратегии с несколькими панелями, классы размеров окон также определяют, какие компоненты навигации вы хотите использовать, чтобы максимально повысить эргономику и использование пространства экрана.
Например, на компактных экранах вы хотите использовать нижнюю панель навигации, на средних — NavRail, а на расширенных — полную панель навигации.
Если вы объедините Nav3 для макетов на уровне приложения и адаптивные компоненты для определения на уровне экрана, то получите приложения, которые будут отлично выглядеть практически при любом размере окна и в любой среде.
Чтобы узнать больше о последних обновлениях Nav 3, в том числе о поддержке ссылок на контент, посмотрите видео Building Type-Safe Deep Links with 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 может проверять интерфейс, чтобы находить и реализовывать быстрые клавиши, настраиваемые указатели, состояния при наведении, подсказки и т. д.
Давайте посмотрим, как это работает.
Я записал демонстрацию этого.
Итак, у нас есть приложение.
Мы можем посмотреть на наших питомцев.
Мы можем обмениваться с ними сообщениями.
Мы можем посмотреть видео с ними.
Да, мы можем посмотреть лучших питомцев.
Но вы уже видите, что это не очень хорошо.
Теперь давайте посмотрим, я просто хочу посмотреть картинку с драмой и кошками.
Хорошо, я хочу увидеть их в большем размере.
Да, это возврат на главную страницу.
Хорошо, я расскажу об этом своему другу.
Да, "Бадди".
Приложение не очень хорошее.
Оно слишком широкое.
Вы видите, что он слишком широкий.
Давайте попробуем изменить размер.
Что?
Ну да, хорошо.
и текст, который я набрал, потерян.
Попросите Gemini исправить это.
Благодаря адаптивному навыку запрос может быть очень простым, например "сделай приложение адаптивным".
Как видите, я использую Gemini 3.0 Flash, которая вместе с навыком может это сделать.
Она освоила навык и теперь начинает применять изменения.
Он задаст несколько вопросов, например: "MediaQuery – это экспериментальная функция.
Вы действительно хотите его использовать?"
и продолжайте.
Мне пришлось ускорить видео, чтобы вырезать лишнее, но если вы придете в демозону, то сможете попробовать все сами.
И это только один из навыков, которые мы выпустили.
Например, у нас есть новый навык, который поможет вам создавать потрясающие возможности для стилуса.
Поэтому приходите в эту зону, чтобы попробовать.
Вот и все.
Изменено 11 файлов.
Давайте посмотрим, как это работает.
Окей.
Grid уже работает.
Панель навигации перемещена в боковую часть экрана.
Давайте попробуем фильтры.
Думаю, теперь они будут работать.
Да, нужно.
Напишу другу, что приложение теперь работает отлично.
Давайте посмотрим, как это выглядит, когда места больше.
Да, он перешел на макет со списком и подробными сведениями.
Этот запуск занял около 20 минут и потребовал около 200 000 токенов.
Окей.
Франческо Романо (Francesco Romano)
Обычно я начинаю с бизнес-метрик и историй успеха, но в случае с ADS я хотел сразу перейти к коду.
Однако я хочу уделить несколько минут, чтобы рассказать о рентабельности инвестиций в разработку.
Сегодня ваше приложение для Android может работать более чем на 580 миллионах активных устройств с большим экраном. Мы также знаем, что пользователи складных и других устройств с большим экраном – одни из самых активных в Android.
Давайте рассмотрим два примера, которые показывают, что происходит, когда вы выпускаете адаптивную сборку.
Первое приложение – Gratitude. Это популярный дневник благодарности, который помогает заботиться о себе.
Благодаря адаптивному дизайну экранов планшеты и складные устройства стали для Gratitude каналом привлечения пользователей с высоким потенциалом роста.
Во-первых, они вдвое увеличили скорость привлечения пользователей на больших экранах.
Количество ежедневных установок на планшеты и складные устройства выросло на 100 %.
Доля пользователей, установивших приложение на устройства с большим экраном, выросла с 7 до 10 %.
Второе – BoxBoxClub, сопутствующее приложение для Формулы-1 и автоспорта.После обновления сборки для складных устройств и больших экранов телеметрия показала значительный рост: на 7,6 % увеличилось общее число активных пользователей и на 54 % – время взаимодействия.
Окей.
Мы подошли к концу выступления, и я хочу дать вам три важных вывода.
Адаптивные приложения подходят для большего количества устройств и пользователей, а также готовы к будущим изменениям в экосистеме.
Compose – основной инструмент для разработки приложений Android.
Все новые инструменты, библиотеки и навыки созданы для 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