はじめに
こんにちは!この記事では、Jetpack ComposeとKotlinを使ってTODOアプリを作成する方法を解説します。
近年、Android開発ではXMLレイアウトからJetpack Composeへのシフトが進み、宣言的UIと状態管理が主流となっています。本記事では、実際に動作するTODOアプリを通じて、Clean ArchitectureとMVVMパターンを組み合わせた、現代的なAndroidアプリ開発を学んでいきます。
記事の目的・対象読者
目的
- Jetpack Composeの基本的な使い方を理解する
- Clean ArchitectureとMVVMパターンを実践的に学ぶ
- Room DatabaseとFlowを使った永続化と状態管理を習得する
- Hiltによる依存性注入の活用方法を理解する
- マルチモジュール設計のベストプラクティスを学ぶ
対象読者
- Kotlin/Android開発の基本文法を理解している方
- Jetpack Composeの概念は知っているが実践経験が少ない方
- Clean Architectureの考え方を実際のアプリで学びたい方
- 2025年時点のAndroid開発ベストプラクティスを知りたい方
ソースコード
プロジェクト構成
ディレクトリ構造と概要
android-app-todo/
├── app/ # アプリケーション・モジュール
│ ├── MainActivity.kt # シングル・アクティビティ
│ └── TodoApplication.kt # Applicationクラス
│
├── core/ # コア・モジュール (共有)
│ ├── common/ # 共有ユーティリティ
│ ├── data/ # データ層のためのDIモジュール
│ │ └── di/
│ │ ├── DatabaseModule.kt # Room DB DI
│ │ └── RepositoryModule.kt # リポジトリ DI
│ ├── domain/ # コア・ドメイン (最小限)
│ └── ui/ # 再利用可能なUIコンポーネント
│ ├── component/ # コンポーネント
│ └── theme/ # Material3 テーマ
│
└── feature/ # 機能モジュール
└── todo/ # TODO機能
├── model/ # ドメイン・モデル (依存関係なし)
├── data/ # データ層
├── domain/ # ビジネス・ロジック
└── ui/ # プレゼンテーション層
Clean Architecture + MVVM + マルチモジュール設計
本プロジェクトはClean Architectureの原則に基づき、MVVMパターンとマルチモジュール設計を採用しています。
| 階層 | 役割 | モジュール例 |
|---|---|---|
| Model | ビジネスモデル定義(Pure Kotlin) | feature:todo:model |
| Data | データの永続化・取得 | feature:todo:data |
| Domain | ビジネスロジック(Use Cases) | feature:todo:domain |
| UI | 画面表示・ユーザー操作 | feature:todo:ui |
| Core | 共通コンポーネント・ユーティリティ | core:common, core:ui |
なぜClean Architecture + マルチモジュールなのか?
レイヤーを明確に分離することで、各層が独立してテスト可能になり、ビジネスロジックがUIやフレームワークから分離されます。マルチモジュール設計により、ビルド時間の短縮(並列コンパイル)、依存関係の明確化、機能の再利用性が向上します。2025年時点のAndroid開発では、これが推奨パターンとなっています。
コンポーネント構成図
UI層からDomain層(Use Cases)を経由してData層(Repository → DAO → Database)へとデータが流れる様子がわかります。
アプリの基本フロー
TODO追加から表示までの流れ
ユーザーがタスクを追加してから画面に表示されるまでの一連の流れを見ていきます。
主要な処理の流れ
-
入力受付:
TodoDialogでユーザー入力(タイトル・詳細)を受け取る - バリデーション: 空文字列でないことを確認
-
Use Case呼び出し:
AddTodoUseCaseを経由してビジネスロジック実行 -
Repository処理:
TodoRepositoryがデータの永続化を担当 -
データベース保存: RoomのDAOでSQLite DBに保存(
createdAt,updatedAtも自動設定) -
Flow通知: Room DAOが
Flow<List<TodoEntity>>を通じて変更を通知 -
自動更新: ViewModelが
StateFlowとして公開し、UIが自動的に再構成(Recomposition) - フィルタリング: 現在のフィルタ条件(すべて/未完了/完了済み)に応じて表示
- レンダリング: Jetpack Composeが自動的に画面を更新
Flowが自動更新を引き起こす理由
RoomのDAOが返すFlowは、データベースに変更があるたびに新しい値を発行します。ViewModelがこれをStateFlowに変換し、ComposeのcollectAsState()で購読することで、データベースの変更が自動的にUIに反映されます。手動でUIを更新する必要はありません。
実装
1. 主要な技術スタック
このアプリで使用している主要なライブラリとバージョンです。
[versions]
kotlin = "2.1.0"
gradle = "8.13.0"
agp = "8.13.0"
compose-bom = "2025.10.00"
hilt = "2.57.2"
room = "2.8.2"
coroutines = "1.10.2"
navigation-compose = "2.9.5"
ksp = "2.1.0-1.0.29"
[libraries]
# Jetpack Compose
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
# Room
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# Coroutines
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
2. 型定義
ドメインモデル(Pure Kotlin、依存なし)
/**
* Todoアイテムのドメインモデル
* Pure Kotlinで定義され、Android依存なし
*/
data class TodoItem(
val id: Long = 0,
val title: String,
val description: String = "",
val isCompleted: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)
Room Entity(データ層)
@Entity(tableName = "todos")
data class TodoEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val title: String,
val description: String,
val isCompleted: Boolean,
val createdAt: Long,
val updatedAt: Long
)
// ドメインモデルへの変換
fun TodoEntity.toModel(): TodoItem = TodoItem(
id = id,
title = title,
description = description,
isCompleted = isCompleted,
createdAt = createdAt,
updatedAt = updatedAt
)
// Entityへの変換
fun TodoItem.toEntity(): TodoEntity = TodoEntity(
id = id,
title = title,
description = description,
isCompleted = isCompleted,
createdAt = createdAt,
updatedAt = updatedAt
)
UI State定義(共通)
/**
* Todo画面のUI状態
*/
sealed interface TodoUiState {
data object Loading : TodoUiState
data class Success(val todos: List<TodoItem>) : TodoUiState
data class Error(val message: String) : TodoUiState
}
/**
* Todoフィルタの種類
*/
enum class TodoFilter {
ALL, // すべて
ACTIVE, // 未完了
COMPLETED // 完了済み
}
/**
* 処理結果を表すResult型
*/
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable) : Result<Nothing>()
data object Loading : Result<Nothing>()
}
3. データ層:Room Database
DAO(Data Access Object)
@Dao
interface TodoDao {
/**
* すべてのTodoを取得(作成日時の降順)
* Flowを返すことで、データベース変更時に自動的に最新データを通知
*/
@Query("SELECT * FROM todos ORDER BY createdAt DESC")
fun getAllTodos(): Flow<List<TodoEntity>>
/**
* Todoを挿入
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(todo: TodoEntity): Long
/**
* Todoを更新
*/
@Update
suspend fun update(todo: TodoEntity)
/**
* Todoを削除
*/
@Delete
suspend fun delete(todo: TodoEntity)
/**
* IDでTodoを削除
*/
@Query("DELETE FROM todos WHERE id = :id")
suspend fun deleteById(id: Long)
/**
* 完了済みTodoを一括削除
*/
@Query("DELETE FROM todos WHERE isCompleted = 1")
suspend fun deleteCompletedTodos()
}
Database定義
@Database(
entities = [TodoEntity::class],
version = 1,
exportSchema = false
)
abstract class TodoDatabase : RoomDatabase() {
abstract fun todoDao(): TodoDao
}
Repository実装
/**
* Todoデータの操作を定義するインターフェース
*/
interface TodoRepository {
fun getAllTodos(): Flow<Result<List<TodoItem>>>
suspend fun addTodo(todo: TodoItem): Result<Long>
suspend fun updateTodo(todo: TodoItem): Result<Unit>
suspend fun deleteTodo(id: Long): Result<Unit>
suspend fun deleteCompletedTodos(): Result<Unit>
}
/**
* TodoRepositoryの実装
* DAOとドメインモデル間のマッピングとエラーハンドリングを担当
*/
class TodoRepositoryImpl @Inject constructor(
private val todoDao: TodoDao
) : TodoRepository {
override fun getAllTodos(): Flow<Result<List<TodoItem>>> {
return todoDao.getAllTodos()
.map { entities ->
Result.Success(entities.map { it.toModel() })
}
.catch { e ->
emit(Result.Error(e))
}
}
override suspend fun addTodo(todo: TodoItem): Result<Long> {
return try {
val id = todoDao.insert(todo.toEntity())
Result.Success(id)
} catch (e: Exception) {
Result.Error(e)
}
}
override suspend fun updateTodo(todo: TodoItem): Result<Unit> {
return try {
todoDao.update(todo.toEntity())
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
override suspend fun deleteTodo(id: Long): Result<Unit> {
return try {
todoDao.deleteById(id)
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
override suspend fun deleteCompletedTodos(): Result<Unit> {
return try {
todoDao.deleteCompletedTodos()
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
}
4. ドメイン層:Use Cases
各ビジネスロジックを単一責任のUse Caseとして分離します。
GetAllTodosUseCase
/**
* すべてのTodoを取得するUseCase
*/
class GetAllTodosUseCase @Inject constructor(
private val repository: TodoRepository
) {
operator fun invoke(): Flow<Result<List<TodoItem>>> {
return repository.getAllTodos()
}
}
AddTodoUseCase
/**
* Todoを追加するUseCase
*/
class AddTodoUseCase @Inject constructor(
private val repository: TodoRepository
) {
suspend operator fun invoke(title: String, description: String = ""): Result<Long> {
val todo = TodoItem(
title = title,
description = description,
isCompleted = false,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
return repository.addTodo(todo)
}
}
UpdateTodoUseCase
/**
* Todoを更新するUseCase
*/
class UpdateTodoUseCase @Inject constructor(
private val repository: TodoRepository
) {
suspend operator fun invoke(todo: TodoItem): Result<Unit> {
val updatedTodo = todo.copy(updatedAt = System.currentTimeMillis())
return repository.updateTodo(updatedTodo)
}
}
ToggleTodoUseCase
/**
* Todoの完了状態を切り替えるUseCase
*/
class ToggleTodoUseCase @Inject constructor(
private val repository: TodoRepository
) {
suspend operator fun invoke(todo: TodoItem): Result<Unit> {
val toggledTodo = todo.copy(
isCompleted = !todo.isCompleted,
updatedAt = System.currentTimeMillis()
)
return repository.updateTodo(toggledTodo)
}
}
DeleteTodoUseCase
/**
* Todoを削除するUseCase
*/
class DeleteTodoUseCase @Inject constructor(
private val repository: TodoRepository
) {
suspend operator fun invoke(id: Long): Result<Unit> {
return repository.deleteTodo(id)
}
}
DeleteCompletedTodosUseCase
/**
* 完了済みTodoを一括削除するUseCase
*/
class DeleteCompletedTodosUseCase @Inject constructor(
private val repository: TodoRepository
) {
suspend operator fun invoke(): Result<Unit> {
return repository.deleteCompletedTodos()
}
}
5. UI層:ViewModel
@HiltViewModel
class TodoViewModel @Inject constructor(
private val getAllTodosUseCase: GetAllTodosUseCase,
private val addTodoUseCase: AddTodoUseCase,
private val updateTodoUseCase: UpdateTodoUseCase,
private val deleteTodoUseCase: DeleteTodoUseCase,
private val toggleTodoUseCase: ToggleTodoUseCase,
private val deleteCompletedTodosUseCase: DeleteCompletedTodosUseCase
) : ViewModel() {
// フィルタ状態
private val _filter = MutableStateFlow(TodoFilter.ALL)
val filter: StateFlow<TodoFilter> = _filter.asStateFlow()
// UI状態(Todoリスト + フィルタを結合)
val uiState: StateFlow<TodoUiState> = combine(
getAllTodosUseCase(),
_filter
) { result, filter ->
when (result) {
is Result.Loading -> TodoUiState.Loading
is Result.Success -> {
val filteredTodos = filterTodos(result.data, filter)
TodoUiState.Success(filteredTodos)
}
is Result.Error -> TodoUiState.Error(
result.exception.message ?: "Unknown error"
)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = TodoUiState.Loading
)
/**
* フィルタリングロジック
*/
private fun filterTodos(todos: List<TodoItem>, filter: TodoFilter): List<TodoItem> {
return when (filter) {
TodoFilter.ALL -> todos
TodoFilter.ACTIVE -> todos.filter { !it.isCompleted }
TodoFilter.COMPLETED -> todos.filter { it.isCompleted }
}
}
/**
* Todo追加
*/
fun addTodo(title: String, description: String = "") {
viewModelScope.launch {
addTodoUseCase(title, description)
}
}
/**
* Todo更新
*/
fun updateTodo(todo: TodoItem) {
viewModelScope.launch {
updateTodoUseCase(todo)
}
}
/**
* Todo完了状態切り替え
*/
fun toggleTodo(todo: TodoItem) {
viewModelScope.launch {
toggleTodoUseCase(todo)
}
}
/**
* Todo削除
*/
fun deleteTodo(id: Long) {
viewModelScope.launch {
deleteTodoUseCase(id)
}
}
/**
* 完了済みTodoを一括削除
*/
fun deleteCompletedTodos() {
viewModelScope.launch {
deleteCompletedTodosUseCase()
}
}
/**
* フィルタ変更
*/
fun setFilter(filter: TodoFilter) {
_filter.value = filter
}
}
combineを使った状態の結合
combineは複数のFlowを1つに結合します。ここでは、TodoリストのFlowとフィルタのFlowを結合し、どちらかが変更されるたびに最新のフィルタ済みリストを生成します。これにより、データベースの変更とフィルタ変更の両方に自動的に対応できます。
6. UI層:Jetpack Compose
共有UIコンポーネント:TodoItemCard
@Composable
fun TodoItemCard(
todo: TodoItem,
onToggle: (TodoItem) -> Unit,
onEdit: (TodoItem) -> Unit,
onDelete: (Long) -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// チェックボックス
Checkbox(
checked = todo.isCompleted,
onCheckedChange = { onToggle(todo) }
)
Spacer(modifier = Modifier.width(8.dp))
// タイトルと詳細
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = todo.title,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (todo.isCompleted) {
TextDecoration.LineThrough
} else {
null
}
)
if (todo.description.isNotEmpty()) {
Text(
text = todo.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// 編集ボタン
IconButton(onClick = { onEdit(todo) }) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "編集"
)
}
// 削除ボタン
IconButton(onClick = { onDelete(todo.id) }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "削除",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
共有UIコンポーネント:TodoDialog
@Composable
fun TodoDialog(
todo: TodoItem? = null,
onDismiss: () -> Unit,
onConfirm: (title: String, description: String) -> Unit
) {
var title by remember { mutableStateOf(todo?.title ?: "") }
var description by remember { mutableStateOf(todo?.description ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(text = if (todo == null) "Todo追加" else "Todo編集")
},
text = {
Column {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("タイトル") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("詳細(任意)") },
modifier = Modifier.fillMaxWidth(),
minLines = 3
)
}
},
confirmButton = {
TextButton(
onClick = {
if (title.isNotBlank()) {
onConfirm(title.trim(), description.trim())
onDismiss()
}
},
enabled = title.isNotBlank()
) {
Text("保存")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("キャンセル")
}
}
)
}
メイン画面:TodoScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoScreen(
viewModel: TodoViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val filter by viewModel.filter.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
var editingTodo by remember { mutableStateOf<TodoItem?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Todo App") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showAddDialog = true }
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Todo追加"
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
// フィルタチップス
FilterChips(
currentFilter = filter,
onFilterChange = { viewModel.setFilter(it) }
)
Spacer(modifier = Modifier.height(16.dp))
// UI状態に応じた表示
when (uiState) {
is TodoUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator()
}
}
is TodoUiState.Success -> {
val todos = (uiState as TodoUiState.Success).todos
if (todos.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Text(
text = "Todoがありません",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
TodoList(
todos = todos,
onToggle = { viewModel.toggleTodo(it) },
onEdit = { editingTodo = it },
onDelete = { viewModel.deleteTodo(it) }
)
// 完了済み削除ボタン
if (todos.any { it.isCompleted }) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.deleteCompletedTodos() },
modifier = Modifier.fillMaxWidth()
) {
Text("完了済みTodoを削除")
}
}
}
}
is TodoUiState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Text(
text = "エラー: ${(uiState as TodoUiState.Error).message}",
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
// 追加ダイアログ
if (showAddDialog) {
TodoDialog(
onDismiss = { showAddDialog = false },
onConfirm = { title, description ->
viewModel.addTodo(title, description)
}
)
}
// 編集ダイアログ
editingTodo?.let { todo ->
TodoDialog(
todo = todo,
onDismiss = { editingTodo = null },
onConfirm = { title, description ->
viewModel.updateTodo(
todo.copy(title = title, description = description)
)
}
)
}
}
7. 依存性注入:Hilt設定
Application Class
@HiltAndroidApp
class TodoApplication : Application()
MainActivity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TodoAppTheme {
TodoScreen()
}
}
}
}
DatabaseModule
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideTodoDatabase(
@ApplicationContext context: Context
): TodoDatabase {
return Room.databaseBuilder(
context,
TodoDatabase::class.java,
"todo_database"
).build()
}
@Provides
fun provideTodoDao(database: TodoDatabase): TodoDao {
return database.todoDao()
}
}
RepositoryModule
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindTodoRepository(
impl: TodoRepositoryImpl
): TodoRepository
}
状態管理
状態管理とは?
「状態(state)」とは、アプリの中で変化するデータのことを指します。例えば、タスクの一覧やフィルタ状態などがそれにあたります。AndroidではStateFlowとJetpack ComposeのcollectAsState()を使ってこの状態を安全かつリアクティブに管理します。
シーケンス図:タスク追加
ユーザーがタスクを追加する際の各層のやり取りを示します。
シーケンス図:タスク完了切り替え
タスクの完了/未完了を切り替える操作の流れです。
シーケンス図:フィルタリング
フィルタチップをタップした際の動作です。
重要なポイント
1. 単方向データフロー(Unidirectional Data Flow)
Jetpack Composeでは、データは常に上位から下位へ流れ、イベントは下位から上位へ伝播します。
ViewModel (StateFlow)
↓ state
TodoScreen (collectAsState)
↓ props
TodoItemCard
↑ event (onToggle, onDelete)
子コンポーネントは状態を直接変更せず、イベントコールバックを通じて親に通知します。
2. 状態のホイスティング(State Hoisting)
UIの状態は、それを必要とする最も低い共通祖先で管理します。本アプリでは:
- ViewModel: アプリ全体の状態(Todoリスト、フィルタ)
- TodoScreen: 一時的なUI状態(ダイアログの表示/非表示、編集中のTodo)
- TodoDialog: 入力フォームの状態(タイトル、詳細)
3. 不変性(Immutability)
Kotlinのdata classのcopy()を使い、元のオブジェクトを変更せず新しいオブジェクトを生成します。
// ✅ 良い例:copyで新しいインスタンス生成
val updatedTodo = todo.copy(
isCompleted = !todo.isCompleted,
updatedAt = System.currentTimeMillis()
)
// ❌ 悪い例:data classは基本的にvalなので直接変更不可
// todo.isCompleted = !todo.isCompleted // コンパイルエラー
4. Flowによるリアクティブプログラミング
RoomのDAOが返すFlowは、データベースの変更を自動的に通知します。
@Query("SELECT * FROM todos ORDER BY createdAt DESC")
fun getAllTodos(): Flow<List<TodoEntity>>
ViewModelでcombine()を使い、複数のFlowを結合:
val uiState: StateFlow<TodoUiState> = combine(
getAllTodosUseCase(), // Flow<Result<List<TodoItem>>>
_filter // MutableStateFlow<TodoFilter>
) { result, filter ->
// どちらかが変更されたら再計算
when (result) {
is Result.Success -> {
val filtered = filterTodos(result.data, filter)
TodoUiState.Success(filtered)
}
// ...
}
}.stateIn(...)
UIではcollectAsState()で購読し、自動的に再構成:
val uiState by viewModel.uiState.collectAsState()
5. Clean Architectureの依存関係ルール
依存関係は常に内側(ドメイン)へ向かい、外側(UI、データ)は内側に依存します。
UI層 → Domain層(Use Cases) → Data層(Repository Interface)
↑
実装(RepositoryImpl)
- UI層: ViewModelはUse Caseに依存
- Domain層: Use CaseはRepositoryインターフェースに依存(実装には依存しない)
- Data層: RepositoryImplがインターフェースを実装
これにより、各層を独立してテストでき、実装の詳細を隠蔽できます。
6. Hiltによる依存性注入
コンストラクタインジェクションにより、テスタビリティと保守性が向上します。
@HiltViewModel
class TodoViewModel @Inject constructor(
private val getAllTodosUseCase: GetAllTodosUseCase,
private val addTodoUseCase: AddTodoUseCase,
// ...
) : ViewModel()
-
@HiltViewModel: ViewModelの自動生成 -
@Inject constructor: 依存関係の自動解決 - モジュールで設定したSingletonインスタンスを注入
7. Resultパターンによる型安全なエラーハンドリング
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable) : Result<Nothing>()
data object Loading : Result<Nothing>()
}
- Null安全性を保ちつつエラーを表現
- when式で網羅性チェック(コンパイル時に漏れを検出)
- ローディング状態も型として表現
まとめ
これまでに、Jetpack Compose、Clean Architecture、MVVM、Hilt、Room、Flowを組み合わせた、モダンなAndroid TODOアプリを構築しました。本章では、学んだ内容を振り返り、2025年時点のAndroid開発ベストプラクティスをどのように適用したかを整理します。
学んだこと
- Jetpack Compose: 宣言的UIによる直感的な画面構築
- StateFlow / Flow: リアクティブな状態管理とデータストリーム
- Room Database: 型安全なローカルデータ永続化
- Hilt: 依存性注入による疎結合な設計
- Clean Architecture: レイヤー分離による保守性・テスタビリティの向上
- MVVM: ViewModelによるUI状態の管理
- Use Case Pattern: ビジネスロジックの単一責任化
- マルチモジュール設計: 機能分離とビルド効率化
- Result Pattern: 型安全なエラーハンドリング