본문 바로가기
Flutter/Flutter 필수개념

[Flutter] state 관리와 provider

by 붕어사랑 티스토리 2021. 12. 14.
반응형

https://docs.flutter.dev/development/data-and-backend/state-mgmt/declarative

 

Start thinking declaratively

How to think about declarative programming.

docs.flutter.dev

해당 문서는 플러터 공식 문서를 기반으로 작성합니다.

 

 

 

0. 개요

 

본 문서는 Flutter의 UI의 기본적인 개념과 App State에 대한 정의를 익힌 뒤 State관리를 위한 Provider 사용법에 대해 배울 예정입니다.

 

 

 

 

 

1. Declarative UI에 대한 소개

먼저 소개해 드릴 내용은 Declarative 라는 개념입

니다.

 

플러터는 기본적으로 Declarative style 프레임 워크입니다. 다른 프레임워크(안드로이드나 IOS 같은 경우는 imperative style 이고요

 

뜻을 보면 다음과 같습니다.

 

Declarative : 선언적

Imperative : 지시적

 

 

 

 

 

 

가령 아래와 같은 ViewB b를 배경색을 바꾸고 아이템 목록도 바꾼다고 쳐 봅시다.

 

Imperative에 대한 예시

윈도우나 안드로이드 IOS같은경우는 UI를 작성할때 UI를 관리하는 여러 함수들이 있고 이를 통해 UI를 조작합니다.

아래가 대표적인 Imperative에 스타일의 코드입니다.

 

// Imperative style
b.setColor(red)
b.clearChildren()
ViewC c3 = new ViewC(...)
b.add(c3)

코드를 보시면 아시겠지만 View b에 대응하는 객체를 얻은 뒤에 color를 set 해주고 childeren을 clear 해 주고 ViewC를 일일이 프로그래머의 수작업으로 달아줍니다. 이것이 바로 Imperative 스타일에 대한 예시입니다.

 

 

Declarative에 대한 예시

자 그럼 이를 Flutter 방식으로 바꾸면 어떻게 될까요?

 

// Declarative style
return ViewB(
  color: red,
  child: ViewC(...),
)

Declarative에서는 View의 구성요소들은 immutable 입니다. immutable이니 화면을 바꾸기 위해서는 위젯을 그냥 통째로 rebuild 해 버립니다. 그리고 이를 위해서는 setState를 호출해야 하죠.

 

 

매번 rebuild를 하면 속도가 느려지지 않을까 하는 생각이 드실 겁니다. 허나 플러터는 빠릅니다. 그 이유는 다음과 같습니다.

 

 

위젯을 새로 rebuild 한다 하였는데 한가지 주목할 점은 위 코드에서 return 하는 위젯은 경량화 된 데이터 입니다.

 

그리고 플러터에서는 UI를 구성하기위해 RenderObject라는 것을 사용합니다. RenderObject는 화면의 메 프레임마다 존재합니다.

 

만약 경량화된 데이터가 setState로 인해 변한다면 경량화된 데이터는 RenderObject에게 이를 알리고 UI를 다시 구성하도록 합니다.

 

리빌드를 하지만 실제로는 경량화된 데이터만 리빌드 되기 때문에 플러터는 높은 퍼포먼스를 유지할 수 있습니다.

 

 

 

 

 

그럼 Declarative는 무슨 장점이 있나요?

가령 위젯의 배경화면 색에대한 데이터가 바뀌었다고 칩시다.

그럼 imperative는 다음과 같이 명시적으로 적어 주어야 합니다.

b.setColor(red)

허나 플러터의 경우 바로 rebuild 되기 때문에 이렇게 imperative하게 적어줄 필요가 없습니다.

코드관리가 한결 수월하죠.

 

 

 

2. Ephemeral state vs App state

플러터를 하시면서 state라는 단어를 꾸준히 보실 것 입니다. 이런 state를 관리하는 방법을 익히는건 필수겠지요.

 

여기서 state라고 다 똑같은 state가 아닙니다. 플러터에서는 Ephemeral state와 App state라는 개념으로 state개념을 명확히 나누고 있습니다.

 

결론부터 말하면 다음과 같습니다.

 

 

Ephemeral State : 다른 위젯에서 필요하지 않고 현재 위젯만이 필요한 state

App State : 여러 위젯들이 접근해야할 데이터, state

 

Ephemeral State에 대한 예제

PageView에서 현재 Page Index는 보통 PageView 위젯에서만 필요하므로 Ephemeral State입니다.

 

App Sate에 대한 예제

간단한 예를 들면 어떤 앱에서 notification 정보가 어느 페이지를 가든 보여야 한다고 칩시다.

그럼 notification에 대한 정보는 App Sate가 됩니다.

 

 

 

여기서 한가지 기억할 점은 Ephemeral Sate와 Aps State는 명확한 기준이 없습니다.

PageView의 index가 다른 위젯에서 필요하다면 이는 App State가 될 수도 있는 것 입니다.

 

아래 다이어그램을 참고하면 이해가 쉽습니다.

 

 

 

 

 

 

위 개념이 왜 필요하냐고요? provider는 App State와 관련된 패키지기 때문입니다.

 

Emphemeral State : 하나의 위젯안에서만 필요하기 때문에, Stateful 위젯에다가 State정의하면 됨

AppState : 여러 위젯에 필요하기 때문에 Provider를 사용해야됨

 

 

 

 

 

3. State관리의 필요성, 문제상황 인지

Ephemeral State와 App state에 대한 개념을 배웠습니다. 이제 우리가 주목할 state는 App state라고 감이 잡히시겠지요?

 

 

아래와 같은 위젯 트리가 있다고 칩시다.

 

 

  • Root 노드에 어떤 State를 가지고 있다고 가정합니다.
  • 가장 아래 주황색 Widget에서 이 State가 필요합니다.
  • Root 노드에서는 이 주황색 위젯에 State를 전달하기 위해 생성자를 통해 모든 위젯에 State를 내려주었습니다.

 

State를 하나 던져주기 위해 State가 필요없는 중간과정의 위젯들은 모드 생성자에 state값을 하나씩 뚫어줘야 합니다.

 

 

쓸데 없는 코드 낭비이지요.

 

 

 

 

이를 해결하기 위해 다른방법으로 모든 위젯들을 하나의 build 메소드에 묶었다고 가정 해 봅시다.

 

만약 이상태에서 State가 변하면?

 

State가 필요한건 주황색 위젯이므로 주황색 위젯만 rebuild 해 주면 되는데 모든 위젯들이 rebuild 되어 버립니다.

 

퍼포먼스에 문제가 생기겠지요?

 

 

이 문제를 해결하기 위해서 플러터는 주황색 위젯에서 바로 root 위젯으로 접근 할 수 있는 InheritedWidget이라는걸 제공하는데 앞으로 배울 Provider가 더 좋은거니깐 이 글에선 생략하도록 하겠습니다.

 

 

 

 

4. State는 어디에 저장해야 하는가?

먼저 다음과 같은 쇼핑앱이 있다고 칩시다.

 

위 위젯은 다음과 같이 구분됩니다.

 

  • MyCatalog위젯과 MyCart 위젯 페이지가 있습니다.
  • MyCatalog에는 MyAppBar와 쇼핑목록인 MyListItem이 있습니다
  • MyCatalog에서 아이템들을 선택하면 MyCart에서 선택한 아이템들과 함께 total price가 표시됩니다.

 

 

여기서 주목할 점이 있지요? 우리는 state관리에 대해 배울겁니다. 앱의 영상을 보시면 아이템 목록을 add 하면 MyCart에 데이터가 저장됩니다.

 

그럼 이러한 State는 어느 위젯에 저장해야 할 까요?

 

 

Lifting state up

정답은 바로 / state를 사용하는 위젯의 / 위쪽에 있는 위젯에 / state를 저장해야 합니다.

 

이렇게 하면 우리는 MyCart를 리빌드 할 때 MyApp에서 그냥 state만 전달해주면 끝나기 때문입니다.

 

 

 

만약 state를 MyApp이 아닌 다른곳에 저장한다면? 가령 예를 들어 MyListItem에 저장한다면 MyCart에 데이터를 전달해 주기 위해 길을 뚫어주고 인터페이스를 만드는 작업들을 해야 겠죠. 상당히 불편합니다.

 

반응형

5. 부모의 State에 접근하기, Provider

자 여러분은 이제 state를 어디에 저장해야되는지 알게되었습니다. 그럼 자식위젯이 부모의 State에 어떻게 접근할까요?

 

 

 

콜백을 사용하여 MyListItem에 전달하고 MyListItem에서 state가 바뀌면 MyApp에서 호출되도록 할까요?

그럼 콜백을 계속 생성자에 내려줘야 하기에 쉽지 않습니다. 좋은방법은 아닙니다.

 

 

 

Flutter는 이를 해결하기 위해 InheritedWidget, InheritedNotifier, InheritedModel 같은 위젯을 제공합니다.

 

허나 지금 이 내용을 소개하는 구글페이지에서는 수준낮은 위젯이고 이 페이지에서 사용하지 않을거라 합니다.

 

(그럼 왜만들었냐?)

 

 

 

대신 우리는 이를 해결하기 위해 provider 라는 패키지를 사용할 것입니다.

 

아래처럼 pubspec.yaml 에 프로바이더를 설치 해 주세요.

name: my_name
description: Blah blah blah.

# ...

dependencies:
  flutter:
    sdk: flutter

  provider: ^6.0.0

dev_dependencies:
  # ...

그리고 

 

import 'package:provider/provider.dart';

 

를 하시면 provider 사용 준비는 끝났습니다.

 

 

 

6. Provider의 세가지 필수 개념

 

Provider를 사용하기 위해선 다음 세가지 개념을 필수로 이해해야 합니다.

 

  • ChangeNotifier 혹은 그냥 Class : 여러 위젯들이 읽을 state값을 담을 클래스입니다.
  • ChangeNotifierProvider / Provider : 자식위젯들에게 state를 전달해줄 위젯입니다.
  • Consumer / Selector : ChangeNotifierProvider가 제공해주는 ChangeNotifier를 사용할 리스너 위젯입니다.

 

 

7. ChangeNotifier

ChangeNotifier는 이름에서 보면 알 수 있듯이 state가 변화면 Listner에게 state변화를 알리는 클래스 입니다.

사용방법은 다음과 같습니다.

 

  • 원하는 클래스에 ChangeNotifier를 상속합니다.
  • 값이 변하면 notifyListeners() 함수를 호출합니다.
class CartModel extends ChangeNotifier {
  /// 내부적으로 사용될 Cart의 목록
  final List<Item> _items = [];

  /// An unmodifiable view of the items in the cart.
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// 현재 아이템들의 모든 가격
  int get totalPrice => _items.length * 42;

  /// 아이템이 추가되면 리스너에게 아이템이 추가됨을 알린다.
  void add(Item item) {
    _items.add(item);
    // 해당 ChangeNorifiter를 감시하고있는 위젯들에게
    // 상태변화를 알리고 rebuild 하도록 한다.
    notifyListeners();
  }

  /// 아이템 목록을 클리어 하고 리스너에게 이를 알린다.
  void removeAll() {
    _items.clear();
    notifyListeners();
  }
}

 

여기서 notifyListenser() 함수는 ChangeNotifier를 감시하고 있는 리스너에게 state변화를 알리고 위젯을 리빌드 하도록 하는 함수 입니다.

 

만약 리빌드를 원하지 않고, 그냥 값만 전달하고 싶으면 일반적인 클래스를 사용하면 됩니다.

 

 

8. ChangeNotifierProvider

ChageNotifierProvider는 위젯입니다. 그리고 이 위젯은 자식위젯에게 ChangeNotifier을 전달 해 줍니다.

그리고 위 예제에서는 MyApp 위젯에 ChangeNotifierProvider를 제공해 줘야겠죠?

 

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}

 

