[Jetpack Compose] 簡単なMemoアプリでRoom (DAO)を学ぶ

Memoアプリ自体の基礎的な実装はこちらを参考に。

[Jetpack Compose] 簡単なMemoアプリでMVVMアーキテクチャを掴む

今回はアプリを開き直してもデータが保存されるようにRoom を使用してローカルデータベースにデータを保存するようにします。

Dependencies

build.gradle.kts (アプリ単位)

以下を追加

plugins {
	alias(libs.plugins.android.application) apply false
	alias(libs.plugins.jetbrains.kotlin.android) apply false
	// 追加
	id("com.google.dagger.hilt.android") version "2.51.1" apply false
}

build.gradle.kts (モジュール単位)

dependenciesに以下を追加、syncする。

dependencies {
	implementation(libs.androidx.core.ktx)
	implementation(libs.androidx.lifecycle.runtime.ktx)
	implementation(libs.androidx.activity.compose)
	implementation(platform(libs.androidx.compose.bom))
	implementation(libs.androidx.ui)
	implementation(libs.androidx.ui.graphics)
	implementation(libs.androidx.ui.tooling.preview)
	implementation(libs.androidx.material3)
	// 追加 -- ここから
	// ViewModel
	implementation(libs.androidx.lifecycle.viewmodel.compose)
	// Hilt
	implementation(libs.hilt.android)
	kapt(libs.hilt.android.compiler)
	// Room
	implementation(libs.androidx.room.runtime)
	annotationProcessor(libs.androidx.room.compiler)
	kapt(libs.androidx.room.compiler)
	implementation("androidx.room:room-ktx:2.2.1")
	// Coroutines
	implementation(libs.kotlinx.coroutines.android)
	// 追加 -- ここまで
	testImplementation(libs.junit)
	androidTestImplementation(libs.androidx.junit)
	androidTestImplementation(libs.androidx.espresso.core)
	androidTestImplementation(platform(libs.androidx.compose.bom))
	androidTestImplementation(libs.androidx.ui.test.junit4)
	debugImplementation(libs.androidx.ui.tooling)
	debugImplementation(libs.androidx.ui.test.manifest)
}

Util系 (UUID, DateなどをDatabaseに保存するための変換処理)

Utils.kt

fun formatDate(time: Long): String {
    // タイムスタンプ(ミリ秒)からDateオブジェクトを生成
    val date = Date(time)

    // 表示形式を指定するSimpleDateFormatオブジェクトを生成
    // "EEE, d MM hh:mm aaa" は、曜日, 日 月 時:分 午前/午後 を表す形式
    // Locale.getDefault() は、システムのデフォルトのロケールを使用
    val format = SimpleDateFormat("EEE, d MM hh:mm aaa", Locale.getDefault())

    // Dateオブジェクトを指定された形式の文字列に変換して返す
    return format.format(date)
}

Converter.kt

class DateConverter {
    // Date型をLong型のタイムスタンプに変換する関数
    @TypeConverter
    fun timeStampFromDate(date: Date): Long {
        return date.time // Dateオブジェクトをミリ秒単位のタイムスタンプに変換
    }

    // Long型のタイムスタンプをDate型に変換する関数
    @TypeConverter
    fun dateToTimeStamp(timeStamp: Long): Date? {
        return Date(timeStamp) // タイムスタンプをDateオブジェクトに変換
    }
}

class UUIDConverter {
    // UUID型をString型に変換する関数
    @TypeConverter
    fun fromUUID(uuid: UUID): String? {
        return uuid.toString() // UUIDオブジェクトを文字列に変換
    }

    // String型をUUID型に変換する関数
    @TypeConverter
    fun toUUID(uuidString: String?): UUID? {
        return UUID.fromString(uuidString) // 文字列をUUIDオブジェクトに変換
    }
}

Database管理ファイル系

NoteDatabase.kt

Noteエンティティを扱うためのデータベースを定義し、データへのアクセスを提供するファイル。

TypeConvertersとNoteDatabaseDaoでエラーが出ますが以下で作成するので一旦スルーでokです。

@Database(entities = [Note::class], version = 1, exportSchema = false)
@TypeConverters(DateConverter::class, UUIDConverter::class)
abstract class NoteDatabase: RoomDatabase() {
	abstract fun noteDao(): NoteDatabaseDao
}

NoteDatabaseDao

データベース(倉庫)に対して、ノート(情報)を出し入れするための設計図(DAO)というイメージ。

notes_tblも以下で定義していくので一旦スルーでOK。

@Dao
interface NoteDatabaseDao {
	@Query("SELECT * FROM notes_tbl")
	fun getNotes(): Flow<List<Note>>

	@Query("SELECT * FROM notes_tbl WHERE id = :id")
	suspend fun getNoteId(id: String): Note

	@Insert(onConflict = OnConflictStrategy.REPLACE)
	suspend fun insert(note: Note)

	@Update(onConflict = OnConflictStrategy.REPLACE)
	suspend fun update(note: Note)

	@Query("DELETE FROM notes_tbl")
	suspend fun deleteAll()

	@Delete
	suspend fun deleteNote(note: Note)
}

DI (Dependency Injection)系

AppModule.kt

@InstallIn(SingletonComponent::class) // アプリケーションのライフサイクル全体でインスタンスを保持
@Module
object AppModule {

