Flutter Riverpod Provider 알아보기

최근까지 플러터앱을 개발 하면서 GetX를 주로 사용했다.
GetX가 플러터 입문자가 배우기 쉽고 다양한 툴을 함께 제공하기 때문에 개발하기 쉬운 탓이 있었다. 하지만 GetX를 사용하기 위해서는 상위 위젯을 반드시 GetMaterialApp 위젯으로 랩핑해야한다. 이런 부분에서는 범용성이나 확장성이 떨어진다. 최근 fluent_ui 패키지를 사용해보고 싶었는데 이 부분이 걸림돌이 되었다.

이제는 Riverpod를 통해 GetX를 벗어나고자 한다. 그리고 Riverpod를 학습하면서 느낀바는 React의 Recoil과 Cusom Hooks 사용 방법과 배우 비슷하다는 느낌을 받았다.

먼저 Flutter Riverpod 패키지에서 제공하는 Provider에서 대해서 정리해보았다.


Provider

Provider는 다음 용도로 사용된다.

  • 계산된 값 캐싱한다.
  • 다른 providers(예: Repository/HttpClient)에 값을 노출한다.
  • 테스트 또는 위젯이 값을 재정의(override)하는 방법을 제공한다.
  • select를 사용하지 않고 providers/위젯의 리빌드를 최소화한다.

목록을 필터링하는 것은 비용이 들 수 있다. 그러므로 리렌더링할때마다 목록을 필터링하고 싶지 않는 상황에서 Provider를 사용할 수 있다.

다음 할 일 목록을 관리하는 Todo 코드를 통해 Provider 사용 방법을 알아본다.

class Todo {
  Todo(this.description, this.isCompleted);
  final bool isCompleted;
  final String description;
}

class TodosNotifier extends StateNotifier<List<Todo>> {
  TodosNotifier() : super([]);

  void addTodo(Todo todo) {
    state = [...state, todo];
  }
  // TODO: "removeTodo"와 같은 다른 메소드들을 추가하기
}

final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});


다음 코드에서 Provider를 사용하여 완료된 할 일만 필터링한다.

final completedTodosProvider = Provider<List<Todo>>((ref) {
  // todosProvider로부터 모든 todos을 가져온다.
  final todos = ref.watch(todosProvider);

  // 완료된 todos만 반환한다.
  return todos.where((todo) => todo.isCompleted).toList();
});

completedTodosProvidertodosProvider 의 변경이 없으면 다시 계산하지 않는다.

그 다음 completedTodosProvider를 통해 완료된 할 일 목록을 위젯에 표시한다.

Consumer(builder: (context, ref, child) {
  final completedTodos = ref.watch(completedTodosProvider);
  // TODO: a ListView/GridView/...등을 사용하여 todos를 표시하기
  // ...
});

여기서 중요한 점은 완료된 할 일 목록 데이터가 캐시된다는 것이다.
이제 완료된 할 일 목록을 여러번 읽어도 할 일 데이터가 변경되지 않는 이상 다시 계산되지 않는다.


Provider를 사용하여 위젯 리빌드 횟수 줄이기

다음 예제는 이전/다음 버튼을 활성화/비활성화하는 것이다.

stepper example

현재 페이지 인덱스가 0이면 이전 버튼을 비활성화 되어야 한다. 다음 코드를 살펴보자.

final pageIndexProvider = StateProvider<int>((ref) => 0);

class PreviousButton extends ConsumerWidget {
  const PreviousButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 만약 첫페이지가 아니라면 이전 버튼이 활성화 된다.
    final canGoToPreviousPage = ref.watch(pageIndexProvider) == 0;

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).update((state) => state - 1);
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? null : goToPreviousPage,
      child: const Text('previous'),
    );
  }
}

이 코드의 문제점은 현재 페이지를 변경할 때마다 "이전" 버튼이 재빌드 된다는 것이다.
활성화/비활성화 상태가 변경될 때만 버튼을 재빌드하도록 개선해 보자.

이 코드 문제의 원인은 PreviousButton 위젯 내부에서 이전 페이지로 이동할 수 있는지 여부를 계산하고 있다는 것이다.

해결 방법은 페이지 인덱스를 체크하는 로직을 위젯 외부 Provider로 제공하는 것이다.

