ローソンデジタルイノベーション テックブログ

ローソンデジタルイノベーション(LDI)の技術ブログです

Flutter の RiverpodライブラリでMVVMを実現させよう!

こんにちは、Android開発エンジニアの岡田です。

今回はFlutterのライブラリであるRiverpodを使うことで、MVVMアーキテクチャを実現させた経験を記事にします。

はじめに

具体的な話をする前に、開発環境やどんな方に読んでいただく想定の記事なのかについて記載します。

開発環境

  • Flutter:バージョン 3.7.7
  • flutter_riverpod:バージョン 2.3.1
  • hooks_riverpod:バージョン 2.3.1

対象読者

本記事は以下のような方々に向けた記事です。

  • Riverpodを使ったことがない人!
  • Flutterをこれから始める人!!

かくいう自分もFlutter歴は3ヶ月ほどしかありません。
この記事を執筆することで理解を深められたら、と目論んでいます。。。

開発システムについて

社内の有志メンバーで在宅勤務報告システムの開発を行いました。
この開発で得たFlutterの知見をもとに本記事を執筆します。
開発システムの概要は以下記事を御覧ください。

techblog.ldi.co.jp

開発システムのアーキテクチャ

開発システム全体のアーキテクチャは以下記事を御覧ください。

techblog.ldi.co.jp

今回はその中でもMVVMに注目します。

MVVMについて

MVVMについてはご存じの方が多いと思われるので、 今回は概要のみを簡単に説明します。
MVVMはアーキテクチャの1つで、ソフトウェアの役割を以下の3つに分担します。

  • Model:ソフトウェアのドメイン部分、データ処理を担当
  • View:ソフトウェアのUIを担当
  • ViewModel:ソフトウェアの状態管理を担当

MVVM イメージ図

役割を分担することで、コードの肥大化やスパゲッティー化を防ぎます。
これによりシステムの保守性が向上するというメリットがあります。
ちなみに皆さんは「状態管理」という言葉をご存知でしょうか?
状態についてはそのままの意味ですが、例を記載してみます。

  • 電子マネー系アプリの残高
  • 前回のログイン日時
  • 投稿した記事のいいね数

改めて考えるとソフトウェアは様々な状態の集合体とも言えますね。
この状態の管理をUIと分けて実装することが、MVVMの特徴の1つです。

Riverpodについて

RiverpodはFlutterで「状態管理」を行うためのライブラリです。 先程説明したViewModelの役割と一致しています。 そのため、Riverpodの機能を利用するとViewModelが実装しやすくなると考え、RiverpodライブラリでMVVMを実現することにしました。 また、Riverpodを利用することで、ViewとViewModelを簡単につなぎ合わせることができます!

次にViewModelとして利用したRiverpodのStateNotifierProviderの各要素について簡単に説明します。 riverpod.dev Riverpodでは以下の3つの要素に分けて、状態管理を行います。

  • State:ソフトウェアの状態を表す要素
  • Notifer:Stateを内包している要素、ViewはNotiferを用いて状態管理を行う
  • Provider:ViewにNotiferを提供する要素

実際の使用例

それではRiverpodを使ってどのようにMVVMを実現させていくのか、
開発システムの内容を元に以下の具体例に沿って説明していきます。

  1. 業務開始画面の業務開始ボタンをタップ
  2. 業務開始の処理が実行
  3. 処理の成功、失敗を画面にメッセージで表示

処理の結果を成功、失敗という状態としてViewModel側が持つことで、状態を検知したView側が画面に処理の結果を表示する、といった流れです。

ViewModel

はじめにProviderのコードです。 ※実際の開発システムのコードを抜粋し、一部変更しています。

/// 業務開始画面の ViewModel の Provider
final workStartViewModelProvider = StateNotifierProvider.autoDispose<
        WorkStartViewModelNotifier, WorkStartPageState>(
    (ref) => WorkStartViewModelNotifierInjector.inject(ref));

ProviderはStateNotiferProviderを使用しています。
またコードに記載されているWorkStartViewModelNotifierInjector.injectメソッドが実際にNotiferを返します。 ここでは依存性の注入(DI)が行われているのですが、 今回の記事では詳細を割愛させていただきます。

次にNotiferのコードを紹介します。

/// 業務開始画面のViewModel
class WorkStartViewModelNotifier extends StateNotifier<WorkStartPageState> {
  final Ref _ref;
  final WorkStartModel _model;

  WorkStartViewModelNotifier(this._ref, this._model,
      {required WorkStartPageState state})
      : super(state);

