이 글은 다음의 글을 실제로 따라 해보면서 정리한 일부 내용이다.

Flutter Pagination with Riverpod: The Ultimate Guide

Simple Pagination App

https://github.com/korca0220/flutter_tmdb_with_riverpod

Architecture

Flow

Untitled

Directory Structure

/src
  /feature
    /favorites
      ...
    /movies  
      /data
        /data_source
          /remote
        /repository
        /model
      /domain
        /entity
        /repository
        /use_case
      /presentation
        /widgets
        /movie_detail
        /movies
      /utils
  /global
    /extensions
    /http
      dio_providoer.dart
    /route
      router_provider.dart
      main_navigation.dart
main.dart

ShellRoute

Shell?

Shell은 Flutter에서 ‘기본적인 Ui’ 라고 생각하면 된다. 즉, 기본 Layout or Frame 이라고 생각할 수 있다. 일반적으로 하단에 내비게이션을 두는 Ui의 경우에 사용하는데. 이 경우 하단 내비는 보통 상태만 바뀌고 그 상태에 따라서 메인 화면이 바뀌는 식이다.

여기서 바텀 내비게이션을 ‘Shell’ 이라고 볼 수 있다.

ShellRoute

Shell 은 기본 프레임이다. 그러면 ShellRoute는 ? ‘기본 프레임 내에서의 라우팅’ 이라고 생각하면 된다. 즉, 프레임 내에서 고정 되어 있는 Ui 말고 라우팅이 이뤄지는 화면이다.

Untitled

Implement(go_router, riverpod)

// router.dart

enum AppRoute {
  movies,
  movie,
  favorites,
}

final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search');
final _favoritesNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'favorite');

final goRouterProvider = Provider((ref) {
  return GoRouter(
    initialLocation: '/movies',
    navigatorKey: _rootNavigatorKey,
    debugLogDiagnostics: true,
    routes: [
      StatefulShellRoute.indexedStack(
          builder: (context, state, navigationShell) {
            return MainNavigation(navigationShell: navigationShell);
          },
          branches: [
            StatefulShellBranch(
              navigatorKey: _searchNavigatorKey,
              routes: [
                GoRoute(
                  path: '/movies',
                  name: AppRoute.movies.name,
                  pageBuilder: (context, state) => NoTransitionPage(
                    key: state.pageKey,
                    child: const MovieSearchScreen(),
                  ),
                  routes: [
                    GoRoute(
                      path: ':id',
                      name: AppRoute.movie.name,
                      pageBuilder: (context, state) {
 
                        return MaterialPage(
                            key: state.pageKey,
                            child: const MovieDetailScreen());
                      },
                    ),
                  ],
                ),
              ],
            ),
            StatefulShellBranch(
              navigatorKey: _favoritesNavigatorKey,
              routes: [
                GoRoute(
                  path: '/favorites',
                  name: AppRoute.favorites.name,
                  pageBuilder: (context, state) => NoTransitionPage(
                    key: state.pageKey,
                    child: const FavoritesScreen(),
                  ),
                ),
              ],
            ),
          ]),
    ],
  );
});

Router 부분

final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search');
final _favoritesNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'favorite');
StatefulShellRoute.indexedStack(
  builder: (context, state, navigationShell) {
    return MainNavigation(navigationShell: navigationShell);
  },
...
// main_navigation.dart

class MainNavigation extends StatelessWidget {
  const MainNavigation({
    Key? key,
    required this.navigationShell,
  }) : super(key: key ?? const ValueKey('ScaffoldWithNestedNavigation'));

  final StatefulNavigationShell navigationShell;

  void _goBranch(int index) {
    navigationShell.goBranch(
      index,
      initialLocation: index == navigationShell.currentIndex,
    );
  }

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.sizeOf(context);

    if (size.width < 450) {
      return ScaffoldWithNavigationBar(
        body: navigationShell,
        currentIndex: navigationShell.currentIndex,
        onDestinationSelected: _goBranch,
      );
    } else {
      return ScaffoldWithNavigationRail(
        body: navigationShell,
        currentIndex: navigationShell.currentIndex,
        onDestinationSelected: _goBranch,
      );
    }
  }
}

MainNavigation(Shell) 부분

  void _goBranch(int index) {
    navigationShell.goBranch(
      index,
      initialLocation: index == navigationShell.currentIndex,
    );
  }

내비게이션 아이콘을 눌렀을때 shell routing이 되는 메서드 로직이다. 기존의 go_router의 go, push와 다르게 goBranch를 사용한다. 전달 한 index(선택된 index)가 초기 브랜치의 인덱스와 동일하면 초기 브랜치로 이동한다.

<aside> ⭐ GPT 설명

이 코드는 특정한 브랜치로 이동하는 _goBranch 메서드를 정의하는 것입니다. 이 메서드는 navigationShell

