LoginSignup
225
195

More than 3 years have passed since last update.

【Android】2020年からの MVVM【実践】

Last updated at Posted at 2020-12-20

Android Studio 4.1.1 androidx.lifecycle 2.2.0 Kotlin 1.4.20 kotlinx.coroutines

2020-10-27、kotlinx.coroutines 1.4.0 がリリースされました。
これにより SharedFlowStateFlow が stable になり、
MVVM1 が実装しやすくなりました。

一方で、MVVM を十分に理解せずに使っている方がまだ多いように感じています。2

そこで実際の実装の流れにそってサンプルプログラムの作りを見ながら、
MVVM はどのように設計すべきか、
これからはどのように実装するのがよいかを解説します。

なお、文中で紹介するソースコードは大幅に省略しています。
全体はこちらをご覧ください。
https://github.com/sdkei/Android_MVVM_Mediator_Login

対象読者

次のことについて初級程度の知識を持っている方を対象にしています。

  • Android Architecture Component
  • Data Binding
  • Kotlin Coroutines

コラム:Android 公式推奨アーキテクチャは MVVM ではない!

Android 公式推奨アーキテクチャ
皆さんご存じであろうこの図。
Android 公式の「アプリ アーキテクチャ ガイド」 で「アプリの推奨アーキテクチャ」として紹介されているものです。

このガイドをもって「Google が推奨するアーキテクチャは MVVM だ」と言う方が非常に多いのですが、
これは MVVM ではありません
理由は次のとおりです。

  • そもそもこのガイドのどこにも MVVM だとは書かれていない。
  • Model とよばれる領域が、Repository が参照している一部だけである。
  • 次のような記述があり、ビジネスロジックを ViewModel が持つ設計になっている。

ViewModel オブジェクトは、特定の UI コンポーネント(フラグメントやアクティビティなど)のデータを提供します。また、モデルとやり取りするデータ処理のビジネス ロジックを含んでいます。

小規模なアプリであればこのアーキテクチャの方がシンプルでよいかもしれません。
しかし大規模になると、Activity / Fragment に表示のロジックを持たせてしまって見通しが悪くなったり、ViewModel にビジネスロジックを持たせていることでそこが肥大化してしまったりします。
大規模なアプリでは MVVM を用いる方がよいでしょう。

サンプルプログラムとして作るもの

結城浩著「増補改訂版 Java言語で学ぶ デザインパターン入門」 第16章「Mediator ― 相手は相談役1人だけ」のサンプルプログラムである「名前とパスワードを入力するログインダイアログ」を作ります。

ログイン画面

使い方は次のとおりです。

  • ゲストでのログイン[Guest]か登録ユーザーでのログイン[Login]かを選択する。
  • 登録ユーザーでのログインの場合はユーザー ID[Username]とパスワード[Password]を入力する。
  • [OK]ボタンを押してログインする。
  • キャンセルするなら[Cancel]ボタンを押す。

UI は次のように動作します。

  • [Guest]が選択された状態では
    [Username]と[Password]は入力不可。
  • [Login]が選択された状態では
    [Username]は入力可。
  • [Login]が選択された状態で
    [Username]に1文字以上入力されていれば
    [Password]は入力可。
  • [OK]ボタンは
    • [Guest]が選択された状態では常に押下可。
    • [Login]が選択された状態では
      [Username]と[Password]が共に1文字以上入力されていれば押下可。
  • [Cancel]ボタンは常に押下可。

ボタンが押されたときの動作は次のとおりです。

  • [OK]ボタンが押されたら
    • [Guest]が選択されていれば次の画面に進む。
    • [Login]が選択されていれば
      • ユーザー ID とパスワードが合っていれば次の画面に進む。
      • そうでなければエラーメッセージを表示する。
  • [Cancel]ボタンが押されたら前の画面に戻る。

実装

MVVM の3層

MVVM ではプログラムを次の3層に分けます。

  • Model
  • View
  • ViewModel

MVVM という名前はこれら3層の名前から来ています。

3層の依存関係

これらは次のような依存関係になっています。
(MVVM という名前の順番とは異なることに注意!)

