Flutter BLoC Pattern - (2)

SEUNGHWANLEE·2021년 9월 14일
2

Flutter

목록 보기
2/6
post-thumbnail

이 글은 package:flutter_bloc을 사용하였습니다. rxdart를 사용하거나 다른 패키지를 다룰 경우 사용법이 다르다는 것을 참고해주세요 😄

우선 BLoC 패턴을 들어가기 앞서 아래와 같은 구조의 이해가 필요하다. 이번 여름 스타트업에서 팀원들에게 소개한 Data Flow이다. 3-Tier Architecture라고 검색해보면 자세하게 설명되어있는 글이 많으니 참고해도 좋을 것 같다.

짧게 위 그림에 대해 설명하자면 사용자가 보면 화면은 Presentation layer인데 이때 사용자가 화면을 터치하면서 여러 event를 발생시킨다. 처음부터 사용자가 보는 화면은 state에 따라 다르기 때문에 반복적인 setState(() {})는 일어나지 않는다.

그리고 event가 발생하면 Business Logic Layer로 event가 전달되어 event에 맞는 request를 할 수 있도록 매핑(mapping)을 해준다. 그리고 선택된 요청(request)가 서버로 전송되고 서버에서도 마찬가지로 3개의 layer로 나뉘게 되어 데이터가 흐른다. 올바른 요청(request)에 의한 성공적인 응답(response:status code == 200)이 도착한다면 Data Layer는 이를 기다리고 있다가 Business Logic Layer에게 전해준다.

성공적인 응답을 받은 Business Logic Layer는 현재 저장되어있는 state를 알맞게 변경하여 Presentation Layer에게 전해준다. 그렇게 되면 Presentation Layer에서는 때로는 로딩 이후 새로운 화면이 로드되거나 로딩 없이 데이터가 전달되면서 Fake UI를 줄 수도 있다.

요약

  1. 사용자가 화면에서 이벤트 발생
    → Presentation Layer 👉 Business Logic Layer: event
  2. Business Logic Layer에서 event에 맞는 함수(function)으로 매핑(mapping)
    → Business Logic Layer: event 🧩 function
  3. Data Layer로 필요한 param 전달
    → Business Logic Layer 👉 Data Layer: param
  4. Data Layer에서 Server로 요청(request)후 wait
    → Data Layer 👉 Server
  5. 성공적인 응답인 경우에만 Data Layer에서 Business Logic Layer로 전달
    → Data Layer 👉 Business Logic Layer: result
  6. 전달 받은 resultstate 변경 후 yield
    → Business Logic Layer: state ♻️
  7. yieldstate로 Presentation Layer 변경
    → Business Logic Layer 👉 Presentation Layer: state


개인적으로 BLoC 패턴을 구현할 때 3개의 directory를 만들어서 관리를 해주었다.

lib
...
└─── bloc
│   │   {{name}}_bloc.dart
│   │   {{name}}_event.dart
│   │   {{name}}_state.dart
└─── model
│   │   {{name}}.dart
└─── repository
│   │   {{name}}_repository.dart
...

여기서 model은 Provider에서 만든 것과 같이 만들고 bloc 디렉토리는 bloc, event 그리고 state로 구성한다. 마지막으로 respository는 Data Layer에 해당한다. 이제부터 directory 하나씩 살펴보도록 하겠다.


Model

데이터의 사용을 용이하게 만들어 줄 Model이다. 이번 포스팅에서는 게시글(Notice)를 예시로 들겠다. 예를 들어서 다음과 같은 모델이 있다고 가정해보자.

class Notice {
	int noticeID;
   	String textBody;
   	String creator;
}

게시글이란 클래스안에는 noticeID, textBody 그리고 creator가 있다고 가정해본다. 여기에 jsonSerializable()을 사용해서 json과의 작업을 프로그래머에 의한 버그 없이 작업할 수 있도록 한다.

Flutter 공식문서 바로가기 👉

완성된 모습은 아래와 같을 것이다.

