RxDart와 Provider를 이용한 flutter앱 상태관리
아래 코드는 플러터 앱을 초기화 하면 자동으로 생성 되는 코드 입니다.
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
저는 개발을 하면서 종종 "텃밭을 가꿀 때는 호미를 사용해라"라는 말을 자주 사용합니다. 쟁기나 트랙터는 분명 호미보다 발전되고 강력한 도구이기는 하지만 텃밭을 가꿀 때는 호미만으로도 충분하며 그 이상의 도구는 오히려 거추장스러울 뿐 필요하지 않다고요. 우리가 만들려는 앱이 하나의 화면에 한손으로 셀 수 있는 상태만을 가지고 있다면 저는 위 코드 처럼 setState() 함수를 이용하여 앱을 만들라고 말하고 싶습니다. 하지만 우리가 만들고 싶어하는 대부분의 앱들은 이런 작은앱이 아닐 것입니다. 서버와 통신을 하고, 사용자의 입력을 받고, 여러 상태를 혼합해여 화면에 보여주는 수많은 상태와 화면 혹은 위젯을 가지는 복잡한 앱일 것이라고 생각됩니다.
이런 복잡한 상태를 관리하기 위해서 보통 provider, riverpod, BloC 같은 패키지들을 많이 사용하고 있는 것으로 알고 있습니다. 채용사이트에서 flutter관련 직종의 요구사항을 보아도 대부분 provider, riverpod 또는 GetX를 이용한 상태관리 경험을 요구하고, 개발자 커뮤니티의 질문/응답을 보아도 riverpod, getX와 관련된 질문들이 대부분 입니다. 하지만 저는 RxDart + Provider 조합을 이용하여 상태관리를 하는 앱을 만들어 왔고, 지금도 그 방법을 고수하고 있습니다. 이 글에서는 RxDart + Provider를 이용한 간단한 상태관리 방법에 대하여 소개하려 합니다.
Rx는 reactive programming을 위한 강력한 도구입니다. 이 글에서는 RxDart의 BehaviorSubject를 이용한 상태관리를 설명하려 합니다. BehaviorSubject는 초기값을 가지며 새로운 구독자(listener/subscriber)가 생기면 가지고 있던 값을 방출(emit)합니다. 여기서 중요한 점을 구독자가 BehaviorSubject를 구독하는 시점에 가지고 있던값을 방출 한다는 것입니다. 만약 구독 이후 방출되는 값부터 구독하고 싶다면 PublishSubject를 사용하면 됩니다.
final BehaviorSubject<int> _counter = BehaviorSubject<int>.seeded(0);
Stream<int> get counterStream => _counter.stream;
void _incrementCounter() {
_counter.add(_counter.value + 1);
}
위 코드에서는 _counter 주제를 만들고 _incrementCounter() 함수에서 _counter 주제에 현재값(_counter.value)에 +1 된 값을 추가하는 코드입니다. 이렇게 하면 _counter의 구독자들은 _counter에 값이 변경 될때 마다 변경된 값을 받아 볼 수 있게 될 것입니다. 그럼 이제 _counter를 StreamBuilder를 이용하여 구독하여 보겠습니다.
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
StreamBuilder<int>(
stream: counterStream,
builder: (context, snapshot) {
return Text(
'${snapshot.data ?? 0}',
style: Theme.of(context).textTheme.headlineMedium,
);
}),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
끝! 입니다. Text 위젯을 StreamBuilder로 감싸고, 스트림 빌더의 값을 Text 위젯에 전달 하였습니다. 이렇게 setState() 함수를 사용하던 코드가 Rx를 이용한 상태관리 코드로 변경 되었습니다.
이글은 bloc 패턴에 대한 글이 아니므로 최대한 간단하게 설명 하자면 "프리젠테이션 레이어와 bloc(비즈니스로직) 레이어를 분리하고, 프리젠테이션 레이어에서는 bloc 레이어에 event를 보내고, bloc은 처리(transition)된값을 Stream를 이용하여 방출한다." 정도일듯합니다. 그리고 우리는 이미 위에서 event를 보내를 위젯 코드와 Stream를 통하여 값을 방출하는 코드를 생성 하였습니다. 프리젠테이션 레이어와 bloc 레이어만 분리하면 그럴듯한 BloC 패턴이 완성됩니다!
class MyHomePageBloc {
final BehaviorSubject<int> _counter = BehaviorSubject<int>.seeded(0);
Stream<int> get counterStream => _counter.stream;
void incrementCounter() {
_counter.add(_counter.value + 1);
}
dispose() {
_counter.close();
}
}
비즈니스 로직을 분리하여 MyHomePageBloc를 만들었습니다. 이제 MyHomePage 위젯에서 MyHomePageBloc를 생성하고 생성된 bloc를 이용하도록 해보겠습니다.
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
late final MyHomePageBloc bloc;
@override
void initState() {
bloc = MyHomePageBloc();
super.initState();
}
@override
void dispose() {
bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
StreamBuilder<int>(
stream: bloc.counterStream,
builder: (context, snapshot) {
return Text(
'${snapshot.data ?? 0}',
style: Theme.of(context).textTheme.headlineMedium,
);
}),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: bloc.incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
MyHomePage 위젯의 initState() 함수에서 MyHomePageBloc를 생성하고, dispose() 함수에서 bloc를 파괴(?) 하도록 하였습니다. ("생성하고 생성한 곳에서 부셔라" 오래된 규칙입니다! 우선 외워두세요.) 그리고 이제 StreamBuilder빌더는 블럭의 counterStream를 구독하고 기존 _incrementCounter() 함수는 bloc.incrementCounter()로 대체 되었습니다.
비즈니스 로직과 프리젠테이션 영력을 분리하고, bloc 영역에서 스트리밍 되는 상태를 widget에 반영한다. widget은 사용자 이벤트를 받아 bloc에게 fire 한다!
이것으로 아주 단순한 형태의 BloC 패턴이 구현 되었습니다.
하지만 슬프게도 우리 만드는 앱들이 이보다 훨씬 복잡합니다. 위젯과 하위 위젯 그리고 하위 위젯을 거느린 수많은 가지들을 가지는 복잡한 위젯트리로 구성된 앱들 입니다. 트리를 구성하는 위젯들은 각자 자신만의 상태와 비즈니스 로직을 가지기도 하고, 서로 공유된 를 가지상태와 비즈니스 로직을 가지기도 합니다.
아래 코드에서는 Scaffold의 body 부분을 BodyWidget으로 분리하고, BodyWidget에게 bloc 인스턴스를 전달해 주도록 수정하였습니다.
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
late final MyHomePageBloc bloc;
@override
void initState() {
bloc = MyHomePageBloc();
super.initState();
}
@override
void dispose() {
bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: BodyWidget(bloc),
floatingActionButton: FloatingActionButton(
onPressed: bloc.incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
class BodyWidget extends StatelessWidget {
final MyHomePageBloc bloc;
const BodyWidget(this.bloc, {super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
StreamBuilder<int>(
stream: bloc.counterStream,
builder: (context, snapshot) {
return Text(
'${snapshot.data ?? 0}',
style: Theme.of(context).textTheme.headlineMedium,
);
}),
],
),
);
}
}
이 글의 시작에서도 말했듯 텃밭을 가꿀 때는 호미를 사용하면 됩니다!! 이 방법은 단순한 방법이지만 이런 단순한 구조의 앱에서는 오히려 이런 단순함이 장점이 됩니다. 하지만 계속 말하듯 우리가 만들려는 앱들은 보통 이렇게 단순하지 않습니다. 꼬리에 꼬리를 무는 복잡한 위젯 트리를 가지는 앱에서 이런식으로 공통된 상태와 비즈니스로직를 공유하기 위하여 위젯에서 위젯으로 bloc 인스턴스를 전달하다 보면 "이 bloc은 어디서 왔으며 어디로 가는가?" "이 bloc은 저기 있는 bloc과 과연 같은 bloc인가?" 등의 문제에 빠지게 되고, 코드는 매우 복잡해 집니다. 이런 문제를 피하기 위한 방법으로 Singleton를 사용하거나 전역 상태를 두고 그곳에 모든 것을 집어넣는 등의 방법을 쓸 수도 있겠지만 이 방법들도 각자의 문제를 가지고 있습니다. 이렇게 특정 위젯트리에서 상태를 공유하고 싶을때 사용할수 있는 방법으로 flutter에는 InheritedWidget이라는 것이 있습니다.
https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html
XXX.of(context)와 같은 코드를 자주 써보셨다면 이미 InheritedWidget을 사용하고 계신겁니다. InheritedWidget은 이름 그대로 상속 위젯 입니다. InheritedWidget를 이용하면 특정 값을 하위 위젯들에서 물려줄 수 있습니다.
class MyHomePageBlocInheritedWidget extends InheritedWidget {
const MyHomePageBlocInheritedWidget({
super.key,
required this.bloc,
required super.child,
});
final MyHomePageBloc bloc;
static MyHomePageBlocInheritedWidget? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<MyHomePageBlocInheritedWidget>();
}
static MyHomePageBlocInheritedWidget of(BuildContext context) {
final MyHomePageBlocInheritedWidget? result = maybeOf(context);
return result!;
}
@override
bool updateShouldNotify(MyHomePageBlocInheritedWidget oldWidget) => true;
}
MyHomePageBlocInheritedWidget를 생성 하였습니다.
context.dependOnInheritedWidgetOfExactType<T>() 함수는 위젯트리에서 가장 가까운 <T>의 InheritedWidget을 찾아 줍니다. 이 함수를 이용하면 하위 위젯에서 상위 위젯 어디엔가 있는 공통된 InheritedWidget<T>를 얻어올 수 있게 됩니다.
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
late final MyHomePageBloc bloc;
@override
void initState() {
bloc = MyHomePageBloc();
super.initState();
}
@override
void dispose() {
bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MyHomePageBlocInheritedWidget(
bloc: bloc,
child: Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: const BodyWidget(),
floatingActionButton: FloatingActionButton(
onPressed: bloc.incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
),
);
}
}
class BodyWidget extends StatelessWidget {
const BodyWidget({super.key});
@override
Widget build(BuildContext context) {
final bloc = MyHomePageBlocInheritedWidget.of(context).bloc;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
StreamBuilder<int>(
stream: bloc.counterStream,
builder: (context, snapshot) {
return Text(
'${snapshot.data ?? 0}',
style: Theme.of(context).textTheme.headlineMedium,
);
}),
],
),
);
}
}
MyHomePage 위젯에서 Scaffold를 MyHomePageBlocInheritedWidget으로 감싸고, MyHomePage 위젯의 하위 위젯인 BodyWidget에서는 MyHomePageBlocInheritedWidget.of(context)를 이용하여 상위 위젯의 bloc를 물려 받습니다. 이렇게 InheritedWidget을 이용한다면 필요한 위젯트리에서 필요한 위치에 내에서 비즈니스 로직과 상태를 공유할 수 있습니다. 하지만 InheritedWidget에는 치명적인 단점이 있습니다. 그것은 바로 매번 구현하기 귀찮고 복잡하자는 것입니다!!! 그래서 플리터 팀에서는 InheritedWidget 대신 Provider를 이용하는 것을 추천하고 있습니다.
https://pub.dev/packages/provider
"A wrapper around InheritedWidget to make them easier to use and more reusable."
Provider는 InheritedWidget를 쉽게 사용할 수 있게 해줍니다. Provider는 이 외에도 많은 기능이 있지만 저는 주로 InheritedWidget를 쉽게 사용하는 용도로 사용합니다. 이제 거추장스러운 InheritedWidget 대신에 Provider를 이용해 보겠습니다.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: Provider<MyHomePageBloc>(
create: (context) => MyHomePageBloc(),
dispose: (context, value) => value.dispose(),
child: const MyHomePage(title: 'Flutter Demo Home Page')),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
late final MyHomePageBloc bloc;
@override
void initState() {
bloc = context.read<MyHomePageBloc>();
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: const BodyWidget(),
floatingActionButton: FloatingActionButton(
onPressed: bloc.incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
class BodyWidget extends StatelessWidget {
const BodyWidget({super.key});
@override
Widget build(BuildContext context) {
final bloc = context.read<MyHomePageBloc>();
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
StreamBuilder<int>(
stream: bloc.counterStream,
builder: (context, snapshot) {
return Text(
'${snapshot.data ?? 0}',
style: Theme.of(context).textTheme.headlineMedium,
);
}),
],
),
);
}
}
class MyHomePageBloc {
final BehaviorSubject<int> _counter = BehaviorSubject<int>.seeded(0);
Stream<int> get counterStream => _counter.stream;
void incrementCounter() {
_counter.add(_counter.value + 1);
}
dispose() {
_counter.close();
}
}
완성된 전체 코드입니다. 이름 부터 흉측했던 MyHomePageBlocInheritedWidget를 제거 하고, MaterialApp 위젯에서 Provider으로 MyHomePage 위젯을 감싸고, MyHomePageBloc를 생성하여 하위 위젯에서 공통된 MyHomePageBloc를 사용할 수 있도록 주입하였습니다. 이제 하위 위젯에서는 Provider가 제공하는 BuildContext의 extension인 read() 함수를 이용하여 MyHomePageBloc에 쉽게 접근할 수 있습니다.
이렇게 RxDart와 Provider를 이용한 BloC 패턴을 구현하는 방법을 정리하여 보았습니다.
감사합니다.!!