🚀

MVVMとMVIの違いを一度で理解する

2024/05/09に公開

オリジナル記事の作成者へのクレジット

この記事はPhill Lackner氏のビデオを参考にして作成しました。オリジナルビデオは以下のリンクからご覧いただけます:

https://www.youtube.com/watch?v=b2z1jvD4VMQ


はじめに

ネイティブAndroid開発を学ぶ際には、MVVMとMVIという用語が避けて通れないですね。一部の人々はMVVMを使うことを絶対に主張し、他の人々はMVIがどんな種類のアプリにも最適だと言います。そして、ほとんどの人々が実際の違いを理解していないように感じますし、単に他の開発者からのイデオロギーを盲目的に追っているように思えます。だからこのビデオでは、これらのパターンを一度として比較し、同じ点、重要な違いを明らかにし、最終的にはあなたのプロジェクトに適したパターンを選択できるようにします。そして、すべてを単にこのカメラに向かって言うのではなく、実際に実践的な例を示して、その違いがすぐにわかるようにします。

MVVMとMVIって何?

しかし、MVVMとMVIに戻りましょう。まず、これらのパターンについて実際に同じであることについて話しましょう。最終的に、MVVM(Model View ViewModelの略)とMVI(Model View Intentの略)の両方は、いわゆるプレゼンテーションパターンです。そして、これがほとんどの人が知らない最初のことです。これらのパターンは、プレゼンテーション層を異なる部分に分けるためにだけ使用されます。これらのパターンは、アプリ全体を異なる部分に分けるためではありません。なぜなら、「MVVMを使うべきか、それともクリーンアーキテクチャを使うべきか?」という話をよく聞くからです。いいえ、これらはまったく異なるものです。両方を同じプロジェクトで使用できます。なぜなら、クリーンアーキテクチャはアプリ全体を異なる部分、つまり異なるレイヤーに分割しますが、MVVMはプレゼンテーション層、つまりクリーンアーキテクチャプロジェクトのほんの一部を異なる部分に分割します。

MVVMとMVIの共通部分

さて、これらの2つのパターン、MVVMとMVIが何を表しているかを知ったので、まずは共通の用語、つまりモデルとビューについて考えてみましょう。まず、ソフトウェアエンジニアリングの意味でモデルは非常に広い概念であり、さまざまなものを指すことができます。MVVMとMVIの場合、モデルはビジネスルールとビジネスロジックを実装します。つまり、彼らは単にプロジェクト全体の要件を実装します。例えば、ソーシャルネットワークアプリの場合、ユーザーがプロフィールを持ち、投稿したり、投稿にコメントしたりするというのはプロジェクト全体の要件です。「投稿の下にコメントを付けたい」といったものであり、コメントが持つべきデータ型などです。さらに、これらのビジネスルールは、投稿が実際に何であるか、投稿が何から成り立っているかを示します。それは単なるテキストなのか、画像を含むのか。そして最終的に、Kotlinではこれらは非常に頻繁に通常のデータクラスとして表現されます。

以下のクラスを見てください:

data class Comment(
    val id: String,
    val author: User,
    val content: String,
)

これはコメントのデータクラスであり、この特定のソーシャルネットワークプロジェクトのスコープまたはドメインにおけるコメントモデルを表しています。つまり、"コメントには確かにidがあり、そのコメントを書いたauthorがいます"と言います。再び、これは別のモデルであるユーザーモデルであり、後でお見せしますが、contentという形式のstringがあります。

次に、ユーザーモデルを見てみると:

data class User(
    val id: String,
    val username: String,
    val email: String,
    val role: Role,
) {
    fun isAllowedToDeletePost(post: Post): Boolean {
        return when(role) {
            Role.ADMIN -> true
            Role.USER -> id == post.author.id
        }
    }
}

enum class Role {
    USER, ADMIN
}