import 'package:json_annotation/json_annotation.dart';

part 'notice.g.dart';

()
class Notice {
  int noticeID;
  String textBody;
  String creator;
    
  factory Notice.fromJson(Map<String, dynamic> json) => _$NoticeFromJson(json);

  Map<String, dynamic> toJson() => _$NoticeToJson(this);
}

json 직렬화와 model의 크기가 너무 커진다면 package:freezed를 써보는 것도 추천한다.

최근에 확인해보니 Build:failing으로 되어있네요 🧐 08월24일

Model을 위와 같이 만들어 Business Logic Layer에서 json parsing하는 작업과 json으로 만들어 내는 일을 보다 쉽게 할 수 있다.


Business Logic Layer : Bloc

사용자가 보내는 모든 event를 관리해주는 Business Logic Layer이다. 위에서 언급했듯이 MVVM과 BLoC의 다른 점은 BLoC은 어느 곳에서든 관계없이 접근할 수 있다는 점이다.

우선, BLoC을 구성하기 위해서는 3가지가 작성이 되어야한다. BLoC과 관련된 Event, State 그리고 Repository가 있어야한다.

Event

사용자로부터 일어날 수 있는 모든 경우의 수를 생각하면 쉽다. 예를 들어 버튼을 누를 때 어떤 작업이 실행되야 하는지를 생각하면 된다.

예시가 게시글이므로 간단하게 생각할 수 있는 경우의 수는 '게시글 읽어오기', '게시글 올리기(등록하기)', '게시글 수정하기', '게시글 삭제하기'가 있을 것이다.

  • 게시글 읽어오기 : GetNoticeEvent
  • 게시글 올리기 : CreateNoticeEvent
  • 게시글 수정하기 : EditNoticeEvent
  • 게시글 삭제하기 : DeleteNoticeEvent

이를 코드로 작성해보면 아래와 같이 작성할 수 있다.

import 'package:equatable/equatable.dart';

import 'model.dart'; // path of the model.dart


abstract class NoticeEvent extends Equatable {}

class GetNoticeEvent extends NoticeEvent {
  
  List<Object?> get props => [];
}

class CreateNoticeEvent extends NoticeEvent {
  final Notice notice;

  CreateNoticeEvent({
    required this.notice,
  });

  
  List<Object?> get props => [notice];
}

class EditNoticeEvent extends NoticeEvent {
  final Notice notice;

  EditNoticeEvent({required this.notice});

  
  List<Object?> get props => [notice];
}

class DeleteNoticeEvent extends NoticeEvent {
  final Notice notice;

  DeleteNoticeEvent({required this.notice});

  
  List<Object?> get props => [notice];
}

먼저 추상(abstract) 클래스를 Equatableextends해서 생성해준다. Equatable을 상속받아 사용하기 때문에 get propsoverride해준다.

그 후 추상클래스를 상속받는 이벤트 클래스를 생성해준다. 이 클래스들은 사용자가 발생시킬 수 있는 이벤트에 해당한다. 서버에서 등록, 수정 및 삭제기능은 Notice를 통채로 받아서 작업할 수 있도록 하였다.

이벤트는 발생할 때 클래스로 생성되므로 모두 final로 처리를 해주었다.
Dart: final vs. const 글 보러 가기 👉

State

사용자에 보여질 화면의 상태라고 생각하면 이해하기 수월하다. 사용자에게는 Presentation Layer로 yieldstate가 보여진다. 예를 들어 위에 작성한 NoticeBloc에 대해서는 아래와 같이 State를 작성할 수 있다.

import 'package:equatable/equatable.dart';

import 'model.dart'; // path of the model.dart


abstract class NoticeState extends Equatable {}

class NoticeEmpty extends NoticeState {
  /// Default Constructor will be used
  
  List<Object> get props = [];  
}

class NoticeLoading extends NoticeState {
  /// Default Constructor will be used
  
  List<Object> get props = [];
}

