Android Kotlin Fundamentalsで学ぶ その6
はじめに
この記事はGoogleが提供しているCodelabの中のAndroidを作りながら学ぶAndroid Kotlin Fundamentalsコースで学習した内容を自分用に残していくものです。間違っていることなどあればコメントをいただけるとありがたいです!
この記事について
その6では、Room
と言われるデータベースライブラリについて説明します。また、kotlinの子ルーチンを使ってデータベースの操作を行う方法を学びます。
Create a Room Database
6-1今回は、TrackMySleepQuality-Starter
を使います。
Room とは
Roomとは、SQLite全体を対象とする抽象化レイヤを提供し、SQLite を最大限に活用しつつ、スムーズなデータベースアクセスを可能にする。
そもそもAndroidはSQLiteをサポートしているが、直接SQLiteを操作するには労力がかかってしまう。そこでRoomライブラリを使うことでDB操作をしやすくしたものって感じかな?
Roomの導入
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: データベースにアクセスするためのメソッドを格納する
コンポーネント間の関係はこんな感じ(google developers参照)
SleepNight
エンティティを作成
@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はデータベースにアクセスするためのカスタムインターフェースを定義するようなもの。アノテーションと関数をセットで定義する。
@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の作成
完成コードはこんな感じ
@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
でインスタンスを作成する。
Coroutines and Room
6-2このセッションでは、コルーチンを使ってDB操作をスレッドで行うことと、フォーマットされたデータをTextView
で表示すること。
SleepTrackerについてのViewModelの追加
- SleepTrackerViewModelを追加する
class SleepTrackerViewModel(
val database: SleepDatabaseDao,
application: Application) : AndroidViewModel(application) {
}
- SleepTrackerViewModelFactoryを追加
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")
}
}
- SleepTrackerFragmentを更新する
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
}
- レイアウトにViewModelのバインディングを追加
<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を中断可能な関数として定義できる。また、コルーチンないから呼び出す必要がある。
suspend
を追加する
DAOに@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
を追記する。
睡眠時間のインスタンスを作成
# 時刻を保持
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)
fun onStartTracking() {
viewModelScope.launch {
val newNight = SleepNight()
insert(newNight)
tonight.value = getTonightFromDatabase()
}
}
private suspend fun insert(night: SleepNight) {
database.insert(night)
}
-
end
: 終了時刻を保存(ターゲット->tonight)
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)
fun onClear(){
viewModelScope.launch {
clear()
tonight.value = null
}
}
private suspend fun clear(){
database.clear()
}
データの表示
start, end, clearボタンを押した際のデータの表示・更新を行う
onClick
をそれぞれ指定する
<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へ反映させるためには、スレッドが必要である。
Use LiveData to control button states
6-3ナビゲーションの追加
stop
ボタンを押したらSleepTrackerFragment
からSleepQualityFragment
へ遷移する処理を追加する。
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側でオブザーバを追加
override fun onCreateView(...){
...
sleepTrackerViewModel.navigateToSleepQuality.observe(viewLifecycleOwner, Observer { night ->
night?.let {
this.findNavController().navigate(
SleepTrackerFragmentDirections
.actionSleepTrackerFragmentToSleepQualityFragment(night.nightId))
sleepTrackerViewModel.doneNavigating()
}
})
}
睡眠ごとの質を記録する
SleepQualityFragmentではこのような6つのアイコンで質を評価する。
押した後、SleepTrackerFragmentへ遷移する。
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の内容はテンプレみたい。
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")
}
}
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ファイルのデータバインディングの設定と、各アイコンのリスナを設定する
<data>
<variable
name="sleepQualityViewModel"
type="com.example.android.trackmysleepquality.sleepquality.SleepQualityViewModel" />
</data>
// クリックリスナーの設定
android:onClick="@{() -> sleepQualityViewModel.onSetSleepQuality(5)}"
UIの変更
start
ボタン、stop
ボタン、clear
ボタンをandroid:enabled
を用いてボタンっぽくする
各ボタンに対応する変数を夜のデータがあるかどうかで中身を定義する
val startButtonVisible = Transformations.map(tonight) {
it == null
}
val stopButtonVisible = Transformations.map(tonight) {
it != null
}
val clearButtonVisible = Transformations.map(nights) {
it?.isNotEmpty()
}
各ボタンの属性を追加
id: start_button
android:enabled="@{sleepTrackerViewModel.startButtonVisible}"
id: stop_button
android:enabled="@{sleepTrackerViewModel.stopButtonVisible}"
id: clear_button
android:enabled="@{sleepTrackerViewModel.clearButtonVisible}"
Transformations.map()
はLiveDataのメソッドで、今回の場合、start
、end
ボタンならLiveDataオブジェクトのtonight
がnullかどうかをboolean値で返している。
start
ボタンを押したら、startButtonVisible
がfalseになり、xml
ファイルで定義したandroid:enabled
がfalseになりボタンが押せなくなる。end
ボタンはその逆。またclear
ボタンはDBの中に睡眠データがあるかどうかで判定している。
clearボタン時にDB削除
clear
ボタンを押したらDBの中を削除し、SnackBar
を用いて通知する。
カプセル化したSnackBarオブジェクトを用意
private var _showSnackbarEvent = MutableLiveData<Boolean>()
val showSnackBarEvent: LiveData<Boolean>
get() = _showSnackbarEvent
fun doneShowingSnackbar() {
_showSnackbarEvent.value = false
}
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
を初期化
fun onClear() {
viewModelScope.launch {
clear()
tonight.value = null
_showSnackBarEvent.value = true
}
}
完成形がこんな感じ
下がclear
ボタンを押した後に出てくるSnackBar
まとめ
今回は、Roomと呼ばれるkotlinのSQLiteマッピングライブラリとコルーチンを用いてデータの保存や更新、またUIの変更について学んだ。1度に理解するには重ためだったかなという印象。ひっそり復習していきたいですね。
Discussion