はじめまして。WINTICKET アプリチームの @lcdsmao です。

一年以上 WINTICKET の Flutter でリプレース開発をし続けて、ついに今年の 4 月に正式に Android 版をリリースしました。 リプレースについて、ぜひ @wadackel記事@akihisasen記事を見てください。

WINTICKET はすでに 3 年以上運営していて、既存のアプリの画面数は 100 ページ以上ある大規模なアプリです。 また、開発メンバーも多く、開発期間中の異動はありますが、エンジニアだけで 5 名以上のメンバーが稼働しています(現在は 9 名です)。 このような状況で順調にリプレースができ、リリースしてからも安定に運用できているアプリの設計について、紹介できればと思います。

概要

アプリの全体設計は Clean Architecture に寄せていて、大きく Data、 Domain、 State、 UI の 4 つのレイヤーで構成されています。 Riverpod を使用して、Dependency Injection (DI) と状態管理を行いました。 Widget 周りの実装は主に Flutter Hooks で書いています。

Native と比べて、Web 界隈ではすでに宣言的 UI に関する技術が先行していて、知見も豊富にあります。 Flutter は宣言的 UI を使用しているため、設計をする前に Web (主に React) 周りの知見も参考として取り入れました。

状態管理の方法について考えていた頃に、React で Recoil という状態管理ライブラリが登場しました。 Native で馴染んだ MVVM のような、よくある 1 ページ 1 ViewModel の構成では、ViewModel の膨大化と状態の共有の難しさに私は課題を感じました。 それと比べて、Recoil では、アプリの状態を複数で平行な状態で構成していて、コードの分離と状態の共有が非常に容易にできます。

私は Recoil の利点を参考にすると先程挙げたような課題を解決できると思い、WINTICKET アプリに Recoil のコンセプトを取り入れたいと考えました。 ちょうど Flutter 界隈にも Riverpod が登場し、 その設計は Recoil に非常に似ていて、Recoil 風な状態管理が実現しやすいと考え、 Riverpod を採用しました。 (ちなみに Riverpod の作者、 Remi 氏も両者似ていると Tweet していました。)

採用した Flutter Hooks は少し学習コストが高めですが、公式の書き方と比べると

  • ボイラープレートコードが少ない
  • ロジックの共有がしやすい

というメリットが大きいので、導入しました。 また Hooks Riverpod が存在するので、 RiverpodFlutter Hooks が非常に相性がいいのもポイントです。

また、各レイヤーのテスタビリティを意識して、コード生成や Inversion of Control (IoC) を利用して、テストのしやすさを向上しました。

レイヤーの依存グラフは下記の図のようになります:

レイヤーの依存グラフ

イメージしやすいように、ディレクトリ構造も先に貼っておきます。 これから順番に細かく説明していきます。

ディレクトリ構造
.
└── lib/
    ├── data/
    │   ├── local/
    │   │   ├── secure_preferences.dart
    │   │   └── shared_preferences.dart
    │   ├── keirin/
    │   │   └── keirin_race.dart
    │   └── ...
    ├── domain/
    │   ├── keirin/
    │   │   ├── get_keirin_race.dart
    │   │   ├── get_keirin_race.fake.dart
    │   │   ├── get_keirin_race_odds.dart
    │   │   └── get_keirin_race_odds.fake.dart
    │   ├── ...
    │   ├── use_case.dart
    │   └── fake_use_case.dart
    ├── state/
    │   ├── keirin/
    │   │   ├── keirin_race_detail.dart
    │   │   ├── keirin_race_detail.fake.dart
    │   │   ├── keirin_race_odds.dart
    │   │   └── keirin_race_odds.fake.dart
    │   ├── ...
    │   ├── state_notifier.dart
    │   └── fake_state_notifier.dart
    └── ui/
        ├── widget/
        │   ├── button.dart
        │   ├── text.dart
        │   └── ...
        ├── domain_widget/
        │   ├── keirin/
        │   │   ├── keirin_app_bar.dart
        │   │   └── keirin_app_bar.story.dart
        │   ├── default_container/
        │   │   └── default_container.dart
        │   └── ...
        ├── page/
        │   └── keirin/
        │       ├── connected_keirin_race_page.dart
        │       ├── keirin_race_page.dart
        │       └── keirin_race_page.story.dart
        └── ...

