🪐

Viewからロジックを切り離すとViewModelになってしまうよな

2024/11/24に公開

自然となってしまう?

Flutterで開発をしているときに、View側の状態管理はStatefulWidgetflutter_hooksを使うことが多かった。ConsumerStatefulWidgetを使うこともあった。しかしView側のコードに処理が増えていくと🪐カオスな光景を見てしまうことがあった😇

昔はなんでもRiverpodでStateの管理をしようとしていた。これが僕のやり方。僕じゃない人でもViewからロジックを分けて、Stateだけ持たせたクラスを作り画面遷移やダイアログを出すコードだけ、View側に持たせる設計にこだわっている人がいた。

Viewからロジックを分けると、自然とViewModelになるらしい?
本当かな。。。
画面が状態を持っているのがViewModelらしいが。元々ViewModelはMicrosoftの人が考えたらしい。
https://learn.microsoft.com/ja-jp/dotnet/architecture/maui/mvvm

Microsoft公式

ViewModel
ビュー モデルでは、ビューでデータ バインドできるプロパティとコマンドを実装し、変更通知イベントを通じて状態の変更をビューに通知します。 ビュー モデルで提供されるプロパティとコマンドでは、UI によって提供される機能を定義しますが、その機能の表示方法はビューで決定されます。

ヒント

非同期操作で UI の応答性を維持します。

マルチプラットフォーム アプリでは、ユーザーのパフォーマンス認識を向上させるために、UI スレッドのブロックを解除したままにする必要があります。 したがって、ビュー モデルでは、I/O 操作に非同期メソッドを使用し、イベントを発生させ、プロパティの変更をビューに非同期的に通知します。

ビュー モデルには、必要なモデル クラスとビューのやりとりを調整する役割もあります。 通常、ビュー モデルとモデル クラスの間には一対多の関係があります。 ビュー モデルでは、ビュー内のコントロールが直接データ バインドできるように、モデル クラスをビューに直接公開することを選択する場合があります。 この場合、モデル クラスは、データ バインディングと変更通知イベントをサポートするように設計される必要があります。

各ビュー モデルでは、ビューで簡単に使用できる形式のモデルからのデータが提供されます。 これを実現するために、ビュー モデルではデータ変換を行うことがあります。 ビュー モデルにこのデータ変換を配置することをお勧めします。これにより、ビューがバインドできるプロパティが提供されるためです。 たとえば、ビュー モデルでは、2 つのプロパティの値を組み合わせて、ビューで表示しやすくすることができます。

ヒント

変換レイヤーでデータ変換を一元化します。

また、ビュー モデルとビューの間に位置する個別のデータ変換レイヤーとしてコンバーターを使用することもできます。 これは、たとえば、ビュー モデルで提供されない特殊な書式がデータに必要な場合などに必要になることがあります。

ビュー モデルがビューとの双方向データ バインディングに参加するには、そのプロパティで PropertyChanged イベントを発生させる必要があります。 ビュー モデルは、INotifyPropertyChanged インターフェイスを実装し、プロパティが変更されたときに PropertyChanged イベントを発生させることで、この要件を満たします。

コレクションの場合は、ビュー フレンドリな ObservableCollection<T> が提供されます。 このコレクションでは、コレクションの変更通知を実装するため、開発者はコレクションに INotifyCollectionChanged インターフェイスを実装する必要がなくなります。

https://developer.android.com/topic/libraries/architecture/viewmodel?hl=ja

Google公式

ViewModel の概要

Android Jetpack の一部。

bookmark_border

ViewModel クラスは、ビジネス ロジックまたは画面レベルの状態ホルダーです。状態を UI に公開し、関連するビジネス ロジックをカプセル化します。状態がキャッシュに保存され、構成が変更されてもそれが維持されることが主なメリットです。つまり、アクティビティ間を移動するときや、画面の回転などの構成の変更に従うときに、UI でデータを再度取得する必要がありません。

目標: このガイドでは、ViewModel の基本、最新の Android 開発における位置づけ、およびそれらをアプリに実装する方法について説明します。
状態ホルダーの詳細については、状態ホルダーのガイダンスをご覧ください。また、UI レイヤの一般的情報については、UI レイヤのガイダンスをご覧ください。

ViewModel のメリット