이라는 객체에 있는 goBranch 메서드를 호출합니다. 여기에는 브랜치의 인덱스가 전달됩니다. 또한, 이 메서드는 해당 브랜치가 현재 보이는 위치인지 아닌지를 확인하고, 그에 따라 초기 위치로 이동하는지를 결정합니다.

</aside>

Data Source(Remote)

API Http 통신에 대한 내용을 정의한다. 크게 의미는 없을지 모르나 Interface와 Implement부로 나뉘어 구성한다.

Interface

abstract interface class MoviesRemoteDataSource {
  Future getMovies({
    MoviesQueryData? queryData,
    CancelToken? cancelToken,
    int? movieId,
  });

  Future getNowPlayingMovies({
    required int page,
    CancelToken? cancelToken,
  });
}

Implement

final class MoviesRemoteDataSourceImpl implements MoviesRemoteDataSource {
  const MoviesRemoteDataSourceImpl(this.client);

  final Dio client;

  @override
  Future<dynamic> getMovies({
    MoviesQueryData? queryData,
    CancelToken? cancelToken,
    int? movieId,
  }) async {
    assert(queryData != null || movieId != null,
        'queryData or movieId must be provided');

    final uri = Uri(
      scheme: 'https',
      host: 'api.themoviedb.org',
      path: queryData != null ? '3/search/movie' : '3/movie/$movieId',
      queryParameters: {
        'api_key': Env.apiKey,
        'include_adult': 'true',
        if (queryData != null) ...{
          'page': '${queryData.page}',
          'query': queryData.query,
        }
      },
    );
    final response = await client.getUri(uri, cancelToken: cancelToken);

    return response.data;
  }

  @override
  Future<dynamic> getNowPlayingMovies({
    required int page,
    CancelToken? cancelToken,
  }) async {
    final uri = Uri(
      scheme: 'https',
      host: 'api.themoviedb.org',
      path: '3/movie/now_playing',
      queryParameters: {
        'api_key': Env.apiKey,
        'include_adult': 'true',
        'page': '$page',
      },
    );

    final response = await client.getUri(uri, cancelToken: cancelToken);

    return response.data;
  }
}

각 메서드에서 Model로 변환하여 넘길수도 있지만 data source 부분에서는 원시적인 동작만 하는게 맞다고 생각하여. 굳이 변환하지 않았다.


Provider

final movieDataSourceProvider = Provider((ref) {
  return MoviesRemoteDataSourceImpl(
    ref.read(dioProvider),
  );
});


Model

Data Layer - Model

@freezed
class TMDBMoviesResponse with _$TMDBMoviesResponse {
  factory TMDBMoviesResponse({
    required int page,
    required List<TMDBMovie> results,
    @JsonKey(name: 'total_pages') required int totalPages,
    @JsonKey(name: 'total_results') required int totalResults,
    @Default([]) List<TMDBMovie> movies,
  }) = _TMDBMoviesResponse;

  factory TMDBMoviesResponse.fromJson(Map<String, dynamic> json) =>
      _$TMDBMoviesResponseFromJson(json);
}

Domain Layer - Entity

@freezed
class TMDBMovie with _$TMDBMovie {
  factory TMDBMovie({
    required int id,
    required String title,
    String? overview,
    @JsonKey(name: 'poster_path') String? posterPath,
    @JsonKey(name: 'release_date') String? releaseDate,
  }) = _TMDBMovieBasic;

  factory TMDBMovie.fromJson(Map<String, dynamic> json) =>
      _$TMDBMovieFromJson(json);
}

굳이 Model, Entity로 나눠놓기는 했는데. 사실상 Entity에 그냥 놓고 써도 될 것 같다.. 내 생각에 Model, Entity 정의는 다음과 같다.

Repository

Repository - Interface(Domain Layer)

Domain Layer의 Repository 에서는 DataSource를 가공해서 사용 할 메서드들을 인터페이스로 정의한다.

DataSource 부분과 다른점은 Raw data → 기능적 구분 이 된다는 것이다. 만약 URL이 동일하고 어떠한 조건에 따라서 얻는 데이터가 다르다고 한다면 1:N(DataSource : Repository)의 구조로 구성할 수도 있다.

추가적으로 dynamic → Type(return value) 명세가 되어있다.

abstract interface class MoviesRepository {
  Future<TMDBMoviesResponse> searchMovies({
    required MoviesQueryData queryData,
    CancelToken? cancelToken,
  });

  Future<TMDBMoviesResponse> nowPlayingMovies({
    required int page,
    CancelToken? cancelToken,
  });

  Future<TMDBMovie> movie({
    required int movieId,
    CancelToken? cancelToken,
  });
}

Repository - Implement(Data Layer)

final class MoviesRepositoryImpl implements MoviesRepository {
  const MoviesRepositoryImpl(this.remoteDataSource);

  final MoviesRemoteDataSource remoteDataSource;

  @override
  Future<TMDBMovie> movie({
    required int movieId,
    CancelToken? cancelToken,
  }) async {
    final result = await remoteDataSource.getMovies(
      movieId: movieId,
      cancelToken: cancelToken,
    );

    return TMDBMovie.fromJson(result);
  }

