Android 생태계는 방대하며, 사용자는 휴대전화, 태블릿, 폴더블, 데스크톱 환경 간에 원활하게 전환합니다. 이제 Compose 우선이 되었으므로 이 세션에서는 크기 조절이 가능한 창과 다양한 가로세로 비율에 맞게 앱을 설계하는 방법을 살펴봅니다. 기존 레이아웃 가정이 무너져 UI가 늘어나고 상태가 손실되는 경우를 다룹니다.
Jetpack Compose, Navigation 3, WindowManager의 최신 기능을 활용하여 소형 화면부터 듀얼 창 디스플레이까지 자연스럽게 확장되는 동적 레이아웃을 빌드하는 방법을 알아보세요. Gemini와 적응형 스킬과 같은 Google의 스킬을 사용하여 개발 워크플로를 가속화하고 대형 화면에 맞게 기존 코드베이스를 자동으로 리팩터링하는 전략을 알아봅니다.
MediaQuery API는 창 크기 및 접기 상태와 같은 활성 UI 기능을 선언적으로 추적합니다. 여러 기존 플랫폼 관리자를 Compose 상태로 결합하는 것을 대체합니다.Grid 및 FlexBox 프리미티브는 적응형 레이아웃 구성 요소를 제공합니다. Grid는 단일 컴포저블 내에서 매개변수화된 다차원 정렬을 지원하는 반면, FlexBox는 1차원 공간 분산 및 래핑을 제공합니다.스크립트
프란체스코 로마노
안녕하세요.
오늘은 Jetpack Compose를 사용하여 모든 화면에 맞게 앱을 확장하는 방법에 대해 기술적으로 자세히 알아보겠습니다.
모든 화면에 맞게 확장해야 하는 이유
Android 생태계가 다양한 폼 팩터로 확장되었기 때문입니다.
APK는 휴대전화, 폴더블, 태블릿, Chromebook, Googlebook, TV, 자동차 인포테인먼트 시스템, 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에서는 이 선택 해제가 삭제됩니다.
따라서 적응형 앱을 빌드하는 것은 기본적인 플랫폼 요구사항입니다.
지난 10년 동안 Google은 생태계 전반의 팀과 긴밀히 협력하면서 적응형 앱을 빌드하는 것이 왜 어렵게 느껴지는지 직접 경험했습니다.
상태 손실, 단편화된 창 API, 레이아웃 전환 시점에 관한 불분명한 신호, 코드베이스 깊숙이 자리 잡은 오래된 가정이 있습니다.
그래서 지난 10년간 개발자 과제에서 배운 모든 것을 두 곳에 직접 빌드했습니다. 첫째, UI 툴킷 자체입니다.
오늘은 Jetpack Compose의 주요 새 기능인 미디어 쿼리, 그리드, FlexBox, Navigation 3를 살펴보겠습니다.
또한 Android 스튜디오의 Gemini와 Android CLI를 위한 공식 Android 기술을 통해 개발자와 함께 코드를 작성하는 에이전트형 도구에서도 Gemini를 사용할 수 있습니다.
예를 들어 올해 초 Google I/O에서 출시된 Compose 적응형 기술을 살펴보겠습니다.
이 앱은 이미 휴대전화 레이아웃을 더 큰 창 크기로 이전할 수 있었습니다.
이제 상태 손실을 수정하고, 탁자 모드와 같은 폴더블 고유의 환경을 구현하고, 키보드 단축키, 맞춤 커서, 도움말 등을 검색하고 구현하여 입력 지원을 개선할 수도 있습니다.
이 모든 것의 기반이 되는 Jetpack Compose부터 시작해 보겠습니다.
Jetpack Compose는 2021년에 안정화 버전이 출시되었으므로 올해로 5주년을 맞이합니다.
지난 5년 동안 수천 개의 프로덕션 앱에서 Compose를 사용하여 적응형 앱을 빌드하는 것이 얼마나 더 쉽고 안정적인지 확인했습니다.
이제 Android 개발은 공식적으로 Compose 우선입니다.
즉, 모든 새로운 최신 적응형 레이아웃, 스타일 지정, 탐색 구성요소가 Compose용으로 빌드됩니다.
시스템 아키텍처 관점에서 적응형 앱을 위한 Compose가 기존 뷰 시스템보다 우수한 가장 큰 장점은 상태와 UI가 상호작용하는 방식입니다.
이를 설명하기 위해 뷰 시스템과 간단히 비교해 보겠습니다.
뷰 시스템이 어떻게 작동했는지 생각해 보세요.
UI는 정적 XML 파일에서 한 번 확장한 변경 가능한 객체의 트리였습니다.
리소스 한정자가 서로 달랐습니다. 여기서 주요 문제인 서로 다른 리소스 한정자 간의 중복부터 살펴보겠습니다.
XML로 적응형 UI를 빌드할 때는 레이아웃 파일 내에 조건부 로직이 없습니다.
대신 레이아웃, 레이아웃 최소 창 600dp, 레이아웃 가로 모드 등 리소스 폴더 전체에서 전체 계층 구조를 복제합니다.
오른쪽의 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와 같은 속성을 업데이트하는 경우 컴파일러에서 태블릿 레이아웃에서도 업데이트하도록 강제하지 않습니다.
또한 구성요소가 여러 파일에 걸쳐 분리되어 있으므로 시간이 지남에 따라 구조적 드리프트가 거의 확실하게 발생합니다.
이러한 중복은 구성 변경 중 상태 손실이라는 또 다른 문제와도 관련이 있습니다.
뷰 시스템에서 UI 구성요소는 스테이트풀(Stateful)입니다.
EditText는 내부 Editable 버퍼, 맞춤 커서 위치, 포커스 상태, 일부 상태를 소유합니다.
따라서 사용자가 기기를 펼치거나 특정 기준점을 넘어 창 크기를 조절하면 Android는 전체 뷰 계층 구조를 소멸시키고 새 XML 파일을 처음부터 확장합니다.
사용자 입력을 저장하기 위해 프레임워크는 onSaveInstanceState를 사용합니다. 이 메서드는 해당 뷰의 ID에 연결된 상태를 패킹합니다.
하지만 이로 인해 세 가지 장애 모드가 발생할 수 있습니다.
첫째, 다른 XML에서 실수로 ID를 text_msg 대신 text_input로 지정한 경우 상태 복원은 경고 없이 사용자의 입력 텍스트를 자동으로 삭제합니다.
둘째, onRestoreInstanceState는 onViewCreated 이후에 실행되므로 ViewModel 옵저버와 자주 충돌하여 콜백 루프를 일으킬 수 있습니다.
셋째, 한정자 간에 위젯 유형을 바꾸는 경우(예: 작은 화면에서는 스피너를 사용하고 더 넓은 화면에서는 RadioGroup을 사용하려는 경우) 동일한 ID를 공유하면 런타임에 ClassCastException으로 인해 비정상 종료됩니다.
하지만 Jetpack Compose에서 UI는 한 번 구성하는 객체가 아닙니다.
UI는 상태의 순수 함수입니다.
이로 인해 구성 변경의 정의가 완전히 바뀌었습니다.
Compose에서 구성 변경이란 무엇인가요?
힌트가 있습니다.
프란체스코 로마노
네.
Compose에서 구성 변경은 또 다른 리컴포지션일 뿐이며 리컴포지션은 항상 발생합니다.
사용자가 기기를 펼쳐서 창이 넓어지면 컴포저블이 새 크기를 읽고 출력이 이 크기에 따라 달라지는 컴포저블이 다시 실행됩니다.
이벤트는 여기까지입니다.
이제 최신 Compose 도구와 구성요소를 사용하여 최신 적응형 앱을 작성하는 것이 어떤 의미인지 살펴보겠습니다.
XML 환경에서는 '휴대전화, 태블릿, 데스크톱 중 어디에서 실행 중인가?'와 같은 질문을 던진 후 기기 카테고리에 따라 UI를 분기해야 했습니다.
하지만 그렇게 하지 말아야 할 근본적인 이유가 있습니다.
정확히 동일한 실제 기기에서 실행되는 경우에도 애플리케이션 컨텍스트와 UI 기능은 런타임에 동적으로 변경될 수 있습니다.
따라서 제가 드리고 싶은 말씀은 UI 결정은 현재 활성 상태인 기능, 즉 현재 창 크기, 상태, 일부 하드웨어 특성에 따라 이루어져야 한다는 것입니다.
외부 모니터의 데스크톱 창 모드라는 전형적인 예를 살펴보겠습니다.
Pixel 휴대전화를 책상 위의 외부 모니터에 연결하면 6인치 세로 화면에서 실행 중이던 앱 프로세스가 물리적 키보드와 마우스가 연결된 32인치 와이드 모니터에서 그대로 실행됩니다.
따라서 앱이 실행 중인 기기 유형을 확인해도 실제로 표시되는 화면이나 입력 방식에 관해 보장되는 것은 없습니다.
또한 Android는 멀티 윈도우를 지원하므로 창 크기를 조절할 수 있습니다.
Googlebook의 화면이 휴대전화보다 크더라도 앱은 다른 앱과 함께 너비 400dp의 창에서 실행될 수 있습니다.
따라서 정적 물리적 디스플레이 측정항목을 쿼리하는 것도 의미가 없을 수 있습니다.
따라서 Compose 우선 앱을 설계하려면 환경을 쿼리하는 방법을 이해해야 합니다.
먼저 UI 기능의 개념을 공식화해 보겠습니다.
UI 기능에는 창 너비와 높이 또는 폴더블 상태뿐만 아니라 현재 활성 상태인 입력 및 출력 모달리티도 포함됩니다.
데스크톱 환경에서는 사용자가 항상 터치 스크린을 사용하는 것은 아닙니다.
키보드와 마우스를 사용할 수 있습니다.
자동차 시스템에서 일부 자동차에는 로터리 노브 또는 다른 입력, 다른 신호 입력 방식이 있습니다.
또한 화면 크기와 입력 외에도 시청 거리와 같이 사용자가 디스플레이에서 얼마나 떨어져 있는지, 하드웨어 카메라나 마이크가 현재 연결되어 있는지 여부도 고려해야 합니다.
이것이 UI 기능의 전부입니다.
프란체스코 로마노
이제 모든 신호를 검색하려면 서로 다른 소스에서 5~6개의 서로 다른 API를 연결해야 했습니다.
구성 객체, WindowMetricsCalculator, WindowInfoTracker, InputManager, PackageManager가 필요했고, 그런 다음 각각을 Compose 상태로 수동으로 변환해야 했습니다.
그렇게 하는 것이 좋았나요?
아니요
이 문제를 해결하기 위해 androidx.compose.ui에서 MediaQuery API를 도입했습니다.
mediaQuery { ... }
derivedMediaQuery { ... }CSS 미디어 쿼리에서 영감을 받았습니다.
여기서 미디어라는 단어는 오디오나 동영상 재생을 의미하지 않습니다.
UI가 렌더링되는 매체, 즉 환경을 의미합니다.
MediaQuery는 UI 기능을 쿼리하는 단일 선언적 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: 현재 폴더블에 사용되며 평면, 탁자, 책 모드가 있습니다.
pointerPrecision는 기본 포인팅 기기의 정밀도를 알려줍니다. 마우스, 스타일러스, 트랙패드의 경우 Fine, 터치의 경우 Coarse, 조이스틱, 컨트롤러의 경우 Blunt, 그 외의 경우 None입니다.
keyboardKind은 실제, 가상 또는 없음일 수 있습니다.
hasCamera 및 hasMicrophone는 이 하드웨어를 사용할 수 있는지 동적으로 추적합니다.
연결할 수 있는 외부 카메라를 생각해 보세요.
viewingDistance는 Near, Medium 또는 Far를 보고합니다.
하지만 기기가 테이블탑 모드인지 감지하는 실제 예를 살펴보면 더 쉽게 이해할 수 있습니다.
테이블탑 모드는 사용자가 기기를 반으로 접어 표면에 놓을 수 있는 자세입니다.
장시간 동영상 재생, 영상 통화, 타임랩스 촬영에 적합합니다.
레시피를 따라 하거나 슬라이드쇼를 볼 때도 매우 유용합니다.
이제 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 인스턴스를 가져오고, 흐름이자 수명 주기 인식 Compose 상태인 windowLayoutInfo를 수집한 다음, displayFeatures를 반복하여 FoldingFeature이고 절반 열려 있으며 가로인 것이 있는지 확인해야 했습니다.
자세를 감지하는 것만으로도 이 정도입니다.
상용구가 많습니다.
새로운 MediaQuery API를 사용하면 이 모든 것이 몇 줄의 코드로 줄어듭니다.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}mediaQuery 함수는 단일 람다 매개변수를 사용하며, 이 람다의 수신자 범위 내에서 방금 살펴본 속성 집합을 노출합니다.
이 예에서는 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")
}
}
}첫 번째는 정보 밀도입니다.
따라서 사용자가 마우스가 있는 노트북, 데스크톱 모니터를 사용하고 포인터 정밀도가 Fine인 경우 UI 구성요소의 패딩을 이 예시의 8Dp에서 2Dp로 줄여 더 많은 콘텐츠를 편안하게 표시할 수 있습니다. 사용자가 손가락을 사용하지 않으므로 터치 영역이 더 작아질 수 있습니다.
또한 기능 기반 작업도 있습니다.
채팅 입력창이 있다고 가정해 보겠습니다. 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를 제공하기만 하면 되며, Compose UI 테스트를 실행하기 위해 폴더블 기기를 실행하고 90°로 설정하거나 마우스를 연결할 필요가 없습니다.
프란체스코 로마노
이제 UI 기능을 쿼리하는 방법을 알았습니다.
이제 실제 레이아웃을 빌드하는 방법을 살펴보겠습니다.
이를 위해 androidx.compose.foundation.layout의 새로운 레이아웃 기본 요소인 Grid를 소개하겠습니다. 이제 안정화 버전으로 제공됩니다.
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...
}그리드를 사용하면 사용 가능한 컨테이너 크기에 맞게 열과 행에 항목을 표시하여 2차원 레이아웃을 구현할 수 있습니다.
구성 블록에서 할 수 있는 몇 가지 작업을 살펴보겠습니다.
열 또는 행을 호출하여 새 요소나 트랙을 추가할 수 있으며 repeat와 같은 표준 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 이전에는 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으로 정의할 수 있습니다.
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에서 두 개의 구성 람다를 정의합니다.
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)
}
}기본 동영상 플레이어 레이아웃의 경우 행 1개와 열 1개로 구성된 그리드를 구성하지만 셀이 하나만 있더라도 동일한 셀에 영역 이름 2개를 할당할 수 있습니다.
이 경우 이전에 살펴본 것처럼 컨트롤러가 플레이어 위에 오버레이됩니다.
탁자 모드의 경우 행 2개와 열 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()
}
}
}이제 두 구성 간에 동적으로 전환되는 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는 지능형 공간 분산, 축소, 여러 줄 줄바꿈이 필요한 1차원 레이아웃을 처리합니다.
또한 FlowRow 및 FlowColumn과 같은 두 개의 개별 컴포저블 대신 사용할 수 있는 편리하고 반응형 레이아웃입니다. 하위 컴포저블을 다시 만들지 않고도 구성 블록 내에서 방향을 동적으로 변경할 수 있는 단일 컨테이너이기 때문입니다.
또한 전체 CSS FlexBox 알고리즘을 구현하므로 wrap, justify-content, align-items, gap을 사용할 수 있습니다.
각 하위 요소에는 basis, relative grow, shrink factors, alignSelf 등을 제공하는 Modifier.flex가 있습니다.
프란체스코 로마노
이제 화면 수준 정의에서 앱 수준 레이아웃으로 시야를 넓혀 보겠습니다. 단일 화면뿐만 아니라 전체 애플리케이션에서 최상위 탐색 및 멀티 창 화면 정렬을 구성하는 방법을 살펴보겠습니다.
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...
}한 가지 주목할 점은 표준 MediaQuery가 아닌 derivedMediaQuery를 사용한다는 것입니다.
이유가 무엇인가요?
창 너비와 높이는 자주 변경되는 값이기 때문입니다.
사용자가 데스크톱에서와 같이 앱의 크기를 조절하는 경우 이러한 값은 프레임마다 변경될 수 있습니다.
또한 모든 프레임에서 앱 수준 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을 사용한 유형 안전 딥 링크 빌드 강연을 확인하세요.
프란체스코 로마노
좋습니다. 오늘은 MediaQuery, Grid, FlexBox, Nav3 등 많은 새로운 API를 다뤘습니다.
지금까지 Jetpack Compose를 사용하여 적응형 앱을 빌드하는 방법을 알아봤습니다.
하지만 에이전트형 개발 시대에는 Jetpack Compose를 사용할 때 얻을 수 있는 또 다른 매우 중요한 이점이 있습니다. 바로 훨씬 더 효과적이라는 점입니다.
키노트에서 들으셨겠지만, Compose로 이전한 후 토큰을 30% 이상 적게 사용하는 앱이 확인되었으며, Compose 앱의 에이전트 워크플로를 더욱 최적화하기 위해 노력하고 있습니다.
오늘은 Compose 적응형 기술의 주요 업데이트를 소개해 드리겠습니다.
android skills list
android skills add adaptive앞서 말씀드린 것처럼 레이아웃을 Grid, Flexbox, MediaQuery로 이전하는 것 외에도 이제 앱 전반에서 엔드 투 엔드 상태 관리를 명시적으로 처리하며, 탐색, 스크롤 위치, 활성 미디어 재생, 절반만 입력된 텍스트 입력 등을 보존하는 등 실제 상태 손실 버그에 대해 기술을 테스트합니다.
이제 이 기술에는 미디어 및 카메라 화면을 테이블탑 모드로 자동 리팩터링하는 등의 폴더블 상태 최적화도 포함됩니다. 하지만 앱이 Googlebook 또는 마우스와 키보드가 있는 외부 모니터에서 실행되는 경우 레이아웃은 일부에 불과합니다.
이제 적응형 기술을 통해 UI를 감사하여 키보드 단축키, 맞춤 포인터 커서, 마우스 오버 상태, 도움말 등을 찾고 구현할 수 있습니다.
실제로 어떻게 작동하는지 살펴보겠습니다.
이 데모를 녹화했습니다.
앱이 있습니다.
반려동물을 볼 수 있습니다.
메시지를 주고받을 수 있습니다.
동영상을 살펴보겠습니다.
네, 인기 반려동물을 확인할 수 있습니다.
하지만 이미 보시다시피 좋지 않습니다.
이제 드라마틱한 분위기의 고양이 사진을 보고 싶다고 가정해 보겠습니다.
좋아, 더 크게 보고 싶어.
홈으로 돌아갑니다.
알겠습니다. 친구에게 알려주겠습니다.
"Buddy"라니, 음.
앱이 좋지 않습니다.
너무 넓기도 하고요.
너무 넓은 것을 확인할 수 있습니다.
크기를 조절해 보겠습니다.
내용
그래, 알았어.
입력한 텍스트가 사라집니다.
이제 Gemini에게 이 문제를 해결해 달라고 요청해 보겠습니다.
적응형 기술 덕분에 프롬프트는 '앱을 적응형으로 만들어 줘'와 같이 간단할 수 있습니다.
보시다시피 저는 Gemini 3.0 Flash를 사용하고 있으며, 이 모델은 스킬과 함께 이 작업을 수행할 수 있습니다.
기술을 선택했고 이제 변경사항을 실행하기 시작합니다.
그러면 'MediaQuery는 실험용입니다.
정말 사용하시겠어요?"
계속 진행합니다.
시간 관계상 일부 내용을 생략했지만 데모 영역에 오시면 직접 기술을 사용해 보실 수 있습니다.
이것은 Google이 출시한 기술 중 하나에 불과합니다.
예를 들어 멋진 스타일러스 환경을 구축하는 데 도움이 되는 새로운 기술도 있습니다.
그러니 이 구역으로 와서 사용해 보세요.
완료되었습니다.
파일 11개가 수정되었습니다.
실제로 어떻게 작동하는지 알아보겠습니다.
좋습니다.
Grid는 이미 작동 중입니다.
탐색 메뉴가 측면으로 이동했습니다.
필터를 사용해 보겠습니다.
이제 작동할 것 같습니다.
네, 그렇습니다.
좋아, 친구에게 앱이 이제 잘 작동한다고 메시지를 보내야겠어.
공간이 더 넓으면 어떻게 보이는지 살펴보겠습니다.
네, 목록 세부정보 레이아웃으로 이전되었습니다.
이 실행은 약 20분이 걸렸고 약 200,000개의 토큰이 사용되었습니다.
좋습니다.
프란체스코 로마노
보통은 비즈니스 측정항목과 성공 사례로 이야기를 시작하지만, 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