Roomを使って格納するデータとViewModel連携のあれこれ。

つくりたいもの

  • Roomでデータ格納
  • 格納するデータをMVVMで表示
  • 一部データは後で書き換えられるようにする

手探りで進めた結果ですので参考程度にお願いします。割と雑に書いているけど許して。

つくる

格納するデータ(Entity)定義

こんな感じ。

@Entity(tableName = "prize_history")
data class PrizeHistory(
    @PrimaryKey
    @ColumnInfo(name = "tweet_id")
    val tweetId: String,

    @ColumnInfo(name = "account_id")
    val accountId: String,

    @ColumnInfo(name = "tweet_content")
    val tweetContent: String,

    @ColumnInfo(name = "closing_date")
    val closingDate: MutableLiveData<Date>
)

この時点で書き換えたいデータはMutableLiveDataにしておく。DAO, Databaseはあまり凝ったことはしていないので調べてください。
Date型値はそのままDBに突っ込めないのでConverterも作成しておく。

class Converters {
    @TypeConverter
    fun fromTimestamp(value: Long?): MutableLiveData<Date>? {
        return value?.let { MutableLiveData(Date(it)) }
    }

    @TypeConverter
    fun dateToTimestamp(date: MutableLiveData<Date>?): Long? {
        return date?.value?.time
    }
}

表示するレイアウトのxml

RecylerViewで表示する中の、各行のデータレイアウトの一部を抜粋。

<layout xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
                name="item"
                type="com.rutilicus.twitter_prize_management.PrizeHistory" />
        <variable
                name="viewModel"
                type="com.rutilicus.twitter_prize_management.PrizeHistoryViewModel" />
    </data>
    <LinearLayout
            android:orientation="horizontal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:id="@+id/linearlayout_history_item"
            android:background="@drawable/border">
        <!-- 中略 -->
        <TextView
                android:id="@+id/textview_content_closing"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight=".20"
                android:maxLines="1"
                app:convertDate="@{item.closingDate}"
                android:onClick="@{() -> viewModel.onClickClosingDate(item)}"
        />
        <!-- 後略 -->
    </LinearLayout>
</layout>

あまり凝ったことはしていないと思うが、Date型の値を表示するためにBindingAdapterを使用している。こんな感じのコード。

object BindingAdapters {
    @BindingAdapter("convertDate")
    @JvmStatic
    fun convertDate(view: TextView, date: MutableLiveData<Date>?) {
        view.text = date?.value?.let { SimpleDateFormat("MM/dd", Locale.JAPANESE).format(it) }
}

ViewModel

インターネット上の情報をもういろいろ見て作りました……

class PrizeHistoryViewModel(application: Application) : AndroidViewModel(application) {
    private var itemsRaw = mutableListOf<PrizeHistory>()

    private val _items = MutableLiveData<List<PrizeHistory>>(emptyList())
    val items: LiveData<List<PrizeHistory>> = _items.distinctUntilChanged()

    private val _uiState = MutableStateFlow(PrizeHistoryUiState())
    val uiState: StateFlow<PrizeHistoryUiState> = _uiState

    private var dao: PrizeHistoryDao?

    init {
        if (DatabaseInstance.database == null) {
            DatabaseInstance.createDatabase(getApplication<Application>().applicationContext)
        }
        dao = DatabaseInstance.database?.prizeHistoryDao()
    }

    fun loadPrizeHistory() {
        val loadItems = mutableListOf<PrizeHistory>()

        CoroutineScope(Dispatchers.Main).launch {
            withContext(Dispatchers.Default) {
                // DBからデータ読み出し・設定
                dao?.getAll()?.forEach { loadItems.add(it) }
            }
            itemsRaw = loadItems
            _items.value = ArrayList(itemsRaw)
        }
    }

    fun updateClosingDate(item: PrizeHistory, newDate: Date) {
        itemsRaw.forEach {
            if (it.tweetId == item.tweetId) {
                it.closingDate.value = newDate
                CoroutineScope(Dispatchers.Default).launch {
                    dao?.updateAll(it)
                }
                return@forEach
            }
        }
    }

