araki tech

for developers including me

Android Kotlin日本語チュートリアル-⑤Roomを使ってデータを管理する

Android Kotlin日本語チュートリアル

Android Kotlin日本語チュートリアル

本連載記事はこれからAndroidアプリ開発を始める人に向けたチュートリアルです。

コンセプトは

  • プログラミングをあまり知らない人でも完走できる
  • プログラミングにある程度詳しい人にも満足できる
  • 実用的な知識を提供する
  • とにかくわかりやすく

で、全9回と長めですが頑張っていきましょう。

このチュートリアルを終える頃には、Android開発の土台が形成されているだけでなくアプリケーションアーキテクチャの知識が出来上がっているはずです。

作成するのは以下のようなメモアプリです。

Android Kotlin日本語チュートリアル

完成品は HiroshiARAKI/AndroidKotlinTutrialで公開していますので適宜参考にしてください。

第5回 : Roomでデータを管理する

第5回は、前回お話ししたAndroid推奨アーキテクチャのうちRepositoryとModelについて実装していきます。

Android Kotlin日本語チュートリアルAndroid Kotlin日本語チュートリアル

AndroidではRepositoryをデータベースとの境界面とし、データベース自体はRoomDatabaseと呼ばれる機能を使って実装されることが多いです。

早速やっていきますが、まずは少しだけ下準備を。

NewMemoFragmentを整備する

NewMemoFragmentを使って学んでいくので、まずは何もないUIを作っていきます。

このFragmentではその名の通り、新しいメモ作成を担います。

まずはレイアウトを決めましょう。

レイアウトを決定する際に、せっかくなのでリソースファイルの一種 @dimen を使ってみます。

res > values内に新たにdimenリソースファイルを選択してパラメータを決めましょう。

Android Kotlin日本語チュートリアルAndroid Kotlin日本語チュートリアル

layout_params.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="new_memo_edit_width">250dp</dimen>
    <dimen name="new_memo_label_width">80dp</dimen>
    <dimen name="new_memo_contents_height">200dp</dimen>
</resources>
fragment_new_memo.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:background="#F6F6E9"
    android:clickable="true">

    <TextView
        android:id="@+id/new_memo_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/new_memo_text"
        android:textSize="20sp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@id/new_memo_guideline_1"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/new_memo_guideline_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.15"/>

    <EditText
        android:id="@+id/new_memo_title"
        android:layout_width="@dimen/new_memo_edit_width"
        android:layout_height="wrap_content"
        android:inputType="text"
        app:layout_constraintTop_toBottomOf="@id/new_memo_guideline_1"
        app:layout_constraintStart_toEndOf="@id/new_memo_title_label"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toTopOf="@id/new_memo_contents" />

    <EditText
        android:id="@+id/new_memo_contents"
        android:layout_width="@dimen/new_memo_edit_width"
        android:layout_height="@dimen/new_memo_contents_height"
        android:inputType="textMultiLine"
        android:maxLines="10"
        android:background="#eeeeee"
        app:layout_constraintTop_toBottomOf="@id/new_memo_title"
        app:layout_constraintStart_toEndOf="@id/new_memo_contents_label"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="@id/new_memo_guideline_2"/>

    <TextView
        android:id="@+id/new_memo_title_label"
        android:layout_width="@dimen/new_memo_label_width"
        android:layout_height="wrap_content"
        android:text="@string/new_memo_title_label"
        android:textSize="18sp"
        app:layout_constraintTop_toBottomOf="@id/new_memo_guideline_1"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/new_memo_title"
        app:layout_constraintBottom_toTopOf="@id/new_memo_contents_label"/>

    <TextView
        android:id="@+id/new_memo_contents_label"
        android:layout_width="@dimen/new_memo_label_width"
        android:layout_height="@dimen/new_memo_contents_height"
        android:text="@string/new_memo_contents_label"
        android:textSize="18sp"
        app:layout_constraintTop_toBottomOf="@id/new_memo_title_label"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/new_memo_contents"
        app:layout_constraintBottom_toBottomOf="@id/new_memo_guideline_2"/>

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/new_memo_guideline_2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.8"/>

    <Button
        android:id="@+id/new_memo_add_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/new_memo_add_button"
        android:textSize="20sp"
        app:layout_constraintTop_toTopOf="@id/new_memo_guideline_2"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Android Kotlin日本語チュートリアルAndroid Kotlin日本語チュートリアル

