0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Roomでメモアプリを永続化する - 実務レベルの詳細設計書

Posted at

1. はじめに

1.1. 目的

本ドキュメントは、先日作成した基本設計書に基づき、シンプルメモアプリにRoomデータベースを用いたデータ永続化機能を実装するための詳細設計を定義する。
本設計書のゴールは、開発者が実装作業に着手する際に、迷いや曖昧さを一切排除し、コードの品質と一貫性を保証することにある。

1.2. 設計思想

  • 関心の分離 (SoC): 各コンポーネントが単一の責任を持つように設計し、テスト容易性とメンテナンス性を最大化する。

  • シングルソースオブトゥルース (SSOT): データベース(Room)を信頼できる唯一のデータ源とし、データの不整合を防ぐ。

  • リアクティブUI: Flowを使用し、データベースの変更がUIに自動的かつ効率的に反映されるリアクティブなアーキテクチャを構築する。

  • 堅牢なエラーハンドリング: データベース操作におけるエラーを適切にハンドリングし、ユーザーにフィードバックできる仕組みを導入する。

2. 実装タスク一覧

本機能の実装は、以下のステップで進める。

  1. 依存関係の追加: Roomライブラリをプロジェクトに導入する。
  2. データ層の実装: Entity, DAO, Databaseを定義する。
  3. Repository層の実装: データアクセスを抽象化するRepositoryを実装する。
  4. ViewModelの改修: ViewModelがRepositoryを利用するように責務を変更する。
  5. 依存性の注入(DI)の導入: RepositoryとViewModelのインスタンスを適切に生成・提供する。

3. Step by Step 詳細設計

3.1. Step 0: 依存関係の追加(build.gradle.kts & libs.versions.toml)

レビュー観点: 本機能実装に必要となるライブラリが、バージョン管理を含め適切に定義されているか。

3.1.1. kspプラグインの適用

Roomのコード自動生成にはkspを利用する。
ファイル: app/build.gradle.kts

plugins {
    // ... (既存のプラグイン)
    alias(libs.plugins.google.ksp) // 追加
}

3.1.2. ライブラリ定義の追加

ファイル: gradle/libs.versions.toml

[versions]
room = "2.6.1"
ksp = "2.0.20-1.0.22" // Kotlinバージョンに合わせて調整

[libraries]
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }

[plugins]
google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } # 追加

3.1.3. 依存関係の宣言

ファイル: app/build.gradle.kts

dependencies {
    // ... (既存のライブラリ)

    // Room
    implementation(libs.androidx.room.runtime)
    ksp(libs.androidx.room.compiler)
    implementation(libs.androidx.room.ktx)
}

3.2. Step 1: データ層の実装 (dataパッケージ)

レビュー観点: データベースのスキーマ定義、データ操作、型変換が、堅牢かつ効率的に実装されているか。

3.2.1. Memo Entityの定義

ファイル: data/model/Memo.kt

package com.example.simplememoapp_android.data.model

import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDateTime

/**
 * データベースの「memos」テーブルを表すエンティティ。
 * 各メモは一意のID、テキスト内容、作成日時を持つ。
 */
@Entity(tableName = "memos")
data class Memo(
    @PrimaryKey(autoGenerate = true) // idを主キーとし、Roomに自動採番を任せる
    val id: Int = 0,
    val text: String,
    val createdAt: LocalDateTime
)

3.2.2. TypeConverterの実装

RoomはLocalDateTime型を直接保存できないため、String型との相互変換ロジックを定義する。
ファイル: data/local/Converters.kt

package com.example.simplememoapp_android.data.local

import androidx.room.TypeConverter
import java.time.LocalDateTime

/**
 * RoomデータベースがLocalDateTime型を保存・読み込みできるようにするための型コンバーター。
 */
class Converters {
    @TypeConverter
    fun fromTimestamp(value: String?): LocalDateTime? {
        return value?.let { LocalDateTime.parse(it) }
    }

