Roomによるデータベースアクセス(Insert,Update,Delete)

  • 投稿者:
  • 投稿カテゴリー:その他

概要

Roomを使ってローカルDBに接続する。
テーブルはItemテーブル1つのみ。data classをItem.ktに定義。
AndroidManifest.xmlに定義したApplicationクラスからdatabaseオブジェクトを作成。
SQLはSelect,Insert,Update,Delete。
画面はフラグメント3画面。nav_graph.xmlでnavigation設定
ファイル構成
root
┣data
┃┣Item.kt // エンティティ情報
┃┣ItemDao.kt // DAO SQLなどを記載
┃┗ItemRoomDatabase.kt // DataBase
┣MainActivity.kt // メイン
┣ItemListFragment.kt // 初期画面。一覧リスト画面
┣AddItemFragment.kt // 項目の追加・編集画面
┣ItemDetailFragment.kt // 販売・削除画面
┣InventryViewModel.kt // ビューモデル及びそのファクトリクラス
┣InventryApplication.kt // アプリケーションクラス
┗ItemListAdapter.kt // RecyclerViewのListAdapter

環境設定

Roomによるデータベースアクセス(select)」と同じ

サンプル

エンティティ

data classでテーブルを定義。
Roomが認識できるように@Entityアノテーションをつける
@Entityアノテーションと(tableName=テーブル名)を記載しテーブル名のdata classを定義
@PrimaryKey(autoGenerate = true)とする事でRoomがidに主キーを設定する
@ColumnInfo(name = カラム名)として項目の型と変数名を定義
Not Null制約は@NonNullを付加する。
通貨などフォーマットが必要な項目は別途拡張関数等を定義しておく。

/**
 * エンティティの定義
 * @Entityアノテーションと(tableName=テーブル名)を記載し
 * テーブル名のdata classを定義
 * @PrimaryKey(autoGenerate = true)とする事でRoomがidに主キーを設定する
 * @ColumnInfo(name = カラム名)として項目の型と変数名を定義
 *
 */
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.text.NumberFormat

@Entity(tableName = "item")
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    @ColumnInfo(name = "name")
    val itemName: String,
    @ColumnInfo(name = "price")
    val itemPrice: Double,
    @ColumnInfo(name = "quantity")
    val quantityInStock: Int,
)

// priceを通貨フォーマットする拡張関数
fun Item.getFormattedPrice(): String =
    NumberFormat.getCurrencyInstance().format(itemPrice)

DAO(interface)

@Daoのアノテーションを付けてinterfaceオブジェクトとして定義。
@Query(SQL文)と取得結果を戻す任意名称の関数をペアで記載。
パラメータありのSQLは関数の引数にパラメータを定義。
DBの変更を監視するにはFlow型で戻す。
更新処理など時間がかかる可能性があるものは関数をsuspendとする事でコルーチンからの起動を可能とする。

/**
 * DAOクラス
 * @Daoのアノテーションを付けてinterfaceオブジェクトとして定義。
 * @Query(SQL文)と取得結果を戻す任意名称の関数をペアで記載。
 * パラメータありのSQLは関数の引数にパラメータを定義。
 * DBの変更を監視するにはFlow型で戻す。
 * 更新処理など時間がかかる可能性があるものは関数をsuspendとする事でコルーチンからの起動を可能とする
 *
 */
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ItemDao {
    // selectの場合は@Queryで()内にSQLを記載
    // 戻りの型も定義しておく(通常はFlowで値の変更を監視)
    @Query("SELECT * from item WHERE id = :id")
    fun getItem(id: Int): Flow<Item>

    @Query("SELECT * from item ORDER BY name ASC")
    fun getItems(): Flow<List<Item>>

    
    // suspend 関数はコルーチンや他の suspend 関数からしか呼び出せない

    // 主キー重複時は無視
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(item: Item)

    @Update
    suspend fun update(item: Item)

    @Delete
    suspend fun delete(item: Item)

}

DataBaseのオブジェクトの定義

