FlutterKaigi 2022「Flutterアプリの安全な変化と拡大を支えるアーキテクチャと単体テスト」のためのサンプルアプリ
現在のmainブランチを配布しています。
iOSユーザまたはPCユーザはこちらでご確認ください。
asdfを使用しています。インストールされていなければこちらを参考にインストールします。
asdf plugin add flutter
asdf plugin add ruby
asdf install
gem install cocoapods
flutter pub get
flutter pub run build_runner build
flutter run
私のGitHubリポジトリ一覧を表示 | 詳細画面ではさらにREADME.mdを表示 |
---|---|
https://api.github.com/users/tfandkusu/repos?page=1
詳細画面で「いいね」を付けると | 一覧画面でも「いいね」が付いている |
---|---|
「いいね」を付けた情報はアプリローカルに保存
Androidのアプリ アーキテクチャガイドに沿った2レイヤ構成のアーキテクチャです。
- APIアクセスなどのネットワーク通信を担当します。
- アプリローカルへの情報の読み書きを担当します。
- 別の画面で行われた更新をもれなく反映するために、複数の画面から使われるデータはRiverpodのStateNotifierに持たせています(所謂「いいね問題」対応)。
- データレイヤを代表して各種データソースにアクセスして、その結果を返却したり、ModelStateNotifierを更新します。
- 画面固有の状態遷移を担当します。
- 1画面に1つのUiModelクラスがあり、そのインスタンスを状態として持つStateNotifierです。
- 画面の状態をWidgetに提供します。
- UiModelStateNotifierとModelStateNotiferの変更を監視し、両者を合成して画面の状態を作成します。
- 画面が開かれた時の処理とユーザ操作によって始まる処理は、このクラスに記述します。
- 処理の内容はUiModelStateNotifierとRepositoryのメソッド呼び出しとなります。
- UiModelProviderから提供されたUI状態に従いWidgetを構築します。
- 画面が開かれた時とユーザ操作に対応して、EventHandlerのメソッド呼び出しを行います。
このサンプルアプリのアーキテクチャのまま実プロダクトを作成すると、EventHandlerクラスとその単体テストが巨大ファイルになり、可読性が悪くなる可能性があります。 そこでドメインレイヤを追加して、1処理を1実装クラスと1単体テストファイルにすることで、巨大ファイルの発生を防ぎます。
このサンプルアプリには画面遷移やToastなど、ワンショットオペレーションを取り扱う場合の対応も実装されています。ホーム画面から詳細画面の遷移を状態ホルダから制御しています。
まず画面の状態に、ワンショットオペレーションの呼び出しパラメータをnullableなフィールドとして追加します。
@freezed
class HomeUiModel with _$HomeUiModel {
/// ホーム画面の状態
///
/// [callDetailScreen] 【追加】詳細画面を呼び出す
const factory HomeUiModel(
{required bool progress,
required List<GithubRepo> repos,
required ErrorUiModel error,
GithubRepo? callDetailScreen}) = _HomeUiModel;
}
画面の状態のStateNotifierに、ワンショットオペレーションの呼び出しパラメータに値を設定するメソッドと、それをnullに戻すメソッドを追加します。
class HomeUiModelStateNotifier extends StateNotifier<HomeUiModel> {
HomeUiModelStateNotifier()
: super(const HomeUiModel(
progress: true, repos: [], error: ErrorUiModel.noError()));
/// 詳細画面を呼び出す
///
/// [repo] 詳細画面を呼び出す対象のGitHubリポジトリ
void callDetailScreen(GithubRepo repo) {
state = state.copyWith(callDetailScreen: repo);
}
/// 詳細画面の呼び出しが完了した時に呼ばれる
void onDetailScreenCalled() {
state = state.copyWith(callDetailScreen: null);
}
}
EventHandlerのメソッドからStateNotifierのメソッドを呼び出します。
/// ホーム画面のイベント処理担当クラス
class HomeEventHandler {
// 略
/// GitHubリポジトリ項目がクリックされたときに呼ばれる
///
/// [repo] クリックされたGitHubリポジトリ
void onClickRepo(GithubRepo repo) {
// 詳細画面を呼び出す
_stateNotifier.callDetailScreen(repo);
}
/// 詳細画面の呼び出しが完了したときに呼ばれる
void onDetailScreenCalled() {
_stateNotifier.onDetailScreenCalled();
}
}
画面のWidgetではref.listenメソッドのコールバック内で checkOneShotOperation
メソッドを呼び出し、そのコールバックに値が設定されたときの処理を記載します。処理が完了した後のEventHandlerメソッド呼び出しも忘れずに行います。
ref.listen(homeUiModelProvider, (previous, next) {
checkOneShotOperation(previous, next, (state) => state.callDetailScreen,
(repo) {
// 詳細画面に遷移する
Navigator.pushNamed(context, DetailScreen.routeName,
arguments: DetailScreenArgument(
id: repo.id,
name: repo.name,
defaultBranch: repo.defaultBranch));
// 遷移した場合は完了報告を行う
eventHandler.onDetailScreenCalled();
});
});
このアーキテクチャは1画面1HookConsumerWidget構成のため、画面の状態が変わるとconstを付けた要素以外のすべての要素がリビルドされます。もし画面の要素数が多く描画パフォーマンスの問題が発生したがリビルド範囲を狭めることで解決できる場合は、下図の構成にすることでリビルド範囲を限定することができます。このサンプルアプリでは詳細画面の上部分と下部分でリビルド範囲を分割しています。