💫

[Flutter] Riverpodで状態管理した一覧画面テンプレートとそのアーキテクチャ例

2022/01/16に公開

背景

アプリ開発で頻出の画面の一つといえば、一覧画面ではないでしょうか。
その一覧画面をテンプレート化することで、所望する一覧画面を素早く作成できることを期待しています。

実装する機能紹介(GIFあり)

今回実装する主な機能は以下になります。

  • リスト表示
  • 無限スクロール(pagenation)
  • 下タブ押下で最上部へスクロール
  • プルリフレッシュ(pull-to-refresh)
  • 検索バーのSliver化
  • (Widget Test, Unit Testも書いています)

これらの機能を含む一覧画面をFlutterを用いて実装します。
状態管理にはRiverpod + StateNotifier + Freezedを使用します。
リストに表示するデータはQiita APIから取得します。

▼ 全コードはGitHubを参照ください。
https://github.com/dev-tatsuya/flutter-qiita-client

以下はそれぞれの機能のGIFになります。

リスト表示

無限スクロール(pagenation)

下タブ押下で最上部へスクロール

プルリフレッシュ(pull-to-refresh)

検索バーのSliver化

解説

まず、ソフトウェアアーキテクチャは以下の図のようになっています。

DDDの考え方をベースとしたオニオンアーキテクチャからユースケース層を省いた構造にしています。(ユースケース層を省いた理由は、その層でドメイン層内のひとつのメソッドをそのままコールしているだけだからです。勿論、複雑な仕様に伴ってユースケース層でドメインオブジェクトを組み立てたり、いくつかのドメインサービスを呼ぶ必要がある場合には、ユースケース層は組み入れます。)
MVVMとも捉えれる構造かと思います。

依存性の方向としては、

プレゼンテーション層UI(フレームワーク依存)
→プレゼンテーション層Controller(pure Dartを心掛ける)
→ドメイン層Repository
インフラ層RepositoryImpl(ドメイン層Repositoryを実装)
→ドメイン層ApiService
インフラ層ApiServiceImpl(ドメイン層ApiServiceを実装)
→インフラ層ApiClient

となります。

ソフトウェアアーキテクチャが定まったので、実装に移ります。
まず、デザインを決めます。今回はシンプルにQiitaのトップページの一覧画面を参考にします。


https://qiita.com/

そのデザインをもとに、ドメインオブジェクト(エンティティ)を作成します。

qiita_post.dart
class QiitaPost {
  QiitaPost(
    this.id,
    this.createdAt,
    this.likesCount,
    this.tags,
    this.title,
    this.url,
    this.user,
  );

  final String id;
  final DateTime createdAt;
  final int likesCount;
  final List<Tag> tags;
  final String title;
  final String url;
  final QiitaUser user;
}

ドメインオブジェクト作成後、それに対応するデータモデルをFreezedを使ってインフラ層に作成します。エンティティに詰め替えるためのメソッドも用意します。

qiita_post_response.dart

class QiitaPostResponse with _$QiitaPostResponse {
  const factory QiitaPostResponse({
    required String id,
    (name: 'created_at') required String createdAt,
    (name: 'likes_count') required int likesCount,
    required List<TagJson> tags,
    required String title,
    required String url,
    required UserJson user,
  }) = _QiitaPostResponse;
  const QiitaPostResponse._();

  factory QiitaPostResponse.fromJson(Map<String, dynamic> json) =>
      _$QiitaPostResponseFromJson(json);

  QiitaPost toEntity() => QiitaPost(
        id,
        DateTime.parse(createdAt),
        likesCount,
        tags.map((e) => e.toEntity()).toList(),
        title,
        url,
        user.toEntity(),
      );
}

次に、Retrofitを使ってAPIクライアントを定義します。(私は元々Android DeveloperだったのでRetrofitが手に馴染みます。)

qiita_api.dart
()
abstract class QiitaApi {
  factory QiitaApi(Dio dio, {String baseUrl}) = _QiitaApi;

  ('/items')
  ('Content-Type: application/json')
  Future<HttpResponse<List<QiitaPostResponse>>> getItems({
    ('Authorization') required String header,
    ('page') int? page,
    ('per_page') int? perPage,
    ('query') String? query,
  });
}

今回、無限スクロールやプルリフレッシュを実装するため、Qiitaよりアクセストークンを発行してAuthorizationヘッダーを付与することで、利用制限緩和をしています。

そのAPIクライアントを呼び出すApiServiceインターフェースをドメイン層に定義し、インフラ層で実装します。