만약 여러개의 ChangeNotifierProvider를 제공해주고 싶다면 다음과 같이 MultiProvider를 사용합니다.

만약, 자식위젯에게 데이터만 제공하고 싶다 하시면 Provider와 그냥 일반 클래스를 사용하시면 됩니다.

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: const MyApp(),
    ),
  );
}

 

 

만약 이미 생성된 ChangeNotifier가 있다면 ChangeNotifierProvider.value 생성자를 이용해줍니다.

MyChangeNotifier variable;

ChangeNotifierProvider.value(
  value: variable,
  child: ...
)

 

 

 

 

9. Consumer

자 우리는 이제 위젯 최상단인 MyApp 위젯에 ChangeNotifier인 CartModel을 ChangeNotifierProvider로 제공하였습니다.

그럼 이제 이걸 자식 위젯에서 사용해 봅시다! 바로 Consumer를 이용하는 겁니다.

 

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

 

여기서 주의할점은 Consumer를 사용할때 제네릭 타입을 반드시 지정해줘야 한다는 점입니다.

위 예제에서는 Consumer<CartModel> 이 되겠지요?

Consumer에서는 builder 인자를 요구합니다. 이 Builder는 ChangeNotifier에서 notifyListeners() 함수를 호출할 때 불리게 됩니다.

 