/// props return [msg]
class NoticeError extends NoticeState {
  final String msg;
  NoticeError({required this.msg});
  
  List<Object> get props = [msg];
}

/// props return [notices]
class NoticeLoaded extends NoticeState {
  final List<Notice> notices;
  NoticeLoaded({required this.notices});
  
  List<Object> get props = [notices];
}

위에서 진행했던 Event와 마찬가지로 Equatable을 이용해서 NoticeState를 생성해주었다. 여기서는 총 4개의 state로 구성하였다.

  • 게시글이 없는 상태 : NoticeEmpty
  • 게시글을 불러오는 상태 : NoticeLoading
  • 게시글을 불러오는 도중에 오류가 발생한 상태 : NoticeError
  • 게시글을 모두 불러온 상태 : NoticeLoaded

상태에 맞게 클래스 내부 property를 설정해주었다. Empty와 Loading의 경우 UI 상에서 보여줄 데이터가 없기 때문에 props안에 그 어떤 것도 넣지 않았다.

하지만 Error나 Loaded 와 같은 경우에는 에러에 대한 message가 필요하고 Loaded는 실제로 받아온 데이터가 출력되야하기 때문에 notices란 property를 선언해주었다.

NoticeList란 클래스를 만들어서 카테고리화도 할 수 있다.
+jsonSerializable 사용


Data Layer

Repository

마지막으로 서버와 직접적으로 통신할 Data Layer이다.
Event 내 클래스의 수와 Respository 클래스 내 method 수는 같다.

예를 들어서 현재 작성한 Event는 4가지로 구성되어있다. 각 Event가 발생했을 때 서버와의 통신이 필요하므로 Repository 클래스 내 method도 4개가 필요하다. (4개이상)

Repository를 구성할 때 주로 사용하는 패키지는 httpdio 가 있다. Token 관리를 효과적으로 해야한다면 dio 를 사용하는 것을 추천한다. dio 를 통해서 Interceptor를 구현해 Token 유효검사를 할 수 있고 보안성을 강화할 수 있다. 다만 http 를 사용하는 경우에는 이를 구현하려면 직접 구현해야하는데 dio 패키지를 사용하면 더 쉽게 구현할 수 있다.

2021년 9월 1일 기준으로 dio 패키지가 flutterchina로 부터 더 이상 유지보수가 되지 않는 것으로 보입니다! dio_http를 이용해주세요 🤩

dio_http 패키지 바로가기 👉

dependencies:
  dio_http: ^5.0.4

09.01.2021 기준
제가 Client용 Interceptor를 개발하다 dio package가 DioError를 잡지 못하는 것을 확인했고, try-catch로 error가 잡히지 않는 것을 다른 issue들을 통해서도 확인했습니다. 현재 dio_http 는 지속적으로 관리가 되고 있어서 사용하는데 좋을 듯 합니다 🔥

그럼 바로 Repository 클래스를 생성해봅시다 !

import 'dart:convert';

import 'package:http/http.dart' as http;

/// import model
import 'model.dart';

/// optional
Map<String, String> header = {
  'Content-Type': 'application/json; charset=utf8'
};

class NoticeRepository {
  /// could be `SharedPreferences`, `token` or `endPoints` etc.
  NoticeRepository()

  /// for [GetNoticeEvent]
  /// return `List<Notice>`
  Future<List<Notice>> getListOfNotice() async {
    final url = Uri.parse('{{your specific url}}');

    final response = await http.get(url, headers: header);

    if(response.statusCode == 200) {
      /// keep korean character safe
      final result = jsonDecode(utf8.decode(response.bodyBytes));
      final notices = (result as List<dynamic>).map((item) => Notice.fromJson(item)).toList();
      return notices;
    } else { 
      return [];  // empty list
    }
  }