ユーザーにもidがあり、ユーザーにはusernameemail、おそらくroleがあります。このroleuserまたはadminになるかもしれません。そして、このようなビジネスルールもここに関数として含まれる可能性があります。ユーザークラスの一部として。特定の投稿を削除する権限があるかどうかは、非常に具体的であり、それはプロジェクトの要件を読むことで明確になります。したがって、それはあなたのプロジェクトのドメインに特有です。この場合、管理者であれば、任意の投稿を削除できます。そして、ユーザーであれば、ユーザーのIDが実際に投稿の著者のIDと同じである場合にのみ、その投稿を削除できます。つまり、ユーザーが投稿の著者である場合に限り、削除できるのです。

完全性のために、投稿モデルもあります。

data class Post(
    val id: String,
    val author: User,
    val content: String,
    val imageUrl: String?,
    val comments: List<Comment>,
    val isLiked: Boolean,
)

各投稿には、authorcontentimageUrlcommentsのリスト、投稿を「いいね」したかどうかのBooleanがあります。これはすでにMVVMMVIの両方で同じ部分です。

Android開発では、独自のドメインレイヤーを利用することが一般的です。ここ数分間、用語「ドメイン」が頻繁に登場しています。もしそのようなドメインレイヤーを組み込む場合、モデルはプレゼンテーションレイヤーに厳密に結びついているわけではなく、むしろ別個のドメインレイヤーに属しています。ただし、ドメインレイヤーを組み込むことは必須ではありません。ドメインレイヤーがない場合、モデルはプレゼンテーションレイヤーの一部となり、その後、MVVMまたはMVIに合致します。

では、これら2つのパターンの間で共通しているのはモデルです。次に、いわゆるビューも共通しています。ビューは最終的にはユーザーが見える部分を指します。つまり、実際のUIであり、Androidでは、XMLレイアウトまたは実際のビュークラス、またはJetpack Composeを使用している場合はコンポーザブルになります。結局のところ、これらのパターンはどちらもプレゼンテーション層を明確に区別することを目指しています:ユーザーに見える部分、データの出どころ(モデル)、そしてこれらのパターンが少し異なるもう一つの要素:ViewModelです。

MVVMでは、ViewModelという用語が略語に含まれていますが、MVIでは含まれていません。MVIはModel-View-Intentの略です。したがって、ここにはこの意図(Intent)があります。しかし、Androidでは、どちらのパターンも通常、ViewModelパターンで実装されます。ViewModelの役割は、状態マッピングロジックを含むことです。つまり、ボタンクリックやリフレッシュスワイプなどの入力UIアクションを処理し、これらのアクションに基づいて状態がどのように見えるかを決定します。例えば、リフレッシュスワイプを行うと、ViewModelは、「今、リフレッシュ中にローディングインジケータを表示する必要があります。その後、リスト内の新しいアイテムを表示する必要があります。」と決定します。これにより、最終的には、一方では入力された状態を受け取り、その状態がUIにとって今何を意味するかを決定する単純なUIがあります。そして、ViewModelは状態がどのように構築されるかを決定し、ビジネスロジック、ビジネスルール、およびすべてのデータが来る場所であるモデルがあります。

これで、ほとんどの場合、これら2つのパターン間でモデル、ビュー、そしてViewModelが同じであることを述べましたが、実際の違いは何でしょうか? そのために、一方では、単一の画面を使用して実装されたMVVMの例と、MVIの例も用意しました。これらの例を使って、違いを理解するのに役立ちます。

MVVMの例

MVVMの画面を見てみましょう:

@Composable
fun MvvmScreenRoot(
    navController: NavHostController,
    viewModel: MvvmViewModel = hiltViewModel(),
) {
    MvvmScreen(
        postDetails = viewModel.postDetails,
        isLoading = viewModel.isLoading,
        isPostLiked = viewModel.isPostLiked,
        onToggleLike = {
            viewModel.toggleLike()
        },
        onBackClick = {
            navController.navigateUp()
        },
    )
}

ここでは、MVVMの画面ルートコンポーザブルを示しています。このコンポーザブルは、単にnavControllerviewModelの参照を受け取り、それからViewModelから実際の画面コンポーザブルに状態を転送します。これは、私が常に使う一般的なパターンであり、ルートコンポーザブルと実際の画面コンポーザブルを持っています。なぜなら、通常のViewModelを実際の画面コンポーザブルに渡すと、プレビューが壊れる可能性があるからです。なぜなら、プレビューがコンストラクタを持つViewModel参照を構築できないからです。このような構造、つまりこのルート画面と通常の画面コンポーザブルは、MVIまたはMVVMとは関係がなく、単にCompose画面のために常に行うことです。

