iOSエンジニアのつぶやき

毎朝8:30に iOS 関連の技術について1つぶやいています。まれに釣りについてつぶやく可能性があります。

知識ゼロからの Kotlin Android アプリリリースへの軌跡 / Day19【Create a Room database編】

イントロダクション

ほとんどのアプリには、ユーザがアプリを閉じた後でも保持する必要のあるデータがあります。たとえば、アプリには、プレイリスト、ゲームアイテムの在庫、経費と収入の記録、星座のカタログ、または刑事的な睡眠データが保持される場合があります。通常、データベースを使用して永続データを保存します。

Room は、Android Jetpack の一部であるデータベースライブラリです。Room は、データベースのセットアップと構成の雑用を多く処理し、アプリが通常の関数呼び出しを使用してデータベースと対話できるようにします。内部的には、RoomSQLite データベースの上にある抽象化レイヤーです。Room の用語、および複雑なクエリのクエリ構文は、SQLite モデルに従います。

以下の画像は、Room データベースがこのコースで推奨される全体的なアーキテクチャにどのように適合するかを示しています。

学ぶこと

  • Room データベースを作成して操作し、データを永続化する方法
  • データベース内のテーブルを定義するデータクラスを作成する方法
  • データアクセスオブジェクト(DAO)を使用して Kotlin 関数を SQL クエリにマップする方法
  • データベースが機能しているかどうかをテストする方法

すること

  • 夜間の睡眠データ用のインターフェースを備えた Room データベースを作成する
  • 提供されているテストを使用してデータベースをテストする

アプリ概要

このコードラボでは、睡眠の質を追跡するアプリのデータベース部分を構築します。アプリはデータベースを使用して、睡眠データを経時的に保存します。

次の図に示すように、アプリにはフラグメントで表される2つの画面があります。

左側に表示されている最初の画面には、追跡を開始および停止するためのボタンがあります。画面には、全てのユーザの睡眠データが表示されます。Clear ボタンは、アプリがユーザーのために収集した全てのデータを完全に削除します。

左側に表示されている2番目の画面は、睡眠の質の評価を選択するためのものです。アプリでは、評価は数値で表されます。開発の目的で、アプリは顔のアイコンとそれに相当する数値の両方を表示します。

ユーザーのフローは次の通りです:

  • ユーザーがアプリを開くと、睡眠追跡画面が表示されます。
  • ユーザーが Start ボタンをタップします。開始時刻を記録して表示します。Start ボタンが無効になり、Stop ボタンが有効になります。
  • ユーザーが Stop ボタンをタップします。これにより、終了時刻が記録され、睡眠品質画面が開きます。
  • ユーザーが睡眠品質アイコンを選択します。画面が閉じ、追跡画面に睡眠終了時間と睡眠の質が表示されます。Stop ボタンが無効になり、Start ボタンが有効になります。アプリは別の夜の準備ができています。
  • データベースにデータがある場合は常に、Clear ボタンが有効になります。ユーザが Clear ボタンをタップすると、全てのデータが消去されます。"Are you sure?" というメッセージは表示されません。

このアプリは、完全なアーキテクチャのコンテキストで以下に示すように、簡略化されたアーキテクチャを使用します。アプリは次のコンポーネントのみを使用します。

  • UI controller
  • ViewModel と LiveData
  • Room database

スターターアプリをダウンロードして検査する

スターターアプリをダウンロードして実行します

  1. TrackMySleepQuality アプリを Github からダウンロードします。

  2. アプリをビルドして実行します。アプリは SleepTrackerFragment fragment の UI を表示しますが、データは表示しません。ボタンはタップに反応しません。

スターターアプリを検査します

Tip: スターターアプリに精通していると、問題が発生した場合に、問題を特定して修正するのが簡単になります。

  1. Gradle ファイルを見てください:

  2. The project Gradle file プロジェクトレベルの build.gradle ファイルで、ライブラリのバージョンを指定する変数に注目してください。スターターアプリで使用されるバージョンは一緒にうまく機能し、このアプリでうまく機能します。このコードラボを終了するまでに、AndroidStudio から一部のバージョンの更新を求めるメッセージが表示される場合があります。アプリにあるバージョンを更新するか、そのままにするかはあなた次第です。奇妙なコンパイルエラーが発生した場合は、最終的な solution app がしようするライブラリバージョンの組み合わせを使用して見てください。

  3. The module Gradle file Room を含む全ての Android Jetpack ライブラリに提供されている依存関係と、コルーチンの依存関係に注意してください。

  4. packages と UI を見てください。アプリには機能によって構成されています。パッケージには、この一連のコードラボ全体でコードを追加するプレースホルダーファイルが含まれています。

  5. Room データベースに関連する全てのコード用の database package

  6. sleepquality および sleeptracker パッケージには、各画面の fragment、view、view model および view model factory が含まれています。

  7. 睡眠品質データの表示に役立つ関数が含まれている Util.kt ファイルを見てください。一部のコードは、あとで作成する view model を参照しているため、コメントアウトされています。

  8. androidTest フォルダー(SleepDatabaseTest.kt) を見てください。このテストを使用して、データベースが意図した通りに機能することを確認します。