이렇게 하면 MyCart 위젯에서 값이 변할 때 마다 totalPrice 값이 바뀌게 되겠지요?

 

 

 

여기서 builder 함수는 인풋이 넘어옵니다.

 

  • 첫번째 인자는 context 입니다. 거의 모든 위젯에서 필요하니 넘어갑시다
  • 두번째는 인자는 부모 위젯에서 ChangeNotifierProvider에서 제공했던 ChangeNotifier의 instance입니다. 여기서는 CartModel의 인스턴스가 되겠지요
  • 세번째 인자는 child 입니다. 퍼포먼스 최적화를 위해 존재합니다. child위젯의 subtree가 너무 클 경우, 그리고 ChangeNotifier가 바뀌어도 영향이 없는 경우 해당 child를 활용합시다.

 

 

아래가 대표적인 child활용의 예시입니다.

return Consumer<CartModel>(
  builder: (context, cart, child) => Stack(
    children: [
      // 이렇게 하면 child를 매번 리빌드 할 필요가 없습니다.
      if (child != null) child,
      Text("Total price: ${cart.totalPrice}"),
    ],
  ),
  // 이곳에서 child를 한번 생성해 줍니다.
  child: const SomeExpensiveWidget(),
);

 

 

 

그리고 Consumer는 가능하면 위젯트리의 가장 아래에 두어야 합니다.