    @TypeConverter
    fun dateToTimestamp(date: LocalDateTime?): String? {
        return date?.toString()
    }
}

3.2.3. MemoDaoインターフェースの定義

ファイル: data/local/dao/MemoDao.kt

package com.example.simplememoapp_android.data.local.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.example.simplememoapp_android.data.model.Memo
import kotlinx.coroutines.flow.Flow

/**
 * Memoエンティティに対するデータベース操作(DAO: Data Access Object)のインターフェース。
 */
@Dao
interface MemoDao {
    /**
     * 全てのメモを最新作成日時順に取得します。
     * データベースの変更をリアクティブに監視するため、Flowを返します。
     */
    @Query("SELECT * FROM memos ORDER BY createdAt DESC")
    fun getAllMemos(): Flow<List<Memo>>

    /**
     * 新しいメモをデータベースに挿入します。
     * suspend関数として定義することで、コルーチンを用いた非同期処理を可能にします。
     * @param memo 挿入するMemoオブジェクト。
     */
    @Insert
    suspend fun insertMemo(memo: Memo)
}

3.2.4. AppDatabaseの定義

ファイル: data/local/AppDatabase.kt

package com.example.simplememoapp_android.data.local

import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.example.simplememoapp_android.data.local.dao.MemoDao
import com.example.simplememoapp_android.data.model.Memo

/**
 * アプリケーションのRoomデータベースの抽象クラス。
 * データベースのバージョン、エンティティ、型コンバーターを定義する。
 */
