Jetpack Composeで作る再利用性の高い画面構造

はじめに
Jetpack Composeでアプリを開発する際、画面構造をどのように整理するかは重要な課題です。
この記事では、メンテナンス性が高く、見通しの良い画面構造の実装パターンを紹介します。
画面構造の3層構成
提案する構造は以下の3層で構成されています
- NavGraph(画面遷移の管理)
- Screen(状態管理)
- Content(UI表示)
主なメリット
- 責務の明確な分離
- 再利用性の向上
- テストのしやすさ
- プレビューの容易さ
各層の役割と実装例
NavGraph層 (Navigation Layer)
画面遷移を管理する最上位層です。
- 画面遷移の管理
- 画面間の連携
- 遷移時のパラメータ受け渡し
@Composable
fun AppNavGraph(
modifier: Modifier = Modifier,
){
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(
onMenuClick = { navController.navigate("menu") }
)
}
composable("menu") {
MenuScreen(
onBackClick = { navController.popBackStack() }
)
}
}
}
Screen層 (State Manager)
ViewModelとの連携や状態管理を行う層です。
- ViewModelとの連携
- 状態管理
- イベントハンドリング
@Composable
fun HomeScreen(
onMenuClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
HomeContent(
onMenuClick = onMenuClick,
uiState = uiState
)
}
@Composable
fun MenuScreen(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: MenuViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
MenuContent(
onBackClick = onBackClick,
uiState = uiState
)
}
Content層 (UI Layer)
UI実装に専念する層です。
- UI表示のみに専念
- 状態を受け取って表示
- プレビュー可能な単位
@Composable
fun HomeContent(
uiState: HomeUiState,
modifier: Modifier = Modifier,
onMenuClick: () -> Unit = {},
) {
Column {
Text("Home Screen")
Button(onClick = onMenuClick) {
Text("Go to Menu")
}
}
}
@Preview
@Composable
fun HomeContentPreview() {
val uiState = HomeUiState()
HomeContent(
uiState = uiState,
)
}
Content層の実装における注意点
プレビューの保守性の観点から避けるべきパターン
// ❌ プレビューが困難なパターン
@Composable
fun HomeContent(
viewModel: HomeViewModel,
navController: NavController
) {
// ViewModelやNavControllerに依存した実装
}
推奨されるパターン
// ✅ プレビューが容易なパターン
@Composable
fun HomeContent(
uiState: UiState,
onButtonClick: () -> Unit
) {
// UI描画に専念した実装
}
@Preview
@Composable
fun HomeContentPreview() {
HomeContent(
uiState = UiState(),
onButtonClick = {}
)
}
なぜContent層にViewModelやNavControllerを直接渡すべきでないのか
-
プレビューの困難さ
- Hiltなどの依存性注入を使用したViewModelやNavControllerを必要とするComposableは@Previewで確認できない
- UI開発のイテレーションが遅くなる
-
テストの複雑化
- ViewModelやNavControllerのモック作成が必要
- UIのユニットテストが書きにくい
-
関心の分離が不十分
- UIロジックとビジネスロジックが混在
- コードの再利用性が低下
3層構成パターンのメリット
関心の分離
- 各層の責務が明確
- コードの見通しが良い
- 変更の影響範囲が限定的
- UIロジックとビジネスロジックの分離
テスタビリティ
- Content層は簡単にプレビュー可能
- ViewModelやNavControllerに依存しない設計
- UIの迅速な開発イテレーション
- Screen層はViewModelのテストで対応
- ビジネスロジックの単体テストが容易
- モックの作成が最小限
- 画面遷移のテストが容易
- Navigation層での独立したテストが可能
メンテナンス性
- 新機能追加時の影響範囲が明確
- バグの特定が容易
- チーム開発での役割分担がしやすい
- コードの再利用性が向上
- Content層のUIコンポーネントの再利用
- Screen層のロジック共有
開発効率
- プレビューによる迅速なUI開発
- コードレビューの効率化
- 変更範囲が明確
補足:NavHostについて
NavHostの実装
NavHostは、Jetpack Composeを利用したAndroidアプリの画面遷移を管理するための重要なコンポーネントです。
従来のAndroidアプリ開発では、Activityの切り替えやFragmentの制御で画面遷移を実現していましたが、
Jetpack ComposeではこのNavHostを使用することで、シンプルかつ宣言的に画面遷移を実装できます。
NavHostの基本的な役割
- アプリ内の画面遷移の制御
- 画面間のデータの受け渡し
- バックスタック(戻る操作)の管理
以下のコード例では、HomeScreenとMenuScreenという2つの画面間の遷移を実装しています
@Composable
fun AppNavGraph() {
val navController = rememberNavController()
NavHost(
navController = navController, // 画面遷移を制御するコントローラー
startDestination = "home" // アプリ起動時に表示する画面
) {
// "home"という名前の画面を定義
composable("home") {
HomeScreen(
onMenuClick = { navController.navigate("menu") } // メニュー画面への遷移
)
}
// "menu"という名前の画面を定義
composable("menu") {
MenuScreen(
onBackClick = { navController.popBackStack() } // 前の画面に戻る
)
}
}
}
コードの解説
- rememberNavController()
- 画面遷移を管理するコントローラーを作成
- アプリ内での画面遷移の履歴を保持
- NavHost
- 画面遷移の定義をまとめるコンテナ
- startDestinationで最初に表示する画面を指定
- composable()
- 各画面の定義
- 第一引数の文字列(例:”home”)が画面のIDとなる
- 画面遷移の方法
- navController.navigate(“menu”)で指定した画面に遷移
- navController.popBackStack()で前の画面に戻る
このように、NavHostを使用することで、画面遷移のロジックを一箇所にまとめて管理できます。
従来のFragment遷移と比べて、コードがシンプルになり、保守性も向上します。
補足:型安全な画面遷移の実装
画面遷移時のデータは @Serializable アノテーションを使用したモデルで管理することで、型安全な実装が可能です。
@Serializable
sealed class ScreenRoute {
data object Home: ScreenRoute() // 引数が不要な場合はdata object
data class Detail( // 引数が必要な場合はdata class
val id: String,
val title: String
): ScreenRoute()
}
@Composable
fun AppNavGraph(
navController: NavHostController = rememberNavController()
) {
NavHost(
navController = navController,
startDestination = ScreenRoute.Home::class
) {
composable<ScreenRoute.Home> {
HomeScreen(
onNavigateToDetail = { id, title ->
navController.navigate(ScreenRoute.Detail(id, title))
}
)
}
composable<ScreenRoute.Detail> { backStackEntry ->
val route = backStackEntry.toRoute<ScreenRoute.Detail>() // 型安全にパラメータを取得
DetailScreen(
id = route.id,
title = route.title
)
}
}
}
この方法のメリット:
- 型安全なデータの受け渡しが可能
- データクラスの利用で意図が明確
- コンパイル時の型チェック
- 画面遷移の引数が明確に定義される
※ @Serializableを使用するには、kotlinx-serializationの依存関係追加が必要です:
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
}
まとめ
この3層構造を採用することで、Jetpack Composeを使用したアプリ開発において、
より整理された、メンテナンス性の高いコードベースを実現できます。
特に規模の大きいアプリケーションにおいて、この構造は真価を発揮します。
画面数が増えても破綻しにくく、チームでの開発もスムーズに進められます。
参考
Google 公式サンプル
-
- Googleの最新のベストプラクティスを実装
- マルチモジュール構成での3層構造の実例
-
- シンプルな構成での実装例
- Content層とScreen層の分離が分かりやすい
-
- ナビゲーションの実装例
- 状態管理の参考に最適
- Navigation での型安全性
- 公式推奨の実装方法
- 型安全な引数の受け渡し
これらのサンプルは定期的にアップデートされ、最新のComposeのプラクティスを反映しています。特に「Now in Android」は実際のプロダクションアプリを想定した実装になっており、本記事で説明した構造の実践的な応用例として参考になります。