  /// for [CreateNoticeEvent]
  /// return `Notice` or `bool` whatever you want
  Future<bool> postNotice({required Notice notice}) async {
    final url = Uri.parse('{{your specific url}}');
    /// tokens can be added
    final respones = await http.post(url, 
      headers: {...header, 'Authorization': 'JWT ' + '{{token}}'},
      body: jsonEncode(notice.toJson()));
    
    if (response.statusCode == 200) {
      /// keep korean character safe
      final result = jsonDecode(utf8.decode(response.bodyBytes));
      return true;
    } else {
      return false;
    }
  }
}

Repository 클래스 같은 경우는 사용하는 API마다 url, endpoint, token 종류, 요구되는 header, body 그리고 receive 받는 결과가 모두 다르기 때문에 사용하는 API를 참고해서 작성해주시길 바랍니다 🔥

쉬운 예시로 get, post를 사용해서 작성해보았는데, return type은 원하는 type으로 설정해서 사용하면 된다. 간혹 한글이 깨지는 경우가 있어서 utf8로 encoding/decoding을 해주었다.

알맞게 Repository 클래스를 만들었다면 이제 Event, State 그리고 Repository를 모두 이어줄 Bloc를 만들어주면 된다.


BLoC

위에서 작성한 3개를 묶어줄 NoticeBloc를 만들어 보자.

import 'package:flutter_bloc/flutter_bloc.dart';

import 'washing_machine_event.dart';
import 'washing_machine_state.dart';

/// import model
import 'model.dart';

class NoticeBloc extends Bloc<NoticeEvent, NoticeState> {
  final NoticeRepository noticeRepository;
  /// constructor - init with `Empty` State
  NoticeBloc({required this.noticeRepository}) : super(NoticeEmpty());

  /// required method
  
  Stream<NoticeState> mapEventToState(NoticeEvent event) async* {
    if (event is GetNoticeEvent) {
      yield* _mapGetNoticeEventToState(event);
    } else if (event is CreateNoticeEvent) {
      yield* _mapCreateNoticeEventToState(event);
    } else if (event is EditNoticeEvent) {
      yield* _mapEditNoticeEventToState(event);
    } else if (event is DeleteNoticeEvent) {
      yield* _mapDeleteNoticeEventToState(event);
    }
  }

  Stream<NoticeState> _mapGetNoticeEventToState(GetNoticeEvent event) async* {
    try {
      yield NoticeLoading();
      
      final notices = await noticeRepository.getListOfNotice(); // returns List<Notice>
      
      yield NoticeLoaded(notices: notices);
    } catch (e) {
      yield NoticeError(msg: e.toString());
    }
  }
}

Bloc 클래스를 만들어 줄 때는 필요한 Repository를 생성자로 초기화를 해주고, super를 사용해서 Empty인 state로 Bloc을 초기화해준다.

mapEventToState란 method를 반드시 override 해주어야한다. 그리고 if-else로 event 마다 state를 yield 해줄 수 있도록 모두 method를 선언해주어야한다.

예시로 _mapGetNoticeEventToState(event)를 구현했다. 처음에 NoticeLoading()yield 해서 사용자에게 서버로 부터 데이터를 받아오고 있다는 표시(?)를 해준다.

그리고 나서 await를 선언해주고 데이터를 받아올 때까지 기다린다. 이때까지도 사용자는 로딩화면을 계속 보게 된다.

성공적으로 받아오게되면 NoticeLoaded(notices: notices)yield 되면서 사용자에게 받아온 데이터가 출력된다. 반면에 데이터를 받아오는 Data layer에서 오류가 발생한다면 지금은 notice에 빈 list가 return 되기 때문에 repository 클래스 내 method에서도 try-catch로 wrap을 해주는 것이 좋은 방법이 될 수 있다.

데이터 parsing이나 status code 예외처리는 추가로 필요한 작업입니다 😭

이렇게 작성된 BLoC을 바탕으로 이제 사용자 화면에 출력하는 일만 남았다.


Presentation Layer : UI

우선 BLoC은 앱 내 state를 관리하는 로직이기 때문에 Stateful Widget으로 주로 다루지만 때에 따라서 Stateless Widget를 사용해도 좋다.