この記事のサンプルに使用している Package のバージョンは以下になります:

Data レイヤー

データレイヤー

Data レイヤーはビジネスロジックが必要な DataSource を持つレイヤーです。 Protobuf/JSON リクエストを介してアクセス可能な API から提供されるサービスや、 Database/SharedPreferences などの永続化はこのレイヤーに置きます。

Riverpod でインスタンスを提供し、Domain レイヤーに DI します。

ちなみに Repository Pattern でもう 1 回抽象することもよくありますが (少なくとも Android 界隈では)、 WINTICKET は同じデータを複数 DataSource から取得するケースがほとんどないので、 採用しませんでした。

Retrofit を使用した API サービスの自動生成

API サービスは Retrofit を使って実装しています。 Retrofit は簡単な Interface を定義することで、Interface に対応した API サービスを自動生成してくれます。 冗長なコードを書かなくても済み、とても便利です。 また、テストの際に定義した Interface を Implement することで、data レイヤーの Mock/Fake も簡単に作成できます。

例:

final keirinRaceDataSourceProvider = Provider<KeirinRaceDataSource>(
  (ref) => KeirinRaceDataSource(ref.watch(apiDioProvider)),
);

@RestApi()
abstract class KeirinRaceDataSource {
  factory KeirinRaceDataSource(Dio dio) = _KeirinRaceDataSource;

  @GET('/v1/keirin/race/{raceNumber}')
  Future<KeirinGetRaceResponse> getRace({
    @Path('raceNumber') required int raceNumber,
  });

  // ...他のAPI
}

Interface を定義して永続化データの操作を共通化

現在 WINTICKET では Shared PreferencesFlutter Secure Storage を使って永続化データを管理しています。 2 つのライブラリの API は同じではないですが、保存しているデータ自体が Key/Value の Map 構造なので、 使いやすいラッパー PreferencesDataSource を作成しました。

PreferencesDataSource は以下のことを実現しています:

  • 永続化のデータを Stream 化できるようにして、データが変更されたときその値を Stream に反映
  • Adapter Pattern を利用したPreferencesDataSourceAdapterを作成することで、 Shared PreferencesFlutter Secure Storage を意識せずに利用可能に

ちなみにラッパーの PreferencesDataSource の API 設計は AndroidX の DataStore をインスパイアしています。

PreferencesDataSourcePreferencesDataSourceAdapter は以下のような感じで、実装の詳細は割愛させていただきます:

abstract class PreferencesDataSource {
  factory PreferencesDataSource(PreferencesDataSourceAdapter adapter) =
    // 実装の部分を省略させていただきます
    _PreferencesDataSourceImpl;

  // `Preferences`はMap型のデータを持っています
  // データが変わったら、新しい`Preferences`としてこのStreamに流します
  Stream<Preferences> get preferences;

  // `MutablePreferences`は`Preferences`の可変バージョンです
  // `action`で既存のデータに対して変更したら、変更後の値を永続化させ、上記のStreamに流します
  Future<void> edit(FutureOr<void> Function(MutablePreferences prefs) action);
}

abstract class PreferencesDataSourceAdapter {
  // SharedPreferencesなどに保存
  Future<void> save(String key, Object? value);

  // 永続化から削除
  Future<void> delete(String key);

  // 永続化から全てのデータをMap形として取得
  Future<Map<String, Object?>> getAll();
}

最後に、 Shared PreferencesFlutter Secure StoragePreferencesDataSourceProviderとして定義します:

final sharedPreferencesDataSourceProvider = Provider<PreferencesDataSource>(
  (ref) {
    // `SharedPreferencesDataSourceAdapter`は`PreferencesDataSourceAdapter`を継承したものです
    final adapter = SharedPreferencesDataSourceAdapter(
      ref.watch(sharedPreferencesProvider),
    );
    return PreferencesDataSource(adapter);
  },
);

final securePreferencesDataSourceProvider = Provider<PreferencesDataSource>(
  (ref) {
    // `FlutterSecureStorageDataSourceAdapter`は`PreferencesDataSourceAdapter`を継承したものです
    final adapter = FlutterSecureStorageDataSourceAdapter(
      ref.watch(flutterSecureStorageProvider),
    );
    return PreferencesDataSource(adapter);
  },
);

Domain レイヤー

ドメインレイヤー

再利用可能なビジネスロジック

Domain レイヤーは、複雑なビジネスロジックをカプセル化する役割を果たします。 そのカプセル化は UseCase という形で統一で表現します。

UseCase のインターフェース定義です:

abstract class UseCase<Param, Result> {
  Result call(Param param);
}

Param は入力パラメーターで、Result は出力結果で、FutureStream 型の Result が多いです。

UseCase 及びその Provider の定義例:

final getKeirinRaceUseCaseProvider = Provider<GetKeirinRaceUseCase>(
  (ref) => GetKeirinRaceUseCase(
    ref.watch(keirinRaceDataSourceProvider),
  ),
);

class GetKeirinRaceUseCase
    extends UseCase<GetKeirinRaceUseCaseParam, Future<KeirinRaceDetailModel>> {
  GetKeirinRaceUseCase(this._keirinRaceDataSource);

  final KeirinRaceDataSource _keirinRaceDataSource;

  @override
  Future<KeirinRaceDetailModel> call(GetKeirinRaceUseCaseParam param) async {
    final response = await _keirinRaceDataSource.getRace(
      raceNumber: param.raceNumber,
    );
    return KeirinRaceDetailModel.fromV1(response);
  }
}

UseCase を定義することで、コードが適切な粒度に分割ができ、ビジネスロジックも再利用しやすくなります。

コード生成でテストビリティを担保

UseCase を依存するロジックをテストしやすいように、 Build Runner を用いて、UseCaseFake を自動生成するようにしています。

UseCase はとてもシンプルな Interface で、その Fake バージョンはこのように先に定義しておきます:

class FakeUseCase<Param, Result> extends UseCase<Param, Result> {
  Result Function(Param)? _callback;

  void resultCallback(Result Function(Param param) callback) {
    _callback = callback;
  }

  void result(Result v) {
    _callback = (_) => v;
  }

  @override
  dynamic noSuchMethod(Invocation invocation) {
    // UseCaseの`call`の実装を`_callback`にデリゲートします
    if (invocation.memberName == const Symbol('call')) {
      return _callback!(invocation.positionalArguments[0] );
    }
    super.noSuchMethod(invocation);
  }
}

そして、FakeUseCase を継承した各 UseCase は容易に生成できます:

class FakeGetKeirinRaceUseCase extends FakeUseCase<GetKeirinRaceUseCaseParam,
    Future<KeirinRaceDetailModel>> implements GetKeirinRaceUseCase {}

Mock 系パッケージの使用と比べて以下のメリットがあります:

  • セットアップコードが少ない
  • resultCallbackresult という 2 つシンプルな API だけ使いこなせばいい

テストの際以下のように簡単にセットアップするだけで済みます:

void main() {
  late FakeGetKeirinRaceUseCase getKeirinRaceUseCase;
  late TestTarget testTarget;

  setUp(() {
    getKeirinRaceUseCase = FakeGetKeirinRaceUseCase();
    testTarget = ProviderContainer(overrides: [
      // テスト対象が依存するProviderの値をFakeでOverrideします
      getKeirinRaceUseCaseProvider.overrideWithValue(getKeirinRaceUseCase),
    ]).read(...);
  });

  test('it works', () {
    final keirinRaceModel = ...;
    // Fakeで返すものを設定します
    getKeirinRaceUseCase.result(Future.value(keirinRaceModel));

    testTarget.doSomething();

    expect(
      testTarget.someActualValue,
      someExpectedValue,
    );
  });
}