SleepNight Entity を作成します

Android では、データはデータクラスで表され、関数呼び出しを使用してデータにアクセスして変更します。ただし、データベースの世界では、entities と queries が必要です。

  • Entity は、データベースに格納するオブジェクトまたは概念、およびそのプロパティを表します。Entity クラスはテーブルを定義し、そのクラスの各インスタンスはテーブルの行を表します。各プロパティは列を定義します。アプリでは、Entity は睡眠の夜に関する情報を保持します。
  • クエリは、データベーステーブルまたはテーブルの組み合わせからのデータまたは情報の要求、またはデータに対してアクションを実行するための要求です。一般的なクエリは、Entity の取得、挿入、および更新です。例えば、開始時刻で並べ替えて、記録されている全ての睡眠の夜を照会できます。

Room は、Kotlin データクラスから SQLite テーブルに格納できる Entity に、そして関数宣言から SQLクエリにいたるまで、全ての大変の作業を行います。

各 Entity を注釈付きデータクラスとして定義し、相互作用を注釈付きインターフェイスであるデータアクセスオブジェクト(DAO)として定義する必要があります。Room は、これらの注釈付きクラスを使用して、データベースにテーブルを作成し、データベースに作用するクエリを作成します。

SleepNight Entity を作成します

このタスクでは、1日の睡眠を 注釈付きデータクラスとして定義します。

一晩の睡眠のために、開始時間、終了時間、そして品質評価を記録する必要があります。

また、夜を一意に識別するための ID が必要です。

  1. database パッケージで、SleepNight.kt ファイルを見つけて開きます。

  2. ID、開始時間、終了時間、および数値の睡眠品質評価のパラメータを使用して、SleepNight データクラスを作成します。

  3. sleepQuality を初期化する必要があるため、-1 に設定して、品質データが収集されていないことを示します。

  4. また、終了時刻を初期化する必要があります。開始時刻に設定すると、終了時刻がまだ記録されていないことを通知します。
data class SleepNight(
       var nightId: Long = 0L,
       val startTimeMilli: Long = System.currentTimeMillis(),
       var endTimeMilli: Long = startTimeMilli,
       var sleepQuality: Int = -1
)
  1. クラスの宣言の前に、データクラスに @Entity アノテーションを付けます。テーブルに daily_sleep_quality_table という名前を付けます。tableName の引数はオプションですが、推奨されます。ドキュメントで他の引数を調べることができます。

プロンプトが表示されたら、androidx ライブラリから Entity と他の全ての注釈をインポートします。

@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(...)
  1. nightId を主キーとして識別するには、nightId プロパティに @PrimaryKey アノテーションを付けます。Room が各 Entity の ID を生成するように、パラメータに autoGeneratetrue に設定します。これにより、各夜の ID が一意であることが保証されます。
@PrimaryKey(autoGenerate = true)
var nightId: Long = 0L,...
  1. 残りのプロパティに @ColumnInfo アノテーションを付けます。以下に示すように、パラメータを使用してプロパティ名をカスタマイズします。
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(
       @PrimaryKey(autoGenerate = true)
       var nightId: Long = 0L,

       @ColumnInfo(name = "start_time_milli")
       val startTimeMilli: Long = System.currentTimeMillis(),

       @ColumnInfo(name = "end_time_milli")
       var endTimeMilli: Long = startTimeMilli,

       @ColumnInfo(name = "quality_rating")
       var sleepQuality: Int = -1
)
  1. コードをビルドして実行し、エラーがないことを確認します。

DAO を作成する

このタスクでは、data access object(DAO)を定義します。Android では、DAO はデータベースを挿入、削除、および更新するための便利なメソッドを提供します。

Room データベースを使用する場合は、コードで Kotlin 関数を定義して呼び出すことにより、database に query を実行します。これらの Kotlin 関数は SQL クエリにマップされます。annotations を使用して DAO でこれらのマッピングを定義すると、Room が必要なコードを作成します。

DAO は、database にアクセスするためのカスタムインターフェースを定義するものと考えてください。