3層の依存関係

これは
Model 層は ViewModel 層や View 層のことを知らず、
ViewModel 層は View 層のことを知らない、
ということです。

このため Model 層 → ViewModel 層 → View 層の順で実装すると一層ずつ完結できて3やりやすいです。

今回もこの順番で進めていきます。

パッケージ構成

パッケージはトップレベルで3つに分けるのがおすすめです。
そうすれば各コードがどの層に属するものか分かりやすく、
誤った依存を機械的に発見できます。

今回の実装だと次のようになります。

パッケージ
io.github.sdkei.loginmvvm.view
io.github.sdkei.loginmvvm.viewmodel
io.github.sdkei.loginmvvm.model

大規模なアプリであれば層ごとにモジュールを分けてしまうのもよいかもしれません。

Model

Model 層の役割

Model 層はビジネスロジックとそれに関するデータ管理を受け持ちます。

UI 仕様には依存しないため、
コマンドライン版や別のプラットフォームでも(言語やバイナリの互換性があれば)使い回せます。

今回のビジネスロジックはなんでしょうか?

——「ログイン可否判断」ですね。

ユーザー管理クラス

ログイン可否判断をするには、
登録済みのユーザーとそのパスワードが管理されている必要があります。

その管理を行うクラス UserRepository を作成します。
Repository パターンですね。

UserRepository.kt
package io.github.sdkei.loginmvvm.model

// 省略

/** 登録ユーザーを管理するリポジトリー。 */
object UserRepository {

    // 省略

    /**
     * 登録ユーザーを認証する。
     *
     * @return 成功したかどうか。
     */
    suspend fun authenticate(userId: String, password: String): Boolean {
        // 省略
    }
}

実際にはユーザーを登録する機能などもありますが、
当記事で説明する範囲ではユーザー認証機能だけを使います。

認証する際には、通常、DB やネットワークなどへの I/O が発生します。
そこで認証関数は suspend にしました。
(GitHub に上げた実装では手抜きで簡単のために Map を使っています。)

ログイン可否判断クラス

ログイン可否判断を行うクラス LoginUseCase を作成します。

このクラスではログイン中のユーザーの管理も行います。

LoginUseCase.kt
package io.github.sdkei.loginmvvm.model

// 省略

/** ログインの管理を行う。 */
@Suppress("ObjectPropertyName")
object LoginUseCase {

    /** ゲストユーザーのユーザー ID。 */
    const val GUEST_USER_ID = ""

    private val mutex = Mutex()

    private val userRepository: UserRepository
        get() = UserRepository

    /** ログイン中のユーザーのユーザー ID。 */
    val loginUserId: StateFlow<String?>
        get() = _loginUserId
    private val _loginUserId = MutableStateFlow<String?>(null)

    /**
     * 登録ユーザーでログインする。
     *
     * [mutex] でロックした状態で呼び出さ **ない** こと。
     *
     * @return 成功したかどうか。
     */
    suspend fun loginRegisteredUser(userId: String, password: String): Boolean {
        mutex.withLock {
            checkNotLogin()

            return userRepository.authenticate(userId, password).also { isSucceeded ->
                if (isSucceeded) {
                    _loginUserId.value = userId
                }
            }
        }
    }

    /**
     * ゲストユーザーでログインする。
     *
     * [mutex] でロックした状態で呼び出さ **ない** こと。
     */
    suspend fun loginGuest() {
        mutex.withLock {
            checkNotLogin()

            _loginUserId.value = GUEST_USER_ID
        }
    }

    /**
     * ログアウトする。
     *
     * [mutex] でロックした状態で呼び出さ **ない** こと。
     */
    suspend fun logout() {
        mutex.withLock {
            checkLogin()

            _loginUserId.value = null
        }
    }

    /**
     * ログイン中であることを確認する。
     *
     * [mutex] でロックした状態で呼び出すこと。
     */
    private fun checkLogin() {
        check(mutex.isLocked)

        check(loginUserId.value != null) { "未だログインされていません。" }
    }

