민프
[Flutter] BLoC - 3. 목록 튜토리얼 앱에 적용해보기 (without Cubit) 본문
이번 포스팅에는 Flutter와 블록 라이브러리를 사용하여 사용자가 스크롤할 때 네트워크를 통해 데이터를 가져오고 로드하는 앱을 구현하겠습니다.
API로는jsonplaceholder를 데이터 소스로 사용합니다 .
(https://jsonplaceholder.typicode.com/posts?_start=0&_limit=2)
이번 프로젝트에서 패키지로 Equatable, Bloc Concurrency 가 있는데
사전 지식이 있으면 본 포스팅에 이해가 됨으로 참고하시길 바랍니다.
그럼 프로젝트 구조부터 알아보겠습니다.
프로젝트 구조
애플리케이션은 기능 기반 디렉터리 구조를 사용합니다.
이 프로젝트 구조를 통해 자체 포함된 기능을 통해 프로젝트를 확장할 수 있습니다.
이 예제에서는 단일 기능(게시 기능)만 있으며 별표(*)로 표시된 Barrel 파일이 있는 각 폴더로 분할됩니다.
├── lib
| ├── posts
│ │ ├── bloc
│ │ │ └── post_bloc.dart // API로부터 게시물을 가져오고, 상태를 업데이트 합니다.
이 부분을 정의하기전에 post_event부터 정의해야합니다.
| | | └── post_event.dart // post_bloc에 이벤트를 전달하는 클래스 입니다.
해당 포스팅에서는 PostFetched가 있지만 다른 이벤트도
정의해서 전달할 수 있습니다.
| | | └── post_state.dart // post_bloc의 상태를 나타내는 클래스 입니다.
프리젠테이션 레이어에 여기에 정의 된 상태 값들을 알려주는 역할을 합니다.
예를 들어서) api의 상태값, posts의 정보값, 최대 게시물 수에 도달했는지 여부
| | └── models
| | | └── models.dart* // 모델 클래스를 모두 export하는 파일 입니다.
| | | └── post.dart // 게시물 데이터 모델을 정의하는 파일입니다.
API로부터 받은 JSON 데이터를 Dart 객체로 변환하는 역할을 합니다.
│ │ └── view
│ │ | ├── posts_page.dart // 게시물 페이지를 나타냅니다.
여기에서 BlocProvider를 이용하여서 post_bloc를 제공합니다.
│ │ | └── posts_list.dart // 게시물을 표시하는 위젯입니다.
스크롤 이벤트에 반응하여 추가 게시물을 로드합니다.
| | | └── view.dart* // 뷰 관련 파일을 모두 export하는 파일입니다.
| | └── widgets
| | | └── bottom_loader.dart
| | | └── post_list_item.dart // 각 게시물을 표시하는 위젯입니다.
| | | └── widgets.dart* // 위젯 관련 파일을 모두 export하는 위젯입니다.
│ │ ├── posts.dart* // posts폴더의 모든 파일을 export하는 파일입니다.
│ ├── app.dart
│ ├── simple_bloc_observer.dart // BLoC 이벤트와 상태 변화를 관찰하고 로깅하는 observe를 정의하는 파일입니다.
│ └── main.dart
├── pubspec.lock
├── pubspec.yaml
pubspec.yaml
name: flutter_infinite_list
description: A new Flutter project.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
bloc: ^8.1.0
bloc_concurrency: ^0.2.0
equatable: ^2.0.3
flutter:
sdk: flutter
flutter_bloc: ^8.1.1
http: ^0.13.0
stream_transform: ^2.0.0
dev_dependencies:
bloc_test: ^9.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.0
flutter:
uses-material-design: true
소스 코드
Data Model : models / post.dart
import 'package:equatable/equatable.dart';
final class Post extends Equatable {
const Post({required this.id, required this.title, required this.body});
final int id;
final String title;
final String body;
@override
List<Object> get props => [id, title, body];
}
Post 데이터 모델을 정의하는 부분입니다.
Post는 블로그 포스트나 기사 등을 나타내는 데이터 모델로, 각 포스트는 고유의 ID, 제목, 그리고 본문을 가지고 있습니다.
특별한 부분은
Equatable 패키지를 import하여 Post 객체의 동일성을 쉽게 비교할 수 있습니다.
Post Events : bloc / post_event.dart
part of 'post_bloc.dart';
sealed class PostEvent extends Equatable {
@override
List<Object> get props => [];
}
final class PostFetched extends PostEvent {}
PostBloc이 처리할 이벤트를 정의하는 부분입니다.
PostBloc은 사용자의 입력 (스크롤링 등)에 반응하여 더 많은 포스트를 가져와 프레젠테이션 레이어에 표시할 수 있도록 합니다.
예를들어서) 동작방식을 보면
- PostBloc은 PostEvent를 받아 PostState로 변환합니다.
- PostFetched 이벤트는 사용자가 스크롤을 하거나 더 많은 포스트를 보고 싶을 때 발생합니다.
- PostBloc은 이 이벤트를 받고, 필요에 따라 API 호출 등을 통해 더 많은 포스트를 가져옵니다.
- 가져온 포스트 데이터는 PostState로 변환되어 프/레젠테이션 레이어에 전달됩니다.
이렇게 PostEvent와 PostState를 정의하고 사용함으로써, 이벤트와 상태의 관리가 명확해지고 코드의 유지 관리가 용이해집니다.
Post States : bloc / post_state.dart
part of 'post_bloc.dart';
enum PostStatus { initial, success, failure }
// initial
// 초기 상태입니다. 이 상태에서 프레젠테이션 레이어는 로딩 인디케이터를 렌더링합니다.
// PostSuccess
// 포스트를 성공적으로 불러온 상태입니다. posts 리스트에 포스트가 있고,
// hasReachedMax로 더 불러올 포스트가 있는지 여부를 알 수 있습니다.
// PostFailure
// 포스트를 불러오는 데 실패한 상태입니다. 에러가 발생했음을 프레젠테이션 레이어에 알립니다.
final class PostState extends Equatable {
const PostState({
this.status = PostStatus.initial,
this.posts = const <Post>[],
this.hasReachedMax = false,
});
final PostStatus status;
final List<Post> posts;
final bool hasReachedMax;
PostState copyWith({
PostStatus? status,
List<Post>? posts,
bool? hasReachedMax,
}) {
return PostState(
status: status ?? this.status,
posts: posts ?? this.posts,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
);
}
@override
String toString() {
return '''PostState { status: $status, hasReachedMax: $hasReachedMax, posts: ${posts.length} }''';
}
@override
List<Object> get props => [status, posts, hasReachedMax];
}
위 코드는 PostBloc의 상태를 정의합니다.
프레젠테이션 레이어는 이 상태를 사용하여 적절한 UI를 렌더링합니다.
동작 방식을 보면
- PostBloc은 PostEvent를 받아 PostState로 변환합니다.
- 프레젠테이션 레이어는 PostState를 기반으로 UI를 렌더링합니다.
- 예를 들어, PostInitial 상태에서는 로딩 인디케이터를 보여주고, PostSuccess 상태에서는 포스트 리스트를 보여줍니다.
- PostFailure 상태에서는 에러 메시지 등을 표시하여 사용자에게 문제를 알립니다.
이렇게 상태를 정의하고 관리함으로써, 이벤트와 상태의 변화를 명확하게 추적하고, UI를 더욱 빠르고 효율적으로 업데이트할 수 있습니다.
Post Bloc : bloc / post_bloc.dart
PostBloc은 PostEvent를 입력으로 받아 PostState를 출력하는 BLoC입니다.
이 BLoC는 HTTP 클라이언트에 의존하여 API에서 포스트를 가져옵니다.
import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_infinite_list/bloc/bloc.dart';
import 'package:flutter_infinite_list/post.dart';
part 'post_event.dart';
part 'post_state.dart';
class PostBloc extends Bloc<PostEvent, PostState> {
PostBloc({required this.httpClient}) : super(const PostState()) {
/// TODO: register on<PostFetched> event
}
final http.Client httpClient;
}
위 코드를 보면
Bloc을 extends 하고, PostEvent를 입력으로, PostState를 출력으로 사용합니다.
또 코드 중
on<PostFetched>(_onPostFetched);는
PostFetched 이벤트가 발생할 때 _onPostFetched 메서드를 호출하도록 설정합니다.
PostBloc({required this.httpClient}) : super(const PostState()) {
on<PostFetched>(_onPostFetched);
}
Future<void> _onPostFetched(PostFetched event, Emitter<PostState> emit) async {
if (state.hasReachedMax) return;
try {
if (state.status == PostStatus.initial) {
final posts = await _fetchPosts();
return emit(state.copyWith(
status: PostStatus.success,
posts: posts,
hasReachedMax: false,
));
}
final posts = await _fetchPosts(state.posts.length);
emit(posts.isEmpty
? state.copyWith(hasReachedMax: true)
: state.copyWith(
status: PostStatus.success,
posts: List.of(state.posts)..addAll(posts),
hasReachedMax: false,
));
} catch (_) {
emit(state.copyWith(status: PostStatus.failure));
}
}
위 코드는 PostFetched 이벤트를 처리합니다.
상태에 따라 새 포스트를 가져오거나, 에러 상태를 설정하거나, 더 이상 가져올 포스트가 없음을 알립니다.
여기서 최적화 할 수 있는 방법은
API가 불필요하게 요청되는 것을 맏는 것 입니다.
여기에서 Event Transformer를 사용하여 아래 코드와 같이 이벤트 처리를 Customise를 합니다.
아래 코드에서 throttleDroppable은 이벤트를 지정된 시간 동안 디바운스하여 API를 불필요하게 호출하는 것을 방지합니다.
import 'package:stream_transform/stream_transform.dart';
const throttleDuration = Duration(milliseconds: 100);
EventTransformer<E> throttleDroppable<E>(Duration duration) {
return (events, mapper) {
return droppable<E>().call(events.throttle(duration), mapper);
};
}
class PostBloc extends Bloc<PostEvent, PostState> {
PostBloc({required this.httpClient}) : super(const PostState()) {
on<PostFetched>(
_onPostFetched,
transformer: throttleDroppable(throttleDuration),
);
}
PostBloc의 전체코드는 아래와 같습니다.
import 'dart:async';
import 'dart:convert';
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_infinite_list/posts/posts.dart';
import 'package:http/http.dart' as http;
import 'package:stream_transform/stream_transform.dart';
part 'post_event.dart';
part 'post_state.dart';
const _postLimit = 20;
const throttleDuration = Duration(milliseconds: 100);
EventTransformer<E> throttleDroppable<E>(Duration duration) {
return (events, mapper) {
return droppable<E>().call(events.throttle(duration), mapper);
};
}
class PostBloc extends Bloc<PostEvent, PostState> {
PostBloc({required this.httpClient}) : super(const PostState()) {
on<PostFetched>(
_onPostFetched,
transformer: throttleDroppable(throttleDuration),
);
}
final http.Client httpClient;
Future<void> _onPostFetched(
PostFetched event,
Emitter<PostState> emit,
) async {
if (state.hasReachedMax) return;
try {
if (state.status == PostStatus.initial) {
final posts = await _fetchPosts();
return emit(
state.copyWith(
status: PostStatus.success,
posts: posts,
hasReachedMax: false,
),
);
}
final posts = await _fetchPosts(state.posts.length);
posts.isEmpty
? emit(state.copyWith(hasReachedMax: true))
: emit(
state.copyWith(
status: PostStatus.success,
posts: List.of(state.posts)..addAll(posts),
hasReachedMax: false,
),
);
} catch (_) {
emit(state.copyWith(status: PostStatus.failure));
}
}
Future<List<Post>> _fetchPosts([int startIndex = 0]) async {
final response = await httpClient.get(
Uri.https(
'jsonplaceholder.typicode.com',
'/posts',
<String, String>{'_start': '$startIndex', '_limit': '$_postLimit'},
),
);
if (response.statusCode == 200) {
final body = json.decode(response.body) as List;
return body.map((dynamic json) {
final map = json as Map<String, dynamic>;
return Post(
id: map['id'] as int,
title: map['title'] as String,
body: map['body'] as String,
);
}).toList();
}
throw Exception('error fetching posts');
}
}
위 코드를 기반으로 동작 방식을 정리해보자면 아래와 같습니다.
- PostFetched 이벤트가 발생하면 _onPostFetched 메서드가 호출됩니다.
- _onPostFetched 메서드 내에서 현재 상태를 확인하여 새로운 포스트를 가져오거나 에러를 처리합니다.
- _fetchPosts 메서드를 호출하여 API에서 포스트를 로드합니다. startIndex를 사용하여 특정 위치부터 포스트를 로드할 수 있습니다.
- 포스트를 성공적으로 가져오면 PostSuccess 상태를, 에러가 발생하면 PostFailure 상태를, 더 이상 가져올 포스트가 없으면 hasReachedMax를 true로 설정하여 상태를 업데이트합니다.
- 이벤트 트랜스포머를 사용하여 이벤트를 디바운스하여 API를 효율적으로 호출합니다.
Presentation Layer
- simple_bloc_observer.dart
import 'package:bloc/bloc.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_infinite_list/app.dart';
import 'package:flutter_infinite_list/simple_bloc_observer.dart';
void main() {
Bloc.observer = const SimpleBlocObserver();
runApp(const App());
}
Bloc.observer는 이전 포스팅에서 말씀드렸듯 BLoC 패턴에서 이벤트, 상태 전환, 에러 등을 관찰하고 로깅하는 역할을 합니다.
- post_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_infinite_list/posts/posts.dart';
import 'package:http/http.dart' as http;
class PostsPage extends StatelessWidget {
const PostsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Posts')),
body: BlocProvider(
create: (_) => PostBloc(httpClient: http.Client())..add(PostFetched()),
child: const PostsList(),
),
);
}
}
위 코드에서 PostsPage라는 이름의 위젯을 정의하고 있습니다.
이 위젯은 BlocProvider를 사용하여 PostBloc 인스턴스를 생성하고 하위 트리에 제공합니다.
또한 앱이 로드될 때 PostFetched 이벤트를 추가하여 초기 배치의 게시물을 요청하고
PostsList 위젯은 PostBloc의 상태에 따라 게시물을 표시합니다.
- post_list.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_infinite_list/posts/posts.dart';
class PostsList extends StatefulWidget {
const PostsList({super.key});
@override
State<PostsList> createState() => _PostsListState();
}
class _PostsListState extends State<PostsList> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<PostBloc, PostState>(
builder: (context, state) {
switch (state.status) {
case PostStatus.failure:
return const Center(child: Text('failed to fetch posts'));
case PostStatus.success:
if (state.posts.isEmpty) {
return const Center(child: Text('no posts'));
}
return ListView.builder(
itemBuilder: (BuildContext context, int index) {
return index >= state.posts.length
? const BottomLoader()
: PostListItem(post: state.posts[index]);
},
itemCount: state.hasReachedMax
? state.posts.length
: state.posts.length + 1,
controller: _scrollController,
);
case PostStatus.initial:
return const Center(child: CircularProgressIndicator());
}
},
);
}
@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
void _onScroll() {
if (_isBottom) context.read<PostBloc>().add(PostFetched());
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
}
PostsList라는 StatefulWidget을 정의하고 있습니다.
이 위젯은 게시물을 표시하고 사용자가 스크롤을 할 때 추가 게시물을 로드하는 기능을 담당합니다.
여기까지 API를 받아서 PostPage->PostList 까지 어떻게 동작하는지 중간점검을 해보겠습니다.
- 앱이 시작되면 PostsPage 위젯이 렌더링되고, BlocProvider를 통해 PostBloc 인스턴스가 생성됩니다.
- PostBloc 인스턴스는 PostFetched 이벤트를 받아 초기 게시물을 로드합니다.
- PostsList 위젯은 BlocBuilder를 사용하여 PostBloc의 상태에 따라 UI를 빌드합니다.
- 사용자가 스크롤을 하면 _onScroll 메서드가 호출되고, 사용자가 페이지의 90%까지 스크롤하면 PostFetched 이벤트가 PostBloc에 추가됩니다.
- PostBloc은 PostFetched 이벤트를 처리하여 추가 게시물을 로드하고, 상태를 업데이트합니다.
- BlocBuilder는 새로운 상태를 받아 UI를 업데이트합니다.
이러한 방식으로 PostBloc, PostsPage, PostsList 등이 서로 연결되어 동작하며,
사용자는 스크롤을 통해 무한히 게시물을 로드할 수 있게 됩니다.
마지막으로 디렉토리 구조에 대한 동작 과정을 추가로 설명해드리자면
- main.dart
- 앱이 시작되면 main.dart에서 runApp 함수를 호출하여 App 위젯을 렌더링합니다.
- Bloc.observer는 SimpleBlocObserver로 설정되어 BLoC의 모든 상태 변화와 이벤트를 로깅합니다.
- app.dart
- App 위젯은 PostsPage를 home으로 설정하여 게시물 페이지를 렌더링합니다.
- posts_page.dart
- PostsPage 위젯은 BlocProvider를 사용하여 PostBloc 인스턴스를 생성하고, PostFetched 이벤트를 추가하여 초기 게시물을 로드합니다.
- PostsList 위젯이 자식으로 추가되어 게시물을 표시합니다.
- post_bloc.dart
- PostBloc은 PostFetched 이벤트를 받아 API로부터 게시물을 로드하고, 상태를 업데이트합니다.
- posts_list.dart
- PostsList 위젯은 BlocBuilder를 사용하여 PostBloc의 상태에 따라 UI를 빌드합니다.
- 사용자가 스크롤을 하면 _onScroll 메서드가 호출되고, 사용자가 페이지의 90%까지 스크롤하면 PostFetched 이벤트가 PostBloc에 추가됩니다.
- post_bloc.dart (다시)
- PostBloc은 새로운 PostFetched 이벤트를 처리하여 추가 게시물을 로드하고, 상태를 업데이트합니다.
- posts_list.dart (다시)
- BlocBuilder는 새로운 상태를 받아 UI를 업데이트합니다. 추가 게시물이 리스트에 추가됩니다.
완성된 코드는 아래 github에서 확인하실 수 있습니다.
https://github.com/felangel/Bloc/tree/master/examples/flutter_infinite_list
참고링크
https://github.com/machty/ember-concurrency
https://pub.dev/packages/bloc_concurrency
https://pub.dev/packages/stream_transform
https://pub.dev/packages/bloc_concurrency
https://pub.dev/packages/equatable
https://bloclibrary.dev/#/flutterinfinitelisttutorial?id=project-structure
'[Flutter]' 카테고리의 다른 글
[Flutter] 애플 로그인을 구현해보자 (IOS) (0) | 2024.03.18 |
---|---|
[Flutter] 민감한 정보는 SharedPreferences 대신 FlutterSecureStorage를 사용하여 저장하자(android-KeyChain, IOS - KeyStore) (2) | 2023.12.20 |
[Flutter] stream_transform 라이브러리에 대해서 알아보자 (0) | 2023.10.20 |
[Flutter] Bloc Concurrency 라이브러리에 대해서 알아보자 (0) | 2023.10.20 |
[Flutter] Equatable 라이브러리에 대해서 알아보자 (0) | 2023.10.20 |