Androidアプリ開発を行う際、公式も推奨しているViewModelを使ったアーキテクチャを採用することが多いと思います。
ViewからViewModelに処理を移動させていくと、Viewに全てを書くよりは幾分かマシになります。
一方、アプリケーションが複雑になっていくと、今度はViewModelが肥大化するという問題に直面するでしょう。
この問題は様々な要因が考えられ、解決は容易ではありません。
私自身、いつも頭を悩ましながら、コードの整理に努めています。
今回は、私が最近試みた中で比較的うまく行っている方法の中から、Modelに処理を寄せる話と、UseCaseレイヤーの話を、コードの例を交えながら紹介します。
MVVMアーキテクチャにおけるModelの取り扱いは非常に曖昧です。
そのため、Modelに対する私なりの解釈を説明しながら、Modelに処理を書く方法について解説を行います。
Modelとしてよく利用されるクラスとして、以下の2種類のクラスが存在します。
Repositoryはデータにアクセスするという重要な役割が存在し、それだけに専念させるべきで、ここにロジックを含ませるのは得策ではないでしょう。
私は、データを表現するオブジェクトクラスに、多くのドメインロジック、ビジネスロジックを持たせ、データと振る舞いは一緒に管理すべきだと考えています。
ドメインモデルにロジックが十分含まれていないものは「ドメインモデル貧血症」と呼ばれ、ViewModel等のサービスレイヤーが肥大化する良くないパターンとされています。
例えば、TODOアプリでタスクを一つ完了させる処理を考えます。
これを全てViewModelに記述すると、以下のようになります。
data class Task(
val id: String,
val title: String,
val isCompleted: Boolean
)
class ToDoViewModel: ViewModel {
private val _list = MutableLiveData<List<Task>>()
val list: LiveData<List<Task>> = _list
fun completeTask(task: Task) {
val current = _list.value
val next = current.map {
if (it.id == task.id) {
it.copy(isCompleted = true)
} else {
it
}
}
_list.value = next
}
}
これだけで十分に複雑ですが、実際には他にもすべきことはたくさんあり、さらに複雑になっていきます。
ここで、Task
等のオブジェクトクラスにメソッドを追加していきます。
Task
は完了させることができるため、完了させるメソッドを追加すべきでしょう。
data class Task(
val id: String,
val title: String,
val isCompleted: Boolean
) {
fun getCompleted(): Task {
return this.copy(isCompleted = true)
}
}
ここで重要なこととしては、直接パラメータを書き換えるのではなく、パラメータを変更した新たなオブジェクトを返すようにしています。
オブジェクトを直接変更してしまうと、影響範囲を予想しずらく、予期せぬ不具合を引き起こす可能性があります。
data classであれば copy
メソッドを使うことで簡単に実現することができます。
また、リストに対しても特定のタスクを完了させるというメソッドを追加させましょう。
これにはいくつかの方法があります。
はじめに紹介するのは、拡張関数でリストにメソッドを追加する方法です。
一番変更が少なくて済みますが、拡張関数の乱用はコードの見通しを悪くするため、注意が必要です。
fun List<Task>.getCompleted(task: Task): List<Task> {
return this.map {
if (it.id == task.id) {
it.getCompleted()
} else {
it
}
}
}
もう一つはdata classを利用するパターンです。
データへのアクセスや変更に一手間かかりますが、他のオブジェクトクラスと形が揃うため、見通しが良いと感じています。
data class TaskList(
val value: List<Task> = emptyList()
) {
fun getCompleted(task: Task): TaskList {
val next = value.map {
if (it.id == task.id) {
it.getCompleted()
} else {
it
}
}
return TaskList(next)
}
}
Kotlin 1.5からStableになったvalue classを利用しても良いでしょう。
どちらの方法を利用しても、ViewModelは以下のようにシンプルになります。
class ToDoViewModel: ViewModel {
private val _list = MutableLiveData(TaskList())
val list: LiveData<TaskList> = _list
fun completeTask(task: Task) {
_list.value = _list.value.getCompleted(task)
}
}
このように処理をModelに寄せることで、オブジェクトの処理を使いまわししやすいだけでなく、モデルのテストを書くだけで多くのロジックを担保できるメリットもあります。
@Test
fun getCompleted() {
val task1 = Task(id = "1")
val task2 = Task(id = "2")
val target = TaskList(listOf(task1, task2))
val actual = target.getCompleted(task1)
val expected = TaskList(listOf(
Task(id = "1", isCompleted = true),
task2
))
assertThat(actual).isEqual(expected)
}
効果的だと感じているのは、ViewModel等のレイヤークラスで直接copy
メソッドを呼ばないということです。
copy
メソッドが呼ばれるようなケースに付いて、一つずつ名前付けを行いModel内で行うことで、オブジェクトに対する操作が明確になります。
上記の方法で最大限Modelに処理を書いたとしても、ViewModelが肥大化する場合、アプリケーション自体が複雑な可能性があります。
そういった場合は、アプリケーションビジネスロジックを記述するレイヤーを追加することで改善するかもしれません。
これらは大抵UseCaseと呼ばれています。
例えば、TODOアプリで、無料会員は10件までしか登録できない、という仕様を想定します。(現実的ではないですが、あくまで例として)
こういったロジックは、支払状況も確認しなければならないため、全てをモデルに書くことが困難です。
まずはModelとViewModelで実現する例です。
data class Task(
val id: String,
val title: String,
val isCompleted: Boolean
) {
companion object {
fun create(title: String): Task {
return Task(
id = getUUID(),
title = title,
isCompleted = false
)
}
}
}
data class TaskList(
val value: List<Task> = emptyList()
) {
val size: Int get() = value.size
fun getAdded(task: Task): TaskList {
return TaskList(value + task)
}
}
class ToDoViewModel(
private val paymentRepository: PaymentRepository
): ViewModel() {
private val _list = MutableLiveData(TaskList())
val list: LiveData<TaskList> = _list
fun addTask(title: String) {
val current = _list.value
if (!paymentRepository.isPaid && current.size >= 10) {
requirePayment()
return
}
val task = Task.create(title)
_list.value = current.getAdded(task)
}
private fun requirePayment() {
// 支払い訴求の表示等
}
}
頑張ってModelに処理を書きましたが、まだViewModelにだいぶ処理が残っていますね。
さらにModelに処理を移していくことは可能かもしれませんが、データのフローが複雑になることが予想されます。
特に、ModelからRepositoryやAPI/DBに直接アクセスすることは、依存の方向が煩雑になるため、必ず避けるべきと考えます。
では、UseCaseレイヤーを追加してみましょう。
enum class AddResult {
Successed,
RequirePayment
}
class ToDoUseCase(
private val paymentRepository: PaymentRepository
) {
private val _list = MutableLiveData(TaskList())
val list: LiveData<TaskList> = _list
fun addTask(title: String): AddResult {
val current = _list.value
if (!paymentRepository.isPaid && current.size >= 10) {
return AddResult.RequirePayment
}
val task = Task.create(title)
_list.value = current.getAdded(task)
return AddResult.Successed
}
}
class ToDoViewModel(
private val toDoUseCase: ToDoUseCase
): ViewModel() {
val list: LiveData<TaskList> = toDoUseCase.list
fun addTask(title: String) {
when(toDoUseCase.addTask(title)) {
AddResult.RequirePayment -> requirePayment()
AddResult.Successed -> { }
}
}
private fun requirePayment() {
// 支払い訴求の表示など
}
}
アプリケーションビジネスロジックをUseCaseに移動させたことで、ViewModelは表示に関するロジックに集中できるようになりました。
例えば、アイテムを課金することで10枠追加される、みたいな仕様が追加された場合であっても、ロジック自体はUseCaseレイヤーの修正で実現できます。
また逆に、タスク追加時になんらかのUI表示をさせたい場合、ViewModelとViewの変更のみで済みます。
処理の目的と変更頻度によってクラスは分けるべきです。
今回の内容を整理すると、以下のように考えることができます。
あくまでも関心の分離が重要であり、ViewModelの全ての処理をUseCaseに移動させたり、複数のViewModelの共通化の目的でUseCaseを作成するのは好ましくありません。
UIロジックを共通化したいのであれば、UIレイヤー内で共通のクラスに移譲させたり、ビジネスロジックが複雑化しているのであれば、UseCaseレイヤー内でリファクタを検討すべきでしょう。
それぞれのレイヤーの責務を考えてコードを書くことで、結果的にViewModelに書くべきコード量は減り、メンテナンスやテストが容易になります。
しばし「銀の弾丸はない」と言われるように、複雑なアプリケーションの実装は、一筋縄に解決できるものではありません。
私自身、今回紹介した以外にもViewModelの分割や他クラスへの処理の移譲など、コードの見通しが良くなるよう各ケースごとに試行錯誤しています。
今回紹介した方法も参考にしつつ、色々試してもらえると幸いです。