민프

[Flutter] BLoC - 3. 목록 튜토리얼 앱에 적용해보기 (without Cubit) 본문

[Flutter]

[Flutter] BLoC - 3. 목록 튜토리얼 앱에 적용해보기 (without Cubit)

민프야 2023. 10. 20. 17:44

이번 포스팅에는 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은 사용자의 입력 (스크롤링 등)에 반응하여 더 많은 포스트를 가져와 프레젠테이션 레이어에 표시할 수 있도록 합니다.

 

예를들어서) 동작방식을 보면

  • PostBlocPostEvent를 받아 PostState로 변환합니다.
  • PostFetched 이벤트는 사용자가 스크롤을 하거나 더 많은 포스트를 보고 싶을 때 발생합니다.
  • PostBloc이 이벤트를 받고, 필요에 따라 API 호출 등을 통해 더 많은 포스트를 가져옵니다.
  • 가져온 포스트 데이터는 PostState로 변환되어 프/레젠테이션 레이어에 전달됩니다.

이렇게 PostEventPostState를 정의하고 사용함으로써, 이벤트와 상태의 관리가 명확해지고 코드의 유지 관리가 용이해집니다.

 

 

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를 렌더링합니다.

 

동작 방식을 보면

  • PostBlocPostEvent를 받아 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');
  }
}

위 코드를 기반으로 동작 방식을 정리해보자면 아래와 같습니다.

  1. PostFetched 이벤트가 발생하면 _onPostFetched 메서드가 호출됩니다.
  2. _onPostFetched 메서드 내에서 현재 상태를 확인하여 새로운 포스트를 가져오거나 에러를 처리합니다.
  3. _fetchPosts 메서드를 호출하여 API에서 포스트를 로드합니다. startIndex를 사용하여 특정 위치부터 포스트를 로드할 수 있습니다.
  4. 포스트를 성공적으로 가져오면 PostSuccess 상태를, 에러가 발생하면 PostFailure 상태를, 더 이상 가져올 포스트가 없으면 hasReachedMax를 true로 설정하여 상태를 업데이트합니다.
  5. 이벤트 트랜스포머를 사용하여 이벤트를 디바운스하여 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 까지 어떻게 동작하는지 중간점검을 해보겠습니다. 

  1. 앱이 시작되면 PostsPage 위젯이 렌더링되고, BlocProvider를 통해 PostBloc 인스턴스가 생성됩니다.
  2. PostBloc 인스턴스는 PostFetched 이벤트를 받아 초기 게시물을 로드합니다.
  3. PostsList 위젯은 BlocBuilder를 사용하여 PostBloc의 상태에 따라 UI를 빌드합니다.
  4. 사용자가 스크롤을 하면 _onScroll 메서드가 호출되고, 사용자가 페이지의 90%까지 스크롤하면 PostFetched 이벤트가 PostBloc에 추가됩니다.
  5. PostBlocPostFetched 이벤트를 처리하여 추가 게시물을 로드하고, 상태를 업데이트합니다.
  6. BlocBuilder는 새로운 상태를 받아 UI를 업데이트합니다.

이러한 방식으로 PostBloc, PostsPage, PostsList 등이 서로 연결되어 동작하며,

사용자는 스크롤을 통해 무한히 게시물을 로드할 수 있게 됩니다.

 

마지막으로 디렉토리 구조에 대한 동작 과정을 추가로 설명해드리자면

  1. main.dart
    • 앱이 시작되면 main.dart에서 runApp 함수를 호출하여 App 위젯을 렌더링합니다.
    • Bloc.observer는 SimpleBlocObserver로 설정되어 BLoC의 모든 상태 변화와 이벤트를 로깅합니다.
  2. app.dart
    • App 위젯은 PostsPage를 home으로 설정하여 게시물 페이지를 렌더링합니다.
  3. posts_page.dart
    • PostsPage 위젯은 BlocProvider를 사용하여 PostBloc 인스턴스를 생성하고, PostFetched 이벤트를 추가하여 초기 게시물을 로드합니다.
    • PostsList 위젯이 자식으로 추가되어 게시물을 표시합니다.
  4. post_bloc.dart
    • PostBlocPostFetched 이벤트를 받아 API로부터 게시물을 로드하고, 상태를 업데이트합니다.
  5. posts_list.dart
    • PostsList 위젯은 BlocBuilder를 사용하여 PostBloc의 상태에 따라 UI를 빌드합니다.
    • 사용자가 스크롤을 하면 _onScroll 메서드가 호출되고, 사용자가 페이지의 90%까지 스크롤하면 PostFetched 이벤트가 PostBloc에 추가됩니다.
  6. post_bloc.dart (다시)
    • PostBloc은 새로운 PostFetched 이벤트를 처리하여 추가 게시물을 로드하고, 상태를 업데이트합니다.
  7. 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

Comments