abstract class ApiService {
  Future<ApiResponse<List<QiitaPostResponse>>> getItems({
    int? page,
    int? perPage,
    String? query,
  });
}

class ApiServiceImpl implements ApiService {
  ApiServiceImpl(this._read);

  final Reader _read;
  QiitaApi get _api => _read(qiitaApiProvider);
  ApiResponseFactory get _factory => _read(apiResponseFactoryProvider);

  
  Future<ApiResponse<List<QiitaPostResponse>>> getItems({
    int? page,
    int? perPage,
    String? query,
  }) async {
    return _factory.apiCall(_api.getItems(
      header: _bearerToken,
      page: page,
      perPage: perPage,
      query: query,
    ));
  }

  final _bearerToken = 'Bearer ${dotenv.env['access_token']}';
}

戻り値はApiResponse型というFreezedでユニオンを表現してインフラ層のRepositoryで処理分けをしています。(ここで戻り値にデータモデルを参照しているのが個人的にはいただけなくて、ドメイン層がインフラ層を知ってしまっているのでどうにかしたいポイントです。解決法あれば教えていただきたいです。)
ヘッダーに指定するアクセストークンはApiServiceの実装クラスで.envファイルより取得しています。

ApiResponseFactoryはAPIクライアントの戻り値<HttpResponse>をさばいて先程のApiResponse型に格納するクラスで、APIクラスを呼ぶ際の便利ラッパーとして機能しています。

class ApiResponseFactory {
  Future<ApiResponse<T>> apiCall<T>(Future<HttpResponse<T>> api) async {
    try {
      final _response = await api;
      final res = _response.response;
      if (_isSuccessful(res.statusCode)) {
        return ApiSuccessResponse(_response.data);
      }

      return handleExpectedException(res);
    } on NetworkExceptions catch (error) {
      return ApiFailureResponse(NetworkExceptions.getDioException(error));
    } on DioError catch (error, stackTrace) {
      return _handleDioError(error, stackTrace);
    } on Exception catch (error) {
      return ApiFailureResponse(UnexpectedError(reason: error.toString()));
    }
  }
}

次に、ApiServiceを呼び出すRepositoryインターフェースをドメイン層に定義し、インフラ層で実装します。

abstract class PostRepository {
  Future<List<QiitaPost>> fetch({
    int? page,
    int? perPage,
    String? query,
  });
}

class PostRepositoryImpl implements PostRepository {
  PostRepositoryImpl(this._read);
  final Reader _read;

  ApiService get _api => _read(apiServiceProvider);

  
  Future<List<QiitaPost>> fetch({
    int? page,
    int? perPage,
    String? query,
  }) async {
    return (await _api.getItems(page: page, perPage: perPage, query: query))
        .maybeWhen(
      success: (List<QiitaPostResponse> jsons) {
        return jsons.map((e) => e.toEntity()).toList();
      },
      failure: (NetworkExceptions error) => throw error,
      orElse: () => throw const NetworkExceptions.unexpectedError(),
    );
  }
}

ここでの戻り値はドメインオブジェクトです。
実装クラスではApiServiceをDIし、対象のメソッドを呼びます。
ここで、データクラスからドメインオブジェクトに詰め替えを行っています。

次に、StateNotifierを継承するControllerを作成するためのStateをFreezedで用意します。


class PostListState with _$PostListState {
  const factory PostListState({
    (<QiitaPost>[]) List<QiitaPost> posts,
    (false) bool hasNext,
    (1) int page,
    String? query,
    (PageStateLoading()) PageState pageState,
  }) = _PostListState;
}

StateNotifierに指定する型をAsyncValue<T>としたいのですが、Controller内でTを取得する際にstate.valueT?で扱いにくいので、StateのプロパティにPageStateというユニオンをFreezedで定義をしてそれを使用しています。できればAsyncValueを使用したいので解決法を教えていただけると嬉しいです。
▼ issueはコチラ
https://github.com/dev-tatsuya/flutter-qiita-client/issues/1

Controllerを定義します。名前付きコンストラクタはアノテーションにも記載のように、テスト(Widget Test)でのみ使用します。

class PostListController extends StateNotifier<PostListState> {
  PostListController(this._read) : super(const PostListState()) {
    fetch();
  }

  
  PostListController.withDefaultValue(
    PostListState state,
    this._read,
  ) : super(state);

  final Reader _read;
  PostRepository get _repo => _read(postRepositoryProvider);

