[Fluter] Riverpod 상태 관리의 화면 템플릿 및 아키텍처 예

배경.


앱 개발에 빈번하게 등장하는 화면 중 하나라면 화면을 일람하는 게 아닐까.
이 일람화면을 템플릿화함으로써 원하는 일람화면을 빠르게 제작할 수 있을 것으로 기대된다.

내장형 기능 소개(GIF 포함)


이번 실시의 주요 기능은 다음과 같다.
  • 목록 표시
  • 무한 스크롤
  • 아래 탭을 눌러 맨 위로 스크롤
  • 스트레칭 리셋(pull-to-refresh)
  • 검색 표시줄의 Sliver화
  • (Widget Test, Unit Test라고도 쓰임)
  • Flutter를 사용하여 이러한 기능을 한 눈에 볼 수 있습니다.
    상태 관리에는 Riverpod+State Notifier+Freezed가 사용됩니다.
    목록에 표시된 데이터는 Qiita API에서 가져옵니다.
    ▶ 모든 코드는 GiitHub 참조.
    https://github.com/dev-tatsuya/flutter-qiita-client
    다음은 각 기능의 GIF입니다.

    목록 보기



    무한 스크롤



    탭을 누르고 위로 스크롤



    새로 고침



    검색 표시줄 잡기



    해설


    우선 소프트웨어 구조는 아래 그림과 같다.

    DDD의 아이디어를 바탕으로 한 양파 구조에서 용례층 구조를 생략했다.(용례층을 생략한 이유는 이 층에서 역층 내의 방법을 직접 호출하는 것일 뿐이다. 물론 복잡한 규격에 따라 용례층에서 역 대상을 조립하거나 몇 개의 역 서비스를 불러야 할 경우 용례층은 가입할 수 있다..)
    MVM도 잡을 수 있는 구조라고 생각해요.
    의존적인 방향으로
    레이어 UI 표시(프레임 종속)
    → 프레젠테이션 레이어 Controller(pure Dart 참고)
    → 도메인 레이어 Repository
    인프라 레이어 Repository Impl(도메인 레이어 Repository 구현)
    → 도메인 계층 ApiService
    인프라 계층 ApiServiceImpl(도메인 계층 ApiService 구현)
    → 인프라 계층 ApiClient
    .
    소프트웨어 구조가 확정되어 지금부터 실시한다.
    디자인을 먼저 결정합니다.이번에는 Qita 첫 페이지의 일람화면을 간단하게 참고했다.

    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,
        @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(),
        );
      }
    }
    
    이곳의 반환값은 역 대상입니다.
    실장류에서 DIRepository를 대상으로 하는 방법이다.
    데이터 클래스에서 역 대상으로 전환하고 있습니다.
    이어 상속ApiServiceStateNotifier을 위해 프리즈드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 여기 있다.
    https://github.com/dev-tatsuya/flutter-qiita-client/issues/1
    정의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 측면의 지침으로 이동합니다.AsyncValueController의 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에 따라 캐시를 하지 않아도 된다.
    https://github.com/rrousselGit/river_pod/blob/master/packages/riverpod/CHANGELOG.md#200-dev0 AsyncValue에서 사용AsyncValue.isRefreshing하면 검색창이 좋아지고 리셋 기능도 추가할 수 있으며 PostListPageVisibilityDetector로 포장된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.controllerScrollController 방법으로 맨 윗부분으로 스크롤한다.
    onTap: (index) {
      if (ref.read(postListScrollControllerProvider).hasClients) {
        ref.read(postListScrollControllerProvider).animateTo(
    	  0,
    	  duration: const Duration(milliseconds: 300),
    	  curve: Curves.easeOut,
    	);
      }
    },
    
    자세한 내용은 GiuHub을 참조하십시오.
    https://github.com/dev-tatsuya/flutter-qiita-client
    이렇게 더 좋은 방법이 있다면 꼭 알려주세요.
    끝까지 봐주셔서 감사합니다.

    좋은 웹페이지 즐겨찾기