final pageIndexProvider = StateProvider<int>((ref) => 0);

// 이전 페이지로 돌아갈 수 있는지 계산하기 위한 프로바이더
final canGoToPreviousPageProvider = Provider<bool>((ref) {
  return ref.watch(pageIndexProvider) == 0;
});

class PreviousButton extends ConsumerWidget {
  const PreviousButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 위젯 내부에서는 이전 페이지로 이동할 수 있는지 여부를 더 이상 계산하지 않는다.
    final canGoToPreviousPage = ref.watch(canGoToPreviousPageProvider);

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).update((state) => state - 1);
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? null : goToPreviousPage,
      child: const Text('previous'),
    );
  }
}

이렇게 리팩토링하면 페이지 인덱스가 변경될 때마다 PreviousButton 위젯은 더이상 재빌드 되지 않는다. canGoToPreviousPageProvider 값의 변경이 있을 때만 재빌드 될 것이다. PreviousButton 위젯 성능이 향상되었다.


StateNotifierProvider

StateNotifierProviderStateNotifier를 수신하고 expose하는데 사용되는 Provider이다.

다음 용도로 사용된다.

  • 커스텀 이벤트에 반응하여 시간이 지남에 따라 변경될 수 있는 불변 상태immutable state를 expose 한다.
  • 일부 상태state를 수정하기 위한 비즈니스 로직을 한 곳에서 중앙 집중 관리하여, 시간이 지남에 따라 유지 관리 가능성이 개선된다.

다음 예제와 같이 할 일 목록을 구현하는데 사용할 수 있다. 할 일을 추가하는 addTodo 메서드를 노출 할 수 있다.

// StateNotifier의 상태는 변경할 수 없다.
// 구현을 돕기 위해 Freezed와 같은 패키지를 사용할 수 있다.
@immutable
class Todo {
  const Todo({required this.id, required this.description, required this.completed});

  // 클래스의 모든 속성은 반드시 'final' 이어야 한다.
  final String id;
  final String description;
  final bool completed;

  // Todo는 변경할 수 없는 immutable 이므로
  // 아래와 같이 Todo를 복제할 수 있는 함수를 구현한다.
  Todo copyWith({String? id, String? description, bool? completed}) {
    return Todo(
      id: id ?? this.id,
      description: description ?? this.description,
      completed: completed ?? this.completed,
    );
  }
}

// StateNotifierProvider에 전달할 StateNotifier 클래스이다.
// 이 클래스는 "state" properties 외부에 상태를 노출해서는 안된다. 
// 즉, public getter/properties가 없다!
// 이 클래스의 public 메서드는 UI가 상태를 수정할 수 있도록 한다.
class TodosNotifier extends StateNotifier<List<Todo>> {
  // 할 일 목록을 빈 목록으로 초기화한다.
  TodosNotifier(): super([]);

  // UI가 할 일을 추가할 수 있도록 한다.
  void addTodo(Todo todo) {
    // 상태가 immutable 라서 `state.add(todo)`를 수행할 수 없다.
    // 대신 이전 항목과 새 항목이 포함된 할 일 목록을 새로 만들어야 한다.
    // 이때는 Dart의 스프레드 연산자가 도움이 된다!
    state = [...state, todo];
    // "notifyListeners" 또는 이와 유사한 것을 호출할 필요가 없다. 
    // "state ="를 호출하면 필요할 때 UI가 자동으로 리빌드된다.
    
  }

  // 할 일 삭제하기
  void removeTodo(String todoId) {
    // 다시 말하지만, 상태는 immutable이다. 
    // 그래서 기존 목록을 변경하는 대신 목록을 새로 만든다.
    state = [
      for (final todo in state)
        if (todo.id != todoId) todo,
    ];
  }

  // 할 일 완료 처리하기
  void toggle(String todoId) {
    state = [
      for (final todo in state)
        // 일치하는 할 일만 완료된 것으로 표시하고 있다.
        if (todo.id == todoId)
          // 다시 한 번, 상태는 변경할 수 없으므로 할 일을 복사해야 한다. 
          // 이전에 구현한 `copyWith` 메소드를 사용한다.
          todo.copyWith(completed: !todo.completed)
        else
          // 다른 todos는 수정하지 않는다.
          todo,
    ];
  }
}