こちらの画面を見てみると:

@Composable
fun MvvmScreen(
    postDetails: Post?,
    isLoading: Boolean,
    isPostLiked: Boolean,
    onToggleLike: () -> Unit,
    onBackClick: () -> Unit,
) {
    if (isLoading) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center,
        ) {
            CircularProgressIndicator()
        }
    } else {
        // 投稿の詳細を表示
    }
}

ここでは、おそらくソーシャルネットワークアプリのドメイン内にある投稿の詳細画面でしょう。ここでは、読み込む必要があるため、nullであるpostDetails、現在読み込んでいるかどうかを示すboolean、投稿が「いいね」されているかどうかを示すbooleanが得られます。そして、ユーザーがその画面やUIで何かを行った場合、これらのラムダを上に伝達します。したがって、このUIに対して実際に何を意味するかを見ると、「読み込み中であれば、読み込み中インジケータを表示したいし、そうでなければ、何らかの投稿の詳細を表示したい」ということです。

そして、ViewModelは以下のようになります:

class MvvmViewModel(
    private val savedStateHandle: SavedStateHandle,
): ViewModel() {

    var postDetails by mutableStateOf<Post?>(null)
        private set

    var isLoading by mutableStateOf(false)
        private set

    var isPostLiked by mutableStateOf(false)
        private set

    init {
        savedStateHandle.get<String>("postId")?.let { postId ->
            loadPost(postId)
        }
    }

    fun toggleLike() {
        postDetails?.let {
            postDetails = it.copy(isLiked = !it.isLiked)
        }
    }

    private fun loadPost(id: String) {
        viewModelScope.launch {
            // isLoading = true
            // postDetails = repository.loadPost(id)
            // isLoading = false
        }
    }
}

ですので、ここにはMVVMのViewModelがあり、UIに影響を与えるさまざまな状態があります。これらはStateFlowであったり、Compose Stateであったりするかもしれませんが、それはこのパターンとは無関係です。そして、ViewModelには状態がどのように変化するか、いつ変化するかというロジックが含まれます。ここでは、最初に投稿を読み込みます。おそらくAPIからなどで読み込むことができます。いいねを切り替えたい場合、ここで投稿の詳細状態を変更し、いいねが切り替わります。投稿を読み込みたい場合、まず読み込み中の状態を更新し、リポジトリから読み込むか、ユースケースなどから読み込んで、再び読み込み中の状態を偽に更新します。つまり、ViewModelがこれらの状態の変化方法を決定します。これがMVVMの構造です。

MVIの例

では、MVIを見てみましょう:

@Composable
fun MviScreenRoot(
    navController: NavHostController,
    viewModel: MviViewModel = hiltViewModel(),
) {
    MviScreen(
        state = viewModel.state,
        onAction = viewModel::onAction,
    )
}

したがって、これはMVI画面の例です。最初の部分はすでに非常に似ています。NavControllerの参照とViewModelを受け取りますが、画面に渡す引数のリストは常に少し異なります。ここでは、1つの状態だけを画面コンポーザブルに渡し、1つのいわゆるonActionまたはonEventラムダを受け取ります。これがMVVMMVIの間の核心的な違いです。

MVIでは、一方で、すべての画面状態を1つの状態ラッパークラス、つまりUIStateクラスに入れます。例えば、次のようになります。

data class MviState(
    val postDetails: Post? = null,
    val isLoading: Boolean = false,
    val isPostLiked: Boolean = false,
)

つまり、最終的には、UIに1つのStateフィールドだけを公開し、UIの外観に影響を与える可能性があるすべての単一のフィールドを含めます。そして、この状態クラスを、その状態の真の単一のソースとして扱い、変更できない不変のプロパティだけを含めるべきです。状態が変化すると、MVIの目標は単に新しいインスタンスと変更されたフィールドで状態全体を置き換えることです。つまり、isLoadingをfalseからtrueに更新する場合、状態クラスの新しいインスタンスを作成し、その新しいインスタンスでisLoadingをtrueに設定し、他のすべてのフィールドを変更せずに残します。これがMVIの違いの一つです。

