이 글은 다음의 글을 실제로 따라 해보면서 정리한 일부 내용이다.
Flutter Pagination with Riverpod: The Ultimate Guide
https://github.com/korca0220/flutter_tmdb_with_riverpod
/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
Shell은 Flutter에서 ‘기본적인 Ui’ 라고 생각하면 된다. 즉, 기본 Layout or Frame 이라고 생각할 수 있다. 일반적으로 하단에 내비게이션을 두는 Ui의 경우에 사용하는데. 이 경우 하단 내비는 보통 상태만 바뀌고 그 상태에 따라서 메인 화면이 바뀌는 식이다.
여기서 바텀 내비게이션을 ‘Shell’ 이라고 볼 수 있다.
Shell 은 기본 프레임이다. 그러면 ShellRoute는 ? ‘기본 프레임 내에서의 라우팅’ 이라고 생각하면 된다. 즉, 프레임 내에서 고정 되어 있는 Ui 말고 라우팅이 이뤄지는 화면이다.
// 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);
},
...
Builder
로 Shell Ui에게 navigationShell을 넘긴다. navigationShell은 Widget이며 Branch Widget이라고 볼 수 있다. 즉, 화면에 child 를 어떻게 표시할지 MainNavigation(Shell Ui)에서 정의하면 된다.// 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>
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 부분에서는 원시적인 동작만 하는게 맞다고 생각하여. 굳이 변환하지 않았다.
final movieDataSourceProvider = Provider((ref) {
return MoviesRemoteDataSourceImpl(
ref.read(dioProvider),
);
});
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 정의는 다음과 같다.
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,
});
}
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는 각 기능에 맞게 비즈니스 로직이 추가 된다.
@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();
});
}
이 부분에서는 3개로 나눠진 Repo 메서드 중 2개를 사용한다. query 결과가 있는 경우와 없는 경우로 분기가 달라지는데 그 이유는 TMDB API에서 searchQuery가 없는 경우는 아무런 데이터를 주지 않기 때문이다.
그렇기 때문에 대체 가능한 API로 nowPlaying
를 사용한다.(상영중인 영화 정보인듯?)
하나의 단일 영화를 갖고 온다. MovieId로 조회하여 갖고오며 DetailScreen에서 사용된다.
각 UseCase들은 개별적으로 Provider가 생성되기 때문에 각각 필요한 기능에 붙여 사용하면된다.
@riverpod
Future<TMDBMoviesResponse> fetchMovies(FetchMoviesRef ref, int page) {
final moviesRepo = ref.watch(moviesRepositoryProvider);
return moviesRepo.nowPlayingMovies(page: page);
}
@riverpod
은 기본값으로 AutoDisposeRef
를 생성한다. @Riverpod(keepAlive: true)
와 대비되는 옵션이라고 할 수 있다.keepAlive : true
로 하면? 앱이 켜있는 동안 데이터는 계속 남아있기 때문에 메모리 오버플로우가 발생할 확률이 있기 때문에 비효율적이다.우린 다음과 같은 기능이 필요하다.
@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();
});
}
keepAlive()
를 사용하면 데이터를 보존시킬 수 있다.코드 로직은 다음과 같이 동작한다.
keepAlive
한다.Riverpod을 많이 사용해보지 않아서 이해가 안되는 부분이 조금 있긴 하지만 나름 직관적이라고 생각. 기존에 많이 사용하던 클린 아키텍처 구조를 사용해서 써봤는데 앱이 좀 더 커지면 어떻게 될지 궁금하기도 하다.