[Fluter] Riverpod 상태 관리의 화면 템플릿 및 아키텍처 예
배경.
앱 개발에 빈번하게 등장하는 화면 중 하나라면 화면을 일람하는 게 아닐까.
이 일람화면을 템플릿화함으로써 원하는 일람화면을 빠르게 제작할 수 있을 것으로 기대된다.
내장형 기능 소개(GIF 포함)
이번 실시의 주요 기능은 다음과 같다.
상태 관리에는 Riverpod+State Notifier+Freezed가 사용됩니다.
목록에 표시된 데이터는 Qiita API에서 가져옵니다.
▶ 모든 코드는 GiitHub 참조.
다음은 각 기능의 GIF입니다.
목록 보기
무한 스크롤
탭을 누르고 위로 스크롤
새로 고침
검색 표시줄 잡기
해설
우선 소프트웨어 구조는 아래 그림과 같다.
DDD의 아이디어를 바탕으로 한 양파 구조에서 용례층 구조를 생략했다.(용례층을 생략한 이유는 이 층에서 역층 내의 방법을 직접 호출하는 것일 뿐이다. 물론 복잡한 규격에 따라 용례층에서 역 대상을 조립하거나 몇 개의 역 서비스를 불러야 할 경우 용례층은 가입할 수 있다..)
MVM도 잡을 수 있는 구조라고 생각해요.
의존적인 방향으로
레이어 UI 표시(프레임 종속)
→ 프레젠테이션 레이어 Controller(pure Dart 참고)
→ 도메인 레이어 Repository
인프라 레이어 Repository Impl(도메인 레이어 Repository 구현)
→ 도메인 계층 ApiService
인프라 계층 ApiServiceImpl(도메인 계층 ApiService 구현)
→ 인프라 계층 ApiClient
.
소프트웨어 구조가 확정되어 지금부터 실시한다.
디자인을 먼저 결정합니다.이번에는 Qita 첫 페이지의 일람화면을 간단하게 참고했다.
이 설계를 바탕으로 필드 대상(실체)을 만듭니다.
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,
@JsonKey(name: 'created_at') required String createdAt,
@JsonKey(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 클라이언트를 정의합니다.(나는 원래 안드로이드 Developer였기 때문에 Retrofit에 익숙하다.)qiita_api.dart
@RestApi()
abstract class QiitaApi {
factory QiitaApi(Dio dio, {String baseUrl}) = _QiitaApi;
('/items')
@Header('Content-Type: application/json')
Future<HttpResponse<List<QiitaPostResponse>>> getItems({
@Header('Authorization') required String header,
@Query('page') int? page,
@Query('per_page') int? perPage,
@Query('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로 UNION을 표시하고 인프라층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
의 인터페이스를 역층으로 정의하고 인프라 층에서 실현한다.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(),
);
}
}
이곳의 반환값은 역 대상입니다.실장류에서 DI
Repository
를 대상으로 하는 방법이다.데이터 클래스에서 역 대상으로 전환하고 있습니다.
이어 상속
ApiService
StateNotifier
을 위해 프리즈드Controller
를 준비한다.나는
class PostListState with _$PostListState {
const factory PostListState({
@Default(<QiitaPost>[]) List<QiitaPost> posts,
@Default(false) bool hasNext,
@Default(1) int page,
String? query,
@Default(PageStateLoading()) PageState pageState,
}) = _PostListState;
}
State
의 유형을 StateNotifier
로 지정하고 싶지만 AsyncValue<T>
에서 Controller
를 얻었을 때T
는 처리하기 어려워서 state.value
의 속성에서 프리즈드T?
의 UNION을 정의했다.가능하다면 사용하고 싶습니다State
. 해결 방법을 알려주시면 기쁩니다.▶ issue 여기 있다.
정의
PageState
.명명 구조자는 서문에도 기재된 것처럼 테스트(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 측면의 지침으로 이동합니다.AsyncValue
는 Controller
의 UI 분리 포장ConnectedPostListPage
을 사용합니다.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),
),
);
}
}
PageState
에서는 분리 외에 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()))),
);
}
}
여기서도 PageDispatcher
를 사용하면 리버포드 2계PageDispatcher
에 따라 캐시를 하지 않아도 된다.AsyncValue
에서 사용AsyncValue.isRefreshing
하면 검색창이 좋아지고 리셋 기능도 추가할 수 있으며 PostListPage
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),
),
),
),
],
),
),
);
}
}
Sliver
에서 미리 감시LastIndicator
하압 라벨을 맨 윗부분으로 스크롤하여 설치한 후 클릭할 때ScrollBar.controller
를 ScrollController
방법으로 맨 윗부분으로 스크롤한다.onTap: (index) {
if (ref.read(postListScrollControllerProvider).hasClients) {
ref.read(postListScrollControllerProvider).animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
},
자세한 내용은 GiuHub을 참조하십시오.이렇게 더 좋은 방법이 있다면 꼭 알려주세요.
끝까지 봐주셔서 감사합니다.
Reference
이 문제에 관하여([Fluter] Riverpod 상태 관리의 화면 템플릿 및 아키텍처 예), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://zenn.dev/dev_tatsuya/articles/cffaa7c50dfad7텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)