Android Kotlin Fundamentalsで学ぶ その6

19 min read読了の目安(約17400字

はじめに

この記事はGoogleが提供しているCodelabの中のAndroidを作りながら学ぶAndroid Kotlin Fundamentalsコースで学習した内容を自分用に残していくものです。間違っていることなどあればコメントをいただけるとありがたいです!

この記事について

その6では、Roomと言われるデータベースライブラリについて説明します。また、kotlinの子ルーチンを使ってデータベースの操作を行う方法を学びます。

6-1 Create a Room Database

今回は、TrackMySleepQuality-Starterを使います。

Room とは

Roomとは、SQLite全体を対象とする抽象化レイヤを提供し、SQLite を最大限に活用しつつ、スムーズなデータベースアクセスを可能にする。
そもそもAndroidはSQLiteをサポートしているが、直接SQLiteを操作するには労力がかかってしまう。そこでRoomライブラリを使うことでDB操作をしやすくしたものって感じかな?

Roomの導入

build.gradle(project)
dependencies {
  def room_version = "2.2.5"

  implementation "androidx.room:room-runtime:$room_version"
  kapt "androidx.room:room-compiler:$room_version"

  // optional - Kotlin Extensions and Coroutines support for Room
  implementation "androidx.room:room-ktx:$room_version"

  // optional - Test helpers
  testImplementation "androidx.room:room-testing:$room_version"
}

依存関係を追加する

主要コンポーネント

  • Database: データ永続化のため
  • エンティティ: データベース内のテーブルを示す
  • DAO: データベースにアクセスするためのメソッドを格納する

Imgur
コンポーネント間の関係はこんな感じ(google developers参照)

SleepNightエンティティを作成

SleepNight.kt

@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
)

アノテーションをつける

  • @Entity(tableName = ""): クラス宣言前につける。テーブルの名前を指定できる。
  • @PrimaryKey(autoGenerate=true/false): プライマリーキーを設定できる。autoGenerateをtrueにすることで各エンティティIDが生成されるため一意のIDとなる。
  • @ColumnInfo(name=""): プロパティ名を指定できる。

DAO(Data Access Object)の作成

DAOはデータベースにアクセスするためのカスタムインターフェースを定義するようなもの。アノテーションと関数をセットで定義する。

SleepDatabaseDao.kt
@Dao
interface SleepDatabaseDao {
    @Insert
    fun insert(night: SleepNight)

    @Update
    fun update(night: SleepNight)

    @Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
    fun get(key: Long): SleepNight?

    @Query("DELETE FROM daily_sleep_quality_table")
    fun clear()

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
    fun getTonight(): SleepNight?

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
    fun getAllNights(): LiveData<List<SleepNight>>
}

@Insert@Updateなどはアノテーションとして用意されているが、用意されていないクエリ操作は@Query("SQL文")で定義する。

RoomDBの作成

完成コードはこんな感じ

SleepDatabase.kt
@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
           }
       }
   }
}

@Databaseをアノテートした抽象クラスを作成する。その際、Roomがデータベースを構築するために引数を加える。

  • entity: エンティティ
  • version: バージョン。スキーマを増やすたびに増やす
  • exportSchema: スキーマのバージョン履歴のバックアップを保持するかどうか

companion objectを使うことでクライアントはクラスをインスタンス化することなくDBを作成、取得することができる。
@Volatileをアノテートすることで、INSTANCEの値が最新の情報になることを示す。書き込みと読み込みがメインメモリとの間で行われるためであるらしい。
private var INSTANCE: SleepDatabase? = nullとすることでDBへの参照を保持するため、接続を繰り返さなくてよくなる。
synchronized(this) {}とすることで、複数スレッドが同時にデータベースインスタンスを要求した場合、1度に1回のスレッドのみコードラップできるので、実行スレッドのみがコードをブロックできる。
そして、instanceがnullのときのみ、Room.databaseBuilderでインスタンスを作成する。

6-2 Coroutines and Room

このセッションでは、コルーチンを使ってDB操作をスレッドで行うことと、フォーマットされたデータをTextViewで表示すること。

SleepTrackerについてのViewModelの追加

  1. SleepTrackerViewModelを追加する
SleepTrackerViewModek.kt
class SleepTrackerViewModel(
        val database: SleepDatabaseDao,
        application: Application) : AndroidViewModel(application) {

}
  1. SleepTrackerViewModelFactoryを追加