@Databaseアノテーションでアクセスするエンティティを指定する。
RoomDatabase型の抽象クラスでDAOへのアクセスを共通化した抽象メソッドを実装
DBアクセスは競合回避のため1つのみインスタス化する(companion object内で管理)
AndroidManifest.xmlで起動したBusScheduleApplicationからgetDatabaseがコールされてDBインスタンスが作成される。
entitiesは使用するエンティティを全て記載
exportSchema=falseでバージョン履歴を保持しない

/**
 * @Databaseアノテーションでアクセスするエンティティを指定する。
 * RoomDatabase型の抽象クラスでDAOへのアクセスを共通化した抽象メソッドを実装
 * DBアクセスは競合回避のため1つのみインスタス化する(companion object内で管理)
 * AndroidManifest.xmlで起動したBusScheduleApplicationからgetDatabaseがコールされてDBインスタンスが作成される。
 * entitiesは使用するエンティティを全て記載
 * exportSchema=falseでバージョン履歴を保持しない
 */

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class ItemRoomDatabase : RoomDatabase() {
    // DAOの定義
    abstract fun itemDao(): ItemDao

    // DataBaseインスタンス
    companion object {
        // @Volatile(変数をキャッシュに保存しない)
        @Volatile
        private var INSTANCE: ItemRoomDatabase? = null

        // DataBaseインスタンスを返すメソッド
        fun getDatabase(context: Context): ItemRoomDatabase {
            // synchronizedで複数のスレッドからの実行を抑止する
            // fallbackToDestructiveMigrationはバージョンアップ時にDBを再作成
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    ItemRoomDatabase::class.java,
                    "item_database"
                )
                    .fallbackToDestructiveMigration()
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

アプリケーションクラス

AndroidManifest.xmlのapplicationタグでandroid:name=Applicationクラス名とする事で指定クラスのインスタンス化を一番先に行う事ができる。
これを利用してアプリケーションクラスの派生クラスからDatabaseオブジェクトを先にインスタンス化する。

/**
 * AndroidManifest.xmlのapplicationタグでandroid:name=Applicationクラス名
 * とする事で指定クラスのインスタンス化を一番先に行う事ができる。
 * これを利用してアプリケーションクラスの派生クラスから
 * Databaseオブジェクトを先にインスタンス化する。
 */
import android.app.Application
import com.example.inventory.data.ItemRoomDatabase

class InventoryApplication : Application(){
    // Databaseインスタンスを取得する。
    val database: ItemRoomDatabase by lazy {
        ItemRoomDatabase.getDatabase(this)
    }
}

マニフェスト

android:nameはアプリに実装するApplicationサブクラス。
アプリプロセスが開始すると、どのアプリコンポーネントよりも前にこのクラスがインスタンス化される。
Applicationクラスの派生クラスInventoryApplicationを起動してDBインスタンスを取得する。

<?xml version="1.0" encoding="utf-8"?>

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.inventory">
<!--android:nameはアプリに実装するApplicationサブクラス。
    アプリプロセスが開始すると、どのアプリコンポーネントよりも前にこのクラスがインスタンス化される。
    Applicationクラスの派生クラスInventoryApplicationを起動してDBインスタンスを取得する。
-->
    <application
        android:name="com.example.inventory.InventoryApplication"
        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.InventoryApp">
        <activity android:name="com.example.inventory.MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>

</manifest>

ナビゲーション

Fragmentは3画面
1.リスト画面
2.販売・削除画面(引数はitem_id)
3.追加・変更画面(引数はtitleとid。idのdefaultは-1)

画面遷移は
1.→2.→3.→1.
または
1.→3.→1.

<?xml version="1.0" encoding="utf-8"?>

<navigation 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"
    app:startDestination="@id/itemListFragment">

    <fragment
        android:id="@+id/itemListFragment"
        android:name="com.example.inventory.ItemListFragment"
        android:label="@string/app_name"
        tools:layout="@layout/item_list_fragment">
        <action
            android:id="@+id/action_itemListFragment_to_itemDetailFragment"
            app:destination="@id/itemDetailFragment" />
        <action
            android:id="@+id/action_itemListFragment_to_addItemFragment"
            app:destination="@id/addItemFragment" />
    </fragment>
    <fragment
        android:id="@+id/itemDetailFragment"
        android:name="com.example.inventory.ItemDetailFragment"
        android:label="@string/item_detail_fragment_title"
        tools:layout="@layout/fragment_item_detail">
        <argument
            android:name="item_id"
            app:argType="integer" />
        <action
            android:id="@+id/action_itemDetailFragment_to_addItemFragment"
            app:destination="@id/addItemFragment" />
    </fragment>
    <fragment
        android:id="@+id/addItemFragment"
        android:name="com.example.inventory.AddItemFragment"
        android:label="{title}"
        tools:layout="@layout/fragment_add_item">
        <argument
            android:name="title"
            app:argType="string" />
        <argument
            android:name="item_id"
            android:defaultValue="-1"
            app:argType="integer" />
        <action
            android:id="@+id/action_addItemFragment_to_itemListFragment"
            app:destination="@id/itemListFragment"
            app:popUpTo="@id/itemListFragment"
            app:popUpToInclusive="true" />
    </fragment>
</navigation>

レイアウトxml

アクティビティは1つのみでactivity_main.xmlにフラグメントの定義のみ

<?xml version="1.0" encoding="utf-8"?>
<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">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/nav_graph" />

</androidx.constraintlayout.widget.ConstraintLayout>

1.リスト画面

<?xml version="1.0" encoding="utf-8"?>
<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"
    android:layout_margin="@dimen/margin"
    tools:context=".ItemListFragment">

    <TextView
        android:id="@+id/item_name"
        style="@style/Widget.Inventory.Header"
        android:layout_marginStart="@dimen/margin_between_elements"
        android:text="@string/item"
        app:layout_constraintEnd_toStartOf="@+id/item_price"
        app:layout_constraintHorizontal_weight="2"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/item_price"
        style="@style/Widget.Inventory.Header"
        android:layout_below="@+id/item_name"
        android:layout_marginStart="@dimen/margin_between_elements"
        android:text="@string/price"
        android:textAlignment="center"
        app:layout_constraintEnd_toStartOf="@+id/item_quantity"
        app:layout_constraintHorizontal_weight="1"
        app:layout_constraintStart_toEndOf="@+id/item_name"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/item_quantity"
        style="@style/Widget.Inventory.Header"
        android:layout_alignParentEnd="true"
        android:layout_marginEnd="@dimen/margin_between_elements"
        android:text="@string/quantity_in_stock"
        android:textAlignment="center"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_weight="1"
        app:layout_constraintStart_toEndOf="@+id/item_price"
        app:layout_constraintTop_toTopOf="parent" />

    <View
        android:id="@+id/divider"
        style="@style/Divider"
        android:layout_marginTop="@dimen/margin_between_elements"
        app:layout_constraintBottom_toTopOf="@+id/recyclerView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/item_quantity" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:scrollbars="vertical"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/divider" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/floatingActionButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="@dimen/margin_between_elements"
        android:layout_marginBottom="@dimen/margin_between_elements"
        android:contentDescription="@string/add_new_item"
        android:src="@android:drawable/ic_input_add"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:tint="@android:color/white" />

</androidx.constraintlayout.widget.ConstraintLayout>

2.販売・削除画面(引数はitem_id)

<?xml version="1.0" encoding="utf-8"?>
<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"
    android:layout_margin="@dimen/margin"
    tools:context=".ItemDetailFragment">

    <TextView
        android:id="@+id/item_name"
        style="@style/Widget.Inventory.TextView"
        android:layout_width="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="Screwdrivers" />

    <TextView
        android:id="@+id/item_price"
        style="@style/Widget.Inventory.TextView"
        android:layout_width="wrap_content"
        android:layout_marginTop="@dimen/margin"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/item_name"
        tools:text="$5.50" />

    <TextView
        android:id="@+id/item_count_label"
        style="@style/Widget.Inventory.TextView"
        android:layout_width="wrap_content"
        android:text="@string/quantity"
        app:layout_constraintBaseline_toBaselineOf="@+id/item_count"
        app:layout_constraintEnd_toStartOf="@+id/item_count"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent" />

    <TextView
        android:id="@+id/item_count"
        style="@style/Widget.Inventory.TextView"
        android:layout_width="0dp"
        android:layout_marginStart="@dimen/margin_between_elements"
        android:layout_marginTop="@dimen/margin"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/item_count_label"
        app:layout_constraintTop_toBottomOf="@+id/item_price"
        tools:text="5" />

    <Button
        android:id="@+id/sell_item"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/margin"
        android:text="@string/sell"
        app:layout_constraintBottom_toTopOf="@+id/delete_item"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/item_count" />

    <Button
        android:id="@+id/delete_item"
        style="?attr/materialButtonOutlinedStyle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/margin"
        android:text="@string/delete"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/sell_item" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/edit_item"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="@dimen/margin_between_elements"
        android:layout_marginBottom="@dimen/margin_between_elements"
        android:contentDescription="@string/edit_item"
        android:src="@drawable/ic_edit"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:tint="@android:color/white" />

</androidx.constraintlayout.widget.ConstraintLayout>

3.追加・変更画面(引数はtitleとid。idのdefaultは-1)

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="@dimen/margin">

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/item_name_label"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/margin"
            android:hint="@string/item_name_req"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/item_name"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:inputType="textAutoComplete|textCapWords"
                android:singleLine="true" />
        </com.google.android.material.textfield.TextInputLayout>

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/item_price_label"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/margin"
            android:hint="@string/item_price_req"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/item_name_label"
            app:prefixText="@string/currency_symbol">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/item_price"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:inputType="numberDecimal"
                android:singleLine="true" />
        </com.google.android.material.textfield.TextInputLayout>

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/item_count_label"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/margin"
            android:hint="@string/quantity_req"
            app:layout_constraintBottom_toTopOf="@+id/save_action"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/item_price_label">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/item_count"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:inputType="number"
                android:singleLine="true" />
        </com.google.android.material.textfield.TextInputLayout>

        <Button
            android:id="@+id/save_action"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="32dp"
            android:text="@string/save_action"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/item_count_label" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

RecyclerVeiewのレイアウト定義

<?xml version="1.0" encoding="utf-8"?>

<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="48dp"
    android:descendantFocusability="blocksDescendants">

    <TextView
        android:id="@+id/item_name"
        style="@style/Widget.Inventory.ListItemTextView"
        android:layout_width="180dp"
        android:layout_marginStart="@dimen/margin_between_elements"
        android:fontFamily="sans-serif"
        app:layout_constraintEnd_toStartOf="@+id/item_price"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="Cogs and Widgets" />

    <TextView
        android:id="@+id/item_price"
        style="@style/Widget.Inventory.ListItemTextView"
        android:layout_width="100dp"
        android:layout_below="@+id/item_name"
        android:layout_marginStart="@dimen/margin_between_elements"
        android:fontFamily="sans-serif-medium"
        android:textAlignment="center"
        app:layout_constraintEnd_toStartOf="@+id/item_quantity"
        app:layout_constraintHorizontal_weight="1"
        app:layout_constraintStart_toEndOf="@+id/item_name"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="$4.99" />

    <TextView
        android:id="@+id/item_quantity"
        style="@style/Widget.Inventory.ListItemTextView"
        android:layout_width="80dp"
        android:layout_alignParentEnd="true"
        android:layout_marginEnd="@dimen/margin_between_elements"
        android:fontFamily="sans-serif-medium"
        android:textAlignment="center"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_weight="1"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="3" />

</androidx.constraintlayout.widget.ConstraintLayout>

アクティビティ

フラグメントの表示のみ
ナビゲーションコントローラーによる画面遷移

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.NavigationUI.setupActionBarWithNavController

class MainActivity : AppCompatActivity(R.layout.activity_main) {

    // ナビゲーションコントローラー
    private lateinit var navController: NavController

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // ナビゲーションの設定
        // activity_main.xmlに定義したFragmentContainerViewのidを取得
        val navHostFragment = supportFragmentManager
            .findFragmentById(R.id.nav_host_fragment) as NavHostFragment
        navController = navHostFragment.navController
        // アプリバーを表示
        setupActionBarWithNavController(this, navController)
    }

    // 上へボタンを有効
    override fun onSupportNavigateUp(): Boolean {
        return navController.navigateUp() || super.onSupportNavigateUp()
    }
}

ViewModelとViewModelFactry

共有ViewModel
DAOへのアクセスはViewModel経由した形で統一する。
DAOオブジェクトを引数にしてインスタンス化。
これによりViewModelによるライフサイクルの管理が利用できる。
ただし、ViewModelのインスタンス化はそれぞれのフラグメントからではなくViewModelオブジェクトをインスタンス化するファクトリークラスを用いる。

/**
 * 共有ViewModel
 * DAOへのアクセスはViewModel経由した形で統一する。
 * DAOオブジェクトを引数にしてインスタンス化。
 * これによりViewModelによるライフサイクルの管理が利用できる。
 * ただし、ViewModelのインスタンス化はそれぞれのフラグメントからではなく
 * ViewModelオブジェクトをインスタンス化するファクトリークラスを用いる。
 */
import androidx.lifecycle.*
import com.example.inventory.data.Item
import com.example.inventory.data.ItemDao
import kotlinx.coroutines.launch

class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() {

    // Flow型のデータをLiveDataに変換して取得
    val allItems: LiveData<List<Item>> = itemDao.getItems().asLiveData()

    // idでデータを取得
    fun retrieveItem(id: Int): LiveData<Item> {
        return itemDao.getItem(id).asLiveData()
    }

    // 新規レコードの追加処理
    fun addNewItem(
        itemName: String,
        itemPrice: String,
        itemCount: String) {
        // エンティティデータの取得
        val newItem = getNewItemEntry(itemName, itemPrice, itemCount)
        // 追加処理
        insertItem(newItem)
    }

    // 品目の更新処理
    fun updateItem(
        itemId: Int,
        itemName: String,
        itemPrice: String,
        itemCount: String
    ) {
        // エンティティデータの取得
        val updatedItem = getUpdatedItemEntry(itemId, itemName, itemPrice, itemCount)
        // 更新処理
        updateItem(updatedItem)
    }

    // コルーチンにてInsert関数の呼出し
    private fun insertItem(item: Item) {
        // DBのアクセスはメインスレッド外のコルーチンを使用
        viewModelScope.launch {
            // コルーチンからsuspend関数を呼出し
            itemDao.insert(item)
        }
    }

    // 在庫数量の更新
    private fun updateItem(item: Item) {
        // DBのアクセスはメインスレッド外のコルーチンを使用
        viewModelScope.launch {
            // コルーチンからsuspend関数を呼出し
            itemDao.update(item)
        }
    }

    // 品目の削除
    fun deleteItem(item: Item) {
        // DBのアクセスはメインスレッド外のコルーチンを使用
        viewModelScope.launch {
            // コルーチンからsuspend関数を呼出し
            itemDao.delete(item)
        }
    }


    // 在庫数チェック
    fun sellItem(item: Item) {
        if (item.quantityInStock > 0) {
            // 在庫を-1したitemオブジェクトとしてコピーする
            val newItem = item.copy(quantityInStock = item.quantityInStock - 1)
            // コピーオブジェクトをもとにDBを更新する
            updateItem(newItem)
        }
    }

    // 数量が0より大きいかのチェック
    fun isStockAvailable(item: Item): Boolean {
        return (item.quantityInStock > 0)
    }

    // 入力チェック
    fun isEntryValid(itemName: String, itemPrice: String, itemCount: String): Boolean {
        if (itemName.isBlank() || itemPrice.isBlank() || itemCount.isBlank()) {
            return false
        }
        return true
    }

    // 新規データクラスのエンティティを返す
    private fun getNewItemEntry(itemName: String, itemPrice: String, itemCount: String): Item {
        return Item(
            itemName = itemName,
            itemPrice = itemPrice.toDouble(),
            quantityInStock = itemCount.toInt()
        )
    }

    // 更新のデータクラスエンティティを返す
    private fun getUpdatedItemEntry(
        itemId: Int,
        itemName: String,
        itemPrice: String,
        itemCount: String
    ): Item {
        return Item(
            id = itemId,
            itemName = itemName,
            itemPrice = itemPrice.toDouble(),
            quantityInStock = itemCount.toInt()
        )
    }
}


/**
 * ViewModelのメモリ管理を行うためFactoryクラスを
 * 利用してViewModelをインスタンス化する
 */
class InventoryViewModelFactory(
    private val itemDao: ItemDao
    ) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(InventoryViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return InventoryViewModel(itemDao) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

RecyclerViewのAdapter

RecyclerViewのListAdapterクラス
書式は
class クラス名 : ListAdapter(C: DiffUtils.ItemCallback)
A:Object は表示するデータ
B:クラス内でViewHolderを定義しそのクラス名をセット
ViewHolderは2つの抽象メソッドでも使用する
C:DiffUtils.ItemCallback は A:Objectの差分確認方法を実装したItemCallback
クラス内にcompanion objectを作成しDiffUtil.ItemCallbackを作成
2つの抽象メソッドが必要
ListAdapterクラス内には、前述のViewHolderとDiffUtil.ItemCallbackオブジェクトの他にonCreateViewHolderとonBindViewHolderを実装する。
onCreateViewHolder→ViewHolderをリターン
onBindViewHolder→画面スクロール時に現在のposのデータをViewHolderに渡す
引数の部分は画面遷移の関数を受取る(Item型を受取りUnit(void)を返す)
画面遷移メソッドは呼出し元のフラグメント側で設定している

/**
 * RecyclerViewのListAdapterクラス
 *
 * 書式は
 * class クラス名 : ListAdapter<A,B>(C: DiffUtils.ItemCallback)
 * A:Object は表示するデータ
 * B:クラス内でViewHolderを定義しそのクラス名をセット
 * ViewHolderは2つの抽象メソッドでも使用する
 * C:DiffUtils.ItemCallback は A:Objectの差分確認方法を実装したItemCallback
 * クラス内にcompanion objectを作成しDiffUtil.ItemCallbackを作成
 * 2つの抽象メソッドが必要
 *
 * ListAdapterクラス内には、前述のViewHolderとDiffUtil.ItemCallbackオブジェクトの他に
 * onCreateViewHolderとonBindViewHolderを実装する。
 * onCreateViewHolder→ViewHolderをリターン
 * onBindViewHolder→画面スクロール時に現在のposのデータをViewHolderに渡す
 *
 * 引数の部分は画面遷移の関数を受取る(Item型を受取りUnit(void)を返す)
 * 画面遷移メソッドは呼出し元のフラグメント側で設定している
 */

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.inventory.data.Item
import com.example.inventory.data.getFormattedPrice
import com.example.inventory.databinding.ItemListItemBinding

class ItemListAdapter(private val onItemClicked: (Item) -> Unit) :
    ListAdapter<Item, ItemListAdapter.ItemViewHolder>(DiffCallback) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        return ItemViewHolder(
            ItemListItemBinding.inflate(
                LayoutInflater.from(
                    parent.context
                )
            )
        )
    }

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        // カレントデータの取得
        val current = getItem(position)
        // 呼出し元で定義したonItemClickedメソッド(画面遷移)を
        // リスナーに登録する
        holder.itemView.setOnClickListener {
            onItemClicked(current)
        }
        holder.bind(current)
    }

    class ItemViewHolder(private var binding: ItemListItemBinding) :
        RecyclerView.ViewHolder(binding.root) {
        // カレントデータをバインドデータにセットする
        // 通貨は拡張関数を通して取得
        fun bind(item: Item) {
            binding.apply {
                itemName.text = item.itemName
                itemPrice.text = item.getFormattedPrice()
                itemQuantity.text = item.quantityInStock.toString()
            }
        }
    }

    // ListAdapterの差分確認
    companion object {
        private val DiffCallback = object : DiffUtil.ItemCallback<Item>() {
            override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
                return oldItem === newItem
            }
            override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
                return oldItem.itemName == newItem.itemName
            }
        }
    }
}