@Database(entities = [Memo::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    /**
     * MemoDaoインスタンスを提供します。
     */
    abstract fun memoDao(): MemoDao
}

3.3. Step 2: Repository層の実装

レビュー観点: データソースへのアクセスが適切に抽象化され、ViewModelがデータソースの実装詳細に依存しない設計となっているか。エラーハンドリングが適切に行われているか。

ファイル: data/repository/MemoRepository.kt

package com.example.simplememoapp_android.data.repository

import com.example.simplememoapp_android.data.local.dao.MemoDao
import com.example.simplememoapp_android.data.model.Memo
import kotlinx.coroutines.flow.Flow
import java.time.LocalDateTime

/**
 * メモデータに関する操作を抽象化するリポジトリクラス。
 * データソース(今回はMemoDao)とViewModelの間を仲介する責務を持つ。
 */
class MemoRepository(private val memoDao: MemoDao) {

    /**
     * 全てのメモを最新作成日時順に取得します。
     * データベースの変更がリアルタイムにViewModelに伝達されるよう、Flowを返します。
     */
    val allMemos: Flow<List<Memo>> = memoDao.getAllMemos()

    /**
     * 新しいメモをデータベースに挿入します。
     * データベース操作の成功・失敗をResult型でViewModelに伝えます。
     * @param text メモの本文
     * @return 挿入処理の結果。成功時はResult.success(Unit)、失敗時はResult.failure(Exception)
     */
    suspend fun insert(text: String): Result<Unit> {
        return try {
            val newMemo = Memo(
                text = text,
                createdAt = LocalDateTime.now()
            )
            memoDao.insertMemo(newMemo)
            Result.success(Unit)
        } catch (e: Exception) {
            // 必要であればここでログ出力(例: Timber.e(e, "メモの挿入に失敗しました"))
            // や特定のエラー型への変換(例: CustomAppException(e))を行う
            Result.failure(e)
        }
    }
}

設計根拠:

  • Flow>: データベースの変更をリアクティブにViewModelに通知する。
suspend fun insert(text: String): Result<Unit>:
  • suspendにより非同期処理を可能にし、UIスレッドのブロックを防ぐ。

  • Resultにより、DB操作の成功・失敗を明確にViewModelに伝える。Unitは成功時に特に返す値がないことを示す。

  • try-catchブロックでExceptionを捕捉し、安全にエラーをResult.failure()としてラップして返すことで、上位層でのエラーハンドリングを容易にする。

  • Memoオブジェクトの生成: createdAtの採番など、Memoオブジェクトの生成に関するロジックをRepositoryで行うことで、ViewModelの責務をUIロジックにより一層専念させる。

3.4. Step 3: ViewModelの改修

レビュー観点: ViewModelがRepositoryを介してリアクティブにデータを取得し、UIに適したUiStateに変換する責務を正しく果たしているか。RepositoryからのResultを受け取り、エラー状態を適切にUIに反映できるか。

ファイル: ui/viewmodel/MemoViewModel.kt

package com.example.simplememoapp_android.ui.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.simplememoapp_android.data.repository.MemoRepository
import com.example.simplememoapp_android.ui.state.MemoUiState
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

// UiStateの定義を仮置き。このViewModelに合わせて後で修正する。
sealed interface MemoUiState {
    object Loading : MemoUiState
    object Empty : MemoUiState
    data class Success(val memos: List<Memo>) : MemoUiState
    data class Error(val message: String) : MemoUiState // エラー状態を追加
}

class MemoViewModel(private val repository: MemoRepository) : ViewModel() {

    // Repositoryから流れてくる`Flow<List<Memo>>`を、UIが表示すべき`StateFlow<MemoUiState>`に変換する
    val uiState: StateFlow<MemoUiState> = repository.allMemos
        .map { memos ->
            if (memos.isEmpty()) {
                MemoUiState.Empty
            } else {
                MemoUiState.Success(memos)
            }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000L),
            initialValue = MemoUiState.Loading
        )

    /**
     * 新しいメモを追加します。
     * Repositoryからの処理結果(成功・失敗)を受け取り、UIに反映します。
     * @param text 追加するメモの本文。
     */
    fun addMemo(text: String) {
        if (text.isBlank()) return

        viewModelScope.launch {
            val result = repository.insert(text.trim()) // RepositoryからのResultを受け取る
            result.onSuccess {
                // 成功時は何もしない(allMemosが自動で更新され、UIStateが更新されるため)
                // 必要であれば、トースト表示などでユーザーに成功を伝えることも可能
            }.onFailure { e ->
                // 失敗時はUIStateをError状態に更新するなど、ユーザーにエラーを伝える
                // 例: _uiState.value = MemoUiState.Error("メモの保存に失敗しました: ${e.message}")
                // 現状のuiStateはFlowベースなので、別途イベントとして通知するか、
                // UiStateに一時的なエラーメッセージを持たせるなどの考慮が必要
                println("メモの挿入に失敗しました: ${e.message}") // 開発用ログ
                // TODO: UIにエラーを適切に伝えるメカニズムを実装する(次のステップで検討)
            }
        }
    }
}

設計根拠:

  • MemoUiStateへのError状態の追加: RepositoryからのエラーをUIに反映するため、UiStateにError状態を追加する。

  • repository.insert().onSuccess().onFailure(): Result型が提供する便利な関数を用いて、成功時と失敗時で異なる処理を記述する。

  • SharingStarted.WhileSubscribed(5000L): UIが画面に表示されている間だけデータベースの監視を行い、バックグラウンドに回ってから5秒後には監視を停止する。これにより、不要なリソース消費を抑える。

3.5. Step 4: 依存性の注入(DI)の導入

レビュー観点: RepositoryとViewModelが、アプリのライフサイクルに合わせて適切にインスタンス化され、必要な場所に提供される仕組みが実装されているか。

ファイル: MemoApplication.kt (新規作成)

package com.example.simplememoapp_android

import android.app.Application
import androidx.room.Room
import com.example.simplememoapp_android.data.local.AppDatabase
import com.example.simplememoapp_android.data.local.Converters
import com.example.simplememoapp_android.data.repository.MemoRepository

/**
 * アプリケーション全体で共有されるシングルトンオブジェクトの生成と管理を行うApplicationクラス。
 * アプリの起動時に一度だけ生成され、アプリのライフサイクルと同期する。
 */
class MemoApplication : Application() {