ViewModel の代わりに、UI に表示するデータを保持するプレーンクラスを利用できます。これは、アクティビティ間や Navigation デスティネーション間を移動する際に問題になることがあります。インスタンス状態保存メカニズムを使用して保存せずにこれを行うと、そのデータが破棄されます。ViewModel であれば、データの永続性のための便利な API を利用して、この問題を解決できます。

ViewModel クラスの主なメリットは基本的に次の 2 つです。

UI 状態を保持できます。
ビジネス ロジックにアクセスできます。
注: ViewModel は、Hilt や Navigation、Compose などの主な Jetpack ライブラリとの統合を完全にサポートしています。

永続性

ViewModel は、ViewModel で保持される状態と、ViewModel でトリガーされるオペレーションの両方で、永続性を実現します。このようにキャッシュに保存されることで、一般的な構成の変更(画面回転など)があってもデータを再度取得する必要がなくなります。

範囲

ViewModel をインスタンス化する場合、ViewModelStoreOwner インターフェースを実装するオブジェクトを渡します。これは、Navigation デスティネーション、Navigation グラフ、アクティビティ、フラグメント、その他のインターフェースを実装するなんらかのタイプになります。これにより、ViewModelStoreOwner のライフサイクルに ViewModel のスコープが設定されます。これは、ViewModelStoreOwner が完全に削除されるまでメモリ内に残ります。

クラスの範囲は、ViewModelStoreOwner インターフェースの直接または間接のサブクラスです。直接サブクラスは、ComponentActivity、Fragment、NavBackStackEntry です。間接サブクラスの完全なリストについては、ViewModelStoreOwner リファレンスをご覧ください。

ViewModel がスコープされているフラグメントまたはアクティビティが破棄されると、スコープされている ViewModel では非同期処理が続行されます。これが永続性の鍵となります。

詳しくは、ViewModel のライフサイクルに関する以下のセクションをご覧ください。

SavedStateHandle

SavedStateHandle を使用すると、構成の変更でのみならず、プロセスの再作成後もデータを保持できます。つまり、ユーザーがアプリを閉じてから後で開いた場合でも、UI の状態を維持できます。

ビジネス ロジックへのアクセス
ビジネス ロジックの大部分はデータレイヤに存在しますが、UI レイヤにビジネス ロジックも含めることができます。これは、複数のリポジトリからのデータを組み合わせて画面 UI の状態を作成する場合や、特定の種類のデータにデータレイヤが不要な場合などに該当します。

ViewModel は、UI レイヤでビジネス ロジックを処理するのに適した場所です。また、アプリデータの変更のためにビジネス ロジックを適用する必要がある場合、ViewModel はイベントの処理も担い、階層の他のレイヤに委任します。

Flutterで作ってみた

riverpod generator + freezedでState管理をしてボタンを押したらローディングをする処理を作ってみました。
完成品

bool型のfreezdを作成。State classとして使います。

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'loading.freezed.dart';
part 'loading.g.dart';

// loadingのState class

class LoadingState with _$LoadingState {
  const factory LoadingState({
    (false) bool isLoading,
  }) = _LoadingState;

  factory LoadingState.fromJson(Map<String, Object?> json)
      => _$LoadingStateFromJson(json);
}

今回話題に出してるViewModelを作ります。RiverpodやProviderがこれを担当することになる。やることは、UIのStateを管理すること。今回だとボタンを押すと、ローディング中になるロジックの制御を担当してもらいます。

よくあるカウンターの増減、認証のトークンの保持を担当してくれてますね。本業だとCognitoのJWTトークンのStateを管理するのに使われていたような。。。

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:view_model_demo/model/loading.dart';
part 'loading_view_model.g.dart';

(keepAlive: true)
class LoadingViewModel extends _$LoadingViewModel {
  
  LoadingState build() {
    return const LoadingState();
  }

  Future<void> simulateApiCall() async {
    // ローディング状態を更新
    state = state.copyWith(isLoading: true);
    
    // API呼び出しをシミュレート
    await Future.delayed(const Duration(seconds: 3));
    
    // ローディング状態を更新
    state = state.copyWith(isLoading: false);
  }
}