  static const perPage = 10;

  
  Future<void> fetch({
    bool loadMore = false,
  }) async {
    state = state.copyWith(pageState: const PageStateLoading());

    try {
      final newItems = await _repo.fetch(
        page: state.page,
        perPage: perPage,
        query: state.query,
      );
      state = state.copyWith(
        posts: [if (loadMore) ...state.posts, ...newItems],
        hasNext: newItems.length >= perPage,
        pageState: const PageStateSuccess(),
      );
    } on NetworkExceptions catch (ex) {
      state = state.copyWith(pageState: PageStateError(ex));
    }
  }

  void refresh() {
    setPage(1);
    fetch();
  }

  void loadMore() {
    setPage(state.page + 1);
    fetch(loadMore: true);
  }

  void setQuery(String? value) async {
    if (state.query == value) {
      return;
    }

    state = state.copyWith(query: value);
    fetch();
  }

  void setPage(int page) {
    state = state.copyWith(page: page);
  }
}

UI側の説明に移ります。
ConnectedPostListPageではPageStateでのUIの出し分けをラップしたPageDispatcherを使っています。

class ConnectedPostListPage extends ConsumerWidget {
  const ConnectedPostListPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(postListControllerProvider);

    return Scaffold(
      backgroundColor: const Color(0xfff8f8f8),
      appBar: AppBar(
        title: const Text(
          'Flutter Qiita Client',
          style: TextStyle(fontFamily: 'Inter'),
        ),
        centerTitle: true,
        elevation: 1,
        toolbarHeight: 44,
      ),
      body: PageDispatcher.dispatch(
        pageState: state.pageState,
        builder: () => PostListPage(state),
      ),
    );
  }
}

PageDispatcherでは出し分けの他に無限スクロールの際にPage全体がローディングされないようにWidgetをキャッシュすることもしています。

class PageDispatcher {
  static Widget? _lastCachedChild;

  static Widget _cacheWidget(Widget child) {
    _lastCachedChild = child;
    return child;
  }

  static Widget dispatch({
    required PageState pageState,
    required Widget Function() builder,
  }) {
    return pageState.when(
      success: () => _cacheWidget(builder()),
      loading: () =>
          _lastCachedChild ?? const Center(child: CupertinoActivityIndicator()),
      error: (error) => _cacheWidget(Center(child: Text(error.toString()))),
    );
  }
}

ここでもAsyncValueを使えばRiverpod 2系のAsyncValue.isRefreshingによって、キャッシュしなくてもよくなると思います。
https://github.com/rrousselGit/river_pod/blob/master/packages/riverpod/CHANGELOG.md#200-dev0

PostListPageではSliverを使用して、検索バーをいい感じにしたり、プルリフレッシュを組み込んだり、LastIndicatorというVisibilityDetectorでラップしたWidgetクラスを作成して無限スクロールを実現しています。

class PostListPage extends ConsumerWidget {
  const PostListPage(this.state, {Key? key}) : super(key: key);

  final PostListState state;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final posts = state.posts;
    final controller = ref.read(postListControllerProvider.notifier);

    return PrimaryScrollController(
      controller: ref.watch(postListScrollControllerProvider),
      child: Scrollbar(
        child: CustomScrollView(
          slivers: [
            const SliverAppBar(
              floating: true,
              elevation: 0.5,
              bottom: PreferredSize(
                preferredSize: Size.fromHeight(4),
                child: SearchBar(),
              ),
            ),
            CupertinoSliverRefreshControl(
              onRefresh: () async => controller.refresh(),
            ),
            if (posts.isEmpty)
              const SliverFillRemaining(
                child: Center(child: Text('Not Found')),
              ),
            if (posts.isNotEmpty)
              SliverPadding(
                padding: const EdgeInsets.symmetric(vertical: 8),
                sliver: SliverList(
                  delegate: SliverChildBuilderDelegate(
                    (context, index) {
                      if (index == posts.length && state.hasNext) {
                        return LastIndicator(controller.loadMore);
                      }

                      return PostContent(posts[index]);
                    },
                    childCount: posts.length + (state.hasNext ? 1 : 0),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

下タブ押下で最上部へスクロールする実装は、ScrollBar.controllerScrollControllerを監視しておき、タップした際にそのScrollControlleranimateToメソッドで最上部へスクロールさせています。

onTap: (index) {
  if (ref.read(postListScrollControllerProvider).hasClients) {
    ref.read(postListScrollControllerProvider).animateTo(
	  0,
	  duration: const Duration(milliseconds: 300),
	  curve: Curves.easeOut,
	);
  }
},

詳しくはGitHubを参照ください。
https://github.com/dev-tatsuya/flutter-qiita-client

もっとここをこうした方がいいよなどありましたら是非教えていただけると嬉しいです。
最後まで見ていただいてありがとうございました。

Discussion