간단한 앱 상태 관리
원문
이제 선언적 UI 프로그래밍과 임시 및 앱 상태의 차이점에 대해 알았으므로 간단한 앱 상태 관리에 대해 배울 준비가 되었습니다.
이 페이지에서는, provider
패키지를 사용할 것입니다. 플러터를 처음 사용하고 다른 접근 방식(Redux, Rx, hooks 등)을 선택할 강력한 이유가 없다면 이것이야말로 시작해야할 접근 방식일 것입니다. provider
패키지는 이해하기 쉽고 많은 양의 코드를 사용하지 않습니다. 또한 다른 모든 접근 방식에 통용되는 개념을 사용합니다.
다시 말해, 만일 다른 리액티브 프레임워크의 상태관리에 대한 강력한 배경지식이 있다면, 이 페이지에 나열된 패키지 및 튜토리얼을 찾을 수 있습니다.
예시
설명을 위해 다음과 같은 간단한 앱을 고려합니다.
앱에는 카탈로그와 장바구니(각각 MyLoginScreen
, MyCatalog
와 MyCart
위젯으로 표시됨)라는 두개의 별도 화면이 있습니다. 이 앱은 쇼핑앱일 수도 있지만, 간단한 소셜 네트워킹 앱에서 동일한 구조를 상상할 수도 있습니다(카탈로그를 ‘피드’로, 장바구니를 ‘즐겨찾기’로 대체).
카탈로그 화면에는 사용자 지정 앱 바(MyAppBar
)와 많은 리스트 항목들의 스크롤보기(MyListItems
)가 포함됩니다.
다음은 위젯 트리로 시각화 된 앱입니다.
따라서 최소 5개의 하위클래스가 Widget
에 있습니다. 그것들 중 다수가 다른 곳에 “속한” 상태에 접근할 필요가 있습니다. 예를들어 각 MyListItem
은 장바구니에 추가될 수 있어야할 것입니다. 현재 표시된 항목이 이미 장바구니에 있는지 여부를 확인할 수도 있습니다.
이는 우리를 첫번째 질문으로 안내합니다. 우리는 장바구니의 현재 상태를 어디에 두어야할까요?
상태 끌어올리기
플러터에서는 상태를 이를 사용하는 위젯 위에 두는 것이 합리적입니다.
왜냐하면 플러터와 같은 선언적 프레임워크에서 UI를 변경하려면 다시 빌드를 해야하기 때문입니다. MyCart.updateWith(somethingNew)
와 같은 쉬운 방식은 없습니다. 즉, 위젯에 대한 메서드를 호출하여 위젯을 외부에서 강제로 변경하기가 어렵습니다. 그리고 이 작업을 할 수 있다 하더라도 이것은 프레임워크가 당신을 돕는 대신 프레임워크와 싸우는 것입니다.
// BAD: DO NOT DO THIS
void myTapHandler() {
var cartWidget = somehowGetMyCartWidget();
cartWidget.updateWith(item);
}
위의 코드가 작동하더라도, MyCart
위젯에서 다음을 처리해야합니다.
// BAD: DO NOT DO THIS
Widget build(BuildContext context) {
return SomeWidget(
// The initial state of the cart.
);
}
void updateWith(Item item) {
// Somehow you need to change the UI from here.
}
UI의 현재 상태를 고려하여 새 데이터를 이에 적용시켜야합니다. 이런 방식으론 버그를 피하기 어렵습니다.
플러터에서는 매번 내용이 변경될 때마다 새로운 위젯을 생성합니다. MyCart.updateWith(somethingNew)
(메서드 호출) 대신 MyCart(contents)
(생성자)를 사용합니다. 부모의 빌드 메서드에서만 새 위젯을 만들 수 있기 때문에, contents
를 변경하려면 contents
는 MyCart
의 부모나 그 상위에 존재해야합니다.
// GOOD
void myTapHandler(BuildContext context) {
var cartModel = somehowGetMyCartModel(context);
cartModel.add(item);
}
이제 MyCart
는 모든 버전의 UI를 빌드하기 위해 하나의 코드 경로만을 가집니다.
// GOOD
Widget build(BuildContext context) {
var cartModel = somehowGetMyCartModel(context);
return SomeWidget(
// Just construct the UI once, using the current state of the cart.
// ···
);
}
이 예에서 contents
는 MyApp
내부에 존재합니다. 이것이 변경될 때마다, MyCart
위에서 다시 빌드합니다(이후 자세히 설명). 이 때문에 MyCart
는 생명주기에 대해서 신경쓸 필요가 없습니다 - 단지 지정된 contents
에 무엇을 보여줄지 선언할 뿐입니다. 변경될 때 이전 MyCart
위젯은 사라지고 새 위젯으로 완전히 교체가 됩니다.
이것이 우리가 위젯이 불변(immutable)이라고 말할 때 의미하는 바입니다. 위젯은 변경되지 않습니다 - 대체될 뿐입니다.
이제 장바구니의 상태를 어디에 넣어야할지 알았으니, 장바구니에 접근하는 방법을 살펴보겠습니다.
상태에 접근하기
사용자가 카탈로그의 항목 중 하나를 클릭하면 장바구니에 추가됩니다. 하지만 장바구니가 MyListItem
보다 상위에 존재하는데, 우리는 어떻게 해야할까요?
단순한 선택지는 MyListItem
이 클릭 시 호출할 수 있는 콜백을 제공하는 것입니다. 다트의 함수는 일류 객체이므로 원하는 방식으로 전달할 수 있습니다. 따라서 MyCatalog
내부에서 다음을 정의할 수 있습니다 :
@override
Widget build(BuildContext context) {
return SomeWidget(
// Construct the widget, passing it a reference to the method above.
MyListItem(myTapCallback),
);
}
void myTapCallback(Item item) {
print('user tapped on $item');
}
이것은 잘 작동하지만 여러 곳에서 수정해야하는 앱 상태인 경우 많은 콜백들(금방 노후화되는)을 전달해야합니다.
다행스럽게도 플러터에는 위젯이 자손(즉, 자식 뿐만 아니라 그 아래에 있는 모든 위젯)에게 데이터와 서비스를 전달하는 매커니즘이 있습니다. 당신이 기대하는 것처럼 모든 것이 위젯 ™ 인 플러터에서는 이러한 메커니즘들은 단지 특별한 종류의 위젯들일 뿐입니다. - InheritedWidget
, InheritedNotifier
, InheritedModel
등. 우리가 하려는 것에 비해 더 깊은 단계의 이야기이므로 여기서는 다루지 않을 것입니다.
대신에, 우리는 저수준 위젯들에서 작동하지만 사용하기 쉬운 패키지를 사용할 것입니다. 바로 provider
입니다.
provider
를 사용하기 전에, pubspec.yaml
에 의존성을 추가하는 것을 잊지 마세요.
name: my_name
description: Blah blah blah.
# ...
dependencies:
flutter:
sdk: flutter
provider: ^5.0.0
dev_dependencies:
# ...
이제 'package:provider/provider.dart';
를 임포트하고 빌드할 수 있습니다.
provider
와 함께라면 콜백이나 InheritedWidgets
에 대해 걱정하지 않아도 됩니다. 하지만 3가지 개념에 대해서 이해할 필요가 있습니다.
- ChangeNotifier
- ChangeNotifierProvider
- Consumer
ChangeNotifier
ChangeNotifier
는 변경 알림을 리스너에게 전달하는 플러터 SDK에 포함되어 있는 클래스입니다. 즉 무언가가 ChangeNotifier
라면, 그것의 변경사항을 구독할 수 있습니다.(Observable의 형태라고 보시면 됩니다. 이이 단어에 익숙하시다면요.)
provider
에서, ChangeNotifier
는 앱의 상태를 캡슐화하는 한가지 방식입니다. 매우 단순한 앱인 경우 하나의 ChangeNotifier
를 갖고 있을 것입니다. 복잡한 앱인 경우 여러개의 모델들이 있을 것이고 그에 따라 여러개의 ChangeNotifier
들이 있을 겁니다. (provider
에서 ChangeNotifier
를 사용할 필요가 전혀 없지만 작업하기 쉬운 클래스입니다.)
쇼핑 앱 예제에서는 ChangeNotifier
에서 장바구니의 상태를 관리할 것입니다. 다음과 같이 ChangeNotifier
를 상속받는 새 클래스를 만듭니다 :
class CartModel extends ChangeNotifier {
/// Internal, private state of the cart.
final List<Item> _items = [];
/// An unmodifiable view of the items in the cart.
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
/// The current total price of all items (assuming all items cost $42).
int get totalPrice => _items.length * 42;
/// Adds [item] to cart. This is the only way to modify the cart from outside.
void add(Item item) {
_items.add(item);
// This call tells the widgets that are listening to this model to rebuild.
notifyListeners();
}
}
ChangeNotifier
와 관련된 유일한 코드는 notifyListeners()
호출입니다. 모델이 앱의 UI를 변경할 수도 있는 방식으로 변경될 때마다 이 메서드를 호출합니다. CartModel
의 다른 모든 것은 모델 자신과 비즈니스 로직입니다.
ChangeNotifier
는 flutter:foundation
의 일부분이며 플러터의 어떤 상위 수준 클래스에도 의존적이지 않습니다. 테스트에도 용이(위젯 테스트를 사용할 필요도 없습니다)합니다. 예시로, 여기 CartModel
의 간단한 단위테스트가 있습니다:
test('adding item increases total cost', () {
final cart = CartModel();
final startingPrice = cart.totalPrice;
cart.addListener(() {
expect(cart.totalPrice, greaterThan(startingPrice));
});
cart.add(Item('Dash'));
});
ChangeNotifierProvider
ChangeNotifierProvider
는 ChangeNotifier
의 인스턴스를 이의 하위 위젯에 제공하는 위젯입니다.provider
패키지에서 제공됩니다.
우리는 이미 ChangeNotifierProvider
를 어디에 두어야하는지 알고 있습니다 : 접근해야하는 위젯의 상위에 말이죠. CartModel
의 경우, MyCart
와 MyCatalog
상위 위젯 어딘가에 두어야한다는 말입니다.
ChangeNotifierProvider
를 필요보다 높게 배치하고 싶지는 않습니다(스코프를 오염시키고 싶지 않기 때문에). 그러나 이 경우엔, MyCart
와 MyCatalog
의 상위에 있는 위젯은 오직 MyApp
뿐입니다.
void main() {
runApp(
ChangeNotifierProvider(
builder: (context) => CartModel(),
child: MyApp(),
),
);
}
우리가 CartModel
의 새 인스턴스를 생성하는 빌더를 정의하고 있다는 걸 기억하세요. ChangeNotifierProvider
는 CartModel
을 꼭 필요한 경우가 아니라면 재빌드하지 않을만큼 똑똑합니다.
또한 인스턴스가 더이상 필요가 없어진다면 자동으로 CartModel
에서 dispose()
를 호출할 것입니다.
한개 이상의 클래스를 제공하고 싶다면 MultiProvider
를 사용할 수 있습니다.
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(builder: (context) => CartModel()),
Provider(builder: (context) => SomeOtherClass()),
],
child: MyApp(),
),
);
}
Consumer
이제 위의 ChangeNotifierProvider
선언에 의해서 CartModel
이 앱의 위젯으로 제공되었으므로, 사용을 시작할 수 있습니다. 이것은 Consumer
위젯을 통해 이루어집니다 .
return Consumer<CartModel>(
builder: (context, cart, child) {
return Text("Total price: ${cart.totalPrice}");
},
);
우리는 접근하길 원하는 모델의 유형을 지정해야합니다. 이 경우 CartModel
에 접근하길 원하므로 Consumer<CartModel>
라고 적어줍니다. 만일 제네릭(<CartModel>
)을 지정하지 않을 경우, provider
패키지는 사용할 수 없을 겁니다. provider
패키지는 유형을 기반으로 두기 때문에 유형이 없다면 원하는 것을 알 수가 없습니다.
Consumer
위젯이 요구하는 유일한 필수 인자는 빌더입니다. 빌더는 ChangeNotifier
가 변경될 때마다 호출되는 함수입니다. (다시 말하면, 당신이 notifyListeners()
를 당신의 모델에서 호출할 때마다 해당하는 모든 Consumer
위젯의 빌더 메서드들이 호출됩니다.)
빌더는 세 개의 인수로 호출됩니다. 첫번째는 context
로, 매 빌드 메서드마다 받아오는 것입니다.
빌더 함수의 두번째 인수는 ChangeNotifier
의 인스턴스입니다. 우리가 처음에 요청했던 것입니다. 모델의 데이터를 사용하여 주어진 시점에서 UI가 어떻게 보일지 정의 할 수 있습니다.
세번 째 인수는 child
로, 최적화를 위한 것입니다. 당신의 Consumer
아래에 모델이 변경되도 바뀌지 않는 큰 위젯의 하위 트리가 있는 경우, 당신은 이것을 한번만 구성하고 빌더를 통해 얻을 수 있습니다.
return Consumer<CartModel>(
builder: (context, cart, child) => Stack(
children: [
// Use SomeExpensiveWidget here, without rebuilding every time.
child,
Text("Total price: ${cart.totalPrice}"),
],
),
// Build the expensive widget here.
child: SomeExpensiveWidget(),
);
Consumer
위젯을 트리의 가장 깊숙한 곳에 배치하는 것이 제일 좋습니다. 어딘가의 작은 디테일이 변경된 것 때문에 UI의 많은 부분을 재빌드하고 싶지는 않을 것입니다.
// DON'T DO THIS
return Consumer<CartModel>(
builder: (context, cart, child) {
return HumongousWidget(
// ...
child: AnotherMonstrousWidget(
// ...
child: Text('Total price: ${cart.totalPrice}'),
),
);
},
);
대신에:
// DO THIS
return HumongousWidget(
// ...
child: AnotherMonstrousWidget(
// ...
child: Consumer<CartModel>(
builder: (context, cart, child) {
return Text('Total price: ${cart.totalPrice}');
},
),
),
);
Provider.of
때로는 UI를 변경하기 위해 모델의 데이터가 실제로 필요하지는 않지만 여전히 액세스해야합니다. 예를 들어, ClearCart
버튼은 사용자가 장바구니에서 모든 것을 제거할 수 있도록 합니다. 장바구니의 내용물을 표시할 필요가 없으며 clear()
메서드를 호출하기만하면 됩니다.
우리는 Consumer<CartModel>
을 이를 위해 사용할 수는 있지만 그건 낭비일 것입니다. 다시 빌드할 필요가 없는 위젯을 다시 빌드하도록 프레임워크에게 요청하는 것일 겁니다.
이 사용사례처럼, 우리는 listen
파라미터를 false
로 설정하여 Provider.of
를 사용할 수 있습니다.
Provider.of<CartModel>(context, listen: false).add(item);
위의 코드를 사용하면 빌드 메서드 중 notifyListeners
가 호출될 때 이 위젯을 리빌드하지 않도록 설정할 수 있습니다.
함께 모아서
이 문서에서 다루었던 코드 샘플을 확인할 수 있습니다. 만일 더 단순한 예제를 원한다면, provider
로 작업한 단순한 카운터 앱을 확인해보실 수 있습니다.
이 문서를 따라오면서 당신은 상태 기반 어플리케이션을 만드는 능력을 비약적으로 발전시켰을 것입니다. provider
로 어플을 제작함으로써 이 스킬을 갈고 닦아보세요.