ViewModelにはView側のロジックの画面遷移やダイアログのロジックは書いてはいけません。UI層に書くのが正しい責務となっております。普段あまり使わないのですが、mixinを使用してBuildContextを持っている多重継承できるLoadingDialogMixinを作成します。

SNSの案件では、スナックバーを表示するmixinを作っていましたね。これをSignInPage Classに多重継承させておりました。ref.listenでも良さそうですけどね。

import 'package:flutter/material.dart';

// ViewでDialogを表示するコード
mixin LoadingDialogMixin {
  void showLoadingDialog(BuildContext context) {
    showDialog(
      context: context,
      barrierDismissible: false,
      barrierColor: Colors.black54,
      builder: (BuildContext context) {
        return const Center(
          child: CircularProgressIndicator(
            valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
          ),
        );
      },
    );
  }

  void hideLoadingDialog(BuildContext context) {
    Navigator.of(context).pop();
  }

  void showCompleteSnackBar(BuildContext context) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('処理が完了しました')),
    );
  }
}

でこちらがUI層ですね。mixinを多重継承して、ロジック少し書いてますが、最小限にしてsetState()を使わずに、View側のコードを削減してボタンを押すイベントが起きたらインジケーターを表示するビジネスロジックを実装しております。

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:view_model_demo/loading_view_model.dart';
import 'package:view_model_demo/widget/loading_dialog_mixin.dart';

void main() {
  runApp(
    // Adding ProviderScope enables Riverpod for the entire project
    const ProviderScope(child: MyApp()),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(home: LoadingDemo());
  }
}

class LoadingDemo extends ConsumerWidget with LoadingDialogMixin {
  const LoadingDemo({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final loadingState = ref.watch(loadingViewModelProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('ローディングデモ'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: loadingState.isLoading
                  ? null
                  : () => _handleProcessStart(context, ref),
              child: const Text('処理開始'),
            ),
            const SizedBox(height: 20),
            Text(loadingState.isLoading ? '処理中...' : '待機中'),
          ],
        ),
      ),
    );
  }

  Future<void> _handleProcessStart(BuildContext context, WidgetRef ref) async {
    // ダイアログを表示
    showLoadingDialog(context);

    // ViewModelの処理を実行
    await ref.read(loadingViewModelProvider.notifier).simulateApiCall();

    // ダイアログを閉じる
    if (!context.mounted) return;
    hideLoadingDialog(context);

    // 完了メッセージを表示
    if (!context.mounted) return;
    showCompleteSnackBar(context);
  }
}

こちらはデモの動画

https://youtu.be/AKAzlhgUEuc

まとめ

個人開発だとこれだけの処理だとコード長いよな。いらないような?と思ってしまいますが、開発現場に入ってカオスなコードを見てやはり可能な限りViewからロジックは分離したほうがいいなと思いましたね。苦しい思いをすることになる😇

Androidの例だと

ビジネス ロジックの大部分はデータレイヤに存在しますが、UI レイヤにビジネス ロジックも含めることができます。これは、複数のリポジトリからのデータを組み合わせて画面 UI の状態を作成する場合や、特定の種類のデータにデータレイヤが不要な場合などに該当します。

ViewModel は、UI レイヤでビジネス ロジックを処理するのに適した場所です。また、アプリデータの変更のためにビジネス ロジックを適用する必要がある場合、ViewModel はイベントの処理も担い、階層の他のレイヤに委任します。

これを行うことによって保守性や可読性が上がります。とはいえ知識がないと使いこなすことはできないですね💦

Microsoftの解説でも同じと思いますが、UIレイヤにもビジネスロジックはあって、ViewからデータバインディングをしてViewModelを経由してデータレイヤであるModelを更新して、今回だとローディング状態がfalseからtrueに変更されていたということでした。

長くなりましたけど、Viewからビジネスロジックを切り離すと自然にViewModelになっているというお話でした。

ViewModelではない設計パターンもあるのでその場合どうしているのか気になる。。。
Reduxを使うとActionとStoreなるものがありましたが、ViewModelとは違いましたね。そもそもクラスではなかったし。

私の感覚だと、State classを使うことが多いのでViewModelに自然となっている気がした。

使っていないときはどうしているかというと、useState()やsetState()でState管理をしていますね。規模が大きくなってくるとあまりView側にロジックは書きたくないなーと最近思いました🥲

Discussion