    /**
     * ログイン中でないことを確認する。
     *
     * [mutex] でロックした状態で呼び出すこと。
     */
    private fun checkNotLogin() {
        check(mutex.isLocked)

        check(loginUserId.value == null) { "既にログインされています。" }
    }
}

ログイン中のユーザーを MutableStateFlow 型の private プロパティ _loginUserId で管理し、
StateFlow 型の public プロパティ loginUserId として ViewModel 層に公開しています。
StateFlow を使うことで状態の保持ができ、またそれを監視しているオブジェクトに対して状態変更の通知を行えます。

LiveData とよく似ていますね。
しかし Model 層で使うのには LiveData より StateFlow の方が適しています。

LiveData では、

  • 監視するために使われる observe 関数は引数に LifecycleOwner オブジェクトを渡す必要があります。
    しかし Model 層や ViewModel 層には LifecycleOwner がありません。
  • LifecycleOwner が不要な observeForever 関数もありますが、 これは監視が不要になったときに明示的に removeObserver 関数を呼び出さなければメモリリークが起こります。

StateFlow であれば、

  • CoroutineScope を使い、それがアクティブである間だけ監視を行うことができます。
    CoroutineScope は、ViewModel 層であれば ViewModel クラスの拡張プロパティ viewModelScope で取得できますし、Model 層でも簡単に作ることができます。

ViewModel

ViewModel 層の役割

ViewModel 層は次のことを受け持ちます。

  • UI の状態の保持(ラジオボタンはどれが選択されているか、テキストフィールドには何が入力されているか、など)
  • UI コンポーネントに直接依存しない表示のロジック(テキストフィールドが空でなくなったらボタンを押下可能にする、など)

UI コンポーネント同士の連携は View 層で行ってはいけません。それは ViewModel 層の仕事です。
ViewModel 層は UI コンポーネント(View クラスなど)の型などを知っていてはいけません。それは View 層の仕事です。

例えば「テキストフィールドが空でなくなったらボタンを押下可能にする」のであれば、
EditText オブジェクトや Button オブジェクトを直接参照するのではなく、
次のようにします。

  • テキストフィールドの内容を保持するプロパティとボタンが押下可能かどうかを表すプロパティを ViewModel 層に用意し、View 層に公開する。
  • ViewModel 層では、テキストフィールドの内容を保持するプロパティを監視し、値が変更されたらボタンが押下可能かどうかを表すプロパティの値を変更する。
  • View 層では、それらのプロパティの値を監視して UI コンポーネントの表示に反映し、ユーザーによって UI コンポーネントが操作されたらそれをプロパティの値に反映させる。

これを実現するためには、ViewModel 層は UI の外部仕様は知っている必要があります。

ViewModel 層から View 層へは、基本的に UI のカタマリ1つごとに1つのオブジェクトを公開します。
Android では UI のカタマリは ActivityFragment になるはずです。
そしてそれに対して ViewModel 層から公開するオブジェクトは、ライフサイクル管理の都合上、 ViewModel クラスになるはずです。
結果として Activity オブジェクトおよび Fragment オブジェクトと ViewModel オブジェクトは一対一対応になります。

なお、「ViewModel 層には ViewModel クラスしか置いてはいけない」ということはありません
その実装の中で ViewModel クラスでないオブジェクトを使ってよいですし、
それらを View 層 に公開しても問題ありません。

ではここから、ViewModel 層から View 層に公開するクラスである LoginViewModelを実装していきましょう。

「状態」を View 層に公開する

詳しくは View 層の説明で述べますが、
View 層では「コードビハインド」をできるだけ減らすべきです。
そのためにはレイアウトファイルでデータバインディングできる形で
ViewModel 層が持つ「状態」を View 層に公開する必要があります。
双方向データバインディングする必要があるものは MutableLiveData 型、
単方向でよいものは LiveData 型で公開します。

LoginViewModel.kt
package io.github.sdkei.loginmvvm.viewmodel

// 省略

/** ログイン画面の [ViewModel]。 */
class LoginViewModel : ViewModel() {

    /** ゲスト/登録済みユーザーのどちらが選択されているか。 */
    val userType = MutableLiveData<UserType>(UserType.GUEST)

