Flutter로 개발하기(17) - 간단한 상태관리 앱

Simple app state management

원문: https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple

이번 예제

로그인화면, 카탈로그화면, 장바구니화면으로 구성된 앱입니다.

example.gif

widget-tree.png

우리는 최소한 6개의 위젯을 만들 겁니다.

상태를 위로 올리기

플러터에서는 상태를 사용하는 위젯 위(부모 위젯)에 그 상태를 유지하는 게 좋습니다.

// BAD: 이렇게 하지 마세요.
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget(); // 부모 위젯
  cartWidget.updateWith(item);
}
// BAD: 이렇게 하지 마세요.
Widget build(BuildContext context) {
  // 부모 위젯의 build() 메소드
  return SomeWidget();
}

void updateWith(Item item) {
  // UI를 직접 바꿉니다.
}

이러한 코드들이 잘 동작한다고 해도 바뀐 상태에 따라 UI를 직접 바꾸고 갱신하는 방법은 버그를 피하기 어렵습니다.

플러터에서는 상태가 바뀌면 위젯이 다시 만들어집니다. MyCart.updateWith(somethingNew) 대신에 MyCart(contents) 생성자를 사용하세요. 새로운 위젯의 생성은 build() 메소드 안에서만 할 수 있습니다. 그래서 위젯의 상태를 바꾸려면 그 상태를 부모 위젯에 두어야 합니다.

// GOOD
void myTapHandler(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context); // 장바구니 상태
  cartModel.add(item);
}
// GOOD
Widget build(BuildContext context) {
  // 부모 위젯의 build() 메소드
  var cartModel = somehowGetMyCartModel(context); // 장바구니 상태
  return SomeWidget(
    // 장바구니의 현재 상태를 사용해서 UI를 생성합니다. 
    // ···
  );
}

장바구니 상태가 바뀌면 이전의 MyCart 위젯은 새로운 MyCart 위젯으로 대체됩니다. state-change.png 이런 동작은 위젯이 왜 immutable인지 알 수 있게 해줍니다. 위젯은 바뀌지 않고 대체될 뿐입니다.

상태에 접근하기

사용자가 카탈로그 항목 중 하나를 클릭했을 때 그 항목은 장바구니에 추가됩니다. 어떻게 구현할까요?

단순한 선택지는 MyListItem이 클릭됐을 때 호출할 수 있는 콜백을 제공하는 것입니다.

@override
Widget build(BuildContext context) {
  // MyCatalog 위젯이 build() 메소드
  return SomeWidget(
    // 자식 위젯에 콜백 메소드를 인자로 넣습니다.
    MyListItem(myTapCallback),
  );
}

void myTapCallback(Item item) {
  print('user tapped on $item');
}

이 코드는 잘 동작하지만 앱 상태로 만들어야 할 땐 많은 콜백들을 만들어야 하며, 많은 곳들을 함께 수정해야 합니다.

다행히도 플러터에는 하위 계층에 있는 위젯들에게 데이터와 서비스를 제공할 수 있는 메커니즘이 있습니다. 이 메커니즘은 InheritedWidget, InheritedNotifier, InheritedModel에 의해 제공됩니다. 이 위젯들은 조금 low-level이기 때문에 우리는 이것들을 다루진 않고 이 위젯들을 간단하게 다룰 수 있는 패키지 scoped_model을 사용할 것입니다.

scoped_model에는 3가지 컨셉이 있습니다.

  • Model
  • ScopedModel
  • ScopedModelDescendant

Model

scoped_model에서 Model은 앱 상태를 캡슐화합니다. 우리 쇼핑앱 예제에서 장바구니 상태를 관리하기 위해 Model 클래스를 상속할 것입니다.

class CartModel extends Model {
  /// private으로 선언된 장바구니의 상태
  final List<Item> _items = []; 

  /// 장바구니에서 목록뷰를 수정할 수 없게 합니다.
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// 모든 항목들의 현재 합산가격 (모든 항목의 가격이 1$라고 가정합니다.)
  int get totalPrice => _items.length;

  /// 장바구니에 항목을 추가합니다. 이것이 외부에서 장바구니를 수정할 수 있는 유일한 방법입니다.
  void add(Item item) {
    _items.add(item);
    // 이 호출은 Model에게 위젯들이 리빌드 되어야 한다고 말해줍니다.
    notifyListeners();
  }
}

Model은 어느 클래스도 의존하지 않기 때문에 테스트 하기 쉽습니다.

test('adding item increases total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
  });
  cart.add(Item('Dash'));
});

ScopedModel(위젯)

ScopedModel은 Model 인스턴스를 하위 계층의 위젯들에게 제공합니다.

우리는 이미 이것을 어디에 넣어야 할지 압니다. CartModel의 경우 MyCart와 MyCatalog의 공통 조상 위젯에 넣습니다.

void main() {
  final cart = CartModel();

  // You could optionally connect [cart] with some database here.

  runApp(
    ScopedModel<CartModel>(
      model: cart,
      child: MyApp(),
    ),
  );
}

만약 여러 Model을 사용하기 원한다면 중첩시키면 됩니다.

ScopedModel<SomeOtherModel>(
  model: myOtherModel,
  child: ScopedModel<CartModel>(
    model: cart,
    child: MyApp(),
  ),
)

ScopedModelDescendant

이제 CartModel을 ScopedModelDescendant을 통해 위젯들에게 제공할 차례입니다.

return ScopedModelDescendant<CartModel>(
  builder: (context, child, cart) {
    return Text("Total price: ${cart.totalPrice}");
  },
);

ScopedModelDescendant은 빌더입니다. 빌더는 함수입니다. notifyListeners()가 호출되면 ScopedModelDescendant는 다시 호출될 것입니다.

작은 부분을 바꾸자고 거대한 UI를 리빌드 하고 싶진 않을 겁니다.

// 이렇게는 하지 마세요.
return ScopedModelDescendant<CartModel>(
  builder: (context, child, cart) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);
// 대신에 이렇게 하세요.
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: ScopedModelDescendant<CartModel>(
      builder: (context, child, cart) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);

ScopedModel.of

때로는 리빌드 없이 상태를 바꿀 필요가 있습니다. 예를 들면 장바구니화면이 아닌 곳에서 “장바구니 비우기” 버튼을 눌렀을 때 장바구니 상태를 바꾸려고 ScopedModelDescendant를 사용하는 것은 낭비입니다. 이럴 때 ScopedModel.of를 사용하면 notifyListeners()가 호출돼도 위젯이 리빌드 되지 않습니다.

ScopedModel.of<CartModel>(context).add(item);

완전한 예제 코드

링크