もう一つの違いは、このonActionラムダです。

MviScreen(
    state = viewModel.state,
    onAction = viewModel::onAction,
)

これは実際にはMVIに関する"intent"の部分を実装しています。つまり、MVI:Model-View-Intentです。インテントの部分はMVVMとは異なります。これはAndroidのインテントの概念とは関係ありませんが、むしろ特定の画面でユーザーが持つ特定の意図をまとめたいというものです。つまり、最終的には、特定の画面で発生する可能性のあるすべての単一のユーザーインタラクションが、このようなアクションクラスにパッケージ化されます。この場合は、いいねを切り替えるか、戻るかのどちらかです。

sealed interface MviAction {
    data object ToggleLike: MviAction
    data object GoBack: MviAction
}

そして、Kotlinでは、これは通常、sealedインタフェースを使用して実装され、viewModelで実際にUIからビューモデルに送信されたインテントまたはアクションを確認できます。

それで、ViewModelを見てみましょう:

class MviViewModel(
    private val savedStateHandle: SavedStateHandle,
): ViewModel() {

    var state by mutableStateOf(MviState())
        private set

    init {
        savedStateHandle.get<String>("postId")?.let { postId ->
            loadPost(postId)
        }
    }

    fun onAction(action: MviAction) {
        when (action) {
            MviAction.ToggleLike -> toggleLike()
            else -> Unit
        }
    }

    private fun toggleLike() {
        state.postDetails?.let {
            state = state.copy(
                postDetails = it.copy(isLiked = !it.isLiked)
            )
        }
    }

    private fun loadPost(id: String) {
        viewModelScope.launch {
            // isLoading = true
            // postDetails = repository.loadPost(id)
            // isLoading = false
        }
    }
}

最初の見た目では、非常に似ているように見えます。しかし、主な違いは、MVIビューモデルでは単一のStateフィールドしかないことです。これは以前に示したUIStateクラスで実装されています。ですので、MVVMと比較して、再度言及します:

var postDetails by mutableStateOf<Post?>(null)
    private set

var isLoading by mutableStateOf(false)
    private set

var isPostLiked by mutableStateOf(false)
    private set

こちらでは、各フィールドが個別の状態参照としてありますが、MVIでは単一の状態クラスのみがあります。

var state by mutableStateOf(MviState())
    private set

そして、これらのアクション、つまりUIから来る意図を処理するために、ビューモデルはそのようなonAction関数またはonEvent関数を公開する必要があります。この関数は実際のインテントを取得し、when式でどの特定のタイプのアクションに対して実際に何が起こるべきかをチェックします。例えば、いいねを切り替える場合、プライベート関数toggleLikeが呼び出されます。これは再びローカルな状態を変更します。この場合はstate.copyで、これは状態の新しいインスタンスを作成し、その状態の1つまたは複数のフィールドを変更できます。この場合、投稿の詳細値です。

ですので、これらがすでに違いです。MVVMMVIの違いは実際にはあまり大きくありません。これは実際には封印されたインターフェースとすべての状態を1つのUIStateクラスにまとめることについてですが、それ以外は通常、Android上でまったく同じ方法で実装されます。

MVIで少し注意する必要があることは、ビューモデルで実際に処理されないアクションがある場合です:

data object GoBack: MviAction

例えば、戻るというような、それが通常ビューモデルにはないnavControllerが必要なアクションです。

@Composable
fun MviScreenRoot(
    navController: NavHostController,
    viewModel: MviViewModel = hiltViewModel(),
) {
    MviScreen(
        state = viewModel.state,
        onAction = { action ->
		        // ここでインセプト
            when(action) {
                is MviAction.GoBack -> navController.navigateUp()
                else -> Unit
            }
            viewModel.onAction(action)
        },
    )
}

