2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Android入門】Jetpack ComposeでシンプルなTODOアプリを作ろう

2
Posted at

はじめに

こんにちは!この記事では、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追加から表示までの流れ

ユーザーがタスクを追加してから画面に表示されるまでの一連の流れを見ていきます。

主要な処理の流れ

  1. 入力受付: TodoDialogでユーザー入力(タイトル・詳細)を受け取る
  2. バリデーション: 空文字列でないことを確認
  3. Use Case呼び出し: AddTodoUseCaseを経由してビジネスロジック実行
  4. Repository処理: TodoRepositoryがデータの永続化を担当
  5. データベース保存: RoomのDAOでSQLite DBに保存(createdAt, updatedAtも自動設定)
  6. Flow通知: Room DAOがFlow<List<TodoEntity>>を通じて変更を通知
  7. 自動更新: ViewModelがStateFlowとして公開し、UIが自動的に再構成(Recomposition)
  8. フィルタリング: 現在のフィルタ条件(すべて/未完了/完了済み)に応じて表示
  9. レンダリング: Jetpack Composeが自動的に画面を更新

Flowが自動更新を引き起こす理由
RoomのDAOが返すFlowは、データベースに変更があるたびに新しい値を発行します。ViewModelがこれをStateFlowに変換し、ComposeのcollectAsState()で購読することで、データベースの変更が自動的にUIに反映されます。手動でUIを更新する必要はありません。

実装

1. 主要な技術スタック

このアプリで使用している主要なライブラリとバージョンです。

gradle/libs.versions.toml
[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、依存なし)

feature/todo/model/TodoItem.kt
/**
 * 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(データ層)

feature/todo/data/local/TodoEntity.kt
@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定義(共通)

core/common/TodoUiState.kt
/**
 * Todo画面のUI状態
 */
sealed interface TodoUiState {
    data object Loading : TodoUiState
    data class Success(val todos: List<TodoItem>) : TodoUiState
    data class Error(val message: String) : TodoUiState
}
core/common/TodoFilter.kt
/**
 * Todoフィルタの種類
 */
enum class TodoFilter {
    ALL,        // すべて
    ACTIVE,     // 未完了
    COMPLETED   // 完了済み
}
core/common/Result.kt
/**
 * 処理結果を表す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)

feature/todo/data/local/TodoDao.kt
@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定義

feature/todo/data/local/TodoDatabase.kt
@Database(
    entities = [TodoEntity::class],
    version = 1,
    exportSchema = false
)
abstract class TodoDatabase : RoomDatabase() {
    abstract fun todoDao(): TodoDao
}

Repository実装

feature/todo/data/repository/TodoRepository.kt
/**
 * 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>
}
feature/todo/data/repository/TodoRepositoryImpl.kt
/**
 * 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

feature/todo/domain/usecase/GetAllTodosUseCase.kt
/**
 * すべてのTodoを取得するUseCase
 */
class GetAllTodosUseCase @Inject constructor(
    private val repository: TodoRepository
) {
    operator fun invoke(): Flow<Result<List<TodoItem>>> {
        return repository.getAllTodos()
    }
}

AddTodoUseCase

feature/todo/domain/usecase/AddTodoUseCase.kt
/**
 * 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

feature/todo/domain/usecase/UpdateTodoUseCase.kt
/**
 * 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

feature/todo/domain/usecase/ToggleTodoUseCase.kt
/**
 * 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

feature/todo/domain/usecase/DeleteTodoUseCase.kt
/**
 * Todoを削除するUseCase
 */
class DeleteTodoUseCase @Inject constructor(
    private val repository: TodoRepository
) {
    suspend operator fun invoke(id: Long): Result<Unit> {
        return repository.deleteTodo(id)
    }
}

DeleteCompletedTodosUseCase

feature/todo/domain/usecase/DeleteCompletedTodosUseCase.kt
/**
 * 完了済みTodoを一括削除するUseCase
 */
class DeleteCompletedTodosUseCase @Inject constructor(
    private val repository: TodoRepository
) {
    suspend operator fun invoke(): Result<Unit> {
        return repository.deleteCompletedTodos()
    }
}

5. UI層:ViewModel

feature/todo/ui/TodoViewModel.kt
@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

core/ui/component/TodoItemCard.kt
@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

core/ui/component/TodoDialog.kt
@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

feature/todo/ui/TodoScreen.kt
@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

app/todo/TodoApplication.kt
@HiltAndroidApp
class TodoApplication : Application()

MainActivity

app/todo/MainActivity.kt
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TodoAppTheme {
                TodoScreen()
            }
        }
    }
}

DatabaseModule

core/data/di/DatabaseModule.kt
@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

core/data/di/RepositoryModule.kt
@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: 型安全なエラーハンドリング
2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?