	@Singleton
	@Provides // 依存性を提供するための関数であることを示すアノテーション
	// NoteDatabaseからNoteDatabaseDaoを生成し、提供
	fun provideNotesDao(noteDatabase: NoteDatabase): NoteDatabaseDao = noteDatabase.noteDao()
	
	@Singleton
	@Provides
	fun provideAppDatabase(@ApplicationContext context: Context): NoteDatabase =
		Room.databaseBuilder(
			context, // アプリケーションのコンテキスト
			NoteDatabase::class.java, // データベースクラス
			"notes_db" // データベース名
		)
			.fallbackToDestructiveMigration() // スキーマ変更時にデータベースを再構築
			.build() // データベースインスタンスを生成し、提供
}

Repository系

NoteRepository

データベース操作の仲介ポジション。

class NoteRepository @Inject constructor(private val noteDatabaseDao: NoteDatabaseDao) {
	suspend fun addNote(note: Note) = noteDatabaseDao.insert(note = note)
	suspend fun updateNote(note: Note) = noteDatabaseDao.update(note)
	suspend fun deleteNote(note: Note) = noteDatabaseDao.deleteNote(note)
	suspend fun deleteAllNotes(note: Note) = noteDatabaseDao.deleteAll()
	fun getAllNotes(): Flow<List<Note>> = noteDatabaseDao.getNotes().flowOn(Dispatchers.IO).conflate()
}

Model

Note.kt

データベースとして扱えるようにtable名やprimarykeyなどを指定する。

@Entity(tableName = "notes_tbl")
data class Note(
	@PrimaryKey
	val id: UUID = UUID.randomUUID(),
	@ColumnInfo(name = "note_title")
	val title: String,
	@ColumnInfo(name = "note_description")
	val description: String,
	@ColumnInfo(name = "note_entryDate")
	val entryDate: Date = Date.from(Instant.now())
)

ViewModel

NoteViewModel.kt

@HiltViewModel // HiltにViewModelであることを伝えるアノテーション
class NoteViewModel @Inject constructor(private val repository: NoteRepository): ViewModel() {

    private val _noteList = MutableStateFlow<List<Note>>(emptyList()) // ノートのリストを保持するMutableStateFlow
    val noteList = _noteList.asStateFlow() // 外部からの変更を防ぐために、asStateFlowで公開

    init {
        // ViewModelの初期化時に実行される処理
        viewModelScope.launch(Dispatchers.IO) {
            // IOスレッドでデータベースからノートのリストを取得
            repository.getAllNotes()
                .distinctUntilChanged() // 同じリストが連続して流れてくるのを防ぐ
                .collect { listOfNotes ->
                    // 取得したノートのリストをcollectで処理
                    if (listOfNotes.isEmpty()) {
                        // リストが空の場合、ログを出力
                        Log.d("Empty", "Empty List")
                    } else {
                        // リストが空でない場合、_noteListの値を更新
                        _noteList.value = listOfNotes
                    }
                }
        }
    }

    // ノートを追加する関数
    fun addNote(note: Note) = viewModelScope.launch {
        repository.addNote(note) // リポジトリのaddNote関数を呼び出し
    }

    // ノートを更新する関数
    fun updateNote(note: Note) = viewModelScope.launch {
        repository.updateNote(note) // リポジトリのupdateNote関数を呼び出し
    }

    // ノートを削除する関数
    fun removeNote(note: Note) = viewModelScope.launch {
        repository.deleteNote(note) // リポジトリのdeleteNote関数を呼び出し
    }
}

View

MainActivity.kt

@AndroidEntryPoint // HiltにこのActivityで依存性注入を行うことを伝えるアノテーション
class MainActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContent {
			MyNoteAppTheme {
				Surface(modifier = Modifier.fillMaxSize()) {
					val noteViewModel = viewModel<NoteViewModel>()
					NotesApp(noteViewModel = noteViewModel)
				}
			}
		}
	}
}

@Composable
private fun NotesApp(noteViewModel: NoteViewModel) {
	val notesList = noteViewModel.noteList.collectAsState().value
	NoteScreen(
		notes = notesList,
		// 追加処理
		onAddNote = {
			noteViewModel.addNote(it)
		},
		// 削除処理
		onRemoveNote = {
			noteViewModel.removeNote(it)
		}
	)
}

NoteScreen.kt

日付表示部分のTextを以下に置き換え

Text(
     text = formatDate(note.entryDate.time),
     style = MaterialTheme.typography.bodySmall
)

これでビルドを行い、データの取得 / 追加 / 削除ができることを確認。

まとめ

それぞれの機能の役割をポケモンに例えてみる。

Room Database: ポケモンセンター

  • ポケモンセンターは、ポケモンを預かったり、回復させたり、道具を管理したりする場所です。Room Databaseも、アプリのデータを永続的に保存し、管理する役割を担います。

Data Access Objects (DAO): ジョーイさん

  • ジョーイさんは、ポケモンセンターでポケモンの回復や道具の受け渡しなど、トレーナーとポケモンセンターの仲介役を務めます。DAOも、アプリとデータベースの間に立ち、データの読み書きなどの操作を仲介します。

Entities: ポケモン

  • ポケモンは、それぞれ固有のデータ(名前、種類、技など)を持っています。Entitiesも、アプリで扱うデータの構造を定義し、各データの属性(フィールド)を保持します。

Rest of The App: トレーナー

  • トレーナーは、ポケモンと共に冒険し、様々な指示を出します。Rest of The Appも、アプリの主要な機能を提供し、ユーザーの操作に応じてデータを操作します。