YOUTRUSTアプリを支えるViewModelの技術

どうも、株式会社YOUTRUSTのアプリ開発のリードエンジニアをやっているashdikこと朝日(YOUTRUST / X)です。

最近は、中量級のソロでも出来るボードゲームにハマっています。
EARTH、アンドールの伝説、エバーグリーン、The guild of merchant explorers、Dune Imperium、
アルナックの失われし遺跡、イーオンズ・エンド...などなど数々やってきました。
が、圧倒的にハマっているのが、スピリットアイランド
一回1時間かかるのに12月くらいからほぼ毎日やってます。
楽しすぎて出会えたことに本当に感謝です。

⚓️ 概要

第一部は、こちらの記事でYOUTRUSTアプリのレイヤー構成についてざっと説明しました。

tech.youtrust.co.jp

第二部は、レイヤー構成の記事に登場する図のうち、ApiClient 及び Request を説明しました。

tech.youtrust.co.jp

第三部は、レイヤー構成の記事に登場する図のうち、Store を説明しました。

tech.youtrust.co.jp

第四部は、レイヤー構成の記事に登場する図のうち、 Facade を説明しました。

今回は、第五部と称して、 ViewModel について触れていこうと思います。

💻 筆者環境 (執筆時)

name version
Flutter 3.13.9
Dart 3.1.5
flutter_hooks 0.20.3
hooks_riverpod 2.4.5

📓 大前提(お詫び)

(前回に引き続き)

riverpod は黎明期の頃から使っており、 FutureProvider は使われておらず StateNotfierProvider も最近導入し始めたところです。
その辺の構成の参考にしようとしている方には、もしかしたらお役に立てないかもしれません。

ですが、2年半くらい今の構成で作り続けており、非常に作りやすいと感じています。
また、新しくジョインしていただいた方々にもコードが読みやすいと好評いただいているので
一つのパターンとしてありなのではないかなと個人的には思っています。

それではそんな言い訳をしたところで笑、本編の解説に入りたいと思います。

ViewModel

概要

弊社での ViewModel の責務は以下です。

  • 画面の会話相手
  • 適切な Facade の呼び出し

実装イメージ

class UserViewModel {
  const UserViewModel(
    this._read, {
    required this._userId,
  });

  final Reader _read;
  final UserId _userId;

  // 画面表示時に一度だけ行う処理を初期化処理
  Future<void> initState() async {
    await _fetchUser();

    // ログの送信
    TrackingFacade.log(
      read: _read,
      request: UserAccessRequest(userId: _userId),
    );
  }

  // 画面を離れる際の処理
  void dispose() {
  }

  // 状態の再取得 (PullToRefresh)  
  Future<void> reloadState() async {
    await _fetchUser();
  }

  // 最新情報の取得と先頭への追加
  Future<void> peekState() async {
    await _fetchUser();
  }

  Future<void> _fetchUser() async {
    // ユーザ情報の取得
    await UserFacade.fetch(
      read: _read,
      userId: _userId,
    );
  }
}

解説

Readerについて

FacadeStore 内での操作で、 Reader を使っているので保持しています。

initState

画面の表示時に、一度だけ呼ばれます。
主に、

  • その画面の表示に必要なデータの取得
  • 表示ログの送信

などの処理がよばれることが多いです。

dispose

画面の非表示(破棄)時に、一度だけ呼ばれます。
とはいえ、hooks の恩恵もあり、ここに処理が記述されることは多くないです。

reloadState

PullToRefresh など、画面全体のデータの再読み込みの際の処理を記述します。

peekState

バックグラウンドからの復帰時など、最新情報を取得し、既存データとの差分だけを先頭に追加する処理を行います。

fetchUser

Future<void> になっているところだけ少し補足します。
前回までの記事を読んでいただけた方はわかっていただけると思いますが、 Facade 内で取得したものは
Store 内にある StateProvider によって保持されます。 なので、このメソッド自体は Future<void> として返り値なしとなっています。

その他

1画面 1ViewModel

動的な画面であれば、基本的に1画面1ViewModel の対応で作成しています。
shallow なクラスを作ってしまっているな、という実感も少しありますが、以下の2点の理由から作成するようにしています。

  • 画面の対話相手を ViewModel だけにしたい
  • 画面特有の処理を行うクラスが欲しい (ログなど)
  • アプリ全体の構造として統一感を持たせたい

画面固有パラメタの保持

ユーザ詳細画面を想像してみてください。 必ず、どのユーザIDかという識別子が必要になってきます。

その情報も ViewModel は保持しています。

class UserViewModel {
  const UserViewModel(
    this._read,
    required this.userId,
  });

  final Reader _read;
  final UserId _userId;

  Future<void> follow() async {
    UserFacade.follow(
      read: _read,
      userId: _userId,
    );
  }
  ...
}

この _userId を利用することにより、各種メソッドの引数に userId を渡さなくて 済むようになっています。

また、お気づきかもしれませんが UserId 型になっています。
本来であれば、String 型なのですが型安全にするために UserId 型を定義しています。
これにより、確実にユーザーのIDを引数として受け取ることが可能になります。

bool isFollowing({required UserId userId}) => ...;

// GOOD
isFollowing(user.id);

// compile error
isFollowing(post.id);

🎥 最後に

いかがでしたでしょうか?
このシリーズも、とうとう第五部!
全部読んでくれているかたは、ある程度弊社のアプリケーションコードの仕組みについて理解が深まっているのではないでしょうか。

エンジニア募集中っ!

  • このシリーズを読んで、実際に触ってみたい!と思っていただけた方
  • 純粋にYOUTRUSTに興味がある方

YOUTRUSTでは、エンジニアを募集しています!
ぜひ、下のリンクよりご応募お待ちしておりまっす!

herp.careers