最低限のレイアウトが定まりました。

データベース周りの話

それでは本題です。

Androidではデータベース周りは以下の4つのパーツに責務を分けて設計されることが多いです。

  • Entity
    • 一つ一つのデータ。
  • RoomDatabase
    • データベース (SQLite) を抽象化して扱いやすくする。
  • DAO
    • Data Access Objectのことで、データベースにアクセスする際のメソッドを管理する。(読み方は「だお」「でぃーえーおー」と読まれることが多い)
  • Repository
    • DAOを介して取得したデータを加工したり、上位モジュール (ViewModel) が扱いやすいメソッドを提供する。

Repositoryは、DAOのメソッド受け流しになることもあるので省略される場合もありますが、今回はRepositoryも含めて実装していきます。

先に以下のパッケージの依存関係をbuild.gradle (Module)に追記しておきます。

apply plugin: 'kotlin-kapt'

dependencies {
    def room_version = "2.3.0"

    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    implementation 'androidx.core:core-ktx:1.6.0'
    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'com.google.android.material:material:1.4.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    implementation 'androidx.fragment:fragment-ktx:1.3.6'
    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

Entityを設定する

Roomでdata classをEntityとして扱うには、いくつかアノテーションを付与する必要があります。

今回はMemoクラスをそのままEntityとしてしまいます。

@Entity
data class Memo(
    @PrimaryKey(autoGenerate = true) val id: Int,
    @ColumnInfo(name = "title") val title: String,
    @ColumnInfo(name = "contents") val contents: String,
    @ColumnInfo(name = "created_time_millis") val createdTimeMillis: Long,
    @ColumnInfo(name = "update_time_millis") val updateTimeMillis: Long,
    @ColumnInfo(name = "expire_time_millis") val expireTimeMillis: Long
) {

    // ...

@PrimaryKeyの引数 autoGeneratetrueにしておくと自動生成し一意のキーを自動発行してくれます。

@ColumnInfoはデータベースで扱う名前で、未指定の場合プロパティ名がそのまま使われます。

通常、SQLではsnake_caseでの命名ですので、指定したほうが良いです。

上記コードではカラム名がハードコードしましたが、以下のように定数化しておくのが無難です。

@Entity
data class Memo(
    @PrimaryKey(autoGenerate = true) val id: Int,
    @ColumnInfo(name = TITLE) val title: String,
    @ColumnInfo(name = CONTENTS) val contents: String,
    @ColumnInfo(name = CREATED_TIME) val createdTimeMillis: Long,
    @ColumnInfo(name = UPDATE_TIME) val updateTimeMillis: Long,
    @ColumnInfo(name = EXPIRE_TIME) val expireTimeMillis: Long
) {
    companion object {
        const val TITLE = "title"
        const val CONTENTS = "contents"
        const val CREATED_TIME = "created_time_millis"
        const val UPDATE_TIME = "update_time_millis"
        const val EXPIRE_TIME = "expire_time_millis"
        
        // ...

DAOを作成する

次にDAOを実装しましょう。

まずは新しくmodel/ディレクトリを作成してそこでDAOとRoomDatabaseを管理するようにしましょう。

そのパッケージ内にMemoDaoを作成します。

DAOはinterfaceで定義しておいて、実装はAndroidのフレームワーク側で作らせる形になります

import androidx.room.Dao

@Dao
interface MemoDao {
}

Entity同様にアノテーションでDAOを指定します

次に、挿入・取得・削除のメソッドを作ってみます。

@Dao
interface MemoDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertMemo(memo: Memo)

    @Query("SELECT * FROM memo")
    fun fetchAll(): List<Memo>

    @Delete
    fun deleteMemo(memo: Memo)
}

OnConflictStrategy.REPLACEは、同じデータが存在していた場合入れ替えるという指定で、未指定の場合はOnConflictStrategy.ABORTです。

ABORTの場合は、入れ替えも挿入も行われずトランザクション自体が中断されます。

他にはOnConflictStrategy.IGNOREもあり、これはABORTと同じように、競合した際は何もされず無視されますが、トランザクション自体は中断されず、ただ無視するだけです。

@Insert では以下のようにIDを戻り値で返すこともでき、 IGNOREを指定している場合は-1を返してくれます。

@Insert(onConflict = OnConflictStrategy.REPLACE) 
fun insertMemo(memo: Memo): Int




RoomDatabaseを作る

DAOの準備ができたらRoomを使ってAndroidで扱うデータベースを作成します。

DAOと同様にmodel/パッケージ以下にMemoDatabaseを実装しましょう。

import androidx.room.Database
import androidx.room.RoomDatabase
import tech.araki.smartmemo.data.Memo

@Database(entities = [Memo::class], version = 1)
abstract class MemoDatabase : RoomDatabase() {
    abstract fun memoDao(): MemoDao
}

RoomDatabaseは抽象クラスで定義し、実体は後に紹介するビルダーで生成します。

ApplicationでRepositoryを管理する

Repositoryのインスタンス (参照)はアプリケーション全体で1つであるべきです。

毎回データベースをビルドしたり、後々Repositoryでキャッシュを管理したりする場合、色々なActivityやFragmentごとにRepositoryの参照をもつのはスマートではありません。

シングルトンでの定義も良いですが、データベースのビルドにはContextが必要なのでActivity/Fragmentから毎回Contextを渡す必要が出てきてしまいます。

ですので、Androidアプリ全体の根底となるApplicationクラスを独自で作りそこで管理することにしましょう

Repositoryは新しくrepository/ディレクトリを作成してそこで管理することにします。

  • Memo: Dagger Hiltを使ってContextをDI (Dependency Injection) する方法もあります

まずはMemoRepositoryを作成しましょう。

import android.content.Context
import androidx.room.Room
import tech.araki.smartmemo.model.MemoDatabase

class MemoRepository(context: Context) {
    private val dao = Room.databaseBuilder(
        context,
        MemoDatabase::class.java,
        DATABASE_NAME
    ).build().memoDao()

    companion object {
        const val DATABASE_NAME = "memo"
    }
}

まだメソッドは何もないですが、とりあえずRoomDatabaseをビルドしてDAOにアクセスできるようにしておきます。

そうしたら次にパッケージのルート (Activityと同階層) に新しくApplicationを継承したAppを作成します。

そこで、MemoRepositoryの参照を管理します。

import android.app.Application
import tech.araki.smartmemo.repository.MemoRepository

class App : Application() {
    /**
     * [MemoRepository]のインスタンス
     */
    val memoRepository: MemoRepository by lazy { MemoRepository(this) }
}

ちなみに by lazy は遅延初期化で、初めて呼ばれたタイミングで初期化が行われます。

アプリ立ち上げ時には無駄な処理は省きたいのでこうしておきましょう。

最後に、自作のApplicationを経由してからアプリが立ち上がるようにAndroidManifests.xmlに追記します。

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

    <application
        android:name=".App"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.SmartMemo">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

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

</manifest>

NewMemoViewModelを作りMemoRepositoryを挿入する

次に、MemoRepositoryを挿入する対象になる、NewMemoViewModelを作成します。

が、ViewModelに引数で何かを挿入するには、Factoryクラスの作成が必要になります。

この辺は少しややこしいので「こういうものなんだな」と思ってもらってOKです。

一応コメントを簡単につけておきます。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import tech.araki.smartmemo.repository.MemoRepository

class NewMemoViewModel(private val memoRepository: MemoRepository) : ViewModel() {

}

/**
 * [NewMemoViewModel]を生成する独自のFactoryクラス
 */
class NewMemoViewModelFactory(
    private val memoRepository: MemoRepository
    ) : ViewModelProvider.Factory {
    
    @Suppress("UNCHECKED_CAST")  // `as T` のWarningを抑制するアノテーション。無くても実行に影響はない
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        // modelClassがNewMemoViewModelの親クラスであれば
        if (modelClass.isAssignableFrom(NewMemoViewModel::class.java))
            return NewMemoViewModel(memoRepository) as T
        
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

補足で、Kotlinの ::class.java はそのクラスの参照を取得する時に使う特別な記法です。

まだNewMemoViewModelの中身は何も実装していませんが、これを使ってNewMemoFragmentでViewModelを初期化しましょう。

class NewMemoFragment : Fragment(R.layout.fragment_new_memo) {
    private val viewModel: NewMemoViewModel by viewModels {
        NewMemoViewModelFactory((requireActivity().application as App).memoRepository)
    }
    
}

applicationの参照はActivityが持っているので、requireActivity()を経由しています。

手順がたくさんありましたが、これでNewMemoViewModelMemoRepositoryが使えるようになりました。

新しいMemoを発行できるようにする

土台は整いました。

早速、Repositoryを実装して使ってみましょう。

MemoRepository.kt
class MemoRepository(context: Context) {
    private val dao = Room.databaseBuilder(
        context,
        MemoDatabase::class.java,
        DATABASE_NAME
    ).build().memoDao()

    /**
     * MemoをDBに挿入する
     * @param title Memoのタイトル
     * @param contents Memoの内容
     * @param expiredDuration 廃棄するまでの期間
     */
    fun insertMemo(title: String, contents: String, expiredDuration: Long) {
        val currentTime = System.currentTimeMillis()
        dao.insertMemo(
            Memo(
                id = 0,  // autoGenerateの場合は0を指定
                title = title,
                contents = contents,
                createdTimeMillis = currentTime,
                updateTimeMillis = currentTime,
                expireTimeMillis = currentTime + expiredDuration
            )
        )
    }

    // ...
NewMemoViewModel.kt
class NewMemoViewModel(private val memoRepository: MemoRepository) : ViewModel() {

    private val _insertEvent = MutableLiveData<Unit>()
    /** DB挿入イベントLiveData */
    val insertEvent: LiveData<Unit> = _insertEvent

    /**
     * Memoを登録する
     */
    fun registerMemo(
        title: String,
        contents: String,
        expireDuration: Long = TimeUnit.DAYS.toMillis(7)) {
        viewModelScope.launch(Dispatchers.IO) {
            memoRepository.insertMemo(title, contents, expireDuration)
            _insertEvent.postValue(Unit)  // UIスレッドはpostValueで
        }
    }
}

とりあえず、破棄するまでの期間は固定しています。

あとは挿入イベント insertEvent が発生したら、Fragmentを閉じるようにします。

NewMemoFragment.kt
class NewMemoFragment : Fragment(R.layout.fragment_new_memo) {
    private val viewModel: NewMemoViewModel by viewModels {
        NewMemoViewModelFactory((requireActivity().application as App).memoRepository)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        view.findViewById<Button>(R.id.new_memo_add_button).setOnClickListener {
            val title = view.findViewById<EditText>(R.id.new_memo_title).text.toString()
            val contents = view.findViewById<EditText>(R.id.new_memo_contents).text.toString()

            // タイトルが空ならばエラー処理
            // 本体ならばUI上で何かしら文言を表示するが割愛
            if (title.isEmpty())
                return@setOnClickListener

            viewModel.registerMemo(title, contents)
        }

        // DB挿入されたらFragmentを閉じる
        viewModel.insertEvent.observe(viewLifecycleOwner) { dismiss() }
    }

    private fun dismiss() {
        parentFragmentManager.popBackStack()
    }
}

これでデータベースへの挿入が可能になりました。

しかしまだその結果が確認できませんね。

それはまた次回ということで…。

第5回のまとめ

  • Androidのモデル層は以下の4つのパートで構成される
    • Entity … データベースで扱うデータ単体
    • RoomDatabase … AndroidでSQLiteを扱いやすくするためのフレームワーク
    • DAO … データベースにアクセスするためのメソッドを提供
    • Repository … DAOとViewModelの仲介をする
  • Repository (またはデータベース) はアプリケーションにつき一つにする
    • その場合は独自のApplicationに参照を持たせることで解決できる
  • ViewModelに引数を渡す場合はViewModelFactoryを使う

おわりに

第5回はここで終わりになります。

大事なRoomDatabaseを使ったModel層の設計・実装を今回は学びました。

第5回までで学んだことを使えば、もう簡単なアプリは作れるようになっているはずです。

ですが、まだみなさんに知っておいてもらいたいAndroidの機能があるのでもう少し頑張りましょう!

Android Kotlin日本語チュートリアル

参考文献