[Flutter] Riverpodで状態管理した一覧画面テンプレートとそのアーキテクチャ例
背景
アプリ開発で頻出の画面の一つといえば、一覧画面ではないでしょうか。
その一覧画面をテンプレート化することで、所望する一覧画面を素早く作成できることを期待しています。
実装する機能紹介(GIFあり)
今回実装する主な機能は以下になります。
- リスト表示
- 無限スクロール(pagenation)
- 下タブ押下で最上部へスクロール
- プルリフレッシュ(pull-to-refresh)
- 検索バーのSliver化
- (Widget Test, Unit Testも書いています)
これらの機能を含む一覧画面をFlutterを用いて実装します。
状態管理にはRiverpod + StateNotifier + Freezedを使用します。
リストに表示するデータはQiita APIから取得します。
▼ 全コードはGitHubを参照ください。
以下はそれぞれの機能のGIFになります。
リスト表示
無限スクロール(pagenation)
下タブ押下で最上部へスクロール
プルリフレッシュ(pull-to-refresh)
検索バーのSliver化
解説
まず、ソフトウェアアーキテクチャは以下の図のようになっています。
DDDの考え方をベースとしたオニオンアーキテクチャからユースケース層を省いた構造にしています。(ユースケース層を省いた理由は、その層でドメイン層内のひとつのメソッドをそのままコールしているだけだからです。勿論、複雑な仕様に伴ってユースケース層でドメインオブジェクトを組み立てたり、いくつかのドメインサービスを呼ぶ必要がある場合には、ユースケース層は組み入れます。)
MVVMとも捉えれる構造かと思います。
依存性の方向としては、
プレゼンテーション層UI(フレームワーク依存)
→プレゼンテーション層Controller(pure Dartを心掛ける)
→ドメイン層Repository
インフラ層RepositoryImpl(ドメイン層Repositoryを実装)
→ドメイン層ApiService
インフラ層ApiServiceImpl(ドメイン層ApiServiceを実装)
→インフラ層ApiClient
となります。
ソフトウェアアーキテクチャが定まったので、実装に移ります。
まず、デザインを決めます。今回はシンプルにQiitaのトップページの一覧画面を参考にします。
そのデザインをもとに、ドメインオブジェクト(エンティティ)を作成します。
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を使ってインフラ層に作成します。エンティティに詰め替えるためのメソッドも用意します。
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が手に馴染みます。)
()
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.value
がT?
で扱いにくいので、State
のプロパティにPageState
というユニオンをFreezedで定義をしてそれを使用しています。できればAsyncValue
を使用したいので解決法を教えていただけると嬉しいです。
▼ issueはコチラ
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
によって、キャッシュしなくてもよくなると思います。
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.controller
でScrollController
を監視しておき、タップした際にそのScrollController
をanimateTo
メソッドで最上部へスクロールさせています。
onTap: (index) {
if (ref.read(postListScrollControllerProvider).hasClients) {
ref.read(postListScrollControllerProvider).animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
},
詳しくはGitHubを参照ください。
もっとここをこうした方がいいよなどありましたら是非教えていただけると嬉しいです。
最後まで見ていただいてありがとうございました。
Discussion