// 마지막으로 UI가 TodosNotifier 클래스와 상호 작용할 수 있도록 StateNotifierProvider를 사용한다.
final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

이제 StateNotifierProvider를 정의했으므로, 이를 사용하여 UI의 할 일 목록과 상호 작용할 수 있다.

class TodoListView extends ConsumerWidget {
  const TodoListView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 할 일 목록이 변경되면 위젯이 다시 빌드된다.
    List<Todo> todos = ref.watch(todosProvider);

    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            // 할 일을 탭하면 상태를 완료로 변경한다.
            onChanged: (value) => ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

FutureProvider

FutureProviderProvider와 동일하지만 비동기로 동작한다.

FutureProvider 는 다음 용도로 사용된다.

  • 비동기 작업 수행 및 캐싱(예: 네트워크 요청)
  • 비동기 작업의 오류/로드 상태를 처리.
  • 여러 비동기 값을 다른 값들과 결합combining

FutureProvider를 사용하면 일부 변수가 변경될 때 데이터를 자동으로 다시 가져올 수 있다. 즉, 항상 최신값을 유지할 수 있다.

하지만 FutureProvider는 상호 작용 후 계산을 직접 수정하는 방법은 제공하지 않는다. 단순한 사용을 위해서만 설계되었다. 복잡한 로직을 구현하기 위해서는 StateNotifierProvider 사용을 고려해라.

FutureProvider 는 JSON 파일을 읽어 생성한 Configuration 객체를 노출하는 방법으로 사용될 수 있다.

configuration는 일반적으로 async/await 구문으로 수행되지만 Provider 내부에서 수행된다. Flutter asset 시스템에 사용 방법은 다음과 같다.

final configProvider = FutureProvider<Configuration>((ref) async {
  final content = json.decode(
    await rootBundle.loadString('assets/configurations.json'),
  ) as Map<String, Object?>;

  return Configuration.fromJson(content);
});

그런 다음 UI에서는 다음과 같이 사용할 수 있다.

Widget build(BuildContext context, WidgetRef ref) {
  AsyncValue<Configuration> config = ref.watch(configProvider);

  return config.when(
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('Error: $err'),
    data: (config) {
      return Text(config.host);
    },
  );
}

Future가 완료되면 UI가 재빌드된다. 동시에 여러 위젯 구성을 하는 경우에도 asset는 한번만 디코딩된다.
FutureProvider 를 위젯 내부에서 수신하면 에러/로딩 상태를 처리할 수 있는 AsyncValue가 리턴된다.


StreamProvider

StreamProviderFutureProvider와 유사하지만 Future 대신 Stream에 사용된다.

StreamProvider 는 다음 용도로 사용된다.

  • Firebase 또는 web-sockets 수신하기
  • 매 초 마다 다른 provider 재빌드하기

StreamBuilder보다 StreamProvider를 사용하면 다음과 같은 이점이 있다.

  • 다른 providers가 ref.watch를 사용하여 스트림을 수신할 수 있다.
  • AsyncValue 덕분에 로딩과 에러 케이스가 확실하게 처리된다.
  • 브로드캐스트 스트림과 일반 스트림을 구별할 필요가 없다.
  • 스트림에서 내보낸 최신 값을 캐시하여 이벤트가 발생한 후 리스너가 추가되는 경우, 리스너는 여전히 가장 최신 이벤트에 즉시 액세스 할 수 있다.
  • StreamProvider를 재정의overriding하여 테스트 중에 스트림을 쉽게 목킹mocking할 수 있다.

StateProvider

StateProvider 는 상태를 수정하는 방법을 제공하는 Provider이다. StateNotifier 클래스를 작성할 필요가 없어 간단하게 사용할 수 있다.

StateProvider 는 주로 사용자 인터페이스에 의해 단순히 변수를 수정하기 위해 존재한다.

StateProvider의 상태는 다음 용도로 사용된다.

  • 필터 타입과 같은 enum
  • String, 일반적으로 텍스트 필드의 raw 컨텐츠
  • 체크박스용 boolean
  • 페이징 또는 연령 폼 필드용 number

StateProvider 는 다음 경우에는 사용하지 마시오!