    /** ユーザー ID 入力欄の値。 */
    val userId = MutableLiveData<String>("")

    /** ユーザー ID 入力欄を有効にするかどうか。 */
    val isUserIdEnabled: LiveData<Boolean>
        get() = _isUserIdEnabled
    private val _isUserIdEnabled = MutableLiveData(false)

    /** パスワード入力欄の値。 */
    val password = MutableLiveData<String>("")

    /** パスワード入力欄を有効にするかどうか。 */
    val isPasswordEnabled: LiveData<Boolean>
        get() = _isPasswordEnabled
    private val _isPasswordEnabled = MutableLiveData(false)

    /** OK ボタンを有効にするかどうか。 */
    val isOkEnabled: LiveData<Boolean>
        get() = _okEnabled
    private val _okEnabled = MutableLiveData(true)

    /** キャンセルボタンを有効にするかどうか。 */
    val isCancelEnabled: LiveData<Boolean> = MutableLiveData(true) // 常に有効にする。
}

ゲスト/登録済みユーザーのどちらが選択されているかを表すプロパティ userType は、
UserType 型の値(を持つ MutableLiveData)です。
この型は enum クラスであり、Model 層で定義してあります。

UI の状態を表すプロパティ同士の連携

次に、いずれかの UI コンポーネントの状態が変わったら他の UI コンポーネントの状態を変える、という処理を実装します。
そのためには上で実装した LiveData 型プロパティの値の変更を監視する必要があります。

今回は複数のオブジェクトを一カ所で監視して、いずれかが変更されたら複数のオブジェクトを変更するようにします。

複数の LiveData オブジェクトを監視して、いずれかの値が変更されたときにある1つの LiveData オブジェクトの値を変更する場合であれば、その「ある1つの LiveData オブジェクト」を MediatorLiveData にするとよいでしょう。
しかしこれは複数のオブジェクトの値を変更する場合には適していません。
次の理由からです。

  • 自身が LiveData なので、自身以外の値を変更するとわかりにくくなる。
  • 自身が監視されていないと動作しない(他のオブジェクトを監視しない)。

そこで Flow に変換して、ViewModel.viewModelScope のスコープで監視するようにします。

LoginViewModel.kt
package io.github.sdkei.loginmvvm.viewmodel

// 省略

/** ログイン画面の [ViewModel]。 */
class LoginViewModel : ViewModel() {

    // 省略

    // プロパティが更新されたときに、それに依存する他のプロパティが更新されるように監視を開始する。
    init {
        listOf(userType, userId, password).forEach { liveData ->
            liveData.asFlow()
                .onEach { updateStatus() }
                .launchIn(viewModelScope)
        }
    }

    /** 各プロパティの値を、それらが依存する他のプロパティの現在の値に合わせて更新する。 */
    // 「増補改訂版 Java 言語で学ぶデザインパターン入門(結城浩著)」の「16章 Mediator」での
    // サンプルプログラムにある colleagueChanged メソッドに相当する。
    private fun updateStatus() {
        when (userType.value) {
            UserType.GUEST -> {
                _isUserIdEnabled.value = false
                _isPasswordEnabled.value = false
                _okEnabled.value = true
            }
            UserType.REGISTERED -> {
                _isUserIdEnabled.value = true
                if (userId.value.isNullOrEmpty()) {
                    _isPasswordEnabled.value = false
                    _okEnabled.value = false
                } else {
                    _isPasswordEnabled.value = true
                    _okEnabled.value = password.value.isNullOrEmpty().not()
                }
            }
            null -> Unit
        }.exhaustive
    }
}

ちなみに、when のブロックの後ろについている exhaustive は、
when ですべての分岐が網羅されていなければコンパイルエラーとするためのユーティリティ拡張プロパティです。
Kotlin の when で enum を評価するときに、分岐ケースの列挙が不完全であればビルドエラーにする workaround

コマンドとメッセンジャー

最後に、ログインとキャンセルの処理を実装します。