したがって、MVIでは、実際にはMVIスクリーンルートに移動する必要があります。そして、このonActionラムダで、デフォルトではすべてのアクションをビューモデルに転送するだけですが、これらのアクションをインターセプトして、まずアクションをUI自体で処理する必要があるかどうかをチェックする必要があります。そのアクションがgoBackである場合、navControllerを使用してnavigateUpを行いたいと考えます。それ以外の場合は、UIレイヤーで無視します。しかし、それでもすべてのアクションをビューモデルに転送します。これは実際にはここで行う必要があることですが、それがUIで直接処理される必要がある場合のみです。しかし、多くの場合、そうでない場合もあり、以前の方法で置き換えることができます。すなわち、単にviewModel::onActionと記述するだけです。

どっちのパターン使えば良い?

では、今正しいのは何でしょうか?あなたのAndroidアプリで何を使うべきでしょうか?まあ、私はあなたにこれを使ってほしい、あれを使ってほしいとは言いません。最終的に、これらのどちらも非常に実用的で非常に人気のあるパターンです。しかし、私の目標と意図は、単にあなたが違いを理解し、両方を試し、自分自身でどちらがより楽しいか、どちらがチームにより適しているかを決定することです。なぜなら、最終的に、あなたがパターンに固執しても、チームの一部だけがそれに固執して、他の部分がいくつかの不一致を導入するだけであれば、デザインパターンを導入する意味がありません。ただし、各パターンの利点と欠点を強調することができます。

MVIの批評家は、このような意図のレイヤー、この場合のMVIアクションの封印されたインターフェースが、ただの余分なレイヤーであり、スクリーンにコールバックを公開してビューモデルの関数を直接呼び出すこともできると述べています。これはほとんどMVVMが行っていることです。批評家は、大きなスクリーンがある場合、特定のアクションをチェックするための非常に大きなwhen式があると述べています。

fun onAction(action: MviAction) {
    when (action) {
        MviAction.ToggleLike -> toggleLike()
        else -> Unit
    }
}

ある程度、それは真実です。明らかに、このアクションの封印されたインターフェースはある種の追加のクラス、追加のレイヤーですが、一方で、これにより画面のコンポーザブルは私の意見でははるかに軽量化され、読みやすくなります。なぜなら、アプリのすべての画面に対して、2つのパラメーターだけが必要であり、これにより画面はかなり軽量になるからです。

また、when式に関する批評についてですが、個人的にはそのような人々の考え方が理解できませんでした。なぜなら、潜在的に大きなwhen式が問題とされるのか、それでも単一のプライベート関数で作業できるし、どの特定のシナリオで何が起こるかを非常に簡単に把握できます。そして、もし単一の関数がなく、他のすべての関数が非常に大きなスクリーン用にパブリックである場合、Compose UIでは、ここにあるパラメーターのリスト:

@Composable
fun MvvmScreen(
    postDetails: Post?,
    isLoading: Boolean,
    isPostLiked: Boolean,
    onToggleLike: () -> Unit,
    onBackClick: () -> Unit,
)

はかなり増えるでしょう。なぜなら、viewModelからの各関数が個別のラムダである必要があり、各状態がここで個別のフィールドである必要があるからです。もちろん、関連する複数の状態をデータクラスに組み合わせることができ、複数のコールバックもデータクラスに組み合わせることができると主張することができます。しかし、その後、再びこれらの批評家がMVIについて不満を述べる追加のコードが生じることになります。

では、その場合本当に違いがあるのでしょうか? MVIのもう一つの一般的な批評は、MVVMを使用している場合、すべての状態を個別のフィールドとして持っていると、それらを組み合わせるのがはるかに簡単であるという点です。たとえば、それらがStateFlowであり、複数のこれらのStateFlowを組み合わせたい場合、単一の状態クラスよりも単一の状態の方がはるかに簡単です。