SleepTrackerViewModelFactory.kt
class SleepTrackerViewModelFactory(
        private val dataSource: SleepDatabaseDao,
        private val application: Application) : ViewModelProvider.Factory {
    @Suppress("unchecked_cast")
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(SleepTrackerViewModel::class.java)) {
            return SleepTrackerViewModel(dataSource, application) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

  1. SleepTrackerFragmentを更新する
SleepTrackerFragment.kt
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {

        // Get a reference to the binding object and inflate the fragment views.
        val binding: FragmentSleepTrackerBinding = DataBindingUtil.inflate(
                inflater, R.layout.fragment_sleep_tracker, container, false)

        // 値がnullの場合、requireNotNullKotlin関数はIllegalArgumentExceptionをスローします。
        val application = requireNotNull(this.activity).application
        // Databaseからの参照を初期化
        val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
        // viewModelFactoryの初期化
        val viewModelFactory = SleepTrackerViewModelFactory(dataSource, application)
        // viewModelの参照を取得
        val sleepTrackerViewModel = ViewModelProvider(this, viewModelFactory).get(SleepTrackerViewModel::class.java)
        // lifecycleownerを定義
        binding.lifecycleOwner = this
        binding.sleepTrackerViewModel = sleepTrackerViewModel

        return binding.root
    }
  1. レイアウトにViewModelのバインディングを追加
fragment_sleep_tracker.xml
<data>
   <variable
       name="sleepTrackerViewModel"
       type="com.example.android.trackmysleepquality.sleeptracker.SleepTrackerViewModel" />
</data>

この作りは基本的にテンプレみたいだから他でも真似できる。

コルーチン

コルーチンとは、実行時間の長いタスクから結果が得られるまで待機し、実行を続けることができる機能。マルチスレッドっぽく動く。

  • 非同期で動作する
  • 非ブロッキングで動作する: メインスレッドまたはUIスレッドをブロックしないこと
  • サスペンド関数を使って、非同期コードをシーケンシャルにする: 結果が出るまでコルーチンは実行を一時停止し、途中から再開することができる。また、コルーチンが中断している間は実行中のスレッドのブロックを解除する。それで、他の関数やコルーチンが実行できる。

コルーチンで必要な要素

  • job
  • dispacher
  • scope
job

コルーチンをキャンセルできるもの。jobは親子階層に配置する。親jobがキャンセルされれば、子jobもキャンセルされる。つまり、手動でコルーチンをキャンセルするより簡単。

dispacher

スレッドで実行するコルーチンを送信する。

scope

コルーチンが実行されるコンテキストを定義する。コルーチンのjobとdispacherに関する情報を組み合わせたもの。また、コルーチンを追跡する。

kotlinで使うコルーチンアーキテクチャ

CoroutineScope: 全てのコルーチンを追跡することができる。
ViewModelScope: ViewModelごとに定義される。このスコープで起動されたコルーチンはViewModelがクリアされると自動的にキャンセルされる。

データの収集・表示

スタートボタン、ストップボタン、クリアボタンによる時間の収集・表示を行う。
その際、データベースでのデータのやり取りをするため、非同期処理が必要となる。今回はsuspendという機能を使って実装する。

suspend

Coroutineを中断可能な関数として定義できる。また、コルーチンないから呼び出す必要がある。

DAOにsuspendを追加する

SleepDatabaseDao.kt
@Dao
interface SleepDatabaseDao {
    @Insert
    suspend fun insert(night: SleepNight)

    @Update
    suspend fun update(night: SleepNight)

    @Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
    suspend fun get(key: Long): SleepNight?

    @Query("DELETE FROM daily_sleep_quality_table")
    suspend fun clear()

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
    suspend fun getTonight(): SleepNight?

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
    fun getAllNights(): LiveData<List<SleepNight>>
}

このように関数定義の先頭にsuspendを追記する。

睡眠時間のインスタンスを作成

SleepTrackerViewModel.kt
    # 時刻を保持
    private var tonight = MutableLiveData<SleepNight?>()
    # DBに保存されている全ての時刻を保持
    private val nights = database.getAllNights()
    val nightsString = Transformations.map(nights) { nights ->
        formatNights(nights, application.resources)
    }

    init {
        initializeTonight()
    }
    # 初期化
    private fun initializeTonight() {
        viewModelScope.launch {
            tonight.value = getTonightFromDatabase()
        }
    }

    private suspend fun getTonightFromDatabase(): SleepNight? {
        var night = database.getTonight()
        if (night?.endTimeMilli != night?.startTimeMilli) {
            night = null
        }
        return night
    }

ボタンリスナーの設定

  • start: 開始時刻を保存(ターゲット->tonight)
SleepTrackerViewModel.kt
    fun onStartTracking() {
        viewModelScope.launch {
            val newNight = SleepNight()
            insert(newNight)
            tonight.value = getTonightFromDatabase()
        }
    }
    private suspend fun insert(night: SleepNight) {
        database.insert(night)
    }
  • end: 終了時刻を保存(ターゲット->tonight)
SleepTrackerViewModel.kt
    fun onStopTracking(){
        viewModelScope.launch {
            val oldNight = tonight.value ?: return@launch
            oldNight.endTimeMilli = System.currentTimeMillis()
            update(oldNight)
        }
    }
    private suspend fun update(night: SleepNight){
        database.update(night)
    }
  • clear: DBにある時刻をクリア(ターゲット->nights)
SleepTrackerViewModel.kt
    fun onClear(){
        viewModelScope.launch {
            clear()
            tonight.value = null
        }
    }
    private suspend fun clear(){
        database.clear()
    }

データの表示

start, end, clearボタンを押した際のデータの表示・更新を行う
onClickをそれぞれ指定する

fragment_sleep_tracker.xml
<Button
            android:id="@+id/start_button"
            style="@style/SleepButtons"
	    ...
            android:onClick="@{() -> sleepTrackerViewModel.onStartTracking()}"/>

        <Button
            android:id="@+id/stop_button"
            style="@style/SleepButtons"
	    ...
            android:onClick="@{() -> sleepTrackerViewModel.onStopTracking()}"/>

        <Button
            android:id="@+id/clear_button"
            style="@style/SleepButtons"
	    ...
            android:onClick="@{() -> sleepTrackerViewModel.onClear()}" />

tips

  • ブロックと一時停止(中断)の違いは、スレッドがブロックされた時に、他の作業は発生しないこと。スレッドが中断されたら、結果が利用可能になるまで他の作業が行われること。
  • DBにある情報をUIへ反映させるためには、スレッドが必要である。

6-3 Use LiveData to control button states

ナビゲーションの追加

stopボタンを押したらSleepTrackerFragmentからSleepQualityFragmentへ遷移する処理を追加する。

SleepTrackerViewModel.kt
class SleepTrackerViewModel(...){
    // ナビゲーションをLiveDataとして監視する
    private val _navigateToSleepQuality = MutableLiveData<SleepNight>()
    val navigateToSleepQuality: LiveData<SleepNight>
        get() = _navigateToSleepQuality
    // トリガーを初期化
    fun doneNavigating() {
        _navigateToSleepQuality.value = null
    }
    // stopボタンを押したらナビゲーションで渡す時間を更新
    fun onStopTracking() {
        viewModelScope.launch {
	    ...
            _navigateToSleepQuality.value = oldNight
        }
    }
}

Fragment側でオブザーバを追加

SleepTrackerFragment.kt
override fun onCreateView(...){
    ...
    sleepTrackerViewModel.navigateToSleepQuality.observe(viewLifecycleOwner, Observer { night ->
            night?.let {
                this.findNavController().navigate(
                        SleepTrackerFragmentDirections
                                .actionSleepTrackerFragmentToSleepQualityFragment(night.nightId))
                sleepTrackerViewModel.doneNavigating()
            }
        })
}

睡眠ごとの質を記録する

SleepQualityFragmentではこのような6つのアイコンで質を評価する。
押した後、SleepTrackerFragmentへ遷移する。
Imgur

SleepQualityViewModel.kt
class SleepQualityViewModel(
        private val sleepNightKey: Long = 0L,
        val database: SleepDatabaseDao
) : ViewModel() {
    private val _navigateToSleepTracker = MutableLiveData<Boolean?>()
    val navigateToSleepTracker: LiveData<Boolean?>
        get() = _navigateToSleepTracker

    fun doneNavigating() {
        _navigateToSleepTracker.value = null
    }

    fun onSetSleepQuality(quality: Int) {
        viewModelScope.launch {
            val tonight = database.get(sleepNightKey) ?: return@launch
            tonight.sleepQuality = quality
            database.update(tonight)

            _navigateToSleepTracker.value = true
        }
    }
}

SleepQualityViewModelFactoryの内容はテンプレみたい。

SleepQualityViewModelFactory.kt
class SleepQualityViewModelFactory(
        private val sleepNightKey: Long,
        private val dataSource: SleepDatabaseDao
) : ViewModelProvider.Factory {
    @Suppress("unchecked_cast")
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(SleepQualityViewModel::class.java)) {
            return SleepQualityViewModel(sleepNightKey, dataSource) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}
SleepQualityFragment.kt
override fun onCreateView(...){
        // Get a reference to the binding object and inflate the fragment views.
        val binding: FragmentSleepQualityBinding = DataBindingUtil.inflate(
                inflater, R.layout.fragment_sleep_quality, container, false)

        val application = requireNotNull(this.activity).application
        val arguments = SleepQualityFragmentArgs.fromBundle(arguments)

        val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
        val viewModelFactory = SleepQualityViewModelFactory(arguments.sleepNightKey, dataSource)

        val sleepQualityViewModel = ViewModelProvider(this, viewModelFactory)
                .get(SleepQualityViewModel::class.java)

        binding.sleepQualityViewModel = sleepQualityViewModel

        sleepQualityViewModel.navigateToSleepTracker.observe(this, Observer {
            if (it == true) {
                this.findNavController().navigate(
                        SleepQualityFragmentDirections.actionSleepQualityFragmentToSleepTrackerFragment()
                )
                sleepQualityViewModel.doneNavigating()
            }
        })

        return binding.root
}

最後に、xmlファイルのデータバインディングの設定と、各アイコンのリスナを設定する

fragment_sleep_quality.xml
    <data>
        <variable
            name="sleepQualityViewModel"
            type="com.example.android.trackmysleepquality.sleepquality.SleepQualityViewModel" />
    </data>
    
    // クリックリスナーの設定
    android:onClick="@{() -> sleepQualityViewModel.onSetSleepQuality(5)}"

UIの変更

startボタン、stopボタン、clearボタンをandroid:enabledを用いてボタンっぽくする
各ボタンに対応する変数を夜のデータがあるかどうかで中身を定義する

SleepTrackerViewModel.kt

val startButtonVisible = Transformations.map(tonight) {
   it == null
}
val stopButtonVisible = Transformations.map(tonight) {
   it != null
}
val clearButtonVisible = Transformations.map(nights) {
   it?.isNotEmpty()
}

各ボタンの属性を追加

fragment_sleep_tracker.xml
id: start_button
android:enabled="@{sleepTrackerViewModel.startButtonVisible}"
id: stop_button
android:enabled="@{sleepTrackerViewModel.stopButtonVisible}"
id: clear_button
android:enabled="@{sleepTrackerViewModel.clearButtonVisible}"

Transformations.map()はLiveDataのメソッドで、今回の場合、startendボタンならLiveDataオブジェクトのtonightがnullかどうかをboolean値で返している。
startボタンを押したら、startButtonVisibleがfalseになり、xmlファイルで定義したandroid:enabledがfalseになりボタンが押せなくなる。endボタンはその逆。またclearボタンはDBの中に睡眠データがあるかどうかで判定している。

clearボタン時にDB削除

clearボタンを押したらDBの中を削除し、SnackBarを用いて通知する。
カプセル化したSnackBarオブジェクトを用意

SleepTrackerViewModel.kt
private var _showSnackbarEvent = MutableLiveData<Boolean>()

val showSnackBarEvent: LiveData<Boolean>
   get() = _showSnackbarEvent

fun doneShowingSnackbar() {
   _showSnackbarEvent.value = false
}
SleepTrackerFragment.kt
sleepTrackerViewModel.showSnackBarEvent.observe(this, Observer { 
   if (it == true) { // Observed state is true.
       Snackbar.make(
               activity!!.findViewById(android.R.id.content),
               getString(R.string.cleared_message),
               Snackbar.LENGTH_SHORT // How long to display the message.
       ).show()
       sleepTrackerViewModel.doneShowingSnackbar()
   }
})

clearボタンを押した時に_showSnackbarEventを初期化

SleepTrackerViewModel.kt
    fun onClear() {
        viewModelScope.launch {
            clear()
            tonight.value = null
            _showSnackBarEvent.value = true
        }
    }

完成形がこんな感じ
Imgur
Imgur
下がclearボタンを押した後に出てくるSnackBar

まとめ

今回は、Roomと呼ばれるkotlinのSQLiteマッピングライブラリとコルーチンを用いてデータの保存や更新、またUIの変更について学んだ。1度に理解するには重ためだったかなという印象。ひっそり復習していきたいですね。