  @override
  Future<TMDBMoviesResponse> nowPlayingMovies({
    required int page,
    CancelToken? cancelToken,
  }) async {
    final result = await remoteDataSource.getNowPlayingMovies(
      page: page,
      cancelToken: cancelToken,
    );

    return TMDBMoviesResponse.fromJson(result);
  }

  @override
  Future<TMDBMoviesResponse> searchMovies({
    required MoviesQueryData queryData,
    CancelToken? cancelToken,
  }) async {
    final result = await remoteDataSource.getMovies(
      queryData: queryData,
      cancelToken: cancelToken,
    );

    return TMDBMoviesResponse.fromJson(result);
  }
}

UseCase

UseCase는 각 기능에 맞게 비즈니스 로직이 추가 된다.

@riverpod
Future<TMDBMoviesResponse> fetchMovies(
  FetchMoviesRef ref, {
  required MoviesQueryData queryData,
}) {
  final repo = ref.watch(movieRepositoryProvider);

  final cancelToken = CancelToken();

  _cacheRef(ref: ref, cancelToken: cancelToken);

  return queryData.query.isNotEmpty
      ? repo.searchMovies(
          queryData: queryData,
          cancelToken: cancelToken,
        )
      : repo.nowPlayingMovies(
          page: queryData.page,
          cancelToken: cancelToken,
        );
}

@riverpod
Future<TMDBMovie> getMovie(
  GetMovieRef ref, {
  required int movieId,
}) {
  final cancelToken = CancelToken();

  final repo = ref.watch(movieRepositoryProvider);

  return repo.movie(movieId: movieId, cancelToken: cancelToken);
}

void _cacheRef({
  required AutoDisposeRef ref,
  required CancelToken cancelToken,
}) {
  Timer? timer;

  final link = ref.keepAlive();

  ref.onDispose(() {
    cancelToken.cancel();
    timer?.cancel();
  });

  ref.onCancel(() {
    timer = Timer(const Duration(seconds: 30), () {
      link.close();
    });
  });

  ref.onResume(() {
    timer?.cancel();
  });
}

fetchMovies

이 부분에서는 3개로 나눠진 Repo 메서드 중 2개를 사용한다. query 결과가 있는 경우와 없는 경우로 분기가 달라지는데 그 이유는 TMDB API에서 searchQuery가 없는 경우는 아무런 데이터를 주지 않기 때문이다.

그렇기 때문에 대체 가능한 API로 nowPlaying 를 사용한다.(상영중인 영화 정보인듯?)

getMovie

하나의 단일 영화를 갖고 온다. MovieId로 조회하여 갖고오며 DetailScreen에서 사용된다.

각 UseCase들은 개별적으로 Provider가 생성되기 때문에 각각 필요한 기능에 붙여 사용하면된다.

Riverpod cache

@riverpod
Future<TMDBMoviesResponse> fetchMovies(FetchMoviesRef ref, int page) {
  final moviesRepo = ref.watch(moviesRepositoryProvider);
  return moviesRepo.nowPlayingMovies(page: page);
}

우린 다음과 같은 기능이 필요하다.

Caching with Timeout

@riverpod
Future<TMDBMoviesResponse> fetchMovies(
  FetchMoviesRef ref, {
  required MoviesQueryData queryData,
}) {
  final repo = ref.watch(movieRepositoryProvider);

  final cancelToken = CancelToken();

  _cacheRef(ref: ref, cancelToken: cancelToken);

  return queryData.query.isNotEmpty
      ? repo.searchMovies(
          queryData: queryData,
          cancelToken: cancelToken,
        )
      : repo.nowPlayingMovies(
          page: queryData.page,
          cancelToken: cancelToken,
        );
}
void _cacheRef({
  required AutoDisposeRef ref,
  required CancelToken cancelToken,
}) {
  Timer? timer;

  final link = ref.keepAlive();

  ref.onDispose(() {
    cancelToken.cancel();
    timer?.cancel();
  });

  ref.onCancel(() {
    timer = Timer(const Duration(seconds: 30), () {
      link.close();
    });
  });

  ref.onResume(() {
    timer?.cancel();
  });
}

ref.keepAlive

.autoDispose | Riverpod

cacheRef

코드 로직은 다음과 같이 동작한다.

  1. 요청을 keepAlive 한다.
  2. 30초짜리 타이머를 생성한다. 30초가 지나면 keepAlive link를 제거한다.
  3. 같은 요청이 들어오거나, provider가 더 이상 쓰이지 않게 되면 제거한다.

Conclusion

Riverpod을 많이 사용해보지 않아서 이해가 안되는 부분이 조금 있긴 하지만 나름 직관적이라고 생각. 기존에 많이 사용하던 클린 아키텍처 구조를 사용해서 써봤는데 앱이 좀 더 커지면 어떻게 될지 궁금하기도 하다.