  /// 業務開始処理を実行
  void startWork() {
    _model
        .startWork()
        .then((_) => state = state._success)
        .catchError((_) => state = state._error);
  }
}

実際のコードから成功、失敗の状態を反映させる箇所だけ記載しました。 コメントにもあるようにNotiferがViewModelの役割を果たしています。
業務開始処理の実行はstartWorkメソッドを用いて行います。
ここで記載されているmodel.startWorkからわかるように、具体的なデータ処理はModelが行っています。
ViewModelはmodel.startWorkの結果を受けて、成功ならstate._successを、失敗ならstate._errorを用いて状態を更新します。

では最後の要素であるStateのコードを紹介します。

/// 業務開始画面の状態
@immutable
class WorkStartPageState {
  /// 成功メッセージ
  final String? successMessage;

  /// エラーメッセージ
  final String? errorMessage;

  // ViewModelNotifier以外でStateを更新できないように、コンストラクタをprivateにする
  const WorkStartPageState._(this.successMessage, this.errorMessage);

  WorkStartPageState.init(): successMessage = null, errorMessage = null;

  /// 処理完了
  WorkStartPageState get _success => const WorkStartPageState._("処理完了", null);

  /// 処理失敗
  WorkStartPageState get _error => const WorkStartPageState._(null, "処理失敗");
}

こちらも実際のコードから、成功メッセージとエラーメッセージの状態だけ管理するように修正してあります。
Stateの特徴として、イミュータブルであることがあげられます。
先程のNotiferのコードでstate = state._successなどのように、
状態を変化させる手段として代入が行われているのは、イミュータブルであるためです。

View

ViewModelを用いて、Viewがどのように実装されているかをここでは説明します。 はじめにView全体のコードを紹介します。

/// 業務開始のページ
class WorkStartPage extends StatelessWidget {
  final viewModelProvider = workStartViewModelProvider;

  WorkStartPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text('業務開始'),
        ),
        body: Container(
            padding: const EdgeInsets.all(16),
            child: ConstrainedBox(
                constraints: const BoxConstraints.expand(),
                child: Column(children: [
                  Consumer(builder: (context, ref, _) {
                    final successMessage =
                        ref.watch(viewModelProvider).successMessage ??
                            "";
                    final errorMessage =
                        ref.watch(viewModelProvider).errorMessage ?? "";
                    if (successMessage.isNotEmpty) {
                      return MessageWidget.success(message: successMessage);
                    } else if (errorMessage.isNotEmpty) {
                      return MessageWidget.error(message: errorMessage);
                    } else {
                      return const SizedBox(
                        height: 10,
                      );
                    }
                  }),
                  Consumer(builder: (context, ref, _) {
                    return FilledButton(
                      onPressed: () =>
                          ref.read(viewModelProvider.notifier).startWork(),
                      child: const Text('業務開始ボタン'),
                    );
                  }),
                ]))));
  }
}

まず業務開始をタップすることで、ViewModelが業務開始を実行します。

FilledButton(
  onPressed: () =>
      ref.read(viewModelProvider.notifier).startWork(),
  child: const Text('業務開始ボタン'),
);

その結果を受けて、状態が変化することでView側が検知してUIを変更します

Consumer(builder: (context, ref, _) {
  final successMessage =
      ref.watch(viewModelProvider).successMessage ?? "";
  final errorMessage =
      ref.watch(viewModelProvider).errorMessage ?? "";
  if (successMessage.isNotEmpty) {
    return MessageWidget.success(message: successMessage);
  } else if (errorMessage.isNotEmpty) {
    return MessageWidget.error(message: errorMessage);
  } else {
    return const SizedBox(
      height: 10,
    );
  }
}),

ref.watch(viewModelProvider)と記載することで、Viewは値を監視します。
これにより成功メッセージがセットされれば成功メッセージを、
失敗メッセージがセットされれば失敗メッセージを表示するようになります。

Riverpodを使用した感想

Riverpodを利用することで、ViewとViewModelを簡単につなぎ合わせることができました!
ViewはページのUI、Modelは状態管理の役割をメインにコーディングされているため保守性が高いコードとなっています。
一方でRiverpodを用いてMVVMを実装したことにより、コード量は増加してしまっています。
Riverpodを使用するに際しての学習コストもあるため、開発スピードを重視するのであればMVVMを用いない選択も考えられます。
システムを開発する際は保守性と工数を天秤にかけて、アーキテクチャ設計を行っていく必要があるでしょう。

最後に

今後も開発メンバーが技術ブログを更新していく予定ですので、興味がある方は是非「読者になる」をお願いします!