Android Kotlin日本語チュートリアル-⑤Roomを使ってデータを管理する
Android Kotlin日本語チュートリアル
本連載記事はこれからAndroidアプリ開発を始める人に向けたチュートリアルです。
コンセプトは
- プログラミングをあまり知らない人でも完走できる
- プログラミングにある程度詳しい人にも満足できる
- 実用的な知識を提供する
- とにかくわかりやすく
で、全9回と長めですが頑張っていきましょう。
このチュートリアルを終える頃には、Android開発の土台が形成されているだけでなくアプリケーションアーキテクチャの知識が出来上がっているはずです。
作成するのは以下のようなメモアプリです。
完成品は HiroshiARAKI/AndroidKotlinTutrialで公開していますので適宜参考にしてください。
第5回 : Roomでデータを管理する
第5回は、前回お話ししたAndroid推奨アーキテクチャのうちRepositoryとModelについて実装していきます。
AndroidではRepositoryをデータベースとの境界面とし、データベース自体はRoomDatabaseと呼ばれる機能を使って実装されることが多いです。
早速やっていきますが、まずは少しだけ下準備を。
NewMemoFragmentを整備する
NewMemoFragmentを使って学んでいくので、まずは何もないUIを作っていきます。
このFragmentではその名の通り、新しいメモ作成を担います。
まずはレイアウトを決めましょう。
レイアウトを決定する際に、せっかくなのでリソースファイルの一種 @dimen
を使ってみます。
res > values内に新たにdimenリソースファイルを選択してパラメータを決めましょう。
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ではデータベース周りは以下の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
の引数 autoGenerate
はtrue
にしておくと自動生成し一意のキーを自動発行してくれます。
@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()
を経由しています。
手順がたくさんありましたが、これでNewMemoViewModel
でMemoRepository
が使えるようになりました。
新しい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の機能があるのでもう少し頑張りましょう!
参考文献
- Room を使用してローカル データベースにデータを保存する | Android developers
- Room DAO を使用してデータにアクセスする | Android developers
- Android Room とビュー – Kotlin | Android developers
- 独自
Application
クラスにRepositoryを置き、ViewModelに挿入する手法は上記サイトを参考にしています。 - Github: googlecodelabs/android-room-with-a-view
- 独自