Основы работы с приложениями

Масштабируйте приложение для любых экранов с помощью Compose

Колонки

О приложении

О выступлении

Экосистема Android огромна, и пользователи легко переключаются между телефонами, планшетами, складными устройствами и компьютерами. Теперь, когда мы перешли на Compose First, на этом занятии мы рассмотрим, как спроектировать приложение для окон с изменяемым размером и различными соотношениями сторон, где традиционные предположения о макете не работают, что приводит к растянутому пользовательскому интерфейсу и потере состояния.

Узнайте, как новейшие возможности Jetpack Compose, Navigation 3 и WindowManager помогают создавать динамические макеты, которые легко масштабируются от компактных экранов до двухпанельного режима. Вы узнаете, как ускорить разработку с помощью Gemini и наших навыков, например адаптивного, чтобы автоматически проводить рефакторинг устаревших баз кода для больших экранов.

Сводка

  • Платформы Android требуют адаптивных приложений, поскольку начиная с Android 16 система игнорирует блокировку ориентации и соотношения сторон на больших экранах, а в Android 17 будет удалена возможность отказаться от этого в манифесте.
  • Экспериментальный API MediaQuery декларативно отслеживает активные возможности пользовательского интерфейса, такие как размер окна и положение сгиба. Она заменяет собой несколько устаревших менеджеров платформ, которые раньше приходилось объединять, чтобы получить состояние Compose.
  • Стабильные примитивы Grid и FlexBox предоставляют строительные блоки адаптивного макета. Grid позволяет создавать параметризованные многомерные макеты в рамках одной composable-функции, а FlexBox – одномерные макеты с переносом.
  • Jetpack Navigation 3 структурирует многопанельные потоки на уровне приложения вокруг состояния и сцен. Стратегии пользовательских сцен оценивают возможности пользовательского интерфейса и классы размеров окон для динамического переключения между однопанельными и многопанельными презентациями.
  • Навыки Compose Adaptive для Gemini автоматизируют перенос приложений на современные адаптивные компоненты. Обновленный навык реорганизует макеты, устраняет ошибки потери состояния и настраивает ввод с рабочего стола, например сочетания клавиш и курсоры.

Ресурсы

Gemini Notebook

Темы

  • jetpack-compose
  • адаптивные макеты
  • mediaquery
  • grid-layout
  • Flexbox
  • navigation-3
  • android-skills

Расшифровка

Введение в масштабирование приложений для разных экранов

  • Франческо Романо (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, сначала нужно понять, как запрашивать среду.

  • И я хочу начать с попытки формализовать концепцию возможностей пользовательского интерфейса.

  • Возможности пользовательского интерфейса включают не только ширину и высоту окна или складную позу, но также активные в данный момент модальности ввода и вывода.

  • Мы заметили, что пользователи компьютеров не всегда используют сенсорный экран.

  • Они могут использовать клавиатуру и мышь.

  • В автомобильной системе некоторые автомобили имеют поворотную ручку или другой вход, другой способ ввода сигналов.

  • Помимо размера экрана и способа ввода, учитывается расстояние до экрана и наличие подключенной камеры или микрофона.

  • Это все возможности интерфейса.

Определение положения с помощью mediaQuery API

  • Франческо Романо (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.

Адаптивные макеты экрана с Grid и FlexBox

  • Франческо Романо (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 и так далее.

Архитектура макета на уровне приложения и Navigation 3

  • Франческо Романо (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.

  • Спасибо!

Код

Фрагменты кода

Фрагмент XML-кода на 05:17 в видео "Introduction to scaling apps across screens"
<LinearLayout android:orientation="vertical">
  <EditText
      android:id="@+id/text_msg"
      android:inputType="textCapCharacters"
      android:maxLength="12" />
</LinearLayout>
Фрагмент XML-кода на 05:17 в видео "Introduction to scaling apps across screens"
<ConstraintLayout>
  <TextInputEditText
    android:id="@+id/text_input"
    android:inputType="text" />
</ConstraintLayout>
Фрагмент кода на Kotlin в видео "Определение положения устройства с помощью mediaQuery API"
mediaQuery { ... }
derivedMediaQuery { ... }
Фрагмент кода на Kotlin в видео "Определение положения устройства с помощью mediaQuery API"
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
}
Фрагмент кода на Kotlin в разделе "Определение положения устройства с помощью mediaQuery API"
@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
   }
}
Фрагмент кода Kotlin на 13:42 в видео "Определение положения устройства с помощью mediaQuery API"
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
   return mediaQuery {
       when(windowPosture) {
           UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
           else -> VideoPlayerLayout.Default
       }
   }
}
Фрагмент кода на Kotlin в видео "Определение положения устройства с помощью mediaQuery 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")
        }
    }
}
Фрагмент кода на Kotlin в видео "Определение положения устройства с помощью mediaQuery API"
@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()
    }
}
Фрагмент кода Kotlin на 15:40 в видео "Адаптивные макеты экрана с Grid и FlexBox"
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 на 16:50 в видео "Адаптивные макеты экрана с Grid и FlexBox"
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))
}
Фрагмент кода Kotlin на 17:59 в видео "Адаптивные макеты экрана с Grid и FlexBox"
@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)
   }
}
Фрагмент кода Kotlin на 18:51 в видео "Адаптивные макеты экрана с Grid и FlexBox"
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()
   }
}
Фрагмент кода на Kotlin в видео "Адаптивные макеты экранов с Grid и FlexBox"
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)
   }
}
Фрагмент кода Kotlin на 20:00 в видео "Адаптивные макеты экрана с Grid и FlexBox"
@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()
       }
   }
}
Фрагмент кода на Kotlin в видео "Адаптивные макеты экрана с Grid и 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) }
            )
        }
    }
}
Фрагмент кода на Kotlin в видео "Архитектура макета на уровне приложения и Navigation 3"
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...
}
Фрагмент кода на Kotlin в видео "Архитектура макета на уровне приложения и Navigation 3" (23:33)
if (isExpandedWidth) {
    /* return a multi-pane scene */
}
else {
    /* return a single pane scene */
}
Фрагмент кода Shell на 25:18 в видео "Демонстрация разработки и автоматизации с помощью агентов"
android skills list
android skills add adaptive