フラグメント

ItemListFragment
Factoryクラスを使用して共有ViewModelをインスタンス化。
RecyclerViewのadapterに画面遷移設定を行う。
FABの画面遷移設定も行う。

/**
 * Factoryクラスを使用して共有ViewModelをインスタンス化。
 * RecyclerViewのadapterに画面遷移設定を行う。
 * FABの画面遷移設定も行う。
 */
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.inventory.databinding.ItemListFragmentBinding

class ItemListFragment : Fragment() {

    // 共有ビューモデル
    // Factoryクラスを用いてViewModelをインスタンス化
    private val viewModel: InventoryViewModel by activityViewModels {
        InventoryViewModelFactory(
            // DBをインスタンス化し引数のDAOを戻す
            (activity?.application as InventoryApplication).database
                .itemDao()
        )
    }

    private var _binding: ItemListFragmentBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = ItemListFragmentBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 引数がidの画面遷移をRecyclerViewのアダプタに設定
        val adapter = ItemListAdapter {
            val action = ItemListFragmentDirections.actionItemListFragmentToItemDetailFragment(it.id)
            this.findNavController().navigate(action)
        }
        // recyclerViewにアダプタを設定
        binding.recyclerView.adapter = adapter
        // オブザーバー内でsubmitする事によりRecyclerViewが新しいアイテムで更新される
        viewModel.allItems.observe(this.viewLifecycleOwner) { items ->
            items.let {
                adapter.submitList(it)
            }
        }
        // RecyclerViewのレイアウト
        binding.recyclerView.layoutManager = LinearLayoutManager(this.context)

