-
bloc 디자인 패턴FW, Lib 공부/Flutter 2023. 6. 27. 22:10
bloc을 사용하는 이유
내 경우에는 프로젝트의 크기가 커질수록 bloc 패턴을 사용해야 할 필요성이 절실해졌는데,
아래와 같은 상황에서 필요했다.
예시를 위해 아래와 같은 위젯 트리가 형성되어 있다고 생각해보자.
1. 깊은 위젯간 의존 관계
Flutter는 기본적으로 위젯 객체를 인자로 넘겨주는 식으로 구조를 형성하게 된다.
이런식으로 위젯을 쌓다보면 위젯 트리가 깊어지게 되는데, 여기서 문제가 발생한다.
최 상단의 Arkhive 위젯의 멤버 변수의 값을 먼 자식인 PRTSWidget 에서 사용해야 하는 상황에는?
그 사이에 있는 모든 위젯들이 필요한 값을 매개변수로 전달전달 해야 하는 상황이 발생한다.
반대로 PRTSWidget 에서 나온 결과 값이 Arkhive 위젯으로 돌아가야 한다면?
그 사이에 있는 모든 위젯들이 값을 전달하기 위한 콜백함수를 전달전달 해야 하는 상황이 발생한다.
이는 위젯을 생성할 때 전달해야 하는 매개변수의 양을 늘리는 요인이 되며,
코드 유지보수시 의존관계가 있는 모든 중간 위젯까지 손대야 하는 상황이 발생하게 된다.
2. 잦은 위젯 업데이트
Flutter에서 모든 위젯은 부모 위젯이 업데이트 되는 경우에 강제로 업데이트된다.
만약 부모 위젯이 빈번하게 업데이트 되는데, 자식 위젯을 빌드하는데 비용이 크다면 문제가 발생할 수 있다.
예로 들어 HomeScreen에서 값을 변경하는데, 그 값을 Arkhive에서 관리하고, PRTSWidget에서 사용하는 경우,
값을 변경할 때 마다 그 사이에 있는 모든 위젯이 업데이트된다.
만약 사이에 있는 위젯 중 일부가 빌드하는데 오버헤드가 크다면,
이는 심각한 성능 이슈를 유발하게 된다.
3. 상태 저장
Flutter에서 위젯이 dispose되는 순간 해당 위젯이 갖고있던 데이터는 날아가게 된다.
이 데이터를 저장하기 위해서는 아래와 같은 방법을 사용해야 할 것이다.
- 부모 위젯에서 저장
- secure_storage와 같은 라이브러리를 사용해서 데이터를 로컬에 저장
- 전역 변수로 저장
하지만, 1번의 경우에는 위에서 언급했 듯 유지보수의 문제가 있고, 코드도 더러워진다.
2번의 경우를 사용하는 것이 바람직하지만, 쓸데없이 로컬 저장소를 읽고 쓰는데 오버헤드가 생길 뿐더러 관리도 어렵다.
3번의 경우에는 절대로 사용해서는 안 된다.
본인의 경우에는 위 방법을 썼다가 갑자기 앱이 정지하는 현상을 많이 겪었는데,
아마 운영체제가 메모리를 관리하는 과정에서 문제가 생긴것으로 추측된다.
bloc을 사용한다면!
위 세가지 문제를 한 번에 해결할 수 있다.
우선 bloc은 별도의 공간(BlocProvider)에서 상태를 관리하고, 상태를 state 형태로 위젯에 뿌리며,
위젯으로부터 event 형식으로 상태를 변경할 수 있도록 은닉화 하기 때문에
위젯간 의존성이 없어진다.
또한, bloc은 자체 builder를 갖고있는데(BlocBuilder), 이 위젯은 StatefulWidget을 상속받아 구현되었기에,
일단 위젯 스스로 화면을 업데이트 할 수 있다.
이 때, 위젯간 의존성이 제거되었기 때문에 잦은 위젯 업데이트에도 사이에 있는 위젯이 업데이트되지 않아
성능면에서도 도움을 받을 수 있다.
마지막으로 BlocProvider은 특정 bloc을 사용하기 위한 위젯 트리의 최상단에 위치하기 때문에
하위 위젯에서 관리하던 상태가 여기에 보관되는 만큼 상태를 쉽게 저장할 수 있다.
bloc의 개념
bloc은 일종의 middleware로 봐도 무방할 듯 하다.
위 이미지를 기준으로 UI 파트는 도메인 로직으로 사용자가 볼 화면 부분이고,
data 파트는 비즈니스 로직으로 사용자의 데이터가 관리되는 공간이다.
bloc은 사이에 middleware로서 도메인 로직 파트에는 필요한 데이터, 즉 상태(state)를 제공하고,
state를 변경시킬 수 있는 event 인터페이스를 제공한다.
또한 bloc 내부는 비즈니스 로직으로서 모든 상태를 관리하며,
필요에 따라 api 통신을 통한 데이터를 송/수신하는 역할 또한 담당하게 된다.
쉽게 말하면
데이터를 최상단 위젯에서 관리하고,
데이터는 getter로 받을 수는 있지만, setter는 없어 변경하지 못한다.
대신 데이터를 변경할 수 있는 인터페이스 함수를 제공한다고 보면 이해하기 쉽다.
bloc 사용 방법
개발하는남자 개발자분의 강의를 듣고오면 사용법을 보다 잘 이해할 수 있다.
여기선 간략하게 리마인드 하는 느낌으로만 적어두겠다.
1. 사용자 bloc 구현
내가 원하는 상태를 저장할 bloc을 구현한다.
Bloc 클래스를 extends 하여 구현하면 되는데, 제네릭으로 bloc에서 사용할 event, state를 넘겨줘야 한다.
즉, bloc, event, state의 총 3개의 클래스를 구현해야 한다.
// enemy_level_bloc.dart class EnemyLevelBloc extends Bloc<EnemyLevelEvent, EnemyLevelState> { EnemyLevelBloc({int level = 0}) : super(EnemyLevelState(level: level)) { on<EnemyLevelSetEvent>(_enemyLevelSetEventHandler); } Future<void> _enemyLevelSetEventHandler( EnemyLevelSetEvent event, Emitter<EnemyLevelState> emit, ) async { emit(EnemyLevelState(level: event.level)); } }
여기서 Bloc의 제네릭으로 event, state를 넘겨주는데,
일반적으로 한 bloc에서 여려개의 event와 state를 가질 수 있기 때문에 제네릭으로 넘겨주는 클래스는
abstract로 구현하는게 일반적이다.
생성자 함수에서 bloc에 필요한 초기값이나, repository를 받아올 수 있다.
repository?
서버와 api 통신하는 클래스를 의미한다.그리고 super를 통해 Bloc의 생성자 함수를 호출하는데, 이 때 인자로 bloc의 초기 상태를 넘겨준다.
(일반적으론 EnemyLevelState를 상속받은 EnemyLevelInitState와 같은 상태를 넘겨주지만,
이 예시에서는 state가 하나기 때문에 그냥 사용)
생성자 함수 내부에선 on 메서드를 이용해서 각 이벤트가 들어올 때 처리할 로직을 설정해줄 수 있다.
예로 들어 EnemyLevelSetEvent(level: 1) 과 같은 이벤트가 호출된다면,
_enemyLevelSetEventHandler 내부에서 event.level 에 1 값이 전달되게 된다.
등록한 이벤트 핸들러는 Emitter를 받게 되는데, 이 emit을 호출하게 되면 bloc의 상태가 업데이트된다.
// enemy_level_event.dart abstract class EnemyLevelEvent extends Equatable { const EnemyLevelEvent(); @override List<Object?> get props => []; } class EnemyLevelSetEvent extends EnemyLevelEvent { final int level; const EnemyLevelSetEvent({ required this.level, }); @override List<Object?> get props => [level]; }
// enemy_level_state.dart class EnemyLevelState extends Equatable { final int level; const EnemyLevelState({ required this.level, }); @override List<Object?> get props => [level]; }
여기서 Equatable은 객체간 비교를 쉽게 하게 해주는 abstract 클래스이다.
(하단의 @override 부분은 bloc과 관계 없음)
event와 state를 구현하는데,
state는 보통 init, loading, loaded, error 와 같은 여러 상태를 나눠 관리하고,
하나의 abstract 클래스로부터 상속받아 구현한다.
event는 필요한 기능만큼 구현한다.
예로 들어 level 1 증가, 1 감소, 원하는 값으로 설정, 초기화 와 같은 기능이 필요하다면,
각 기능에 맞도록 4개의 event를 만들고, 하나의 abstract 클래스로부터 상속받아 구현한다.
2. bloc 등록
bloc의 상태관리를 받을 위젯의 최상단에 BlocProvider를 통해 bloc을 context에 등록한다.
BlocProvider( create: (context) => EnemyLevelBloc(), child: Scaffold(...), );
이렇게 한다면 child의 Scaffold 위젯의 하위 위젯은 모두 EnemyLevelBloc에 접근할 수 있다.
만약 여러개의 Bloc를 등록해야 한다면, child에 BlocProvider를 넣는것과 같이 중첩해서 넣을 수 있지만,
MultiBlocProvider 라는 위젯도 있으니 이를 사용하면 더 편할 거 같다.
하지만, 두 방법 모두 같은 위젯 트리를 구성하게 되니, 퍼포먼스의 차이는 없다.
3-1. bloc 사용 - BlocBuilder, state
bloc으로 관리되는 state를 사용해서 위젯을 만들고 싶은 경우에 BlocBuilder를 사용한다.
여기서 중요한 점은 BlocBuilder는 일종의 StatefulWidget이므로,
StatelessWIdget 내부에서 BlocBuilder를 사용할 경우에도 화면이 업데이트된다.
BlocBuilder는 builder 라는 필수 파라미터와 bloc, buildWhen 라는 선택 파라미터가 존재한다.
PopupMenuItem( child: BlocBuilder<EnemyListBloc, EnemyListState>( buildWhen: (previous, current) => previous.selectedFilterOption != current.selectedFilterOption, bloc: context.read<EnemyListBloc>(), builder: (context, state) => Column( children: [ ... );
builder는 말 그대로 builder이다.
state 값이 바뀔 때 마다 builder 부분이 실행되어 화면에 위젯을 뿌려주게 된다.
이 때, state 상태에 따라 화면을 다르게 뿌려줄 수 있다.
init, loading, loaded, error 상태에 따라 화면을 다르게 보여줄 수 있으니
이 얼마나 편리한가!
(각 state에 들어있는 멤버 변수만 사용할 수 있다)
buildWhen은 state의 특정 변수의 변화가 있을 때만 build하게 해주는 제어자의 역할을 수행한다.
예로 들어 enemy는 init이후 변하지 않지만, enemyData는 계속 변화하는 상황에서
해당 BlocBuilder의 자식 위젯은 enemy 데이터만 필요한 경우,
아래와 같이 buildWhen을 구성할 경우 enemyData가 아무리 변해도 이 하위 위젯은 업데이트되지 않는다.
buildWhen: (previous, current) => previous.enemy != current.enemy,
즉, 해당 함수가 true를 반환하는 경우에만 BlocBuilder의 builder가 업데이트된다.
마지막으로 bloc은 잘 안쓰인다.
하지만, 특정 상황에서 context에서 Bloc을 못 찾는 경우가 발생하는데,
보통 Popup, Dialog와 같은 위젯을 사용할 때 발생하는거 같다.
이 때 명시적으로 context에서 bloc을 찾아 등록시켜주기 위해 bloc 인자을 사용한다.
3-2. bloc 사용 - event
state를 변화시키기 위해 event를 보내야 하는데,
context에서 bloc을 읽어 event를 add하는 식으로 보낸다.
... 코드를 살펴보면 이해가 될 것이다.
void _onChange( BuildContext context, { required int level, }) { context.read<EnemyLevelBloc>().add(EnemyLevelSetEvent(level: level)); }
StatelessWidget의 경우에는 build 외부의 함수에서 context에 접근하기 위해선 인자로 context를 넘겨줘야 한다.
StatefulWidget의 경우에는 굳이 context를 넘겨 줄 필요 없다. (필요에 따라 넘겨줘야...)
이벤트를 add를 통해 등록시키면, bloc 내부의 onEvent가 등록된 이벤트를 처리하는데,
맨 처음에 on<Event>(_handler) 형태로 등록했던 것을 기억할 것이다.
특정 Event에 맞는 _handler가 수행되며,
그 _handler 내부에서 emit을 호출해 state에 변화를 가하는 것이다.
참고로 한 이벤트 핸들러 내부에서 여러개의 emit를 호출할 수 있기에
비동기 처리 상황에서는 일단 loading state를 emit하고, 결과가 돌아오면 loaded state를 emit하는 형태로
구현하는 것이 일반적이다.
결론
bloc 디자인패턴을 사용하면 도메인 - 비즈니스 로직을 분리할 수 있고,
위젯간 의존성을 최소화 할 수 있으며,
상태에 따른 화면 구성에 용이하다.
여기선 언급은 안했지만,
on 메서드에 transformer를 등록하여 event의 과도한 요청을 개발자 입맛에 맞게 처리할 수 있고,
BlocListener, BlocConsumer 등 유용한 위젯도 많다.
GetX, Provider, Riverpod 등 다른 상태관리 라이브러리가 많지만,
디버깅이 용이하고, 유지보수가 편리한 bloc을 사용하는 것이 개인적으론 바람직하다고 생각한다.
괜히 GitHub star 수가 가장 많은 것이 아닐 것이다.
'FW, Lib 공부 > Flutter' 카테고리의 다른 글
Isolate (0) 2023.06.27