Stateful Widget을 사용해서 예시를 만들어 보자.

우선, Stateful Widget의 lifecycle은 간단하게 initState 👉 build 로 이어진다. 그래서 사용자에게 처음으로 받아온 데이터를 출력해주기 위해서는 build 전인 initState에서 데이터를 fetch하는 작업을 해주면 된다.

FutureBuilder를 사용해본 적이 있다면 사용되는 future 를 initState에서 먼저 부르는 것과 같다.

예시에서는 BlocProvider 를 사용해서 UI를 구성할 것이다. BlocProvider 를 사용하기전에는 Widget의 root에서 MultiBlocProviderMultiProvider로 사용할 Bloc을 선언해주어야한다.

예를 들면 다음과 같이 앱 시작 시 맨 위에 선언해주는 것도 방법이다.

main.dart

void main() {
  runApp(MultiBlocProvider(
    providers: [
      BlocProvider(
        create: (context) => NoticeBloc(noticeRepository: NoticeRepository)),
    ],
    child: const MyApp(),
  ));
}

notice_screen.dart

import ...

class NoticeScreen extends StatefulWidget {
  NoticeScreen({Key? key}) : super(key: key);

  
  _NoticeScreenState createState() => _NoticeScreenState();
}

class _NoticeScreenState extends State<NoticeScreen> {

  
  void initState() { 
    super.initState();
    /// fetch data from server
    BlocProvider.of<NoticeBloc>(context).add(GetNoticeEvent());
  }

  
  Widget build(BuildContext context) {
    return BlocBuilder<NoticeBloc, NoticeState>(
      builder: (context, state) {
        if (state is NoticeEmpty) {
          return emptyScreen;
        } else if (state is NoticeLoading) {
          return loadingScreen;
        } else if (state is NoticeError) {
          final msg = state.msg;
          return errorScreen;
        } else if (state is NoticeLoaded) {
          final notices = state.notices;
          return ListView.seperated(
            itemCount: notices.length,
            itemBuilder: (context, index) => ListTile(
              leading: (index + 1).toString(),
              title: notices[index].textBody, 
              subTitle: notices[index].creator, 
            ),
            separatorBuilder: (context, index) => SizedBox(height: 10),
          );
        }
        /// should not be reached here !!
        return SizedBox();
      }
    )
  }
}

상위 Widget에서 BlocProvider로 사용하려는 Bloc이 선언되어있으면 하위 Widget에서는 해당하는 BlocBlocBuilder와 BlocProvider를 사용할 수 있다.

notice_screen.dart에서는 initState 에서 먼저 데이터를 불러오고 state를 BLoC으로 부터 yield 받는다.

그리고 build에서 BlocBuilder 를 사용해 state 별로 화면을 return 해준다. Stream를 사용하기 때문에 정상적인 행동범위 내에서 function이 종료되거나 그런일은 없고, yield 되기 때문에 화면이 state에 따라서 유동적이게 된다.

if-else 로 선언해준 state 이외의 맨 아래에 return SizedBox(); 로 화면이 출력될 경우에는 Bloc 내부 로직을 살펴보거나 if-else condition을 알맞게 선언해주었는지 확인해봐야 한다.


포스팅을 마치면서

BLoC 패턴을 적용한다는 것은 처음에 많은 작업을 필요로 할 수 있다. 최소 하나의 Model에 관해서 state를 관리하기 위해서 3개의 file을 생성해야하고 이에 대한 Data Layer, Presentation Layer도 만들어주어야하기 때문이다.

하지만 state가 변경되는 경우가 빈번하고 이에 대한 cost를 최소화 하고자 한다면 더 유연한 디스플레이와 함께 BLoC으로 사용자에게 제공할 수 있다.


실제로 사용사례를 확인해보고 싶으시다면 아래 링크를 참고해주세요 😄
2021년 공개SW개발자대회 출품작 woomam-flutter 👉

긴 글 읽어주셔서 감사합니다

profile
잡동사니 😁

0개의 댓글