        // 追加ボタンの画面遷移設定
        binding.floatingActionButton.setOnClickListener {
            val action = ItemListFragmentDirections.actionItemListFragmentToAddItemFragment(
                getString(R.string.add_fragment_title)
            )
            this.findNavController().navigate(action)
        }
    }
}

ItemDetailFragment
Factoryクラスを使用して共有ViewModelをインスタンス化。
引数のidをキーにしてDBからデータを再取得し画面表示
販売・削除・編集処理へのリスナー登録など

/**
 * Factoryクラスを使用して共有ViewModelをインスタンス化。
 * 引数のidをキーにしてDBからデータを再取得し画面表示
 * 販売・削除・編集処理へのリスナー登録など
 */

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.example.inventory.data.Item
import com.example.inventory.data.getFormattedPrice
import com.example.inventory.databinding.FragmentItemDetailBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder

class ItemDetailFragment : Fragment() {
    lateinit var item: Item
    private val navigationArgs: ItemDetailFragmentArgs by navArgs()
    private var _binding: FragmentItemDetailBinding? = null
    private val binding get() = _binding!!

    // 共有ビューモデル
    // Factoryクラスを用いてViewModelをインスタンス化
    private val viewModel: InventoryViewModel by activityViewModels {
        InventoryViewModelFactory(
            // DBをインスタンス化し引数のDAOを戻す
            (activity?.application as InventoryApplication).database
                .itemDao()
        )
    }