State レイヤー

ステートレイヤー

State レイヤーは UI に表示する必要な状態データを持っていて、 Unidirectional Data Flow によるデータの管理をしています。

State レイヤーには二種類があります:

  • State
  • Selector

State を主に使用しています。 State では Single Source of Truth に基づいて、アプリ状態を保持しています。“` Selector が State から派生した状態です。

StateNotifier(Provider) で State を定義

State は以下の特徴があります:

  • 状態は単一の不変な値として公開
  • 各種のイベントに応じて状態が変化可能
  • 状態を変更するロジックは内部に持ち、変更ロジックを隠蔽することで保守性を向上

State は基本 StateNotifierProviderStateNotifier で実装しています。 一般的な MVVM の ViewModel が持つ状態と比べて、State の粒度は小さめで、 RecoilAtoms に似ています。

StateNotifierProvider の基本的な使用方法は、公式の ドキュメント と同様なので、 興味ある方は是非見てください。

非同期状態の実装

非同期状態は、アプリで最も一般的です。 Riverpod が提供している FutureProviderStreamProvider を使って便利に実装できますが、 WINTICKET アプリではそれらの使用をルール上禁止にしています。 前述の通り、非同期状態でも StateNotifierProvider で実装するルールにしています。

(2022/06/17 追記: この記事公開後、Riverpod 作者の Remi 氏はこのルールに反応があって、支持していないようです。)

StateNotifierProvider のみにする理由は以下に挙げられます:

  • StateNotifierProviderFutureProviderStreamProvider の上位互換であるため、同等の挙動が実現できる
  • StateNotifier を継承することで、Method 追加や挙動の変更など柔軟な実装ができる
  • 統一の API になり、Provider の選定時に混乱しづらく、学習コストが減らせる
  • FutureProviderStreamProvider から公開している AsyncValue は、扱いにくい場合がある

StateNotifier の非同期状態を表現するため、まず AsyncEntity というクラスを定義しました:

@freezed
class AsyncEntity<T extends Object> with _$AsyncEntity<T> {
  const factory AsyncEntity({
    T? entity,
    @Default(FetchStatus.idling) FetchStatus fetch,
    Object? error,
  }) = _AsyncEntity<T>;
}

enum FetchStatus {
  loading,
  refreshing,
  paging,
  idling,
}

Riverpod が提供している AsyncValue と比べ、AsyncEntityには以下の 2 つの利点があります。

  • エラー、非同期状態を一括で表現可能。例えば、リフレッシュが失敗した場合でも、以前成功したデータを保持可能
  • 非同期状態(FetchStatus)を柔軟に定義可能

続いて、StateNotifier を継承した AsyncStateNotifier を定義します:

abstract class AsyncStateNotifier<T extends Object>
    extends StateNotifier<AsyncEntity<T>>
    with AsyncEntityStateNotifierMixin<T> {
  AsyncStateNotifier() : super(AsyncEntity<T>()) {
    // StateNotifierの初期化で非同期状態も初期化します
    freshState();
  }

  // `fetch`をoverrideして、非同期状態の取得する方法を示します
  @protected
  Future<T> fetch();

  // `fetch`が実行して、適切に`StateNotifier.state`の更新をします
  //
  // 例:
  // - 初期状態
  //  - AsyncEntity(entity: null, fetch: FetchStatus.idling, error: null)
  // - `freshState`を実行、`fetch`が成功
  //   - AsyncEntity(entity: null, fetch: FetchStatus.loading, error: null)
  //   - AsyncEntity(entity: SomeValue, fetch: FetchStatus.idling, error: null)
  // - `freshState`をもう一回実行、`fetch`が失敗
  //   - AsyncEntity(entity: SomeValue, fetch: FetchStatus.loading, error: null)
  //   - AsyncEntity(entity: SomeValue, fetch: FetchStatus.idling, error: SomeError)
  Future<void> freshState({
    FetchStatus fetchStatus = FetchStatus.loading,
    bool clearEntityOnFailure = false,
  }) => updateStateAsync(
          fetch,
          fetchStatus: fetchStatus,
          clearEntityOnFailure: clearEntityOnFailure,
      );

  // ...
}

コンストラクターで初期化して、状態を非同期で更新します。 fetch 結果を State に反映させる updateStateAsync Method は AsyncEntityStateNotifierMixin という Mixin に定義しています。 もし、コンストラクターでの初期化を必要とせず、任意のタイミングで状態を更新したい場合、AsyncEntityStateNotifierMixin を Mixin することで実装ができます。

AsyncStateNotifier を使った非同期状態の実装は以下のようになります:

class KeirinRaceDetailState extends AsyncStateNotifier<KeirinRaceDetailModel> {
  KeirinRaceDetailState(this._param, this._getKeirinRace);

  final KeirinRaceDetailStateParam _param;
  final GetKeirinRaceUseCase _getKeirinRace;

  // `fetch`をoverrideして、UseCaseを介して状態を取得
  @override
  Future<KeirinRaceDetailModel> fetch() => _getKeirinRace(
        GetKeirinRaceUseCaseParam(
          raceNumber: _param.raceNumber,
        ),
      );
}

最後に、StateNotifierProvider を使って Provider を定義します。

例:

final keirinRaceDetailStateProvider = StateNotifierProvider.autoDispose.family<
    KeirinRaceDetailState,
    AsyncEntity<KeirinRaceDetailModel>,
    KeirinRaceDetailStateParam>(
  (ref, param) => KeirinRaceDetailState(
    param,
    ref.watch(getKeirinRaceUseCaseProvider),
  ),
);

State から派生した Selector

Selector の実装はとてもシンプルで、Provider の中で他の Providerwatch して、派生した状態を作ります。

例:

final isAuthenticatedSelectorProvider = Provider<bool>(
  // `authTokenStateProvider`はStateNotifierProvider
  (ref) => ref.watch(authTokenStateProvider)?.isNotEmpty ?? false,
);

コード生成でテストビリティを担保

UseCase と似たように、 StateNotifierProvider を依存したものをテストしやくすくするようにコード生成を用いて、 自動で Fake を生成するようにしています。

同じく共通な親 Fake Class を定義します:

class FakeStateNotifier<T> extends StateNotifier<T> {
  FakeStateNotifier(T state) : super(state);

  @override
  set state(T value) {
    super.state = value;
  }

  @override
  T get state => super.state;

  @override
  dynamic noSuchMethod(Invocation invocation) async {
    // アプリ内定義したStateNotifierのpublic methodの返り値は`Future<void>`また`void`に制限しています
    // なのでこのままreturnができます
    if (invocation.isMethod) {
      return;
    }
    return super.noSuchMethod(invocation);
  }
}

これで FakeStateNotifier を継承した各 StateFake が生成できますが、 StateNotifier に初期状態を渡さないといけないので、Annotation で渡すようにしています。 FakeState という Annotation をつけた StateNotifier のみ Fake が生成されます:

@FakeState(AsyncEntity<KeirinRaceDetailModel>())
class KeirinRaceDetailState extends AsyncStateNotifier<KeirinRaceDetailModel> {
  // ...
}

生成された Fake は以下のような感じです:

class FakeKeirinRaceDetailState
    extends FakeStateNotifier<AsyncEntity<KeirinRaceDetailModel>>
    implements KeirinRaceDetailState {
  FakeKeirinRaceDetailState(
       [AsyncEntity<KeirinRaceDetailModel> state =
          const AsyncEntity<KeirinRaceDetailModel>()] )
      : super(state);
}

また、次に説明する UI レイヤーの Visual Regression Test のために、 全ての StateNotifierProviderFakeOverride したリストを生成しています:

final defaultStateOverrides = <Override> [
  keirinRaceDetailStateProvider.overrideWithProvider((argument) =>
      StateNotifierProvider.autoDispose((ref) => FakeKeirinRaceDetailState())),
  userStateProvider.overrideWithValue(FakeUserState()),
  // others
] ;

UI レイヤー

UIレイヤー

UI レイヤーでは主に描画に関連するものが入っており、 目的と用途によって以下の分け方(ディレクトリ)をしています:

  • Widget: 色々なところに再利用される Widget。例:AppTextButtonBouncedCard
  • Domain Widget: ドメイン知識を含む Widget。例:SportRaceAppBarBettingRaceTile
  • Page: 一画面(URL)あたりに対応した Widget。例:TopKeirinPageKeirinRacePage
  • そのほか

State レイヤーに繋ぐ Connected Widget

Widget は Connected という Prefix がついているものと、ついていないものの二種類があります。 基本 Connected Widget は Connected ではないペアが存在します。例えば、ConnectedKeirinRacePageKeirinRacePage のようなペアです。 Connected Widget のみが State レイヤーにアクセスすることが可能性で、必要な State を購読して、その値を Connected ではないペアに渡しています。

Hooks RiverpodHookConsumerWidget を使用したサンプルコードは以下のようになります :

class ConnectedKeirinRacePage extends HookConsumerWidget {
  const ConnectedKeirinRacePage({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // stateを購読します
    final user = ref.watch(userStateProvider);
    final race = ref.watch(keirinRaceDetailStateProvider);
    // 購読した値を渡します
    return KeirinRacePage(
      user: user,
      race: race,
    );
  }
}

class KeirinRacePage extends HookConsumerWidget {
  const KeirinRacePage({
    Key? key,
    required this.user,
    required this.race,
  }) : super(key: key);

  final UserModel user;
  final KeirinRaceDetailModel race;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // user,raceを使用してUIを作ります
    return ...;
  }
}

Connected Widget は基本 Page ディレクトリにしか存在しないですが、いくつか Domain Widget ディレクトリにも存在します。 Widget ディレクトリには Connected が絶対に存在しません。 なので Connected Widget は比較的に少なく、依存関係がシンプルな Connected ではない Widget が一番多いです。

Connected Widget と Connected ではない Widget を明確に分離することで、テストがしやすくなります。 一番多い Connected ではない Widget は依存が少ないため、複雑なセットアップが不要で、簡単に Widget Test が書けます。

非同期状態を容易に扱う DefaultContainer

State のセクションでは、状態の中に AsyncEntity<T> タイプの非同期状態が一番多いと説明しました。 Connected Widget は基本、複数の状態に依存するので、その複数の非同期状態をまとめた情報を UI に表示する必要があります。 例えば、非同期状態や全ての状態が揃ったかどうかはこのように取得可能です:

// 任意の`AsyncEntity`はローティング中かどうか
bool isAnyLoading(Iterable<AsyncEntity> entities) {
  return entities.any((element) => element.fetch == FetchStatus.loading);
}

// 全ての`AsyncEntity`は値が持っているかどうか
bool isEveryHasEntity(Iterable<AsyncEntity> entities) {
  return entities.every((element) => element.entity != null);
}

上記を使うと、Connected Widget で非同期状態と非同期処理の結果を UI に反映する実装は以下のようになります:

class ConnectedKeirinRacePage extends HookConsumerWidget {
  const ConnectedKeirinRacePage({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final AsyncEntity<UserModel> user = ref.watch(userStateProvider);
    final AsyncEntity<KeirinRaceDetailModel> race = ref.watch(keirinRaceDetailStateProvider);
    final loading = isAnyLoading([user, race]);
    final ready = isEveryHasEntity([user, race]);
    final error = anyError([user, race]);
    return Stack(
      children:  [
        if (ready) KeirinRacePage(user: user.entity!, race: race.entity!),
        if (loading) LoadingIndicator(),
        if (!loading && !ready && error != null) FullScreenError(error: error),
      ] ,
    );
  }
}

このような非同期状態を表示するロジックは各 Connected Widget では共通したものになるので、 DefaultContainer という Widget に共通化しています。 非同期状態の表示は多少プロダクト仕様に依存するので、DefaultContainer の実装詳細は省略させていだだきます。

DefaultContainer を使用すると、Connected Widget のコードは以下のようにシンプルになります:

class ConnectedKeirinRacePage extends HookConsumerWidget {
  const ConnectedKeirinRacePage({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final AsyncEntity<UserModel> user = ref.watch(userStateProvider);
    final AsyncEntity<KeirinRaceDetailModel> race = ref.watch(keirinRaceDetailStateProvider);
    return DefaultContainer(
      entities: [user, race],
      builder: (context) => KeirinRacePage(user: user.entity!, race: race.entity!),
    );
  }
}

Visual Regression Test (VRT) 及び UI Catalog による品質の担保

前述のように、ほとんどの Widget の依存関係は非常にシンプルなので、一個一個の Widget のテストや、UI Catalog の作成が容易にできます。 VRT と UI Catalog の導入によって、デザイン崩れの早期発見や、再現に手間がかかる UI 表示の確認ができ、アプリの品質の向上に繋がります。

少し宣伝になりますが、弊社のエンジニアが Playbook Flutter というライブラリを作りました。 Playbook Flutterを利用すれば、簡単に VRT と UI Catalog を実装できます。 執筆の時点で、WINTICKET は 392 個の Widget に対して Story を書いています。

Story は PlaybookGenerator 使って作成しています:

const storyTitle = 'AppPrimaryCheck';

@GenerateScenario()
Widget $Check_ON() => AppPrimaryCheck(
      value: true,
      onChanged: (v) {},
    );

@GenerateScenario()
Widget $Check_OFF() => AppPrimaryCheck(
      value: false,
      onChanged: (v) {},
    );

VRT と UI Catalog で不要な依存(Provider)に関しては専用の Wrapper を用意して、Override しています:

class PlaybookContainer extends StatelessWidget {
  PlaybookContainer({
    required this.child,
  });

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      overrides: [
        imageCacheManagerProvider
            .overrideWithValue(CatalogImageCacheManager()),
        analyticsProvider.overrideWithValue(FakeAppAnalytics()),
        // 全てのStateをFakeでoverrideしたもの
        ...defaultStateOverrides,
      ],
      child: child,
      ),
    );
  }
}

defaultStateOverrides は State セクションで少し言及した、全ての State を Fake で Override したものです。

そして、この PlaybookContainer を使って、UI Catalog を作成します:

void main() {
  runApp(
    PlaybookContainer(
      child: CatalogApp(),
    ),
  );
}

class CatalogApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'UI Catalog',
      theme: ThemeData.light(),
      home: PlaybookGallery(
        title: 'Gallery',
        playbook: playbook,
      ),
    );
  }
}

実際の CatalogApp のスクリーンショット:

カタログアプリのスクリンショット カタログアプリのスクリンショット

終わりに

タイトルは完全解説と言いながら、おそらく全体の 80% ほどしかないと思います。 まだまだ細いことがあり、紹介したいものもありますが、今回は割愛させていただきました。 また今後、書いていきたいです。

これから他のメンバーもブログ投稿する予定なので、そちらもよろしくお願いします。

関連記事

  • WINTICKET における Flutter を利用したクロスプラットフォームアプリケーションへの取り組み
  • WINTICKET アプリ Flutter リプレース戦略
  • WINTICKET の Flutter アプリを支えるリリースフローの紹介
  • WINTICKET Flutter アプリと Text Scale Factor の闘い