これらは MVVM では本来「コマンド」4として View 層から呼び出せるように公開するべきものです。
Android には「コマンド」の仕組みがないため、単なる関数として公開します。

これらの関数を実行した結果は返値ではなく「メッセンジャーパターン」を使って発信します。
コマンドに相当する関数はレイアウトファイルでボタン押下などに割り当てられるため、返値を返しても View 層では処理できないからです。
また、ログインとキャンセルの結果である「ログインに成功した」「ログインに失敗した」「キャンセルされた」は「状態」ではなくある瞬間の出来事なので、状態を表すオブジェクトである LiveData を使うのは適切ではありません。

メッセンジャーパターンは、「メッセンジャー」オブジェクトを介してメッセージ(情報)を送受信するデザインパターンです。
送信側はメッセンジャーにメッセージを渡し、受信側はメッセンジャーを監視することでメッセージを受け取ります。
Observer パターンの一種ですね。

メッセンジャーパターンのオブジェクト図

メッセンジャーオブジェクトには SharedFlow クラスが適しています。
監視を開始する際に渡した CoroutineScope がアクティブでなくなれば自動的に監視が解除されるので、監視の解除忘れによるメモリリークが起こらないからです。

メッセージオブジェクトには sealed クラスを使うのがよいでしょう。
サブクラスごとに全く異なる情報を持たせられ、
すべてのサブクラスが既知なので受信側でキャストして
サブクラスごとに異なる処理に回すことができます。
(このあたりについては受信側である View 層を実装する際に見ていきます。)

LoginViewModel.kt
package io.github.sdkei.loginmvvm.viewmodel

// 省略

/** ログイン画面の [ViewModel]。 */
class LoginViewModel : ViewModel() {

    /** メッセージを送出する [Flow]。 */
    val message: SharedFlow<Message>
        get() = _message
    private val _message = MutableSharedFlow<Message>()

    // 省略

    private val loginUseCase = LoginUseCase

    // 省略

    /** ログインし、ログイン後の画面に遷移する。 */
    fun login() {
        when (userType.value) {
            UserType.GUEST -> {
                viewModelScope.launch(Dispatchers.Main) {
                    loginUseCase.loginGuest()

                    Message.Succeeded(LoginUseCase.GUEST_USER_ID).also {
                        _message.emit(it)
                    }
                }
            }
            UserType.REGISTERED -> {
                val userId = checkNotNull(userId.value) { "ユーザーID が null です。" }
                    .also { check(it.isNotEmpty()) { "ユーザーID が空です。" } }
                val password = checkNotNull(password.value) { "パスワードが null です。" }
                    .also { check(it.isNotEmpty()) { "パスワードが空です。" } }

                viewModelScope.launch(Dispatchers.Main) {
                    val isSucceeded = loginUseCase.loginRegisteredUser(userId, password)
                    if (isSucceeded.not()) {
                        _message.emit(Message.Failed(userId))
                        return@launch
                    }

                    Message.Succeeded(userId).also {
                        _message.emit(it)
                    }
                }
            }
            null -> throw IllegalStateException("ユーザー種別が選択されていません。")
        }.exhaustive
    }

    /** ログインをキャンセルし、ログイン前の画面に遷移する。 */
    fun cancel() {
        viewModelScope.launch(Dispatchers.Main) {
            _message.emit(Message.Cancelled)
        }
    }

    ////////////////////////////////////////////////////////////////
    // ネストされた型
    //

    /** [message] プロパティから送出されるメッセージ。 */
    sealed class Message {
        /** キャンセルされたためログイン前の画面に遷移することを要求するメッセージ。 */
        object Cancelled : Message()

        /** ログインに成功したのでログイン後の画面に遷移することを要求するメッセージ。 */
        data class Succeeded(
            val userId: String
        ) : Message()

        /** ログインに失敗したのでそのことをユーザーに通知することを要求するメッセージ。 */
        data class Failed(
            val userId: String
        ) : Message()
    }
}

View

View 層の役割

View 層は UI コンポーネントに依存した処理を受け持ちます。

ViewModel 層の説明でも述べましたが、
View 層では UI コンポーネント同士の連携を実装してはいけません。
それは ViewModel 層の仕事です。

