Contents
Material 3 とテーマシステムの進化
背景と主な特徴
- Material 3 (Material You) は Android 12(Monet)で導入された動的カラー機能をベースに、ライト / ダーク / ハイコントラスト の自動切替を標準実装しています。
- Compose 用
material3ライブラリはMaterialThemeを拡張 し、色・タイポグラフィ・形状(Shape)をすべてコードで一元管理できます。公式ドキュメント: https://developer.android.com/jetpack/compose/themes/material3
実装例:アプリ全体に適用できるテーマ関数
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@Composable fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { // Android 12 以上では DynamicColor が取得可能 val colorScheme = if (darkTheme) dynamicDarkColorScheme(LocalContext.current) else dynamicLightColorScheme(LocalContext.current) MaterialTheme( colorScheme = colorScheme, typography = Typography( bodyLarge = TextStyle(fontSize = 16.sp, lineHeight = 24.sp), titleMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 20.sp) ), content = content ) } |
dynamicLightColorScheme()/dynamicDarkColorScheme()は Monet に基づく色を自動生成します。- カスタムの
Typographyを定義することで、フォントサイズ・ウェイト の統一が可能です。
使いどころ
| シナリオ | 効果 |
|---|---|
| 新規プロジェクト開始時 | テーマ設定だけで UI 全体に一貫したデザインを適用 |
| 既存アプリのマイグレーション | MaterialTheme の呼び出しを書き換えるだけで Material 3 に移行可能 |
| ブランドカラーが頻繁に変わるケース | ColorScheme を外部リソース(JSON 等)から生成し、テーマ関数だけ差し替えれば即反映 |
Compose Compiler のインクリメンタルビルド最適化
何が変わったか
- Compose Compiler 1.5.0 以降で インクリメンタルコンパイル がデフォルト有効化されました(公式リリースノート: https://developer.android.com/jetpack/compose/compiler)。
- 再ビルド時に変更があった箇所だけを再コンパイルするため、ビルド時間の短縮 と IDE の応答性向上 が期待できます。具体的な数値はプロジェクト構成やハードウェアに依存しますが、実際に 10 % 前後の改善が報告されています(Google I/O 2024 発表スライド参照)。
@Stable アノテーションで再コンポジションを抑制
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Stable data class UiState( val items: List<Item>, val isLoading: Boolean = false ) @Composable fun ItemList(state: UiState) { // state が変化しない限り LazyColumn は再描画されません LazyColumn { items(state.items) { item -> Text(text = item.title) } } } |
UiStateが 不変(Immutable) であることをコンパイラに明示すると、内部プロパティが変更されたときだけ UI が再評価されます。- 大規模リストや頻繁に状態が変わる画面で特に有効です。
注意点
| 項目 | 対策 |
|---|---|
@Stable の付与漏れ |
データクラスは基本的に data class だけで十分ですが、Mutable なプロパティが混在する場合は手動で @Stable を付与 |
| インクリメンタルコンパイルの無効化 | gradle.properties に android.compose.compiler.enableNonIncremental=true と書くとオフになるので、意図しない設定変更に注意 |
状態管理パターン:State Hoisting → ViewModel + StateFlow → MVI
1. State Hoisting(状態の持ち上げ)
- 目的: Composable を ステートレス に保ち、状態は外部(主に ViewModel)で保持する。
- 効果: UI とロジックが分離され、テストしやすくなる。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Composable fun ItemList( items: List<Item>, onItemClick: (String) -> Unit, modifier: Modifier = Modifier ) { LazyColumn(modifier = modifier) { items(items) { item -> Text(text = item.title, modifier = Modifier.clickable { onItemClick(item.id) }) } } } |
2. ViewModel + StateFlow(単一データソース)
|
1 2 3 4 5 6 7 8 9 |
class ListViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> = _uiState.asStateFlow() fun selectItem(id: String) { _uiState.update { it.copy(selectedId = id) } } } |
StateFlowは Cold Stream で、画面が観測を開始した瞬間に最新状態を流すため、Compose のcollectAsState()と相性が良いです。
3. MVI(Model‑View‑Intent)へのステップアップ
| コンポーネント | 説明 |
|---|---|
| Intent | UI が送出するユーザー操作(例: ボタンタップ)。 |
| Reducer | Intent と現在の State を受け取り、新しい State を返す純粋関数。 |
| ViewModel | Reducer の結果を StateFlow に流し、UI はそれを購読するだけ。 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
sealed interface ListIntent { data class Click(val id: String) : ListIntent } data class ListState( val items: List<Item> = emptyList(), val selectedId: String? = null ) class ListReducer { fun reduce(state: ListState, intent: ListIntent): ListState = when (intent) { is ListIntent.Click -> state.copy(selectedId = intent.id) } } |
- テスト容易性:
reduce関数だけを単体テストすれば、ロジックの正しさが保証できる。UI 層はViewModelが提供する State を表示するだけになるため、UI テストは「表示結果」だけに集中できる。
コードで構築するモダンデザインシステム
カスタムカラー・タイポグラフィの定義
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
object AppPalette { val primary = Color(0xFF6200EE) val onPrimary = Color.White // Android 12+ の場合は動的取得ロジックを別途実装 } private val AppTypography = Typography( bodyLarge = TextStyle(fontSize = 16.sp, lineHeight = 24.sp), headlineMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 20.sp) ) @Composable fun CustomTheme(content: @Composable () -> Unit) { MaterialTheme( colorScheme = lightColorScheme(primary = AppPalette.primary, onPrimary = AppPalette.onPrimary), typography = AppTypography, content = content ) } |
- ポイント:
CustomThemeをアプリのエントリーポイント(例:MainActivity.setContent) にラップすれば、全画面で同一デザイン基準が適用されます。
再利用可能なコンポーネントと CompositionLocal
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
val LocalCardElevation = compositionLocalOf { 2.dp } @Composable fun AppCard( modifier: Modifier = Modifier, elevation: Dp = LocalCardElevation.current, content: @Composable ColumnScope.() -> Unit ) { Card( modifier = modifier, elevation = CardDefaults.cardElevation(defaultElevation = elevation) ) { Column(modifier = Modifier.padding(16.dp), content = content) } } |
- 呼び出し側で
CompositionLocalProviderを使えば、画面単位・モジュール単位で微調整 が可能です。
|
1 2 3 4 5 6 7 8 9 |
@Composable fun SampleScreen() { CompositionLocalProvider(LocalCardElevation provides 8.dp) { AppCard { Text("高い elevation のカード") } } } |
実務で頻出する UI パターン例(リスト‑詳細・タブナビゲーション)
1. マスター‑ディテール画面(Responsive)
実装のポイント
| 条件 | 推奨コンポーネント |
|---|---|
| 幅が Expanded (tablet, foldable) | Row + LazyColumn / DetailPane を同時表示 |
| 幅が Compact (スマートフォン) | NavHost で画面遷移 |
コード例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@Composable fun MasterDetailScreen(viewModel: ListViewModel = viewModel()) { val state by viewModel.uiState.collectAsState() val windowSize = calculateWindowSizeClass() // AndroidX WindowManager if (windowSize.widthSizeClass == WindowWidthSizeClass.Expanded) { Row(Modifier.fillMaxSize()) { ItemList( items = state.items, onItemClick = viewModel::selectItem, modifier = Modifier.weight(1f) ) DetailPane(itemId = state.selectedId, modifier = Modifier.weight(2f)) } } else { NavHost(navController = rememberNavController(), startDestination = "list") { composable("list") { ItemListScreen(viewModel) } composable("detail/{id}") { backStackEntry -> DetailPane(itemId = backStackEntry.arguments?.getString("id")) } } } } |
calculateWindowSizeClass()はandroidx.window:windowライブラリのユーティリティで、画面幅に応じたサイズクラスを取得できます。
2. タブ + カードベースレイアウト
実装のポイント
| 画面幅 | UI コンポーネント |
|---|---|
| Medium 以上 | NavigationRail(縦型タブ) |
| Compact | TabRow(横型タブ) |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
@Composable fun TabAndCardScreen() { val tabs = listOf("ホーム", "お気入り", "設定") var selectedIndex by rememberSaveable { mutableStateOf(0) } val windowSize = calculateWindowSizeClass() Column(Modifier.fillMaxSize()) { if (windowSize.widthSizeClass >= WindowWidthSizeClass.Medium) { NavigationRail { tabs.forEachIndexed { index, title -> NavigationRailItem( selected = selectedIndex == index, onClick = { selectedIndex = index }, label = { Text(title) } ) } } } else { TabRow(selectedTabIndex = selectedIndex) { tabs.forEachIndexed { index, title -> Tab( selected = selectedIndex == index, onClick = { selectedIndex = index }, text = { Text(title) } ) } } } // カードベースのコンテンツ LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 150.dp), modifier = Modifier.padding(8.dp) ) { items(0..20) { idx -> AppCard { Text("カード #$idx", style = MaterialTheme.typography.titleMedium) } } } } } |
LazyVerticalGridの Adaptive モードにより、画面幅が変わっても最適な列数が自動で決まります。
パフォーマンス最適化とテスト戦略
Lazy 系コンポーネントのベストプラクティス
| テクニック | 効果 |
|---|---|
LazyListState の保存・復元 |
画面遷移後もスクロール位置を保持し、ユーザー体験が向上 |
アイテムキー (key) を明示指定 |
再利用時に不必要な再描画を防止 |
| Paging 3 と組み合わせた無限スクロール | 大量データでもメモリ使用量を抑えつつ、スムーズなロードが可能 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@Composable fun InfiniteNewsFeed(viewModel: NewsViewModel = viewModel()) { val pagingItems = viewModel.pagedNews.collectAsLazyPagingItems() val listState = rememberLazyListState() LazyColumn(state = listState) { items(pagingItems) { news -> NewsCard(news) } item { if (pagingItems.loadState.append is LoadState.Loading) { CircularProgressIndicator(Modifier.align(Alignment.CenterHorizontally)) } } } // スクロール位置の復元例 LaunchedEffect(Unit) { viewModel.savedScrollPosition?.let { listState.scrollToItem(it.index, it.offset) } } } |
Compose Test の基本構成
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@get:Rule val composeTestRule = createComposeRule() @Test fun masterDetail_clickShowsDetail() { // Arrange composeTestRule.setContent { MasterDetailScreen(viewModel = FakeListViewModel()) } // Act composeTestRule.onNodeWithText("Item 3").performClick() // Assert composeTestRule.onNode(hasTestTag("detailPane")) .assertIsDisplayed() .assertHasText("詳細情報: Item 3") } |
hasTestTagは UI 要素に付与したModifier.testTag()を検索するのに便利です。- テストは ロジックと UI の分離 が前提なので、ViewModel にモックデータを流すだけで実装できます。
CI への組み込み(GitHub Actions の例)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
name: Android CI on: push: branches: [ main ] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: temurin java-version: '17' - name: Run Gradle tests run: ./gradlew testDebugUnitTest assembleDebug lintDebug |
- 上記設定だけで ユニットテスト、Compose UI テスト、Lint が自動実行され、品質が継続的に保たれます。
プロジェクトへの導入手順(ステップバイステップ)
- リポジトリを取得
bash
git clone https://github.com/your-org/compose-design-system.git
cd compose-design-system
- Compose の最新安定版を設定
build.gradle.ktsに以下を追記(2025 年 4 月時点の最新版は 1.5.10):
kotlin
android {
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.10"
}
}
dependencies {
implementation("androidx.compose.material3:material3:1.2.0")
// WindowManager と Paging の依存関係も忘れずに
implementation("androidx.window:window:1.2.0")
implementation("androidx.paging:paging-compose:3.2.0")
}
- デザインシステムモジュールを組み込む
designsystemフォルダにあるCustomTheme.kt、AppCard.ktをプロジェクトの共通モジュールへコピー。-
アプリ側
build.gradle.ktsにimplementation(project(":designsystem"))を追加。 -
UI パターンサンプルを自プロジェクトにマージ
| サンプルファイル | 目的 |
|---|---|
MasterDetailSample.kt |
幅に応じたレイアウト切替 |
TabAndCardSample.kt |
タブ / NavigationRail とカードグリッドの実装例 |
- テストコードを移植
test/composeディレクトリにあるテストクラスを自プロジェクトのandroidTestにコピー。-
FakeListViewModelなどのスタブは自アプリのデータ構造に合わせて調整。 -
CI 設定の追加(既存リポジトリに GitHub Actions が無い場合)
bash
mkdir -p .github/workflows
# 上記 YAML を .github/workflows/android-ci.yml に保存
- ビルドとテストの実行確認
bash
./gradlew clean assembleDebug testDebugUnitTest
すべてが成功すれば、最新 Material 3 と Compose Compiler の最適化を活用したデザインシステム がプロジェクトに組み込まれた状態です。
まとめ
| 項目 | 主な効果 |
|---|---|
| Material 3 + カスタムテーマ | コードだけで一貫した UI デザインを実現し、動的カラーにも対応 |
| Compose Compiler のインクリメンタルビルド | 再コンパイル時間が短縮され、開発効率が向上(具体的な数値は環境依存) |
状態管理の段階的導入 (State Hoisting → ViewModel+StateFlow → MVI) |
ロジックのテスト容易性と UI の保守性が大幅に改善 |
| CompositionLocal + Modifier によるコンポーネント再利用 | プロジェクト全体でデザインガイドラインをコードとして統一 |
| Responsive な UI パターン(Master‑Detail、Tab/NavigationRail) | 画面サイズに応じた最適なレイアウト切替がシンプルに実装可能 |
| Lazy 系コンポーネントと Paging の併用 | 大量データでもメモリ使用を抑えつつスムーズなスクロール体験 |
| Compose Test と CI 連携 | UI テストが自動化され、品質保証が継続的に行える |
これらのベストプラクティスとコードスニペットは、2025 年時点で安定提供されている Jetpack Compose の機能を最大限活用するための実務向きガイドです。プロジェクトに適宜取り入れることで、開発速度・保守性・ユーザー体験 を同時に向上させることができます。