一般的な database 操作の場合、Room ライブラリは、@Insert@Delete@Update などの便利なアノテーションを提供します。それ以外の場合は、@Query アノテーションがあります。SQLite でサポートされている任意のクエリを記述できます。

追加のボーナスとして、Android Studio でクエリを作成すると、コンパイラSQL クエリ構文エラーをチェックします。

sleep-tracker データベースの場合、次のことができる必要があります。

  • Insert new nights を挿入します
  • Update 既存の夜の終了時間と品質を更新する
  • Get キーに基づいて特定の夜を取得する
  • Get all nights
  • Get the most recent night
  • Delete データベース内の全てのエントリを削除する

SleepDatabaseDAO を作成する

  1. database package で、SleepDatabaseDao.kt を開きます。
  2. interface SleepDatabaseDao@Dao アノテーションが付けられていることに注意してください。全ての DAO には、@Dao キーワードのアノテーションをつける必要があります。
@Dao
interface SleepDatabaseDao{}
  1. インターフェース内に @Insert アノテーションを追加します。@Insert の下に、Entity クラス SleepNightインスタンスを引数として取る insert() 関数を追加します。

Room は、SleepNight をデータベースに挿入するために必要な全てのコードを生成します。Kotlin コードから insert() を呼び出すと、RoomSQLクエリを実行して、Entity をデータベースに挿入します。(Note: 関数は任意に呼び出すことができます。)

@Insert
fun insert(night: SleepNight)
  1. 1つの SleepNightupdate() 関数を使用して @Update アノテーションを追加します。更新される Entity は、渡されたものと同じキーを持つ Entity です。Entity は他のプロパティの一部または全てを更新できます。
@Update
fun update(night: SleepNight)

残りの機能には便利なアノテーションがないため、@Query アノテーションを使用して SQLite クエリを提供する必要があります。

  1. Long キー引数をとり、nullable の SleepNight を返す get() 関数を @Query アノテーションと定義します。パラメータがない場合はエラーが表示されます。
@Query
fun get(key: Long): SleepNight?
  1. query は、annotation への文字列パラメータとして提供されます。@Query にパラメータを追加します。SQLite クエリである文字列にします。

  2. daily_sleep_quality_table から全ての列を選択します

  3. WHERE the naightIdkey 引数と一致する

:key に注目してください。クエリでコロン表記を使用して、関数の引数を参照します。