  • 상태 검증 로직이 필요한 경우
  • 상태가 복잡한 경우(예: 커스텀 클래스, 리스트/맵, ...)
  • 상태를 수정하는 로직이 단순한 count++보다 더 복잡한 경우.

복잡한 로직의 경우에는 StateNotifierProvider를 사용하고 StateNotifier 클래스를 만드는 것이 좋다.

초기 보일러플레이트는 조금 더 커지지만 커스텀 StateNotifier 클래스를 갖는 것은 프로젝트의 장기적인 유지 관리에 매우 중요하다. 이는 상태 비즈니스 로직을 한 곳에서 중앙 집중화하기 때문이다.

StateProvider 사용 사례: 드롭다운을 사용하여 필터 타입 변경하기

StateProvider는 드롭다운/텍스트 필드/체크박스와 같은 간단한 폼 컴포넌트의 상태 관리에 사용할 수 있다.

StateProvider를 사용하여 제품 목록이 정렬되는 방식을 변경할 수 있는 드롭다운을 구현하는 방법을 살펴보자.

살펴볼 예제 코드는 다음과 같습니다.

class Product {
  Product({required this.name, required this.price});

  final String name;
  final double price;
}

final _products = [
  Product(name: 'iPhone', price: 999),
  Product(name: 'cookie', price: 2),
  Product(name: 'ps5', price: 500),
];

final productsProvider = Provider<List<Product>>((ref) {
  return _products;
});

위 예제에서는 더미 데이터를 사용했지만, 실제 프로그램에서는 FutureProvider를 사용하여 네트워크 요청을 통해 목록을 가져올 수 있다.

그 다음 UI에서는 아래 로직을 수행하여 제품 목록을 표시할 수 있다.

Widget build(BuildContext context, WidgetRef ref) {
  final products = ref.watch(productsProvider);

  return Scaffold(
    body: ListView.builder(
      itemCount: products.length,
      itemBuilder: (context, index) {
        final product = products[index];
        return ListTile(
          title: Text(product.name),
          subtitle: Text('${product.price} \$'),
        );
      },
    ),
  );
}

이제 드롭다운을 추가하여 가격이나 이름별로 제품을 필터링할 수 있다. 이를 위해 DropDownButton 을 사용했다.

enum ProductSortType {
  name,
  price,
}

Widget build(BuildContext context, WidgetRef ref) {
  final products = ref.watch(productsProvider);

  return Scaffold(
    appBar: AppBar(
      title: const Text('Products'),
      actions: [
        DropdownButton<ProductSortType>(
          value: ProductSortType.price,
          onChanged: (value) {},
          items: const [
            DropdownMenuItem(
              value: ProductSortType.name,
              child: Icon(Icons.sort_by_alpha),
            ),
            DropdownMenuItem(
              value: ProductSortType.price,
              child: Icon(Icons.sort),
            ),
          ],
        ),
      ],
    ),
    body: ListView.builder(
      // ... 
    ),
  );
}

이제 StateProvider를 사용하여 productSortTypeProvider를 생성하고, 드롭다운 필터링 상태를 반환하자.

final productSortTypeProvider = StateProvider<ProductSortType>(
  // 기본 sort type인 name을 반환합니다.
  (ref) => ProductSortType.name,
);

다음 로직을 수행하여 provider를 드롭다운 위젯과 연결한다.

DropdownButton<ProductSortType>(
  // sort type이 변경되면 dropdown이 재빌드되어 표시된 아이콘이 업데이트된다.
  value: ref.watch(productSortTypeProvider),
  // 사용자가 dropdown과 상호 작용할 때 provider state를 업데이트한다.
  onChanged: (value) {
      ref.read(productSortTypeProvider.notifier).state = value!;
  },
  items: [
    // ...
  ],
),

이제 sort type을 변경할 수 있다. 아직 제품 목록에는 영향을 미치지는 않는다!
이제 마지막 부분인 제품 목록을 정렬하기 위해 productsProvider를 업데이트해보자.

이를 구현하는 핵심은 ref.watch를 사용하여 productsProvider가 sort type을 가져오고 sort type이 변경될 때마다 Product 목록을 다시 계산하도록 하는 것이다.

구현된 코드는 다음과 같다.

final productsProvider = Provider<List<Product>>((ref) {
  final sortType = ref.watch(productSortTypeProvider);

  switch (sortType) {
    case ProductSortType.name:
      return _products.sorted((a, b) => a.name.compareTo(b.name));
    case ProductSortType.price:
      return _products.sorted((a, b) => a.price.compareTo(b.price));
  }
});

이제 사용자가 정렬 타입을 변경할 때마다 제품 목록은 자동으로 다시 렌더링될 것 입니다.

다음은 전체 코드입니다.

// This code is distributed under the MIT License.
// Copyright (c) 2022 Remi Rousselet.
// You can find the original at https://github.com/rrousselGit/river_pod.

import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: MyHomePage(),
    );
  }
}