    // アプリ全体で共有されるAppDatabaseインスタンス
    // lazyで初期化を遅延させることで、必要になった時のみDBが生成されるようにする
    val database: AppDatabase by lazy {
        Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java,
            "memo_database"
        )
            .addTypeConverter(Converters::class.java) // TypeConverterをDBに登録
            .build()
    }

    // アプリ全体で共有されるMemoRepositoryインスタンス
    // databaseインスタンスに依存するため、databaseの後に初期化される
    val repository: MemoRepository by lazy {
        MemoRepository(database.memoDao())
    }
}

ファイル: AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:name=".MemoApplication" android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.SimpleMemoAppAndroid"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.SimpleMemoAppAndroid">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

ファイル: ui/viewmodel/MemoViewModelFactory.kt (新規作成)

package com.example.simplememoapp_android.ui.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.simplememoapp_android.data.repository.MemoRepository

/**
 * MemoViewModelのインスタンスを生成するためのファクトリクラス。
 * ViewModelが依存するMemoRepositoryを注入するために使用する。
 */
class MemoViewModelFactory(private val repository: MemoRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(MemoViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return MemoViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

ファイル: ui/MemoScreen.kt

package com.example.simplememoapp_android.ui

import android.app.Application
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.simplememoapp_android.MemoApplication // 追加
import com.example.simplememoapp_android.ui.viewmodel.MemoViewModel
import com.example.simplememoapp_android.ui.viewmodel.MemoViewModelFactory // 追加

@Composable
fun MemoScreen() {
    // LocalContextからApplicationインスタンスを取得し、カスタムApplicationクラスにキャスト
    val application = LocalContext.current.applicationContext as MemoApplication
    
    // カスタムファクトリを使ってViewModelを生成
    val viewModel: MemoViewModel = viewModel(
        factory = MemoViewModelFactory(application.repository)
    )

    // ... (既存のUIコード)
    Column {
        Text("ここにメモリストと入力フィールドが表示されます")
        // ここにViewModelのuiStateを監視してUIを構築するコードを記述していく
        // 例: val uiState by viewModel.uiState.collectAsState()
        // if (uiState is MemoUiState.Error) { Text((uiState as MemoUiState.Error).message) }
    }
}

設計根拠:

  • MemoApplication: アプリ全体でシングルトンとして管理すべきAppDatabaseとMemoRepositoryのインスタンス生成を担う。lazyデリゲートにより、初めてアクセスされた時にのみインスタンスが生成される(遅延初期化)。

  • MemoViewModelFactory: ViewModelがMemoRepositoryをコンストラクタで受け取るため、Android OSが直接ViewModelを生成できない。このFactoryがその役割を代行し、MemoApplicationからRepositoryを取得してViewModelに渡す。

  • MemoScreenでのDI: LocalContextからApplicationを取得し、そのApplicationが持つrepositoryをViewModelFactoryに渡すことで、画面(UI)が直接RepositoryやDatabaseの生成に関与することなく、ViewModelを利用できるようになる。

4. 自己内省と残課題

本詳細設計は、Roomを用いたデータ永続化の実装方針として、モダンなAndroid開発のベストプラクティスに準拠しており、実装に着手する上で十分な品質と網羅性を持っていると判断する。特にエラーハンドリングの導入により、より堅牢なシステム構築の第一歩を踏み出した。

残課題:

UIへのエラー通知: 現在のViewModelでは、addMemoでエラーが発生した場合、printlnでログに出力しているだけ。実際にユーザーに「保存に失敗しました」と伝えるためには、UiStateに一時的なエラーメッセージを持たせるか、SharedFlowなどを用いてイベントとしてUIに通知する仕組みが必要となる。これは、ViewModelの改修タスクの後半で具体的に検討する。

本格的な依存性の注入ライブラリの導入: 現在は手動でDIを行っているが、プロジェクトが大規模になるにつれてこの管理は困難になる。次フェーズでは、Hiltを導入し、この依存性注入を自動化することで、よりテストが容易で疎結合なアーキテクチャへと昇華させるべきである。

以上

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?