🏪

MVVMのFlutterアプリでriverpodからreduxへ移行したら保守性とAIコーディングとの相性が良くなった件について

に公開

お決まりの (?)

どうも、高比良ハルカ (@lucatech_) です。自分にぴったりのお出かけ先を見つけられるAIアプリ・iinoを開発する慶應発スタートアップのCEOをやっています。主要大学の修士、メガベンチャー出身者中心にTech Drivenな組織で開発を進めています。少しでもご興味持たれた方はぜひカジュアル面談でお話ししましょう!

https://pitta.me/matches/RrFyJsairpfv

TL;DR

  • 既存のMVVM構成では、チャットUIのような複雑な状態遷移の実装でメソッド粒度がバラつき、処理漏れや肥大化が発生していた
  • Reduxによって状態遷移を明示化し、UI・ロジック・副作用を分離することで、保守性が向上
  • Reduxは記述量が多いが、AIコーディングツール(Claudeなど)を活用して自動生成することで、開発工数は増えず、むしろ精度が上がった
  • MVVMのファットメソッドより、Redux設計の方がAI補完の精度が高い(肌感)

背景: riverpodを用いたステート管理で限界を感じた

例えば、次のような典型的なチャット送信時の処理をイメージしてみましょう。次のようなフローを実行するとします。

  1. ユーザーがメッセージを入力
  2. 入力候補(サジェスト)を表示
  3. 「送信」ボタン押下
  4. 入力内容をチャット履歴に追加
  5. API経由でAIレスポンスを取得
  6. レスポンスをUIに反映

そうした時に、riverpodを用いてMVVMでコードを書き、VMのメソッドにステート更新時に実行されるロジックを書くと、コードが煩雑になりやすいという課題がありました。そこで弊チームでは、特にロジックが煩雑な部分をriverpod → Reduxに置き換える施策に取り組むことにしました。


MVVM実装の問題点(Anti-pattern)

MVVM(+Riverpod)で実装すると、こんな感じのコードになりがちですよね:

class ChatViewModel extends StateNotifier<ChatState> {
  // メインの送信メソッドが肥大化
  Future<void> onSendPressed() async {
    // 状態変更
    state = state.copyWith(isSending: true);

    // 履歴に追加(UIロジック混在)
    _appendMessage(state.inputText);
    _scrollToBottom(); // UI副作用

    try {
      // 非同期API呼び出し
      final responses = await api.getResponse(state.inputText);

      // 状態更新+UI描画
      state = state.copyWith(
        isSending: false,
        responses: responses,
      );
      _renderResponses(responses); // UI混在

    } catch (e) {
      // エラーUI表示+状態更新
      _showErrorDialog(e.toString());
      state = state.copyWith(isSending: false);
    }
  }

  // 他にも入力変更やサジェスト取得など、粒度がバラバラなメソッドが並ぶ
  void onInputChanged(String text) => state = state.copyWith(inputText: text); // わずか1行
  Future<void> fetchSuggestions() async { /* API呼び出しと状態更新を含む20行超 */ }
}

onInputChanged のように1行で完結する軽いメソッドと、onSendPressed のように状態更新・副作用・UI描画が入り混じった数十行の重いメソッドが同居し、メソッド粒度がまちまちになっています。結果、どのメソッドが「状態更新」か、「UI操作」か、「副作用」かが曖昧になり、処理の追加・修正時に既存メソッドを肥大化させやすく、保守性が低下していると感じます。(View側でuseEffectを張り出したら尚更ですね)

また、テスト可能性も低いですね。たとえば「送信ボタン押下後、isSendingがfalseに戻り、responsesが更新されること」をテストしたい場合、onSendPressed には _appendMessage_renderResponses などUI副作用が混在しているため、状態のみを検証する単体テストを行うにはモックや非同期制御をいちいち設定する必要があり、テストコストが高いと感じます。

このように、MVVMでは一見シンプルに見えるものの、状態遷移や副作用が増えるほどにメソッド粒度のばらつき・責務の混在・テストの煩雑化が顕著になります。そこで、私たちは状態変化を明示的に定義し、UI・ロジック・副作用を分離するために、Reduxを導入するに至りました。