class Product {
  Product({required this.name, required this.price});

  final String name;
  final double price;
}

final _products = [
  Product(name: 'iPhone', price: 999),
  Product(name: 'cookie', price: 2),
  Product(name: 'ps5', price: 500),
];

enum ProductSortType {
  name,
  price,
}

final productSortTypeProvider = StateProvider<ProductSortType>(
  // We return the default sort type, here name.
  (ref) => ProductSortType.name,
);

final productsProvider = Provider<List<Product>>((ref) {
  final sortType = ref.watch(productSortTypeProvider);
  switch (sortType) {
    case ProductSortType.name:
      return _products.sorted((a, b) => a.name.compareTo(b.name));
    case ProductSortType.price:
      return _products.sorted((a, b) => a.price.compareTo(b.price));
  }
});

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final products = ref.watch(productsProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Products'),
        actions: [
          DropdownButton<ProductSortType>(
            // When the sort type changes, this will rebuild the dropdown
            // to update the icon shown.
            value: ref.watch(productSortTypeProvider),
            // When the user interacts with the dropdown, we update the provider state.
            onChanged: (value) =>
                ref.read(productSortTypeProvider.notifier).state = value!,
            items: const [
              DropdownMenuItem(
                value: ProductSortType.name,
                child: Icon(Icons.sort_by_alpha),
              ),
              DropdownMenuItem(
                value: ProductSortType.price,
                child: Icon(Icons.sort),
              ),
            ],
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          final product = products[index];
          return ListTile(
            title: Text(product.name),
            subtitle: Text('${product.price} \$'),
          );
        },
      ),
    );
  }
}


Provider를 두 번 읽지 않고 이전 값을 기반으로 상태를 업데이트하는 방법

이전 값을 기반으로 StateProvider의 상태를 업데이트하고 싶을 때가 있다. 다음과 같이 작성할 수 있습니다.

final counterProvider = StateProvider<int>((ref) => 0);

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 이전 값에서 상태를 업데이트하고, counterProvider를 두 번 읽었다.
          ref.read(counterProvider.notifier).state = ref.read(counterProvider.notifier).state + 1;
        },
      ),
    );
  }
}

위 코드를 개선하기 업데이트 기능을 사용할 수 있다. 이 함수는 현재 상태를 수신하고 새 상태를 반환할 것으로 예상하는 콜백을 받는다.

이를 사용하여 이전 코드를 다음과 같이 리팩토링할 수 있다.

final counterProvider = StateProvider<int>((ref) => 0);

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(counterProvider.notifier).update((state) => state + 1);
        },
      ),
    );
  }
}



END.

Sort:  

[광고] STEEM 개발자 커뮤니티에 참여 하시면, 다양한 혜택을 받을 수 있습니다.

[by @anpigon] Flutter Riverpod Provider 알아보기
https://www.steemit.com/@kr-dev.cu1/flutter-riverpod-provider

@kr-dev.cu1님이 당신을 멘션하였습니다.
멘션을 받고 싶거나 받지 않으시려면 댓글을 남겨주세요. 빠른 시일내에 반영하도록 하겠습니다.

@anpigon transfered 1.0 KRWP to @krwp.burn. voting percent : 1.25%, voting power : 20.70%, steem power : 1948353.21, STU KRW : 1200.
@anpigon staking status : 68.5 KRWP
@anpigon limit for KRWP voting service : 0.068 KRWP (rate : 0.001)
What you sent : 1.0 KRWP
Refund balance : 0.931 KRWP [62976161 - 86a808b394551c99ff4592489e59ed271431470e]

Coin Marketplace

STEEM 0.35
TRX 0.12
JST 0.040
BTC 70733.96
ETH 3563.16
USDT 1.00
SBD 4.76