("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
  1. clear 関数と SQLite クエリを使用して別の @Query を追加し、daily_sleep_quality_table から全てを DELETE します。このクエリはテーブル自体を削除しません。

@Delete アノテーションは1つのアイテムを削除し、@Delete を使用して、削除する夜のリストを指定できます。欠点は、テーブルの内容をフェッチまたは知っている必要があることです。@Delete アノテーションは特定のエントリを削除するのに最適ですが、テーブルから全てのエントリをクリアするには効率的ではありません。

@Query("DELETE FROM daily_sleep_quality_table")
fun clear()
  1. getTonight() 関数を使用して @Query を追加します。getTonight() によって返される SleepNight を nullable にして、テーブルが空の場合に関数が処理できるようにします。(テーブルは最初とデータがクリアされた後は空です。)

データベースから "tonight" を取得するには、nightId で降順に並べ替えられた結果のリストの最初の要素を返す SQLite クエリを記述します。LIMIT 1 を使用して、1つの要素のみを返します。

@Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
fun getTonight(): SleepNight?
  1. getAllNights() 関数を使用して @Query を追加します。

  2. SQLite クエリが daily_sleep_quality_table から全ての列を降順で返すようにします。

  3. getAllNights()SleepNight Entity のリストを LiveData として返すようにします。Room は、この LiveData を更新し続けます。つまり、データを明示的に取得する必要があるのは一回だけです。
  4. androidx.lifecycle.LiveData から LiveData をインポートする必要がある場合があります。
@Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
fun getAllNights(): LiveData<List<SleepNight>>
  1. 目に見える変化は見られませんが、アプリを実行してエラーがないことを確認してください。

Room データベースを作成してテストする

このタスクでは、前のタスクで作成した Entity と DAO を使用する Room データベースを作成します。

@Database アノテーションが付けられた抽象データベースホルダークラスを作成する必要があります。このクラスには、データベースが存在しない場合にデータベースのインスタンス を作成するか、既存のデータベースへの参照を返すメソッドが1つあります。

Room データベースの取得は少し複雑なので、コードを開始する前の一般的なプロセスは次の通りです:

  • RoomDatabase を拡張する public abstract クラスを作成します。このクラスは、データベースホルダーとして機能します。Room が実装を作成するため、クラスは抽象的です。
  • クラスに @Database アノテーションを付けます。引数で、database の Entity を宣言し、version number を設定します。
  • companion オブジェクト内で、SleepDatabaseDao を返す abstract method または property を定義します。Room は body を生成します。
  • アプリ全体で必要な Room データベースのインスタンスは1つだけなので、RoomDatabase をシングルトンにします。
  • database が存在しない場合にのみ、Room の database builder を使用してデータベースを作成します。それ以外の場合は、既存のデータベースを返します。

Tip: コードはどの Room データベースでもほぼ同じであるため、このコードをテンプレートとして使用できます。

Database を作成する

  1. database package で、SleepDatabase.kt を開きます。
  2. ファイルに、RoomDatabase を拡張する SleepDatabase という抽象クラスを作成します。

クラスに @Database アノテーションをつける。

@Database()
abstract class SleepDatabase : RoomDatabase() {}
  1. Entity と version prameter が欠落しているとエラーが表示されます。Room がデータベースを構築できるように、@Database アノテーションにはいくつかの引数が必要です。

  2. Entity のリストを持つ唯一のアイテムとして SleepNight を提供します。

  3. version1 に設定します。schema を変更するたびに、version number を増やす必要があります。
  4. Schema の version history のバックアップを保持しないように、exportSchemafalse に設定します。
entities = [SleepNight::class], version = 1, exportSchema = false
  1. データベースは DAO について知る必要があります。クラス内で、SleepDatabaseDao を返す抽象値を宣言します。複数の DAO を持つことができます。
abstract val sleepDatabaseDao: SleepDatabaseDao
  1. その下に、companion オブジェクトを定義します。companion オブジェクトを使用すると、クライアントはクラスをインスタンス化せずにデータベースを作成または取得するためのメソッドにアクセスできます。このクラスの唯一の目的はデータベースを提供することであるため、データベースをインスタンス化する理由はありません。
companion object {}
  1. companion オブジェクト内で、データベースの private nullable 変数 INSTANCE を宣言し、それを null で初期化します。INSTANCE 変数は、データベースが作成されると、データベースへの参照を保持します。これにより、コストのかかるデータベースへの接続を繰り返し開くことを回避できます。

INSTANCE@Volatile アノテーションを付けます。揮発性変数の値がキャッシュされることはなく、全ての書き込みと読み取りはメインメモリとの間で行われます。これにより、INSTANCE の値が常に最新であり、全ての実行スレッドで同じであることを確認できます。つまり、1つのスレッドによって INSTANCE に加えられた変更は、他の全てのスレッドにすぐに表示され、例えば、2つのスレッドがそれぞれキャッシュ内の同じ Entity を更新して問題が発生するという状況は発生しません。

@Volatile
private var INSTANCE: SleepDatabase? = null
  1. INSTANCE の下で、companion オブジェクト内に、データベースビルダーが必要とする Context パラメーターを使用して getInstance() メソッドを定義します。SleepDatabase を返します。getInstance() がまだ何も返さないため、エラーが表示されます。
fun getInstance(context: Context): SleepDatabase {}
  1. getInstance() 内に、synchronized{} ブロックを追加します。これを渡して、context にアクセスできるようにします。

複数のスレッドが同時にデータべースインスタンスを要求する可能性があり、その結果、データベースが1つではなく2つになります。このサンプルアプリではこの問題が発生する可能性は低いですが、より複雑なアプリでは発生する可能性があります。データベースを同期するためにコードをラップするということは、一度に1つの実行スレッドのみがこのコードのブロックに入ることができることを意味します。これにより、データベースが一回だけ初期化されるようになります。

synchronized(this) {}
  1. 同期されたブロック内で、 INSTANCE の現在の値をローカル変数インスタンスにコピーします。これは、ローカル変数でのみ使用できる smart cast を利用するためです。
var instance = INSTANCE
  1. synchronized ブロック内で、synchronized ブロックの最後に return instance します。
return instance
  1. return ステートメントの上に if ステートメントを追加して、instancenull であるかどうか、つまりデータベースがまだないかどうかを確認します。
if (instance == null) {}
  1. instancenull の場合は、データベースビルダーを使用してデータベースを使用してデータベースを取得します。if ステートメントの body で、Room.databaseBuilder を呼び出し、渡したコンテキスト、データベースクラス、およびデータベースの名前 sleep_history_database を指定します。
instance = Room.databaseBuilder(
                           context.applicationContext,
                           SleepDatabase::class.java,
                           "sleep_history_database")
  1. 必要な migration strategy をビルダーに追加します。.fallbackToDestructiveMigration() を使用します。

通常、スキーマが変更された場合の migration strategy を移行オブジェクトに提供する必要があります。移行オブジェクトは、古いスキーマの全ての行を取得し、それらを新しいスキーマの行に変換して、データが失われないようにする方法を定義するオブジェクトです。Migration はこのコードラボの範囲を超えています。簡単な解決策は、データベースを破棄して再構築することです。これは、データが失われることを意味しています。

.fallbackToDestructiveMigration()
  1. 最後に .build() を呼びます。
.build()
  1. if ステートメントないの最後のステップとして INSTANCE = instance を割り当てます。
INSTANCE = instance
  1. 最終的なコードは次のようになります:
@Database(entities = [SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase() {

   abstract val sleepDatabaseDao: SleepDatabaseDao

   companion object {

       @Volatile
       private var INSTANCE: SleepDatabase? = null

       fun getInstance(context: Context): SleepDatabase {
           synchronized(this) {
               var instance = INSTANCE

               if (instance == null) {
                   instance = Room.databaseBuilder(
                           context.applicationContext,
                           SleepDatabase::class.java,
                           "sleep_history_database"
                   )
                           .fallbackToDestructiveMigration()
                           .build()
                   INSTANCE = instance
               }
               return instance
           }
       }
   }
}
  1. コードをビルドして実行します。

これで、Room データベースを操作するための全てのビルディングブロックができました。このコードはコンパイルされて実行されますが、実際に機能するかどうかを判断する方法はありません。したがってこれはいくつかの基本的なテストを追加する良い機会です。

SleepDatabase をテストする

このステップでは、提供されたテストを実行して、データベースが機能することを確認します。これにより、データベースを構築する前にデータベースが確実に機能するようになります。提供されているテストは基本的なものです。本番アプリの場合、全ての DAO で全ての機能とクエリを実行します。

スターターアプリには androidTest フォルダーが含まれています。この androidTest フォルダーには、Android インスチルメンテーションを含む単体テストが含まれています。これは、テストには Android フレームワークが必要であるため、物理デバイスまたは仮想デバイスでテストを実行する必要があることを示しています。もちろん、Android フレームワークを含まない純粋な単体テストを作成して実行することもできます。

  1. Android StudioandroidTest フォルダーで、SleepDatabaseTest ファイルを開きます。

  2. コードのコメントを解除します。

これは、再利用できる別のコードであるため、テストコードの簡単な説明です。

  • SleepDatabaseTest はテストクラスです。
  • @RunWith アノテーションは、テストをセットアップして実行するプログラムであるテストランナーを識別します。
  • セットアップ中に、@Before アノテーションが付けられた関数が実行され、SleepDatabaseDao を使用してメモリ内の SleepDatabase が作成されます。"In-memory" とは、このデータベースがファイルシステムに保存されず、テストの実行後に削除されることを意味します。
  • また、メインメモリデータベースを構築する場合、コードは別のテスト固有のメソッド allowMainThredQueries を呼び出します。デフォルトでは、メインスレッドでクエリを実行しようとするとエラーが発生します。このメソッドを使用すると、メインスレッドでテストを実行できます。これは、テスト中にのみ実行する必要があります。
  • @Test アノテーションが付けられたテストメソッドで、SleepNight を作成、挿入、取得し、それらが同じであることを表明します。何か問題が発生した場合は、例外をスローします。実際のテストでは、複数の @Test メソッドがあります。
  • テストが完了すると、@After アノテーションが付けられた関数が実行され、データベースが閉じられます。

  • テストファイルを右クリックして、Run 'SleepDatabaseTest' を選択します。

  • テストの実行後、SleepDatabaseTest ペインで、全てのテストをパスしたことを確認します。

全てのテストに合格したため、次のことが分かります。

  • database は正しく作成されます
  • SleepNight をデータベースに挿入できます
  • SleepNight を取得することができます
  • SleepNight の品質には正しい値があります

まとめ

  • テーブルを @Entity アノテーションが付けられたデータクラスとして定義します。@ColumnInfo アノテーションが付けられたプロパティをテーブルの列として定義します。
  • @Dao アノテーションが付けられたインターフェースとしてデータアクセスオブジェクト(DAO)を定義します。DAO は、Kotlin 関数をデータベースクエリにマップします。
  • アノテーションを使用して、@Insert@Delete および @Update 関数を定義します。
  • 他のクエリのパラメータとして、SQLite クエリ文字列と共に @Query アノテーションを使用します。
  • データベースを返す getInstance() 関数を持つ抽象クラスを返します。
  • instrumented 化されたテストを使用して、データベースと DAO が期待通りに機能していることをテストします。提供されているテストテンプレートとして使用できます。

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com