    fun onClickClosingDate(item: PrizeHistory) {
        viewModelScope.launch {
            _uiState.update { it.copy(dateFixItem = item) }
            return@launch
        }
    }

    fun dateFixDialogShown() {
        _uiState.update { it.copy(dateFixItem = null) }
    }
}

まず、DBアクセスのビジネスロジックをViewModelに入れる都合上、Contextが必要になるのでAndroidViewModelを継承する。initでアクセス用の初期処理を実行。
loadPrizeHistoryは初期表示時のデータ一斉読み込みメソッド。Mainスコープであれこれやるよう書いているけど不要かも。
updateClosingDateは格納するデータのうち後から書き換え対象となるDate型オブジェクトを更新するメソッド。MutableLiveDataの書き換えとDBの書き換えを同じメソッド内で行う。DBアクセスはMainスコープでできないのでDefaultスコープで実行。
onClickClosingDateは更新データ入力用のダイアログを開くトリガを発火させるメソッド。dateFixDialogShownはダイアログ表示後の後処理メソッド。
ダイアログはUI要素なのでViewModel中には記載しないようにする。StateをUI側で監視して、状態に変化があればダイアログを開くようにする。
Stateを格納するdata classはなんてことのないクラス。

data class PrizeHistoryUiState(
    val dateFixItem: PrizeHistory? = null,
)

Fragment

表示するUI側の実装。

class HistoryFragment : Fragment() {

    private lateinit var prizeHistoryListAdapter: PrizeHistoryListAdapter
    private lateinit var viewModel: PrizeHistoryViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        activity?.application?.let {
            viewModel = ViewModelProvider(
                it as MyApplication, PrizeHistoryViewModelFactory(it))[PrizeHistoryViewModel::class.java]
        }
        return FragmentHistoryBinding.inflate(inflater, container, false)
            .apply {
                recyclerviewHistory.run {
                    layoutManager = LinearLayoutManager(context)
                    adapter = PrizeHistoryListAdapter(viewLifecycleOwner, this@HistoryFragment.viewModel).also {
                        prizeHistoryListAdapter = it
                    }
                }
            }.run {
                root
            }
    }

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

        // データ読み出し処理
        viewModel.loadPrizeHistory()

        viewModel.run {
            items.observe(viewLifecycleOwner) {
                prizeHistoryListAdapter.submitList(it)
            }
        }

        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    uiState.dateFixItem?.let {
                        Calendar.getInstance().apply {
                            val item = uiState.dateFixItem
                            this.time = item.closingDate.value!!
                            this@HistoryFragment.context?.let { context ->
                                DatePickerDialog(
                                    context,
                                    { _, year, month, dayOfMonth ->
                                        Calendar.getInstance().apply {
                                            this.set(year, month, dayOfMonth)
                                            viewModel.updateClosingDate(item, this.time)
                                        }
                                    },
                                    this.get(Calendar.YEAR),
                                    this.get(Calendar.MONTH),
                                    this.get(Calendar.DAY_OF_MONTH),
                                ).show()
                            }
                        }
                        viewModel.dateFixDialogShown()
                    }
                }
            }
        }
    }
}

onCreateViewの処理については定型のViewModelを使用する場合の処理とは離れていますが、これは別Activityからデータの追加処理を行う都合上こうなっています。Applicationを継承したクラスでViewModelStoreを保持するようにしています。それ以外は凝ったことはしていないはず。
onViewCreatedではデータの初期化処理と、データ更新時のRecyclerViewへの反映のための監視、ダイアログを開くためのトリガ発火時の処理を記載。一応動いているつもりではあるが、あまり自信はない……

できたアプリ

こんな感じのアプリになりました。なんだかクラッシュしているようなのだが、Android Vitalsからレポートが見れない(報告されていない?)ので何が起こっているのか分からない……
play.google.com