다양한 statement management
Provider를 위한 MVVM 패턴의 이해와 더불어 다른 방식의 pattern을 적용해볼 순 없을까라는 고민을 하던 차, Stream 객체를 이용해서 Bloc pattern이 제 관심을 이끌었습니다.
Flutter는 상당히 여러가지의 Statement 관리를 지원합니다. 목록을 살펴보면
- Provider
- setState
- InheritedWidget & InheritedModel
- Redux
- Fish-Redux
- Bloc / Rx
- GetIt
- MobX
- Flutter Commands
- Binder
- GetX
- Riverpod
위의 목록 중에 실제 flutter 내에서 지원되는 statement와 third party library로 제공되는 것도 있습니다. 분명한 건, Flutter official 페이지에서도 나열된 것처럼 충분히 사용하기에 부족함이 없는 statement 들입니다.
자세한 내용은 해당 링크에서 확인하세요.
이번에 다룰 내용은 그 중 Stream 객체를 활용하는 Bloc pattern입니다.
Bloc란
Bloc parttern은 실제 보여지는 화면과 비즈니스 로직간을 분리해서 재사용 가능하고 테스트 가능한 코드를 빠르게 만들 수 있도록 돕습니다. Bloc pattern의 핵심은 Stream 객체인데 Future와 더불어 비동기 처리를 위해 사용되는 객체입니다.
아.. 바로 들어가고 싶지만..
그 전에 정말 다양한 것들을 알아두셔야합니다. 한 번 짚고 넘어가보도록 하겠습니다.
비동기 객체
Future
Future는 비동기이며, 일반적인 함수가 결과를 반환 한다면, Future 함수는 결과물을 담고 있는 Future 객체를 반환합니다. 즉, Future는 결과가 준비가 되었을 때 시스템에 알립니다.
Stream
Stream 역시 비동기이지만, 비동기 이벤트의 순서를 가지고 있습니다. 약간 비동기 반복문과 비슷하다고 볼 수 있는데, Stream은 Future와 달리 이벤트가 준비 되었을 때 시스템에 알립니다.
설명이 너무 어렵다고요?
쉽게 설명을 드리자면, Stream은 일종의 방송국 개념이고 이를 듣는 청취자들이 여럿 존재한다고 했을 때 방송을 통해 청취자에게 전달되는 내용은 모두 같은 내용입니다.
Stream은 방송국이고, 청취자는 Listener입니다. 하나의 방송국은 여러 명의 청취자들이 존재하듯, 하나의 Stream에는 여러개의 Listener가 구독할 수 있습니다.
Stream의 비동기 이벤트의 순서란 무엇일까
Future<int> sumStream(Stream<int> stream) async {
var sum = 0;
await for (var value in stream) {
sum += value;
}
return sum;
}
위의 코드를 살펴보면 Stream 객체는 다양한 방법으로 생성 가능하지만 우선 비동기 이벤트 루프를 설명하기 위해 적합한 코드를 가져와봤습니다. 비동기 반복문(보통 await for 라고 표현합니다.)이 Stream의 이벤트 순서대로 반복합니다.
위 코드는 Integer type의 Stream 이벤트를 받아 그 값을 합하고 그 총 합을 담은 Future 객체를 반환합니다. loop 문이 반복을 완료하면 실제로 sumStream 함수는 잠시 동작을 중단하고 Stream이 다음 event를 보내거나 Stream이 완료될 때까지 기다립니다.
비동기 이벤트의 순서는 위처럼 Stream이 특정 이벤트가 준비되면 순서대로 시스템에 알립니다.
위의 내용은 순수한 dart를 기반한 것이라, flutter 의 개발 내용과는 사뭇 다를 수 있으나, 실제적인 동작 원리에 대해 가볍게 이해하고 넘어가면 되겠습니다.
Stream 동작 방식
- Stream을 선언합니다. (방송국 생성!!!)
- StreamController의 stream 프로퍼티를 listen합니다. (Listener 등록: 청취자 등록!!!)
- listen은 StreamSubscription를 반환합니다. (자 등록된 청취자들이 들을 수 있도록 각각 무전기 or 라디오 제공!!)
- StreamTransformer 객체를 통해 데이터를 가공할 수 있습니다. (optional)
Stream을 선언하고 StreamController의 stream 프로퍼티를 listen합니다. listen 메소드를 통해 StreamSubscription 객체를 전달받을 수 있습니다. 그럼 위처럼 특정 이벤트가 준비가 되면 StreamSubscription 객체를 통해 인지할 수 있게 됩니다.
활성화된 listener가 하나라도 있다면 Stream은 events를 생성하기 시작하고 Stream을 통해 데이터, 에러 및 Stream이 닫힌 상태를 활성화된 StreamSubscription 객체에 알립니다.
StreamSubscription 객체는 listening을 중지, 정지, 다시 시작하는 기능을 제공해줍니다.
그렇다고 Stream이 심플한 파이프라인을 갖는 것은 아닙니다. 데이터를 전달하기 전에 가공할 수 있습니다. 즉, Stream 객체에 있는 data를 컨트롤 하기 위해 StreamTransformer 객체를 활용하여 데이터를 capture할 수 있고, 데이터를 조작하고 변형하여 제공할 수 있습니다.
Stream 종류
- Single-subscription Streams
Stream Life cycle 동안 단 하나의 Listener 만을 허용합니다.
- Broadcast Streams
여러 개의 Listener 를 어느 때나 추가할 수 있습니다.
Basic Sample
void main() {
final StreamController ctrl = StreamController();
ctrl.stream.listen((data) => print('$data'));
ctrl.sink.add('my name');
ctrl.sink.add(1234);
ctrl.sink.add({'a': 'element'});
ctrl.sink.add('my name'); ctrl.close();
runApp(MyApp());
}
flutter 프로젝트를 새로 만들고 main 메소드 안에 위와 같은 코드를 작성하고 실행해보면 StreamController의 stream 프로퍼티를 통해 listen을 등록하고, Listener에게 보낼 데이터를 순차적으로 추가하고 난 후에 Stream 객체를 close합니다. 콘솔에는 보낸 데이터가 순차적으로 표시되는 것을 확인할 수 있습니다.
위의 예제는 단일 구독 Stream 예제입니다.
StreamTransformer
void main() {
final StreamController<int> ctrl = StreamController<int>.broadcast(); ctrl.stream
.where((value) => value % 2 == 0)
.listen((event) { print(event); }); for(int i = 1; i < 20; i++) {
ctrl.sink.add(i);
}
ctrl.close(); runApp(MyApp());
}
위의 예제는 broadcast Stream의 예제입니다. 실제로 broadcast의 역할을 하는 코드는 없고 StreamTransformer 객체를 활용하여 Listener에게 전달하기 전에 .where() 절을 통해 data를 가공하여 전달하고 있습니다.
RxDart
위의 설명들은 실제로 Stream 객체를 활용하는 방법을 배운 것이며, Flutter에서 활용하는 데에서는 단순 데이터 수신으로는 부족함이 있습니다. 실제로 data-driven 방식으로 데이터에 의해 반응하도록 하려면 RxDart를 활용해야 합니다.
RxDart는 Dart의 ReactiveX API를 확장하여 만든 라이브러리입니다. Dart와 RxDart의 용어상 가장 큰 차이는 아래와 같습니다.
Dart — Stream, StreamController
RxDart- Observable, Subject
그러나, Dart 2.7 버전부터 Combine 할 목적이 아니라면 Stream을 사용하여도 관계없습니다.
PublishSubject
PublishSubject는 일반적인 broadcast StreamController 객체를 반환합니다. 딱 한 가지 다른 점은 stream 이 Stream 대신 Observable 객체를 반환하는 점입니다.
BehaviorSubject
BehaviorSubject는 PublishSubject와 같은 역할을 하지만 가장 큰 차이점은 Listener에게 전달한 가장 마지막 이벤트도 함께 보내줍니다.
ReplaySubject
ReplaySubject는 PublishSubject, BehaviorSubject와 같은 역할을 하지만, 새로 등록된 Listener에게 그 Listener에게 전달한 가장 처음의 이벤트부터 모든 이벤트를 전달합니다.
StreamSubscription — listen할 필요가 없는 stream은 항상 구독을 cancel 해야합니다. StreamController — 사용되지 않는 StreamController는 close 해야합니다. RxDart Subjects의 BehaviorSubject와 PublishSubject도 사용되지 않으면 close 해야합니다.
Stream을 활용하여 Widget의 데이터 조작하기
class Home extends StatefulWidget {
@override
_HomeState createState() => _HomeState();
}class _HomeState extends State<Home> {
int counter = 0;
StreamController<int> _streamController; @override
void initState() {
super.initState();
_streamController = StreamController<int>();
} @override
void dispose() {
_streamController.close();
super.dispose();
} @override
Widget build(BuildContext context) {
return Material(
child: StreamBuilder<int>(
stream: _streamController.stream,
initialData: counter,
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('$counter'),
RaisedButton(
onPressed: () {
_streamController.sink.add(++counter);
},
child: Text('추가'),
),
],
);
}
)
);
}
}
Basic Sample에서 보여준 StreamController와 코드자체는 크게 차이가 없으나, 가장 크게 차이가 나는 부분은 build() 메소드의 StreamBuilder입니다. StreamBuilder는 listen 되는 이벤트를 구독하여 적절한 구현을 하도록 돕는 Builder 입니다.
onPressed에서 Basic Sample에서 구현한 _streamController.sink.add(); 로 counter의 값을 상승시키고, StreamBuilder는 이를 listen하고 있다가 새로운 stream 이벤트가 도착하면 builder를 호출하여 화면을 다시 그립니다.
Reactive programming
Stream과 RxDart를 활용하여 실질적으로 구현할 수 있는 것은 Reactive programming입니다. Reactive programming은 비동기 데이터 stream 으로 프로그래밍 구현을 하는 겁니다. 다른 말로, 이벤트(tap, long press 등), 변수와 메시지 등 모든 부분이 data stream에 의해 변화가 이루어집니다.
좀 더 단락을 나눠서 설명을 하자면,
- 어플리케이션을 비동기화 시키기
- Stream과 Listener에 의해 변화 감지를 하도록 설계하기
- 이벤트, 변수의 변화 등의 모든 감지가 발생될 때 Stream을 전달하기
- 어플리케이션의 어떠한 위치에 있든 Stream을 listen하고 있는 Listener에게 변화를 전달하고 적절한 행동을 취하게 하기
가 Reactive programming 방식입니다.
또한, 모든 컴포넌트를 디커플링 시킵니다. (의존도 제거)
Provider를 활용하여 MVVM 아키텍쳐를 적용했을 때 서로의 구조체가 상호작용하는 모습이 아래의 그림입니다. 중앙집권 식처럼 ViewModel이 Model과 View 모두 관여하고 있습니다.
물론 Service 도 따로 존재하고 실제로 testable 한 코드를 작성하기도 쉬운 구조이긴 하나 딱 하나의 단점이 있었습니다. View는 해당 ViewModel에 종속적으로 사용되어지며, View의 기능이 많으면 많을 수록 ViewModel의 코드는 점점 거대해지고 분리시키기가 어려웠습니다. 또한, 여러 개의 ViewModel이 하나의 View에 사용되어지면 코드가 비대해지는 경향이 있었습니다.
예, 발로 그렸습니다.
반면에 Responsive Programming은 아래와 같이 중앙 집권식이 아닌 서로가 독립적으로 동작하고, Stream은 누가 Listener인지만 지켜보고, 전달하는 역할, Listener는 어느 Widget이 Listen하고 있는지만 확인 하면 되는 구조라, component화 하기 훨씬 쉬워보입니다.
The BLoC Pattern
드디어 원래 주제로 돌아와 BLoC 패턴에 대해 이야기해볼 수 있을 거 같습니다.
BLoC는 Business LOgic Component의 줄인말로 이름에서 알 수 있듯이 Business Logic은 반드시 따로 분리되어 관리되어야 한다입니다.
BLoC 패턴을 적용하면 아래와 같은 이점이 있습니다.
- Business Logic을 한 개 이상의 BLoC로 분할할 수 있습니다.
- UI 에 함께 뒤섞여 있는 Business Logic을 아예 삭제하여, UI Components가 UI에 대한 부분만 담당할 수 있게 됩니다.
- input(sink)과 output(stream)에 대한 모든 것을 Stream 객체를 통해 처리할 수 있습니다.
- platform에 독립적이게 되고, 환경에 독립적이게 됩니다.
- 같은 코드를 web, mobile 심지어 back-end에 사용할 수 있습니다.(음.. 이건 저도 안 해봐서 아직..)
Layers
BLoC는 기본적으로 3가지의 Layer가 존재합니다.
- Presentation
- Business Logic
- Data
가장 저레벨 레이어부터 진행해보도록 하겠습니다. (user interface 쪽 layer가 가장 마지막에 되겠군요)
Data Layer
Data Layer는 하나 이상의 자원으로 전달 받은 데이터를 수집, 조작하는 Layer입니다. Data Layer는 2가지의 부분으로 나뉠 수 있는데, Repository와 Data Provider입니다. 이 레이어는 쉽게 데이터베이스, 네트워크 요청단, 다른 비동기 데이터 소스와 상호작용하는 곳입니다.
- Data Provider raw data를 제공해주는 역할을 합니다. Data Provider는 대부분 간단한 CRUD 를 수행하는 API를 보여줍니다.
- Repository 1개 이상의 Data Provider를 가지며 BLoC Layer와 상호작용합니다.
Business Logic Layer
화면상의 사용자에 의해 발생한 Input에 대응하는 역할을 하며 한 개 이상의 Repository를 가질 수 있습니다. 예를 들어, Presentation Layer에서 events/actions가 발생했을 때 이를 감지하고 Repository에 상호작용하여 새로운 상태를 가져 Presentation Layer에게 Consume하게 도와줍니다.
Bloc-to-Bloc 커뮤니케이션
Bloc 는 다른 Bloc에 의존될 수 있습니다. 즉, Bloc1이 Bloc2를 가지고 Bloc2에 의해 Bloc1의 값이 변경되도록 구현할 수 있습니다.
Presentation Layer
Presentation Layer는 하나 이상의 Bloc 상태들을 가질 수 있으며, 사용자의 input이나 어플리케이션의 라이프 사이클을 담당합니다.
위의 설명을 다이어그램을 한 번 작성해보았습니다. 도움이 되시길..
아래의 그림은 실제 모델링 설명에 해당하는 그림입니다. 큰 차이가 없어 다행입니다. 이해를 했다는 증거가 되겠군요. 😂
BLoC Pattern Project 시작
무엇을 만들 것인가?
이왕 flutter의 BloC 패턴을 배우려면 project를 하나 만들어가며 배우는 게 낫기 때문에, 영화의 정보를 소개해주는 서비스를 만들어보도록 하겠습니다.
Folder Structure
우선, 프로젝트를 생성하고 폴더 구조를 위의 구조처럼 설정했습니다.
각각의 설명은
presentation은 화면 UI를 표현하는 package
bloc는 Business Logic을 담는 package
repositories는 data를 수정하고 취합하여 bloc에 제공하는 package
data_providers는 raw data를 가지는 package
입니다.
RxDart 설치
Responstive Programming 구현을 위해 RxDart를 설치하고 http 통신을 위해 http도 역시 설치하겠습니다.
Movie 데이터 가져오기
TMDB 라는 사이트를 통해 Movie 데이터를 서버에서 불러올 수 있도록 하겠습니다. 이 링크를 가서 회원가입을 하여 token 정보를 얻으세요.
API 요청을 테스트 해보면 다음과 같은 데이터를 얻을 수 있습니다.
요청 API는 아래와 같습니다. {token}에 획득하신 token을 삽입하여 작성해주세요!
http://api.themoviedb.org/3/movie/popular?api_key={token}
{
“page”: 1,
”results”: [
{
“adult”: false,”backdrop_path”: “/lOSdUkGQmbAl5JQ3QoHqBZUbZhC.jpg”,
”genre_ids”: [53,28,878],
”id”: 775996,
”original_language”: “en”,
”original_title”: “Outside the Wire”,
”overview”: “In the near future, a drone pilot is sent into a deadly militarized zone and must work with an android officer to locate a doomsday device.”,
”popularity”: 3435.973,
”poster_path”: “/e6SK2CAbO3ENy52UTzP3lv32peC.jpg”,
”release_date”: “2021–01–15”,”title”: “Outside the Wire”,
”video”: false,
”vote_average”: 6.5,
”vote_count”: 544
},
]
}
위와 같은 구조의 response 결과입니다.
Domain Model 구성하기
Server로부터 전달받은 JSON 데이터를 Flutter 내부의 입맛대로 사용할 수 있도록 Model를 구성합니다.
우선 models 라는 package를 새로 생성하고 그 안에 ItemModel 과 Movie라는 이름으로 class 를 생성했습니다.
ItemModel
import 'package:bloc_clean_architecture/models/movie.dart';class ItemModel {
final int page;
final int totalPage;
final List<Movie> movies;
final int totalResults; ItemModel({
this.page,
this.totalPage,
this.movies,
this.totalResults,
}); factory ItemModel.fromJSON(Map json) {
List moviesFromJSON = json['results'];
List<Movie> movies = [];
moviesFromJSON.forEach((movie) => movies.add(Movie.fromJSON(movie))); return ItemModel(
page: json['page'],
totalPage: json['total_page'],
movies: movies,
totalResults: json['total_results'],
);
}
}
Movie
class Movie {
final bool adult;
final String backdropPath;
final List<dynamic> genreIds;
final int id;
final String originalLanguage;
final String overview;
final double popularity;
final String posterPath;
final String releaseDate;
final String title;
final bool video;
final dynamic voteAverage;
final int voteCount; Movie({
this.adult,
this.backdropPath,
this.genreIds,
this.id,
this.originalLanguage,
this.overview,
this.popularity,
this.posterPath,
this.releaseDate,
this.title,
this.video,
this.voteAverage,
this.voteCount
}); factory Movie.fromJSON(Map json) {
return Movie(
adult: json['adult'],
backdropPath: json['backdrop_path'],
genreIds: json['genre_ids'],
id: json['id'],
originalLanguage: json['original_language'],
overview: json['overview'],
popularity: json['popularity'],
posterPath: json['poster_path'],
releaseDate: json['release_date'],
title: json['title'],
video: json['video'],
voteAverage: json['vote_average'],
voteCount: json['vote_count'],
);
}
}
API Provider 구현하기
실제 API와 통신하는 부분을 구현해보도록 하겠습니다. data_provider package 안에 movie_api_provider.dart를 생성하고 아래와 같이 코드를 작성합니다.
import 'dart:convert';
import 'package:bloc_clean_architecture/models/item_model.dart';
import 'package:http/http.dart' show Client;class MovieApiProvider {
Client client = Client();
final apiKey = 'Your API CODE'; Future<ItemModel> fetchMovieList() async {
final response = await client.get('<http://api.themoviedb.org/3/movie/popular?api_key=>' + apiKey); if(response.statusCode == 200) {
final data = jsonDecode(response.body);
return ItemModel.fromJSON(data);
}
else {
throw Exception('Failed to load post');
}
}
}
http 모듈을 활용해서 데이터를 가져와 작성된 ItemModel의 객체에 decode 된 json 데이터를 생성하여 호출부로 전달합니다.
Repository 구현하기
이제 실제 data provider에서 전달받은 객체를 BLoC에게 적절히 transformation 하여 전달하는 곳을 구현하고자 합니다. 이 부분에서 실제로 필요한 데이터의 filter나, 적절한 행위가 일어나야하지만, 이번 예제에서는 단순히 전달받은 모델을 토스하여 BloC에게 전달하도록 하겠습니다.
repositories package 에 movie_repository.dart를 생성하고 아래와 같이 코드를 생성합니다.
import 'package:bloc_clean_architecture/data_providers/movie_api_provider.dart';
import 'package:bloc_clean_architecture/models/item_model.dart';class MovieRepository {
final MovieApiProvider _movieApiProvider = MovieApiProvider(); Future<ItemModel> fetchAllMovies() async => _movieApiProvider.fetchMovieList();
}
BloC 구현하기
Repository에서 전달받은 데이터를 Business Logic에 맞춰 화면을 조작해야합니다. 가령 리스트의 수가 증가하면 화면에도 역시 적용시켜야 합니다. 이 workflow 구간을 구현해보도록 하겠습니다.
bloc package에 movie_bloc.dart를 생성하고 아래와 같이 작성합니다.
import 'package:bloc_clean_architecture/models/item_model.dart';
import 'package:bloc_clean_architecture/repositories/movie_repository.dart';
import 'package:rxdart/rxdart.dart';class MovieBloc {
final MovieRepository _movieRepository = MovieRepository();
final PublishSubject _movieFetcher = PublishSubject<ItemModel>(); Stream<ItemModel> get allMovies => _movieFetcher.stream; Future<void> fetchAllMovies() async {
ItemModel itemModel = await _movieRepository.fetchAllMovies();
_movieFetcher.sink.add(itemModel);
} dispose() {
_movieFetcher.close();
}
}
서버에서 비동기로 ItemModel을 전달할 때, 그 때 비로서 Stream을 통해 해당 데이터를 add하여 화면을 조작하게 되는 것입니다.
UI 그리기
UI를 담당하는 presentations package에 movie_list.dart를 생성하고 아래와 같이 구현을 해보도록 하겠습니다.
import 'package:bloc_clean_architecture/bloc/movie_bloc.dart';
import 'package:bloc_clean_architecture/models/item_model.dart';
import 'package:flutter/material.dart';class MovieList extends StatefulWidget {
@override
_MovieListState createState() => _MovieListState();
}class _MovieListState extends State<MovieList> {
final MovieBloc _movieBloc = MovieBloc(); @override
void initState() {
_movieBloc.fetchAllMovies();
super.initState();
} @override
Widget build(BuildContext context) {
return Material(
child: Scaffold(
appBar: AppBar(
title: Text('Movie List'),
),
body: StreamBuilder<ItemModel>(
stream: _movieBloc.allMovies,
builder: (context, snapshot) {
if(snapshot.hasData) {
ItemModel itemModels = snapshot.data;
return GridView.builder(
itemCount: itemModels.movies.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
itemBuilder: (context, index) {
return Image.network(
'<https://image.tmdb.org/t/p/w185${snapshot.data.movies[index].posterPath}>',
fit: BoxFit.cover,
);
},
);
}
else if(snapshot.hasError) return Center(child: Text(snapshot.error.toString()),);
return CircularProgressIndicator();
},
),
),
);
}
}
initState에서 한번 movie 정보를 fetching 하여 가져옵니다.
그 뒤 Bloc에서 비동기 처리가 끝나 결과를 받게 되면 그 결과를 stream을 통해 데이터를 전달하여 화면을 갱신합니다.
짜잔!
마무리
Stream 객체의 사용과 BloC에 대해 배웠습니다. 하지만 BloC 패턴을 적용하기 위해서는 Stream 객체를 꼭 이용해야 하는 것은 아닙니다. BloC는 말 그대로 View와 Business Logic을 떼내어 분리시키는 것이 목적인 하나의 디자인 패턴이며, Provider를 활용하여 MVVM으로도 충분히 위와 같은 코드를 구현할 수 있습니다.
따라서, BloC의 구조와 data flow의 흐름을 인지하는 것이 더 중요합니다.
참고자료
Reactive Programming Streams — BLoC