前々回、前回に続き、今回はRoom
の実装方法をまとめてみようと思います。
この記事の内容
公式のドキュメントやトレーニング「Android Kotlin の基礎」のレッスン6「Room データベースとコルーチン」を参考に実装のポイントとエッセンスをまとめていきます。
前提知識
- kotlinの基礎的な文法
- AndroidStudioの使い方/アプリの作り方
- 画面や画面部品の配置方法
-
Navigation
の実装方法(前々回記事参照) -
ViewModel
の実装方法(前回記事参照)
開発環境
- Windows 10 Home
- Android Studio 4.2.1
作成するサンプル
簡単な単語帳アプリを作成します。
Createボタンをクリックすると画面上のListView
に単語の一覧が表示され、Saveボタンのクリックにより内容をローカルDBに保存します。
ローカルDBにデータが保存済みの場合はLoadボタンによりデータを読み込む機能を実装します。
DeleteボタンがクリックされたらローカルDBのデータを消去します。
この操作イメージだと少し分かりにくいかもしれませんが、LoadボタンをクリックしたときRoom
DBからデータを取得し、その際オーダーを掛けているので画面上のデータの並びも変わっています。
前提となるコード
前述の通りこの記事ではNavigation
、ViewModel
については既知のものとし、詳細は扱いません(詳しくは前回までの記事に書いているので、良かったら見てみてね)。
ただちょっとハンズオンっぽく進めていきたいので、前提となるコードを以下に記します。
また今回はListView
をViewModel
、LiveData
、DataBinding
により実装していますが、以下の記事を参考にさせていただきました(大変分かりやすく、参考になりました)。
【Android】LiveData+DataBinding+ViewModelでListView作成
コード上、自分の記事で触れていない部分はコメントを残しています。
動作イメージはこんな感じ。
Saveボタン、LoadボタンはRoom
の領域なのでこの時点では未実装です。
またDeleteボタンは画面上の表示を削除するまでの実装です。
Navigation
、ViewModel
、DataBinding
用にgradleファイルを設定しておけばとりあえず上記のイメージ通りのアプリにはなります。
レイアウトファイル
activity_main.xml
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<fragment
android:id="@+id/myNavHostFragment"
android:name="androidx.navigation.fragment.NavHostFragment"
app:navGraph="@navigation/navigation"
app:defaultNavHost="true"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
fragment_first.xml
アプリ起動時の画面です。
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="startViewModel"
type="com.warpstudio.android.roomsample.StartViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".StartFragment">
<TextView
android:id="@+id/startText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Welcome to Room Sample APP!"
android:textSize="24sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/toSecondFragmentButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="To SecondFragment"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/startText"
android:onClick="@{() -> startViewModel.onToSecondFragment()}"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
fragment_second.xml
遷移先の画面。
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="secondViewModel"
type="com.warpstudio.android.roomsample.SecondViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".SecondFragment">
<Button
android:id="@+id/createButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Create"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/saveButton"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
tools:layout_editor_absoluteY="0dp"
android:onClick="@{()->secondViewModel.creteData()}"/>
<Button
android:id="@+id/saveButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Save"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/loadButton"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/createButton"
tools:layout_editor_absoluteY="0dp"
android:onClick="@{()->secondViewModel.saveData()}"/>
<Button
android:id="@+id/loadButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Load"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/deleteButton"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/saveButton"
tools:layout_editor_absoluteY="0dp"
android:onClick="@{()->secondViewModel.loadData()}"/>
<Button
android:id="@+id/deleteButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Delete"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/loadButton"
tools:layout_editor_absoluteY="0dp"
android:onClick="@{()->secondViewModel.deleteData()}"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<ListView
android:id="@+id/wordList"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</LinearLayout>
</layout>
list_item.xml
ListView
に表示する行単位のレイアウトです。
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="wordData"
type="com.warpstudio.android.roomsample.WordData" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:text="@{wordData.word}"
android:textSize="18sp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="0.8"/>
<TextView
android:text="@{wordData.speech}"
android:textSize="18sp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="0.2"/>
<TextView
android:text="@{wordData.meaning}"
android:textSize="18sp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"/>
</LinearLayout>
</layout>
navigation.xml
Navigation
ファイルも一応載せときます。
<navigation
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/navigation"
xmlns:tools="http://schemas.android.com/tools"
app:startDestination="@id/startFragment">
<fragment
android:id="@+id/startFragment"
android:name="com.warpstudio.android.roomsample.StartFragment"
android:label="StartFragment"
tools:layout="@layout/fragment_start">
<action
android:id="@+id/action_startFragment_to_secondFragment"
app:destination="@id/secondFragment" />
</fragment>
<fragment
android:id="@+id/secondFragment"
android:name="com.warpstudio.android.roomsample.SecondFragment"
android:label="SecondFragment"
tools:layout="@layout/fragment_second"/>
</navigation>
Fragmentクラス
MainActivity.xml は何もいじっていないので省略。
StartActivity.kt
ViewModel
使ってますが、次の画面に映るボタンがあるだけなんでわざわざそこまでする必要はないんですけどね……。
class StartFragment : Fragment() {
private lateinit var binding: FragmentStartBinding
private lateinit var viewModel: StartViewModel
private lateinit var viewModelFactory: StartViewModelFactory
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_start,
container,
false
)
viewModelFactory = StartViewModelFactory()
viewModel = ViewModelProvider(this, viewModelFactory)
.get(StartViewModel::class.java)
viewModel.eventToSecondFragment
.observe(viewLifecycleOwner, Observer { isToSecond ->
if (isToSecond) {
toSecondFragment()
}
})
binding.startViewModel = viewModel
return binding.root
}
private fun toSecondFragment() {
val action = StartFragmentDirections
.actionStartFragmentToSecondFragment()
NavHostFragment.findNavController(this).navigate(action)
viewModel.onToSecondFragmentComplete()
}
}
StartViewModel.kt
class StartViewModel : ViewModel() {
private val _eventToSecondFragment = MutableLiveData<Boolean>()
val eventToSecondFragment: LiveData<Boolean>
get() = _eventToSecondFragment
init {
}
fun onToSecondFragment() {
_eventToSecondFragment.value = true
}
fun onToSecondFragmentComplete() {
_eventToSecondFragment.value = false
}
}
StartViewModelFactory.kt
class StartViewModelFactory: ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(StartViewModel::class.java)) {
return StartViewModel() as T
}
throw IllegalArgumentException("ERR!")
}
}
SecondFragment.kt
メインの画面です。
ListView
にAdapter
を設定しています。
class SecondFragment : Fragment() {
private lateinit var binding: FragmentSecondBinding
private lateinit var secondViewModel: SecondViewModel
private lateinit var secondViewModelFactory: SecondViewModelFactory
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_second,
container,
false
)
secondViewModelFactory = SecondViewModelFactory()
secondViewModel = ViewModelProvider(this, secondViewModelFactory)
.get(SecondViewModel::class.java)
// wordListのObserverを登録
secondViewModel.wordList.observe(viewLifecycleOwner, Observer { words ->
// 作成されたWordListをもとにListViewのデータを更新
binding.wordList.adapter = WordAdapter(words)
})
binding.secondViewModel = secondViewModel
binding.lifecycleOwner = viewLifecycleOwner
// ListViewのAdapterを初期化
binding.wordList.adapter = WordAdapter(ArrayList(0))
return binding.root
}
}
SecondViewModel.kt
主役(?)となるViewModel
クラス。
class SecondViewModel: ViewModel() {
private val _wordList = MutableLiveData<List<WordData>>()
val wordList: MutableLiveData<List<WordData>>
get() = _wordList
init {
}
// Createボタンクリック時のイベント
fun creteData() {
wordList.value = createWordList()
}
// Saveボタンクリック時のイベント
fun saveData() {
}
// Loadボタンクリック時のイベント
fun loadData() {
}
// Deleteボタンクリック時のイベント
fun deleteData(){
wordList.value = ArrayList(0)
}
private fun createWordList() : ArrayList<WordData> {
var data = arrayListOf(
WordData("ethos", "名", "気風"),
WordData("mediocre", "形", "平凡な"),
// (略)
WordData("lurch", "動", "よろめく")
)
return data
}
}
SecondViewModelFactory.kt
コピペでOKなViewModelFactory
クラス。
class SecondViewModelFactory: ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SecondViewModel::class.java)) {
return SecondViewModel() as T
}
throw IllegalArgumentException("ERR!")
}
}
データ/Adapterクラス
カスタマイズしたListView
にDataBinding
でデータを表示するために実装するクラスです。
WordData.kt
単語の単語名、品詞、意味を保持するデータクラス。
data class WordData (
var word: String,
var speech: String,
var meaning: String
)
WordAdapter.kt
ListView
用のAdapter
クラスです。
// Adapterクラス
class WordAdapter(private var wordDatas: List<WordData>): BaseAdapter() {
// WordAdapterクラス生成時に呼び出されるメソッド
override fun getView(
posistion: Int,
convertView: View?,
parent: ViewGroup
): View {
val binding = if (convertView == null) {
ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
} else {
DataBindingUtil.getBinding(convertView) ?: throw IllegalStateException()
}
with(binding) {
wordData = wordDatas[posistion]
executePendingBindings()
}
return binding.root
}
// その他Overrideが必要なメソッド
override fun getItem(position: Int) = wordDatas[position]
override fun getItemId(position: Int) = position.toLong()
override fun getCount() = wordDatas.size
}
Gradleファイルの更新
作業に入る前にgradleファイルを更新していきます。
Room
はこの辺りに起因するビルドエラーが出やすいので注意してください。
build.gradel(app)ファイルの更新
Room
をか使う場合はkapt
プラグインとdependencies
ブロックに下記の内容を追加してください。
plugins {
// (略)
id 'kotlin-kapt'
}
dependencies {
// (略)
// Room
implementation "androidx.room:room-runtime:2.3.0"
kapt "androidx.room:room-compiler:2.3.0"
implementation "androidx.room:room-ktx:2.3.0"
testImplementation "androidx.room:room-testing:2.3.0"
}
DBの実装
それではDBを実装していきます。
Room
にはEntity
、DAO
、Database
の3要素が必要なので、まずはそれらのクラスを実装していきます。
Entityクラスの作成
新規 Kotlin Class を作成し、data class
を定義します。
Entity
クラスはDBのテーブルに該当し、各の名前と型、主キーの情報を保持します。
あわせてクラスには@Entity
アノテーションとテーブル名の設定が必要です。
@Entity(tableName = "word_data_table")
data class WordEntity (
@PrimaryKey(autoGenerate = true)
var wordId: Long = 0L,
@ColumnInfo(name = "word")
var word: String,
@ColumnInfo(name = "speech")
var speech: String,
@ColumnInfo(name = "meaning")
var meaning: String
)
上記では主キーのautoGenerate
属性をtrueとしていますが、これによりCREATE等の際に主キーは自動生成されます。
DAOクラスの作成
DAO
用に新規 Kotlin Class を作成します。
Room
におけるDAO
はDBに対するCREATE、INSERT、DELETE等の操作を定義するクラスといったところでしょうか。
DAO
はinterface
である必要があります。
また、DBにはコルーチン
を使用してアクセスするため、非同期で実行するメソッドに対してsuspendキーワードを設定しておきます。
@Dao
interface WordDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(word: List<WordEntity>)
@Delete
suspend fun delete(word: List<WordEntity>)
@Query("SELECT * FROM word_data_table ORDER BY wordId DESC")
suspend fun getAllWord(): List<WordEntity>
}
基本的には@Query
アノテーションを設定したメソッドに自分でSQL文を書く必要がありますが、INSERT
やUPDATE
等予め定義された操作に関してはプリセットのアノテーションを利用することもできます。
Databaseクラスの作成
最後にDatabase
クラス(DBホルダークラスト言った方が正しいかな?)を作成します。
DBインスタンスがnullの場合にインスタンスを生成するgetInstance
メソッドを作成します。
@Database(entities = [WordEntity::class], version = 1, exportSchema = false)
abstract class WordDatabase: RoomDatabase() {
abstract val wordDao: WordDao
companion object {
@Volatile
private var INSTANCE: WordDatabase? = null
fun getInstance(context: Context): WordDatabase {
synchronized(this) {
var instance = INSTANCE
if (instance == null) {
instance = Room.databaseBuilder(
context.applicationContext,
WordDatabase::class.java,
"database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
}
return instance
}
}
}
}
DBへのアクセス
前々章で実装したDBにViewModel
からアクセスします。
ViewModel、ViewModelFactoryの改修
ViewModel
、ViewModelFactory
について、引数にDAO
とApplication
を受け取るよう改修します。
下記のコードではAndroidViewModel
を継承元としています。これはViewModel
と基本的には同じですが、コンストラクタのパラメーターとしてアプリケーションのContext
を受け取ることができます。
ViewModel
class SecondViewModel(
val database: WordDao,
application: Application): AndroidViewModel(application) {
// 略
}
ViewModelFactory
class SecondViewModelFactory(
private val dataSource: WordDao,
private val application: Application): ViewModelProvider.Factory {
@Suppress("unchecked_cast")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SecondViewModel::class.java)) {
return SecondViewModel(dataSource, application) as T
}
throw IllegalArgumentException("ERR!")
}
}
Fragment
UI Controller
の(今回はFragment
)onCreate()
メソッドでViewModel
を生成します。
前述の通りViewModel
のコンストラクタにはDAO
のインスタンスとアプリケーションのContext
が必要なため、個別に作成しておきます。
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// (略)
// アプリケーションの生成
val application = requireNotNull(this.activity).application
// DAOインスタンスの生成
val dataSource = WordDatabase.getInstance(application).wordDao
// ViewModelFactoryの生成
secondViewModelFactory = SecondViewModelFactory(dataSource, application)
// ViewModelFactoryの生成
secondViewModel = ViewModelProvider(this, secondViewModelFactory)
.get(SecondViewModel::class.java)
// (略)
}
各処理の実装
ViewModel
にDBにアクセスする処理を追記していきます。
DBへのアクセスはすべてコルーチンスコープ
内で処理する点に注意してください。
ViewModel
class SleepTrackerViewModel(
val database: SleepDatabaseDao,
application: Application) : AndroidViewModel(application) {
// (略)
// Createボタンクリック時のイベント
fun creteData() {
_wordList.value = createWordList()
}
// Saveボタンクリック時のイベント
fun saveData() {
// Createが実行され、LiveDataに値が設定されている場合のみ処理を実行
if (!_wordList.value.isNullOrEmpty()) {
// DataをEntityのListに置き換え
val words = _wordList.value!!.map {
WordEntity(word = it.word, speech = it.speech, meaning = it.meaning)
} as ArrayList<WordEntity>
// 保存処理はコルーチンのスコープ内で実行
viewModelScope.launch {
database.insert(words)
}
}
}
// Loadボタンクリック時のイベント
fun loadData() {
// コルーチンのスコープ内で実行
viewModelScope.launch {
// DBから全レコードを取得
val allWords = database.getAllWord()
// EntityをDataのListに置き換え
val wordList = allWords.map {
WordData(word = it.word, speech = it.speech, meaning = it.meaning)
} as ArrayList<WordData>
// LiveDataにListを設定
_wordList.value = wordList
}
}
// Deleteボタンクリック時のイベント
fun deleteData(){
// LiveDataの削除処理
if (!_wordList.value.isNullOrEmpty()) {
_wordList.value = ArrayList(0)
}
// DBの処理
// コルーチンのスコープ内で処理
viewModelScope.launch {
val allWords = database.getAllWord()
database.delete(allWords)
}
}
// (略)
}
Recap
今回は以上です。
Room
はそれ自体は非常に便利なのですが細かなビルドエラーが出やすく、それがちょっと厄介ですね。