Android では ActivityFragment は UI コンポーネント(View クラス)に依存しますので View 層になります。

コードビハインド

View 層からはできる限り「コードビハインド」を減らすべきです。
コードビハインドというのは手続き型の言語(Kotlin や Java など)によるコードのことです。
宣言型の言語(XML など)によるコードはコードビハインドではありません。
宣言的に実装することでコードの見通しがよくなります。

レイアウトファイルでレイアウトを実装し、
レイアウトファイル上で UI コンポーネントの属性と ViewModel オブジェクトのプロパティとのデータバインディングを行うことで、
コードビハインドを大幅に減らすことができます。

レイアウトファイル

ではレイアウトファイルを作成しましょう。

login_fragment.xml
<!-- 本質でない属性は省略 -->
<?xml version="1.0" encoding="utf-8"?>
<layout>

    <data>

        <variable
            name="viewModel"
            type="io.github.sdkei.loginmvvm.viewmodel.LoginViewModel" />

        <!-- 省略 -->

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        tools:context=".view.LoginFragment">

        <RadioGroup
            android:id="@+id/radio_group_user_type"
            android:checkedButton="@={UserTypeToRadioButtonIdResConverter.convert(viewModel.userType)}">

            <RadioButton
                android:id="@+id/radio_button_guest_user" />

            <RadioButton
                android:id="@+id/radio_button_registered_user" />

        </RadioGroup>

        <TextView
            android:id="@+id/text_view_user_id" />

        <EditText
            android:id="@+id/edit_text_user_id"
            android:enabled="@{viewModel.isUserIdEnabled}"
            android:text="@={viewModel.userId}" />

        <TextView
            android:id="@+id/text_view_password" />

        <EditText
            android:id="@+id/edit_text_password"
            android:enabled="@{viewModel.isPasswordEnabled}"
            android:text="@={viewModel.password}" />

        <Button
            android:id="@+id/button_ok"
            android:enabled="@{viewModel.isOkEnabled}"
            android:onClick="@{() -> viewModel.login()}" />

        <Button
            android:id="@+id/button_cancel"
            android:enabled="@{viewModel.isCancelEnabled}"
            android:onClick="@{() -> viewModel.cancel()}" />

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

ラジオボタンの選択状態とユーザー ID、パスワードは、ユーザーによって変更されたときに View 層から ViewModel 層にも伝える必要があるため、双方向データバインディングします。
各 UI コンポーネントの有効/無効の指定は ViewModel 層から View 層への一方向でよいため単方向データバインディングです。

ボタンがクリックされたときの処理では viewModel オブジェクトが持つ関数を呼び出しています。

コンバーター

どのラジオボタンが選択されているかは RadioButtonGroup 要素の android:checkedButton 属性で指定しますが、
その値は RadioButton オブジェクトのリソース ID です。
これは View 層にあるレイアウトファイルで定義された値であるため、
ViewModel 層で使ってはいけません。

そこで ViewModel 層からは公開するプロパティは UserType 型(の値を持つ MutableLiveData)にして、
それを View 層でコンバーターを使って @IdRes Int 型に変換しています。

login_fragment.xml
        <!-- 省略 -->

        <!-- 本質でない属性は省略 -->
        <RadioGroup
            android:id="@+id/radio_group_user_type"
            android:checkedButton="@={UserTypeToRadioButtonIdResConverter.convert(viewModel.userType)}">

        <!-- 省略 -->
UserTypeToRadioButtonIdResConverter.kt
package io.github.sdkei.loginmvvm.view.converter

// 省略

/** [UserType] とリソース ID を相互変換するコンバーター。 */
object UserTypeToRadioButtonIdResConverter {
    @JvmStatic
    @IdRes
    fun convert(userType: UserType?): Int =
        when (userType) {
            UserType.GUEST -> R.id.radio_button_guest_user
            UserType.REGISTERED -> R.id.radio_button_registered_user
            null -> 0
        }.exhaustive

