Provider 심화 — ChangeNotifier, Consumer, Selector
Provider 심화 — ChangeNotifier, Consumer, Selector
이전 글에서 Provider의 기본을 다뤘습니다. 이번에는 실무에서 자주 쓰는 심화 패턴들을 정리해보겠습니다.
ChangeNotifier 패턴
class TodoModel extends ChangeNotifier {
final List<Todo> _todos = [];
// 불변 리스트로 외부에 노출 (외부에서 직접 수정 방지)
List<Todo> get todos => List.unmodifiable(_todos);
int get completedCount => _todos.where((t) => t.isDone).length;
int get totalCount => _todos.length;
void add(String title) {
_todos.add(Todo(title: title));
notifyListeners();
}
void toggle(int index) {
_todos[index] = _todos[index].copyWith(
isDone: !_todos[index].isDone,
);
notifyListeners();
}
void remove(int index) {
_todos.removeAt(index);
notifyListeners();
}
}
class Todo {
final String title;
final bool isDone;
const Todo({required this.title, this.isDone = false});
Todo copyWith({String? title, bool? isDone}) {
return Todo(
title: title ?? this.title,
isDone: isDone ?? this.isDone,
);
}
}
Consumer — 리빌드 범위 제한
context.watch는 해당 위젯 전체를 리빌드합니다. Consumer를 쓰면 필요한 부분만 리빌드할 수 있습니다.
class TodoScreen extends StatelessWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('할 일 목록'),
// Consumer로 완료 카운트만 리빌드
actions: [
Consumer<TodoModel>(
builder: (context, model, child) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text('${model.completedCount}/${model.totalCount}'),
);
},
),
],
),
// 이 부분은 리빌드되지 않음
body: Consumer<TodoModel>(
builder: (context, model, child) {
return ListView.builder(
itemCount: model.todos.length,
itemBuilder: (context, index) {
final todo = model.todos[index];
return ListTile(
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isDone
? TextDecoration.lineThrough
: null,
),
),
leading: Checkbox(
value: todo.isDone,
onChanged: (_) => model.toggle(index),
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => model.remove(index),
),
);
},
);
},
),
);
}
}
Consumer의 child 파라미터
child는 리빌드되지 않는 부분을 최적화합니다.
Consumer<CounterModel>(
// child는 리빌드되지 않음 (성능 최적화)
child: const Text('변하지 않는 텍스트'),
builder: (context, model, child) {
return Column(
children: [
child!, // 리빌드되지 않는 부분
Text('${model.count}'), // 이 부분만 리빌드
],
);
},
)
Selector — 특정 값만 감시
Selector는 모델의 특정 속성만 감시합니다. 해당 속성이 변경될 때만 리빌드합니다.
// count 값이 변경될 때만 리빌드
Selector<TodoModel, int>(
selector: (context, model) => model.completedCount,
builder: (context, completedCount, child) {
return Text('완료: $completedCount');
},
)
// context.select도 같은 역할
Widget build(BuildContext context) {
final completedCount = context.select<TodoModel, int>(
(model) => model.completedCount,
);
return Text('완료: $completedCount');
}
면접 포인트: watch vs Consumer vs Selector의 차이를 물어볼 수 있습니다. watch는 위젯 전체 리빌드, Consumer는 builder 범위만 리빌드, Selector는 특정 값이 변할 때만 리빌드합니다.
Provider 종류
Provider (읽기 전용)
// 변하지 않는 값을 제공할 때
Provider<ApiService>(
create: (_) => ApiService(),
child: const MyApp(),
)
ChangeNotifierProvider
// ChangeNotifier를 제공할 때 (가장 흔함)
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const MyApp(),
)
FutureProvider
// 비동기 초기화가 필요한 값
FutureProvider<User>(
create: (_) => fetchCurrentUser(),
initialData: User.empty(),
child: const MyApp(),
)
StreamProvider
// 스트림으로 변하는 값
StreamProvider<int>(
create: (_) => countStream(),
initialData: 0,
child: const MyApp(),
)
ProxyProvider
// 다른 Provider에 의존하는 Provider
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthModel()),
// AuthModel에 의존하는 ApiService
ProxyProvider<AuthModel, ApiService>(
update: (_, auth, __) => ApiService(token: auth.token),
),
],
child: const MyApp(),
)
에러 처리 패턴
class DataModel extends ChangeNotifier {
List<Item> _items = [];
bool _isLoading = false;
String? _error;
List<Item> get items => _items;
bool get isLoading => _isLoading;
String? get error => _error;
Future<void> fetchItems() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_items = await apiService.getItems();
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
}
// UI에서 사용
Consumer<DataModel>(
builder: (context, model, _) {
if (model.isLoading) {
return const CircularProgressIndicator();
}
if (model.error != null) {
return Text('에러: ${model.error}');
}
return ListView.builder(
itemCount: model.items.length,
itemBuilder: (context, index) =>
ListTile(title: Text(model.items[index].name)),
);
},
)
Provider 테스트
testWidgets('카운터 증가 테스트', (tester) async {
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: const MaterialApp(home: CounterScreen()),
),
);
// 초기값 확인
expect(find.text('0'), findsOneWidget);
// 버튼 클릭
await tester.tap(find.byType(FloatingActionButton));
await tester.pump();
// 증가 확인
expect(find.text('1'), findsOneWidget);
});
Provider 주의사항
// 1. build 안에서 read 사용하면 상태 변화를 감지 못함
@override
Widget build(BuildContext context) {
// 잘못된 사용: build에서 read
final count = context.read<CounterModel>().count;
// 올바른 사용: build에서 watch
final count = context.watch<CounterModel>().count;
}
// 2. 콜백에서 watch 사용하면 에러
onPressed: () {
// 잘못된 사용: 콜백에서 watch
// final model = context.watch<CounterModel>();
// 올바른 사용: 콜백에서 read
final model = context.read<CounterModel>();
model.increment();
}
// 3. dispose에서 notifyListeners 호출하지 않기
@override
void dispose() {
// notifyListeners(); // 에러 발생!
super.dispose();
}
정리
Consumer로 리빌드 범위를 제한하여 성능을 최적화하세요Selector로 특정 속성만 감시하면 불필요한 리빌드를 줄일 수 있습니다- Provider 종류(Provider, ChangeNotifier, Future, Stream, Proxy)를 용도에 맞게 선택하세요
watch는 build에서,read는 콜백에서 사용하는 규칙을 지키세요- 로딩, 에러, 데이터 3가지 상태를 항상 관리하세요
댓글 로딩 중...