Androidの UI開発は長年XMLレイアウトファイルとActivityやFragmentでのビュー操作が主流でした。しかし、この命令的UIの手法は、状態管理が複雑になりがちで、ビューの同期バグが頻発するという課題を抱えていました。Jetpack ComposeはGoogleが開発した宣言的UIフレームワークで、SwiftUIやFlutterと同様のパラダイムをAndroidに持ち込みました。
筆者のチームは2年前にプロダクションアプリをXMLからCompose に全面移行しました。その過程で得た知見と、Composeの実践的な活用方法を共有します。
Composeでは、UIの各要素を@Composableアノテーションが付いた関数として定義します。これらの関数は再利用可能で、組み合わせることで複雑なUIを構築します。
@Composable
fun UserCard(
user: User,
onClickProfile: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = user.avatarUrl,
contentDescription = "プロフィール画像",
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = user.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onClickProfile) {
Icon(Icons.Default.ChevronRight, "詳細")
}
}
}
}
このコードでは、ユーザーカードを一つのComposable関数として定義しています。XMLでは複数のファイルに分散していたレイアウト定義が、一つの関数に凝集されている点に注目してください。Modifierパラメータを外部から受け取ることで、呼び出し元でレイアウトの調整が可能になります。
Composeの核心は、状態が変化すると関連するUIが自動的に再構築(Recomposition)される仕組みです。
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = { newQuery ->
query = newQuery
viewModel.search(newQuery)
},
label = { Text("検索キーワード") },
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
when (val state = uiState) {
is UiState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
is UiState.Success -> {
LazyColumn {
items(state.results) { item ->
SearchResultItem(item)
}
}
}
is UiState.Error -> {
ErrorMessage(state.message)
}
}
}
}
collectAsStateWithLifecycleを使ってViewModelのFlowをComposeの状態に変換しています。ライフサイクルを考慮した収集が行われるため、バックグラウンド時の無駄な処理を回避できます。rememberSaveableは画面回転などのConfiguration Change時にも値が保持される点がrememberとの違いです。
ComposeはMaterial Design 3(Material You)との統合が優れており、Dynamic Colorにも対応しています。
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
else dynamicLightColorScheme(LocalContext.current)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}
Android 12以降のデバイスでは、壁紙の色に合わせたダイナミックカラーが自動適用されます。これにより、ユーザーごとにパーソナライズされたUI体験を提供できます。
Composeは便利ですが、不用意な実装をするとRecompositionが頻発しパフォーマンスが低下します。以下の点に注意してください。
derivedStateOfでキャッシュし、計算コストを削減するComposeにはUIテスト用のAPIが充実しています。JUnitとComposeテストルールを組み合わせることで、UIの振る舞いを効率的にテストできます。
@Test
fun searchScreen_displaysResults_whenSearchSucceeds() {
val fakeViewModel = FakeSearchViewModel()
composeTestRule.setContent {
SearchScreen(viewModel = fakeViewModel)
}
composeTestRule
.onNodeWithText("検索キーワード")
.performTextInput("Android")
fakeViewModel.emitSuccess(listOf("Result 1", "Result 2"))
composeTestRule
.onNodeWithText("Result 1")
.assertIsDisplayed()
}
セマンティックツリーに基づくアサーションは、UIの内部実装に依存しない堅牢なテストを実現します。
Jetpack Composeは、Android UI開発のパラダイムを根本から変える技術です。宣言的UIにより状態管理のバグが減少し、コードの可読性と保守性が大幅に向上します。XMLからの移行は段階的に進められるため、既存プロジェクトでも無理なく導入可能です。2024年の現在、新規Android開発でComposeを選択しない理由はほとんどないと言えるでしょう。