티스토리 뷰
플러터에서 상태를 쉽게 공유하기 위해서 몇 가지 방법이 있다. 그중에서 Provider를 사용한 상태 관리 방법을 제대로 공부해 볼까 한다. Provider의 공식 문서와 플러터 공식 문서의 상태 관리 문서를 읽고, 나만의 방법으로 정리하였다.
사실 Provider에서도 몇 가지 방법으로 상태 관리를 할 수 있다. 나는 그중에서도 가장 많이 사용되는 ChangeNotifierProvider에 대해서 먼저 정리하려고 한다.
ChangeNotifierProvider는 상태가 변경되면 해당 상태를 참고하고 있는 위젯들을 자동으로 리빌드 할 수 있게 만들어 주는 제공자가 된다.
이 문서에서는 플러터의 상태, ChangeNotifierProvider에 대해서 설명하고, 플러터에서 기본으로 제공하는 카운터 앱을 수정하여 ChangeNotifierProvider로 작동하도록 수정해 보겠다.
플러터는 선언형 프로그래밍이다
플러터는 선언형 UI 프로그래밍이다. 선언형은 상태를 사용해서 UI를 만들게 된다. 따라서 메서드를 사용하는 명령형 프로그래밍 방법과는 다른 방식이 된다. 플러터를 잘 사용하기 위해서는 선언형 프로그래밍으로 생각해야 한다.
임시 상태와 앱 상태
넓은 의미에서 상태란 앱이 실행 중일 때 메모리에 존재하는 모든 것을 의미한다. 하지만 앱을 설계할 때는 좁은 의미로 임시 상태와 앱 상태로 구분하면 된다.
임시 상태란?
임시 상태(UI상태, 로컬 상태라고도 함)는 단일 위젯 내에 포함되는 상태를 의미한다. 이 임시 상태는 StatefulWidget의 State 클래스 내부 필드가 된다. 임시 상태는 외부에서 접근할 필요가 없으며 위젯 내부에서만 변경된다.
앱 상태란?
단일 위젯이 아닌 앱의 여러 부분에서 공유되는 상태를 의미한다. 앱 상태를 애플리케이션 상태 또는 공유 상태라고도 불린다.
명확한 규칙은 없다.
상태를 임시 상태로 만들지 앱 상태로 만들지 명확한 규칙은 없다. 처음에는 임시 상태로 시작했지만, 앱이 기능적으로 성장함에 따라 앱 상태로 변경될 수도 있다.
정답은 없지만 상태가 단일 위젯에만 사용된다면 임시 상태로 만들고, 상태가 위젯들 간에 공유가 되어야 한다면 앱 상태로 만들도록 하자.
Provider는 앱 상태 관리 패키지이다
단일 위젯은 StatefullWidget을 이용해서 쉽게 구현할 수 있다. 그리고 앱 상태는 Provider와 같은 상태 관리 패키지로 구현할 수 있다. 여기서는 Provider의 ChangeNotifierProvider로 앱 상태를 관리하는 방법에 대해서 알아보겠다.
Provider를 pubspec.yaml에 추가하기
Provider를 사용하기 위해서는 먼저 pubspec.yaml에 Provider 의존성을 추가해야 한다.
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
provider: ^6.1.2
의존성을 추가한 후 상단에 get dependencies를 클릭하여 의존성을 설치하도록 하자.
ChangeNotifierProvider 기본 흐름
먼저 ChangeNotifierProvider의 기본 흐름에 대해서 알아보도록 하자.
1. ChangeNotifier 클래스를 만든다.
2. 상태가 공유될 위치에 ChangeNotifierProvider 위젯을 정의한다.
3. Provider가 제공하는 기능으로 상태에 접근한다.
ChangeNotifier 클래스 만들기
먼저 ChangeNotifier 클래스를 만들어야 한다. 이 클래스는 앱 상태를 관리하는 역할을 한다. 어떤 데이터를 앱 상태로 사용할 것인지, 어떤 인터페이스로 구성할 것인지를 결정하게 된다.
이 문서에서는 플러터의 기본 앱인 카운터 앱의 카운터를 앱 상태로 관리하는 Counter 클래스를 만들어 보자.
import 'package:flutter/foundation.dart';
class Counter extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
먼저 Coutner 클래스는 ChangeNotifier를 확장(extends) 해야 한다. Counter 앱에서는 카운터(_count)를 앱 상태를 가지게 된다. 카운터는 _를 접두사로 사용해서 외부에서 접근할 수 없도록 하고 count를 getter로 만들어서 카운터를 얻을 수 있도록 했다.
그리고 increment() 메서드를 만들어서 메서드 호출 시 카운터 1씩 증가하도록 했다. 여기서 중요한 것은 notifyListeners()이다.
notifyListeners()
notifyListeners()는 앱 상태를 참고하고 있는 위젯에게 앱 상태가 변경되었음을 공지해서 참고하고 있는 위젯에게 알려주는 역할을 한다. notifyListeners()를 호출하지 않으면 앱 상태가 변경되는 것을 알 수 없게 되니 꼭 사용하도록 하자.
상태가 공유될 위치에 ChangeNotifierProvider 위젯 정의하기
이제 상태, 즉 Counter를 공유할 위치를 ChangeNotifierProvider 위젯으로 정의해야 한다. 공유할 위치를 기준으로 하위 위젯들은 앱 상태를 접근할 수 있다. 여기서는 최상위에 ChangeNotifierProvider 위젯을 정의하였다.
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => Counter(),
child: const MyApp(),
),
);
}
ChangeNotifierProvider는 제공자(Counter와 같은 상태 클래스)의 생명 주기(객체 생성, 제거, 업데이트)를 관리하게 된다. Counter 객체를 생성하기 위해서 create 인자를 사용한다. 이렇게 만들어진 Counter 객체는 ChangeNotifierProvider가 관리하기 때문에 따로 메모리 해지를 하지 않아도 된다.
그리고 ChangeNotifierProvider는 기본적으로 Lazy로 작동한다. 즉, Counter 객체가 사용되는 시점에서 Counter 객체가 생성되게 된다. 만일 Lazy로 작동하지 않게 하려면 lazy 인자를 false로 전달하면 된다.
ChangeNotifierProvider(
create: (context) => Counter(),
child: const MyApp(),
lazy: false,
)
이제 최상위 위치에서 상태를 공유되기 때문에 이 앱의 모든 하위 위젯들은 Counter 객체에 접근하여 앱 상태를 공유할 수 있게 된다.
앱 상태에 접근하는 방법
앱 상태에 접근하는 방법을 여러 가지가 있다.
- context.watch<T>() : 상태 변화를 감지하고 접근하는 방법
- context.read<T>() : 상태 변화를 감지하지 않고 접근만 하는 방법
- context.select<T, R>(R selector(T value)) : 일부 상태만 감지하고 접근하는 방법
T와 R은 제네릭 타입이다. T는 상태 클래스이고, R은 일부 상태의 타입이 된다. 상태 변화를 감지한다는 것은 상태가 변하면, 상태를 접근하고 있는 현재 위젯이 리빌드 되는 것을 의미한다.
이 예제에서는 context.watch와 context.read를 사용해 보겠다.
context.watch<T>() : 상태 변화를 감지하고 접근하는 방법
context.watch<T>()로 상태를 접근하게 되면 상태가 변화하면, 즉 상태 클래스 내부에서 notifyListeners() 호출 시, 위젯이 리빌드 된다.
Counter의 count 값을 얻어서 Text 위젯으로 출력한다고 가정해 보자. T에 Counter로 작성하고 점(.) 접근으로 count를 얻으면 된다.
Text(
'${context.watch<Counter>().count}',
)
또는 다음과 같이 변수로 만들어서 사용해도 된다.
Builder(builder: (context) {
final counter = context.watch<Counter>;
return Text(
'${counter.count}'
)
}
context.watch는 상태 변화를 감지하기 때문에 상태가 변하면 context.watch가 사용된 위젯이 리빌드 하게 된다.
리빌드가 된다는 것을 꼭 기억하자. 불필요한 리빌드는 앱 성능에 영향을 미치기 때문이다. 그래서 Provider는 리빌드가 불필요한 경우와 일부 상태만 감지해서 리빌드가 되도록 지원한다. 이 방법은 조금 후에 설명하도록 하겠다.
context.read<T>() : 상태 변화를 감지하지 않고 접근만 하는 방법
context.read<T>()는 상태 변화를 감지하지 않고 접근만 하는 방법이 된다. 상태 변화를 감지하지 않기 때문에 상태가 변하여도 위젯은 리빌드 되지 않는다.
따라서 context.read는 위젯이 처음 생성될 때 상태 값을 불러오거나, 상태 클래스의 메서드를 호출하는 용도로 사용하는 것이 좋다.
다음은 플러팅 액션 버튼을 탭 하면 카운터를 1 증가하게 된다.
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read<Counter>().increment();
},
tooltip: 'Increment',
child: const Icon(Icons.add),
)
context.watch와 context.read로 코드 수정하기
이제 카운터 앱을 만들기 위한 기본 지식들을 다 익혔기 때문에 이를 이용해서 카운터 앱 코드를 수정해 보자.
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Provider Demo'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You have pushed the button this many times:'),
Text(
'${context.watch<Counter>().count}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read<Counter>().increment();
},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
context.watch<Counter>().count를 사용해서 카운터 수를 얻었다. context.watch를 사용하였기 때문에 Counter의 상태가 변하게 되면 자동으로 UI에 반영된다. 그리고 context.read<Counter>().increment()를 사용해서 카운터를 증가시킨다.
이렇게 만들어진 앱은 플러팅 액션 버튼을 누를 때마다 카운터가 증가하기 때문에 잘 작동하는 것처럼 보인다. 하지만 context.watch를 사용하였기 때문에 Counter 상태가 변할 때마다 _MyHomePageState의 build는 불필요한 위젯들의 리빌드를 발생하게 된다.
이 예제 코드는 위젯이 적어서 불필요한 리빌드가 성능에 큰 영향을 주진 않는다. 앱의 성장을 대비하여 불필요한 리빌드가 발생하지 않게 하는 방법을 알아보자.
Consumer를 이용하여 불필요한 리빌드를 제거하기
Provider는 상태 변화로 발생 시 특정 위젯만 리빌드 될 수 있도록 Consumer를 제공한다.
Consumer<T>()에서 상태 변화를 감지할 타입을 T에 전달해야 한다. 이 예제에서는 Counter를 전달한다. 그리고 상태 변화 시 호출될 콜백 함수를 builder에 전달해야 한다.
콜백 함수가 호출되면 (buildcontext, T, child)가 매개변수로 전달된다. buildcontext는 위젯 트리에 대한 정보가 담긴 context이며, T는 Consumer<T>에 전달된 타입이 된다. 이 예제에서는 Consumer에 Counter를 전달하였기 때문에 T는 Counter가 된다. 그리고 child는 Consumer의 성능을 높이기 위한 기능인데, 이에 대한 설명은 기회가 된다면 다음에 하도록 하겠다.
카운터 수를 출력하는 Text는 처음에 다음과 같이 만들었다.
Text(
'${context.watch<Counter>().count}',
style: Theme.of(context).textTheme.headlineMedium,
)
이제 불필요한 리빌드를 제거하기 위해서 Consumer로 만들면 다음과 같다.
Consumer<Counter>(
builder: (context, counter, child) {
return Text(
'${counter.count}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
)
Counter의 상태가 변하게 되면 Consumer의 builder가 실행되고, 현재 Counter 상태를 builder의 두 번째 매개변수인 counter로 전달된다. 이 매개변수를 이용해서 최종적으로 '${counter.count}'로 카운터 수를 문자열로 만들어 Text 위젯으로 출력하게 된다.
이렇게 만들어진 전체 코드는 다음과 같다.
import 'package:first_provider/counter.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => Counter(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Provider Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
print("_MyHomePageState build()");
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Provider Demo'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Consumer<Counter>(
builder: (context, counter, child) {
return Text(
'${counter.count}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
)
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read<Counter>().increment();
},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
MyHomePage를 StatelessWdiget으로 변경하기
이제 MyHomePage는 임시 상태를 가지지 않으므로 StatefullWidget일 필요가 없다. 그러므로 StatelessWidget으로 수정해 보자.
import 'package:first_provider/counter.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => Counter(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Provider Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Provider Demo'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Consumer<Counter>(
builder: (context, counter, child) {
return Text(
'${counter.count}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
)
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read<Counter>().increment();
},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
참고 자료
2024.07.02 - [Flutter/상태 관리] - 플러터] 상태 관리 (State management)
2024.07.02 - [Flutter/상태 관리] - 플러터] Provider 패키지 README.md
'Flutter > 상태 관리' 카테고리의 다른 글
플러터] Provider - read<T>() 메서드 (0) | 2024.07.02 |
---|---|
플러터] Provider - Provider 클래스 (0) | 2024.07.02 |
플러터] Provider - Provider.of 정적 메서드 (0) | 2024.07.02 |
플러터] 상태 관리 (State management) (0) | 2024.07.02 |
플러터] Provider 패키지 README.md (0) | 2024.07.02 |