Flutter에서의 상태관리를 위해서 가장 기초적으로 Provider가 있습니다. 그 외에도 여러가지 상태관리가 존재합니다. 오늘은 BLoC의 개념에 대해서 알아보도록 하겠습니다.
들어가기에 앞서, BLoC은 Stream의 개념을 이해한 상태에서 시작하는 것이 좋습니다. 아래의 글을 통해서 Stream에 대해 간단하게 읽어볼 수 있습니다.
[Dart]Stream에 대해서
BLoC은 UI와 비즈니스 로직을 분리하여 만드는 방식으로 Flutter에서 상태관리를 이용할 수 있는 하나의 아키텍쳐 패턴이라고 할 수 있습니다.
중요한 것은 BLoC은 Stream을 이용하기 때문에 Stream에 대해서 정확하게 이해하고 있는 것이 중요합니다.
Flutter에서 BLoC을 직접 구현하면 많은 양의 코드를 생성하게 됩니다. 그렇기에 flutter_bloc 라이브러리를 이용하여 먼저 흐름을 이해하는 것이 좋습니다.
$ flutter pub add flutter_bloc
BLoC에서는 상태를 state라고 하며, 이벤트를 통해서 state를 변경시킬 수 있습니다.
Cubit은 flutter_bloc 라이브러리에서 사용하는 가장 단순하게 BLoC을 사용하게 해주는 개념입니다. Cubit은 BlocBase를 상속받은 클래스이며 state변경 메소드를 외부에 노출시킬 수 있고, state가 변경되면 UI는 state에 대한 notify를 전달받아 UI를 갱신합니다.
Cubit은 아래와 같이 선언할 수 있습니다. Cubit은 state에 대한 타입을 제너릭으로 선언해야 하고, 초기값을 0으로 전달한 형태입니다.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
}
Cubit은 메소드와 emit을 통해 간편하게 UI에게 state를 변경, 전달할 수 있습니다.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
// state에 + 1
void increment() => emit(state + 1);
}
Cubit은 인스턴스를 생성하면 간편하게 사용할 수 있습니다.
void main() {
final cubit = CounterCubit();
print(cubit.state); // 0
cubit.increment();
print(cubit.state); // 1
cubit.close();
}
state는 Stream 형식이므로 더이상 변경을 전달받지 않을 경우에는 close()를 호출하여 state stream을 닫을 수 있습니다.
onChange()메소드를 이용하여 현재 Cubit의 state의 변경을 관찰할 수 있습니다.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void onChange(Change<int> change) {
super.onChange(change);
print(change);
}
}
onError는 Cubit의 에러 핸들링 처리를 담당합니다.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() {
addError(Exception('increment error!'), StackTrace.current);
emit(state + 1);
}
void onChange(Change<int> change) {
super.onChange(change);
print(change);
}
void onError(Object error, StackTrace stackTrace) {
print('$error, $stackTrace');
super.onError(error, stackTrace);
}
}
Bloc은 Cubit과 다르게 메소드가 아니라 이벤트를 통해서 state를 변경시키는 고급 클래스입니다. Cubit과 마찬가지로 BlocBase를 상속받지만, 이벤트를 전달받아 나가는 state로 전환시키는 역할을 합니다.
Bloc은 Cubit과 다르게 메소드가 아닌 이벤트를 정의하여 state의 변경을 요청할 수 있습니다.
sealed class CounterEvent {}
final class CounterIncrementPressed extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0);
}
CounterEvent는 state인 counter에서 발생할 수 있는 base 이벤트 클래스이며, 모든 이벤트는 이 클래스를 상속받습니다. 그리고 CounterIncreamentPressed 처럼 CounterEvent를 상속하는 이벤트 클래스를 정의하여 state의 변경을 요청할 수 있습니다.
sealed class CounterEvent {}
final class CounterIncrementPressed extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncrementPressed>((event, emit) {
// handle incoming `CounterIncrementPressed` event
});
}
}
이 이벤트를 수신한 Bloc은 해당 이벤트로 부터 0개이상의 나가는 state로 변환하는 역할을 수행하는데, 위 처럼 on을 정의해주면 됩니다. 아래는 counter가 1 증가하는 이벤트에 대한 정의 예시입니다.
sealed class CounterEvent {}
final class CounterIncrementPressed extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncrementPressed>((event, emit) {
emit(state + 1);
});
}
}
Bloc은 Cubit처럼 인스턴스를 생성하여 접근하면 되는데, Cubit과 다르게 메소드가 외부로 노출되지 않으므로 Bloc에게 이벤트를 전달시켜 state를 변경시킬 수 있습니다. add()메소드는 이벤트를 전달하는 메소드입니다.
Future<void> main() async {
final bloc = CounterBloc();
print(bloc.state); // 0
bloc.add(CounterIncrementPressed());
await Future.delayed(Duration.zero);
print(bloc.state); // 1
await bloc.close();
}
마찬가지로 close()를 통해 state stream을 닫을 수 있습니다.
Cubit처럼 onChange, onError를 통해 변화를 관찰하거나 에러를 핸들링할 수 있습니다.
sealed class CounterEvent {}
final class CounterIncrementPressed extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncrementPressed>((event, emit) => emit(state + 1));
}
void onChange(Change<int> change) {
super.onChange(change);
print(change);
}
void onError(Object error, StackTrace stackTrace) {
print('$error, $stackTrace');
super.onError(error, stackTrace);
}
}
Bloc은 특별하게 Transition이라는 개념이 존재합니다. Bloc은 Cubit과 다르게 이벤트를 통해서 state를 변경시키기 때문에 이러한 변경의 원인을 캡쳐할 수 있습니다. Transition은 state가 다른 state로 변경되는 것을 의미합니다. 이를 통해서 변경의 원인을 캡쳐하기 위한 onTransition 메소드가 존재합니다.
sealed class CounterEvent {}
final class CounterIncrementPressed extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncrementPressed>((event, emit) {
addError(Exception('increment error!'), StackTrace.current);
emit(state + 1);
});
}
void onChange(Change<int> change) {
super.onChange(change);
print(change);
}
void onTransition(Transition<CounterEvent, int> transition) {
print(transition);
super.onTransition(transition);
}
void onError(Object error, StackTrace stackTrace) {
print('$error, $stackTrace');
super.onError(error, stackTrace);
}
}
Cubit의 가장 큰 장점은 단순성입니다. 위에서의 비교를 보았듯이, Bloc은 이벤트를 정의해야 하지만, Cubit은 가장 단순하게 state를 정의하고 메소드로 state를 변경시킬 수 있습니다.
// Cubit
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
// Bloc
sealed class CounterEvent {}
final class CounterIncrementPressed extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncrementPressed>((event, emit) => emit(state + 1));
}
}
반면에 Bloc은 Cubit보다는 복잡하지만, state가 변경된 원인을 추적할 수 있기 때문에 굉장히 유용하다는 것입니다. 예를 들어, 사용자의 인증 상태를 관리하는 경우 사용자의 인증 상태가 인가에서 비인가로 변경되는 원인을 Bloc은 추적할 수 있습니다.
또한, 고급 Event Transformations를 제공하기에 사용자가 반응형 연산자를 활용해야 되는 경우 유용합니다.
bloc 공식문서에서는 debouncing에 대한 예시로 해당 장점을 설명하고 있습니다. 아래는 공식문서에서 소개한 debouncing 예제입니다.
EventTransformer<T> debounce<T>(Duration duration) {
return (events, mapper) => events.debounceTime(duration).flatMap(mapper);
}
CounterBloc() : super(0) {
on<Increment>(
(event, emit) => emit(state + 1),
/// Apply the custom `EventTransformer` to the `EventHandler`.
transformer: debounce(const Duration(milliseconds: 300)),
);
}
추가적으로 Cubit과 Bloc은 어떻게 보면 확장의 관계이기 때문에 초기에는 Cubit을 이용하여 프로젝트를 구축한 후, 필요에 따라 Bloc으로 리팩토링을 수행하기가 편합니다.
참고자료 : Bloc 라이브러리 다큐멘트(https://bloclibrary.dev/ko/bloc-concepts/#cubit-vs-bloc)