그래야 불필요한 rebuild를 하지 않으니 깐요.

 

다음과 같이 사용하지 마세요

// DON'T DO THIS
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);

 

아래처럼 Consumer를 가장 아래에 두어야 올바른 예시입니다.

// DO THIS
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: Consumer<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);

 

10. Selector

 

위 예제에서 우리는 Consumer에 대해서 배웠습니다.

 

허나 Consumer는 한가지 큰 문제점이 있습니다.

 

하나의 Provider에 여러개의 Consumer가 등록되어 있다고 가정합시다.

각각의 Consumer들은 각기 다른 Provider의 데이터를 listen하고 있다고 합시다.

만약 하나의 데이터가 값이 변하고 notifyListeners()를 호출했다면?

 

그럼 rebuild가 필요없는 모든 Consumer들 까지 rebuild 되겠지요.

 

이를 해결하기 위한 방법이 Selector 입니다.

 

Selector는 Provider의 변화를 듣되 Provider의 특정값의 변화만 감지하는 Consumer 입니다.

 

사용법은 다음과 같습니다.

 

Selector<프로바이터타입, 사용될데이터 타입>{

  selector: (BuildContext, Provider) => 사용할 데이터,

  builder: (context, cart, child) {

    .........

  },

}

 

Selector<CartModel, int>(
  selector: (context, cart) => cart.totalPrice,
  builder: (context, cart, child) {
    return Text('Total price: ${cart.totalPrice}');
  },

 

 

 

 

11. Provider.of

마지막으로 이제 자식 위젯에서 Provider가 제공한 state를 접근해봅시다.

 

가장기본적인 방법은 of 함수 즉 Provider.of 를 이용하는 것입니다.

한가지 주의하실점은 of 함수에 제네릭 타입을 명시해 주어야 한다는 점입니다.

 

아래 예제 처럼요

 

Provider.of<CartModel>

 

Provider.of 에는 listen이라는 변수가 있습니다. 그리고 기본값으로 true 이고요.

 

그리고 Provider의 값이 바뀌게 되면 State.build 함수를 트리거 하여 리빌드 하도록 합니다.

 

 

 

간혹가다가 그저 부모의 데이터만 조작하고 rebuild를 원하지 않는 경우가 있습니다.

그럴경우에 Provider.of 함수에 listen값을 false로 주면 됩니다.

Provider.of<CartModel>(context, listen: false).removeAll();

 

 

12. 코드를 좀더 간단하게 watch, read, select

위 내용을 보니 provider.of 메소드를 이용해서 provider가 제공하는 state를 사용하려면 매번 Consumer와 Selector를 달아주는게 몹시 피곤하지 않을까요?

 

그래서 provider 패키지는 watch와 read, select 라는 함수를 제공합니다.

 

이 함수들을 build메소드 안에서 사용하면 Consumer와 Selector 같은 효과를 내게 됩니다. 즉 위젯을 따로 Consumer와 Selecotr를 감쌀 필요가 없어 코드가 간단해지지요.

  • context.watch<T> :  Provider.of 에서 listen이 true인 상태와 동일합니다. 값의 변화를 읽고 위젯을 rebuild 시킵니다. 단 T안에있는 모든 state를 listen하게 됩니다
  • context.read<T> : Provider.of 에서 listen이 false인 상태와 동일합니다. 값만 읽어옵니다.
  • context.select<T, R>(R cb(T value)) : Provider의 값의 일부만 읽어올 때 사용합니다. selector와 같은 기능을 합니다. 여기서 callback 함수는, ChangeNotifier에서 원하는 값을 꺼내오는 콜백함수를 넣어주면 됩니다

 

 

Widget build(BuildContext context) {
  final person = context.watch<Person>();
  return Text(person.name);
}

Widget build(BuildContext context) {
  final name = context.select((Person p) => p.name);
  return Text(name);
}

 

 

 

 

13. 추가

 

여담이지만.. 공식가이드에서 말한것과 다르게, 실제로는 provider.of만으로도 watch와 read가 가능하다. 즉 애초에 Consumer는 필요없었다는것... 하지만 gpt가 말하길 코드의 가독성과 유지보수를 위해 Consumer를 사용해주는게 좋다고 한다.

 

(이래놓고 context.watch 이런건 왜 만든건지? 아 물론 성능상에 차이가 있다고는 함. of보다 context.watch가 성능이 좋음)

반응형

댓글