Redux化による解決

同じフローをReduxで実装するとどうなるでしょう。私たちは今回手書きで設計を行い、そのステートマシンを元にClaudeでState定義→他を生成・調整というステップで解決を図りました。


🎯 ステートマシン設計

  • イベント: ユーザー操作やAPI結果 (UpdateInput, SendChatStart, SendChatSuccess)
  • 状態: 入力中・サジェスト取得中・送信中など
  • 遷移: 状態とイベントの組み合わせで明示

こんな感じで設計を作成しREADME/claude.mdに落とし込むと、Action・Reducer・Middlewareを高精度で自動生成できます。肌感ですが、ここを自動化すると想定と違う挙動が増え手戻りが増える感じがします。実際には以下のような感じになりますね。


⚙️ Redux構成

Action

class UpdateInput { final String text; UpdateInput(this.text); }
class FetchSuggestionsStart {}
class FetchSuggestionsSuccess { final List<String> suggestions; FetchSuggestionsSuccess(this.suggestions); }
class SendChatStart { final String message; SendChatStart(this.message); }
class SendChatSuccess { final List<String> responses; SendChatSuccess(this.responses); }
class SendChatError { final Object error; SendChatError(this.error); }

おおよそ、ステートマシンのNodeをそのまま持ってくる感じです。

Reducer

ChatState chatReducer(ChatState s, dynamic a) {
  if (a is UpdateInput) return s.copyWith(inputText: a.text);
  if (a is FetchSuggestionsStart) return s.copyWith(suggestLoading: true);
  if (a is FetchSuggestionsSuccess) return s.copyWith(
    suggestLoading: false, suggestions: a.suggestions);
  if (a is SendChatStart) return s.copyWith(isSending: true);
  if (a is SendChatSuccess) return s.copyWith(
    isSending: false, responses: a.responses);
  if (a is SendChatError) return s.copyWith(isSending: false, lastError: a.error);
  return s;
}

ここにステート更新ルールを全て入れ込みます。Three Principle (https://redux.js.org/understanding/thinking-in-redux/three-principles) に基づき、余計な副作用などを入れないようにしましょう。

Middleware

void chatMiddleware(Store<AppState> store, action, NextDispatcher next) {
  if (action is FetchSuggestionsStart) {
    api.fetchSuggestions(store.state.chat.inputText)
      .then((res) => store.dispatch(FetchSuggestionsSuccess(res)))
      .catchError((e) => store.dispatch(SendChatError(e)));
  }
  if (action is SendChatStart) {
    api.getResponse(action.message)
      .then((res) => store.dispatch(SendChatSuccess(res)))
      .catchError((e) => store.dispatch(SendChatError(e)));
  }
  next(action);
}

データフェッチを行います、repository層から呼ぶアプローチを取っています(自分たちはiteration velocityを担保するためにusecase層は省きがちです)

ViewModel (呼び出し側)

class ChatVM {
  final Store<AppState> store;
  ChatVm(this.store);

  void onInputChanged(String text) {
    store.dispatch(UpdateInput(text));
    store.dispatch(FetchSuggestionsStart());
  }

  void onSendPressed(String message) {
    store.dispatch(SendChatStart(message));
  }
}

VMは基本的にactionをdispatchするだけにします。クリーンで流れが読みやすくなりましたね。最終的に1ステート間遷移 = 1reducerになるので、メソッド粒度のばらつきも抑えられます。

さらに、ステートマシンとコードが1対1対応するからか、AIコーディングと相性が良い感じがします(肌感)。ファットなメソッドは命名を工夫したりDocをしっかり書かないと正しく書いてくれない所感。

テスト可能性も良いです。Reducerはテスト可能性も良いです。Reducerは純粋関数なので、
「初期State」と「Action」を与えるだけで、期待される新しいStateを簡潔に検証できます。
副作用はMiddlewareに分離されているため、モック化してActionの発行順序や呼び出し回数を確認するだけで十分です。状態遷移ごとに独立したユニットテストを書けるようになり、テストコードの可読性・保守性も大きく向上しました。

お出かけAI iino開発チーム Tech Blog

Discussion