class MvvmViewModel(
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {

    // ...

    private val _email = MutableStateFlow("")
    private val _isEmailValid = _email
        .map {
            // Validator.isValidEmail(it)
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)

    // ...
}

一般的な例として、ここに何らかのメールフィールドがあるかもしれません。または、デフォルトでは空の文字列であるプライベートなval emailがあります。そして、私たちが反応性でisValidEmailを実装することができます。なぜなら、私たちのメールが変わるたびに、それを、例えば変更されたメールで何らかのバリデータisValidEmailにマップしたいからです。そして、私たちはstateInを呼び出して、それをいくつかの典型的な方法でStateFlowに変換します。whileSubscribedfalseの初期値を持っています。

MVIの批評家はしばしば、単一のフィールドがある場合、この反応性をより良く実装でき、異なる状態をより良く組み合わせることができると言います。しかし、それも実際には真実ではありません。なぜなら、MVIのviewModelに単一のコンポジット状態がある場合、それを簡単にStateFlowに変換できるからです。したがって、私たちは、ここで、これらのフィールドを持っているとしましょう。

data class MviState(
    val postDetails: Post? = null,
    val isLoading: Boolean = false,
    val isPostLiked: Boolean = false,
    val email: String = "",
    val isEmailValid: Boolean = false,
)

val emailはデフォルトで空の文字列です。そして、isValidEmailは、実際にはBooleanで、メールが変更されるたびにリアルタイムで更新されるべきです。

class MviViewModel(
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {

    var state by mutableStateOf(MviState())
        private set

    init {
        snapshotFlow { state.email }
            .map {
                // Validator.isValidEmail(it)
            }
            .onEach {
                state = state.copy(isEmailValid = it)
            }
            .launchIn(viewModelScope)

      // ...
    }
}

そして、viewModelでメールフィールドの変更に反応したい場合、snapshotFlowを使用してそれを行うことができます。メールが変更されるたびにトリガーされるsnapshotFlowを持つことができます。したがって、メールが変更されるたびに、そのフローを私たちのバリデータ、つまり変更されたメールでisValidEmailにマップできます。そして、それを状態に反映させるか、実際の状態を更新します。state.copy isValidEmailで新しい値を。そして、それをviewModelスコープで起動します。これがMVIでの等価コードです。したがって、単一の状態クラスを使用する場合でも、特定の単一のフィールドの変更に対応できることを示すだけです。

総じて、私自身の意見としては、MVIの方が少し読みやすいと考えています。なぜなら、状態クラスを見れば、1つの画面で変更される可能性のあるすべての値がわかるからです。MVVMでは、ここに非常に長い状態のリストがあることがよくあります。まず、StateFlowを使用している場合、1つのStateFlowに対して2つのフィールドが常にあります。1つは公開されたStateFlowで、もう1つは不変のもので、そこにはemailemail.asStateFlowと同じです。

private val _email = MutableStateFlow("")
val email = _email.asStateFlow()

もし画面の状態が30個の状態を含んでいる場合、30個のこのようなコードがあることになります。一方、MVIでは、大きな画面についてほぼ一目で理解できます。MVIについて注意が必要なことは、1つの状態クラスがある場合、XMLを使用してその単一の状態を通常のフロー・コレクタで収集すると、状態の各変更ごとにフロー・コレクタ全体がトリガーされることです。これは、MVVMと比較して、各フィールドごとに複数のフロー・コレクタを持つことができるMVVMと比較して、パフォーマンス上の欠点となります。

その一方で、Jetpack Composeプロジェクトでは、MVIの状態の新しいインスタンスが渡されたときに実際にどのフィールドが変更されたかを検出するのがComposeで十分にスマートです。これには問題があることもあります。特に、このような状態クラスで不安定なフィールド、例えば通常のリストを使用する場合、Composeがそれが変更されたかどうかを確実に判断できない場合があります。しかし、これらは本当に一握りの状況です。そして、この場合、私はこの早まった最適化に取り組まないことを好みます。つまり、問題になる前にパフォーマンスを最適化しようとすることはありません。そのかわりに、コードの可読性を重視します。そのため、私は自分のプロジェクトで通常MVIに固執しています。しかし、これはMVVMが悪いという意味ではなく、MVIが最良であるという意味でもありません。このようなアーキテクチャのデザインパターンについて信仰を持たないでください。試してみて、自分にとって何が良いかを確認してから使用してください。そして、いずれのパターンでも良いプロジェクト構造を持つことができます。

株式会社ガラパゴス(有志)

Discussion