    @InverseMethod("convert")
    @JvmStatic
    fun inverseConvert(@IdRes idRes: Int): UserType? =
        when (idRes) {
            R.id.radio_button_guest_user -> UserType.GUEST
            R.id.radio_button_registered_user -> UserType.REGISTERED
            else -> null
        }.exhaustive
}

なお、コンバーターのコードはコードビハインドとは考えません。
うまく使っていきましょう。

view パッケージ直下のファイルが多い場合は、コンバーター用のサブパッケージを切ると見通しがよくなります。

Fragment

最後に Fragment クラスを作成します。

まず、バインディングの設定を行います。

LoginFragment.kt
package io.github.sdkei.loginmvvm.view

// 省略

/** ログイン画面。 */
class LoginFragment : Fragment() {
    private val viewModel by viewModels<LoginViewModel>()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
    ): View =
        LoginFragmentBinding.inflate(inflater, container, false).also {
            it.viewModel = viewModel
            it.lifecycleOwner = viewLifecycleOwner
        }.root
}

そしてメッセージの受信開始と、受信したときの処理を実装します。

LoginFragment.kt
package io.github.sdkei.loginmvvm.view

// 省略

/** ログイン画面。 */
class LoginFragment : Fragment() {
    private val viewModel by viewModels<LoginViewModel>()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
    ): View =
        LoginFragmentBinding.inflate(inflater, container, false).also {
            it.viewModel = viewModel
            it.lifecycleOwner = viewLifecycleOwner
        }.also {
            // メッセージの監視を開始する。
            // このタイミングである必要はないが、
            // データバインディングとタイミングを合わせておく。
            viewModel.message
                .onEach { onMessage(it) }
                .launchIn(lifecycleScope)
            // ↑lifecycleScope は Dispatchers.Main に束縛されているため、
            // ↑onMessage 関数はメインスレッドで呼び出される。
        }.root

    /** [ViewModel] から送られたメッセージを処理する。 */
    private fun onMessage(message: Message) {
        when (message) {
            is Message.Cancelled -> onMessageCancelled(message)
            is Message.Succeeded -> onMessageSucceeded(message)
            is Message.Failed -> onMessageFailed(message)
        }.exhaustive
    }

    @Suppress("UNUSED_PARAMETER")
    private fun onMessageCancelled(message: Message.Cancelled) {
        findNavController().navigate(R.id.action_loginFragment_to_beforeLoginFragment)
    }

    @Suppress("UNUSED_PARAMETER")
    private fun onMessageSucceeded(message: Message.Succeeded) {
        findNavController().navigate(R.id.action_loginFragment_to_afterLoginFragment)
    }

    @Suppress("UNUSED_PARAMETER")
    private fun onMessageFailed(message: Message.Failed) {
        // ログインできなかったことを通知するダイアログを表示する。
        AlertDialog.Builder(requireContext())
            .setMessage(R.string.dialog_message_login_failed)
            .setPositiveButton(R.string.button_close) { dialog, _ ->
                dialog.cancel()
            }
            .show()
    }
}

メッセージを受信したら、その具象型に応じた処理に回しています。
sealed クラスなのですべての具象型に対応できていることをコンパイル時に保証できます。
(ここでも拡張プロパティ exhaustive を使ってすべての分岐を網羅していることを保証しています。)

コードビハインドをなくすことはできませんが、
データバインディングとメッセージの処理だけの簡潔なコードになりました。

おわりに

長くなりましたが、MVVM を用いた実装の方法を紹介しました。
各層の役割と層同士の依存関係を守って、見通しのよいコードにしていきましょう。

誤りなどがありましたらご指摘いただければ幸いです。

参考

/以上


  1. ソフトウェアアーキテクチャの1つ。有名な MVC から派生したもの。 

  2. もしかしたら私もそうかもしれません。誤りなどあればご指摘いただければ幸いです。 

  3. 実際の開発ではそう都合よくは行きませんが。 

  4. ここでは詳細は述べません。興味があれば次の記事をご覧ください。https://www.atmarkit.co.jp/fdotnet/chushin/greatblogentry_02/greatblogentry_02_03.html 

225
195
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
225
195