    // バインドデータに値をセット
    private fun bind(item: Item) {
        binding.apply {
            // 値をバインドデータにセット
            itemName.text = item.itemName
            itemPrice.text = item.getFormattedPrice()
            itemCount.text = item.quantityInStock.toString()
            // 在庫がない場合はsellItemボタンを無効化
            sellItem.isEnabled = viewModel.isStockAvailable(item)
            // リスナー登録。在庫を1つ減らしてDBを更新するボタン
            sellItem.setOnClickListener { viewModel.sellItem(item) }
            // リスナー登録。削除ボタン
            deleteItem.setOnClickListener { showConfirmationDialog() }
            // リスナー登録。editItemボタン
            editItem.setOnClickListener { editItem() }
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentItemDetailBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // フラグメントの引数を取得
        val id = navigationArgs.itemId
        // retrieveItemで取得したデータ(selectedItem)を
        // observeでフラグメントに反映させる
        viewModel.retrieveItem(id).observe(this.viewLifecycleOwner) { selectedItem ->
            item = selectedItem
            bind(item)
        }
    }

    // 削除の問い合わせダイアログ
    // PositiveButtonの場合は品目削除関数を呼出し
    private fun showConfirmationDialog() {
        MaterialAlertDialogBuilder(requireContext())
            .setTitle(getString(android.R.string.dialog_alert_title))
            .setMessage(getString(R.string.delete_question))
            .setCancelable(false)
            .setNegativeButton(getString(R.string.no)) { _, _ -> }
            .setPositiveButton(getString(R.string.yes)) { _, _ ->
                deleteItem()
            }
            .show()
    }

    // 項目の削除
    private fun deleteItem() {
        // 品目削除関数の呼出し
        viewModel.deleteItem(item)
        // 元の画面に戻る
        findNavController().navigateUp()
    }

    // 項目編集画面への遷移
    private fun editItem() {
        // idを引数として項目編集フラグメントへ遷移
        val action = ItemDetailFragmentDirections.actionItemDetailFragmentToAddItemFragment(
            getString(R.string.edit_fragment_title),
            item.id
        )
        this.findNavController().navigate(action)
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

AddItemFragment
Factoryクラスを使用して共有ViewModelをインスタンス化。
引数のidが存在する場合はDBからデータを再取得し更新処理
引数のidが存在しない場合は新規登録処理

/**
 * Factoryクラスを使用して共有ViewModelをインスタンス化。
 * 引数のidが存在する場合はDBからデータを再取得し更新処理
 * 引数のidが存在しない場合は新規登録処理
 */
import android.content.Context.INPUT_METHOD_SERVICE
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.example.inventory.data.Item
import com.example.inventory.databinding.FragmentAddItemBinding

class AddItemFragment : Fragment() {

    private val navigationArgs: ItemDetailFragmentArgs by navArgs()
    lateinit var item: Item
    private var _binding: FragmentAddItemBinding? = null
    private val binding get() = _binding!!

    // 共有ビューモデル
    // Factoryクラスを用いてViewModelをインスタンス化
    private val viewModel: InventoryViewModel by activityViewModels {
        InventoryViewModelFactory(
            // DBをインスタンス化し引数のDAOを戻す
            (activity?.application as InventoryApplication).database
                .itemDao()
        )
    }

    // バインドデータに値をセット
    private fun bind(item: Item) {
        val price = "%.2f".format(item.itemPrice)
        binding.apply {
            itemName.setText(item.itemName, TextView.BufferType.SPANNABLE)
            itemPrice.setText(price, TextView.BufferType.SPANNABLE)
            itemCount.setText(item.quantityInStock.toString(), TextView.BufferType.SPANNABLE)
            saveAction.setOnClickListener { updateItem() }
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentAddItemBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // フラグメント引数を取得
        val id = navigationArgs.itemId
        // 編集or新規登録画面の判定(default=-1)
        if (id > 0) {
            // idが存在する場合は該当データを再取得し画面表示 
            viewModel.retrieveItem(id).observe(this.viewLifecycleOwner) { selectedItem ->
                item = selectedItem
                bind(item)
            }
        } else {
            // idが存在しない場合は新規登録画面として表示する 
            // ボタン(@+id/save_action)にリスナーを設定
            binding.saveAction.setOnClickListener {
                addNewItem()
            }
        }
    }

    /**
     * Called before fragment is destroyed.
     */
    override fun onDestroyView() {
        super.onDestroyView()
        // Hide keyboard.
        val inputMethodManager = requireActivity().getSystemService(INPUT_METHOD_SERVICE) as
                InputMethodManager
        inputMethodManager.hideSoftInputFromWindow(requireActivity().currentFocus?.windowToken, 0)
        _binding = null
    }

    // 入力チェック
    private fun isEntryValid(): Boolean {
        return viewModel.isEntryValid(
            binding.itemName.text.toString(),
            binding.itemPrice.text.toString(),
            binding.itemCount.text.toString()
        )
    }

    // 新規追加処理
    private fun addNewItem() {
        if (isEntryValid()) {
            // データの新規追加
            viewModel.addNewItem(
                binding.itemName.text.toString(),
                binding.itemPrice.text.toString(),
                binding.itemCount.text.toString(),
            )
        }
        // 一覧画面へ遷移
        val action = AddItemFragmentDirections.actionAddItemFragmentToItemListFragment()
        findNavController().navigate(action)
    }

    // 更新処理
    private fun updateItem() {
        if (isEntryValid()) {
            // データの更新
            viewModel.updateItem(
                this.navigationArgs.itemId,
                this.binding.itemName.text.toString(),
                this.binding.itemPrice.text.toString(),
                this.binding.itemCount.text.toString()
            )
            // 一覧画面へ遷移
            val action = AddItemFragmentDirections.actionAddItemFragmentToItemListFragment()
            findNavController().navigate(action)
        }
    }
}

トレーニング > KOTLIN を用いた ANDROID の基本 > データの永続化 > SQL、ROOM、フローの概要 > Roomとフローの概要 > 3. Room の依存関係を追加する