(Flutter) Riverpod의 Provider 종류

반응형

Provider

Provider는 프로바이더 중에서 가장 기본적인 프로바이더이며, 값을 동기적으로 생성해 줍니다.

Provider는 일반적으로 다음과 같은 용도로 사용됩니다.

  • 계산 결과를 캐싱
  • 다른 공급자(Repository또는 HttpClient)에 값 노출
  • 테스트 또는 위젯이 값을 재정의할 수 있는 방법을 제공
  • select를 사용하지 않고도 프로바이더 및 위젯 리빌드를 줄 일 수 있습니다.

Provider를 사용하여 계산 결과를 캐시하기

Provider는 ref.watch와 함께 사용하면 동기식 작업을 캐싱할 수 있습니다.

예를 들면 Todo 목록에 필터를 적용하는 경우입니다. 목록을 필터링하는 데에는 약간의 비용이 발생 할 수 있습니다. 그렇기 때문에 화면을 다시 렌더링될 할마다 Todo 목록은 다시 필터링하지 않는 것이 이상적입니다. 이 상황에서 Provider를 사용하여 필터링 결과값을 캐싱할 수 있습니다.

이를 위해 Todo 목록을 관리하는 다음과 같은  NotifierProvider가 있다고 가정합니다.

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

class TodosNotifier extends Notifier<List<Todo>> {
  @override
  List<Todo> build() {
    return [];
  }

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

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

여기서 Provider를 사용하여 Todo 목록을 필터링하고 완료된 할일만 표시할 수 있습니다.

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

  // 완료된 할일 목록만 반환합니다.
  return todos.where((todo) => todo.isCompleted).toList();
});

이 코드를 사용하면 이제 completedTodosProvider를 수신하여 완료된 할일 목록만 UI에 표시할 수 있습니다:

Consumer(builder: (context, ref, child) {
  final completedTodos = ref.watch(completedTodosProvider);
  // TODO show the todos using a ListView/GridView/...
  // TODO: ListView/GridView/...를 사용하여 할일 목록을 표시합니다.
});

여기서 흥미로운 부분은 할일 목록 필터링이 캐시된다는 것입니다.
즉, 완료된 할일 목록을 여러 번 읽더라도 할 일이 추가/제거/업데이트될 때까지는 완료된 할일 목록이 다시 계산되지 않습니다.
할 일 목록이 변경될 때 캐시를 수동으로 무효화할 필요가 없다는 점에 유의하십시오. Providerref.watch 덕분에 결과를 다시 계산해야 하는 시기를 자동으로 알 수 있습니다 .

Provider를 사용하여 프로바이더/위젯 리빌드 줄이기

Provider가 다른 프로바이더와 다른 점은 프로바이더가 다시 계산되더라도(일반적으로 ref.watch를 사용할때) 값이 변경되지 않는 경우, 이 프로바이더의 값을 감시하는 위젯/프로바이더는 업데이트하지 않는 점입니다.
이 특성을 활용할 수 있는 예시는, 페이지네이션의 이전/다음 버튼을 활성화/비활성화하는 경우를 들 수 있습니다.

여기서는 “Previous” 버튼에 집중하겠습니다.
이러한 버튼을 순수하게 구현하면 현재 페이지 인덱스를 가져오는 위젯이 될 것이고, 해당 인덱스가 0이면 버튼을 비활성화할 것입니다.

이 코드는 다음과 같습니다.

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

class PreviousButton extends ConsumerWidget {
  const PreviousButton({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 현재 페이지가 첫 페이지(0)가 아닌 경우, previous 버튼 활성화
    final canGoToPreviousPage = ref.watch(pageIndexProvider) != 0;

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

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

이 코드의 문제점은 현재 페이지를 변경할 때마다 “Previous” 버튼이 다시 빌드된다는 것입니다.
활성화와 비활성화 사이를 전환할 때만 버튼이 다시 빌드되는 것이 가장 이상적입니다.

이 문제의 원인은 이전 페이지로 이동할 수 있는지 여부를 ‘Previous’ 버튼 내부에서 계산하고 있기 때문입니다.

따라서 이 문제를 해결하는 방법은 이 로직을 위젯 외부로 추철하여 Provider에 맡기는 것입니다.

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

// 사용자가 이전 페이지로 이동할 수 있는지 여부를 계산하는 Provider
final canGoToPreviousPageProvider = Provider<bool>((ref) {
  return ref.watch(pageIndexProvider) != 0;
});

class PreviousButton extends ConsumerWidget {
  const PreviousButton({super.key});

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

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

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

이렇게 하면 페이지 인덱스가 변경될 때마다  PreviousButton 위젯은 더 이상 리빌드되지 않습니다 .

지금부터 페이지 인덱스가 변경되면 canGoToPreviousPageProvider는 다시 계산됩니다. 그러나 canGoToPreviousPageProvider가 노출한 값이 변경되지 않는다면 PreviousButton 위젯은 다시 빌드되지 않습니다.

이 작은 리팩토링 덕분에 버튼의 성능을 개선하고 위젯과 로직을 분리하여 관리할 수 있습니다.

(Async)NotifierProvider

NotifierProvider는  Notifier를 수신하고 노출하는 데 사용되는 프로바이더입니다 .
AsyncNotifierProvider는 AsyncNotifier를 수신하고 노출하는 데 사용되는 프로바이더입니다 . AsyncNotifier는 비동기적으로 초기화할 수 있는 Notifier 입니다 .
(Async)NotifierProvider(Async)Notifier와 함께 사용자 상호 작용에 반응하여 변경될 수 있는 상태를 관리하기 위한 Riverpod의 권장 솔루션입니다.

일반적으로 다음 용도로 사용됩니다.

  • 사용자 정의 이벤트에 반응한 후 시간이 지남에 따라 변경될 수 있는 상태를 노출합니다.
  • 일부 상태를 수정하기 위한 로직(“비즈니스 로직”)을 한 곳에서 중앙 집중화하여 시간이 지남에 따라 유지 관리성을 향상시킵니다.

사용 예로, NotifierProvider를 사용하여 할 일 목록을 구현할 수 있습니다. 이렇게 하면 UI가 사용자 상호 작용에서 할 일 목록을 수정하도록 하는 addTodo것과 같은 메서드를 노출할 수 있습니다 .

// 불변 상태를 선언합니다.
// 구현을 돕기 위해 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는 불변 객체라서 변경할 수 없으므로 
  // 약간 다른 내용으로 할일을 복제할 수 있는 메서드를 구현했습니다.
  Todo copyWith({String? id, String? description, bool? completed}) {
    return Todo(
      id: id ?? this.id,
      description: description ?? this.description,
      completed: completed ?? this.completed,
    );
  }
}

// NotifierProvider에 전달할 Notifier 클래스입니다.
// 이 클래스는 "state" 프로퍼티 외부에 상태를 노출해서는 안되며, 이는 퍼블릭 게터/프로퍼티가 없음을 의미합니다! 
// 이 클래스의 퍼블릭 메서드는 UI가 상태를 수정할 수 있게 해줍니다.
class TodosNotifier extends Notifier<List<Todo>> {
  // We initialize the list of todos to an empty list
  @override
  List<Todo> build() {
    return [];
  }

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

  // todo를 제거할 수 있습니다.
  void removeTodo(String todoId) {
    // 다시 말하지만, 현재 프로바이더의 상태는 불변 객체입니다. 
    // 따라서 기존 목록을 변경하는 대신 새로운 목록을 만들어야 합니다.
    state = [
      for (final todo in state)
        if (todo.id != todoId) todo,
    ];
  }

  // todo를 완료로 표시할 수 있습니다.
  void toggle(String todoId) {
    state = [
      for (final todo in state)
        // 일치하는 todo만 완료로 표시합니다.
        if (todo.id == todoId)
          // 다시 한 번 말하지만, 
          // 상태는 불변 객체이므로 복사본을 만들어야 합니다.
          todo.copyWith(completed: !todo.completed)
        else
          // 일치하지 않는 다른 todo는 수정하지 않습니다.
          todo,
    ];
  }
}

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

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

class TodoListView extends ConsumerWidget {
  const TodoListView({super.key});

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

    // todo을 스크롤 가능한 ListView로 렌더링합니다.
    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            // todo를 탭하면 완료 상태를 변경합니다.
            onChanged: (value) =>
                ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

사용 예로 AsyncNotifierProvider를 사용하여 원격 할 일 목록을 구현할 수 있습니다. 이렇게 하면 addTodo와 같은 메서드를 노출하여 UI가 사용자 상호작용에 따라 할 일 목록을 수정할 수 있습니다.

@immutable
class Todo {
  const Todo({
    required this.id,
    required this.description,
    required this.completed,
  });

  factory Todo.fromJson(Map<String, dynamic> map) {
    return Todo(
      id: map['id'] as String,
      description: map['description'] as String,
      completed: map['completed'] as bool,
    );
  }

  final String id;
  final String description;
  final bool completed;

  Map<String, dynamic> toJson() => <String, dynamic>{
        'id': id,
        'description': description,
        'completed': completed,
      };
}

// AsyncNotifierProvider에 전달할 AsyncNotifier 클래스입니다.
class AsyncTodosNotifier extends AsyncNotifier<List<Todo>> {
  Future<List<Todo>> _fetchTodo() async {
    final json = await http.get('api/todos');
    final todos = jsonDecode(json) as List<Map<String, dynamic>>;
    return todos.map(Todo.fromJson).toList();
  }

  @override
  Future<List<Todo>> build() async {
    // 원격 리포지토리에서 초기 todo 목록 불러오기
    return _fetchTodo();
  }

  Future<void> addTodo(Todo todo) async {
    // 상태를 로딩 중으로 설정
    state = const AsyncValue.loading();
    // 새로운 todo를 추가하고 원격 저장소에서 todo 목록을 다시 로드합니다.
    state = await AsyncValue.guard(() async {
      await http.post('api/todos', todo.toJson());
      return _fetchTodo();
    });
  }

  Future<void> removeTodo(String todoId) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      await http.delete('api/todos/$todoId');
      return _fetchTodo();
    });
  }

  Future<void> toggle(String todoId) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      await http.patch(
        'api/todos/$todoId',
        <String, dynamic>{'completed': true},
      );
      return _fetchTodo();
    });
  }
}

// 마지막으로 AsyncNotifierProvider를 사용하여 UI가 AsyncTodosNotifier 클래스와 상호 작용할 수 있도록 합니다.
final asyncTodosProvider =
    AsyncNotifierProvider<AsyncTodosNotifier, List<Todo>>(() {
  return AsyncTodosNotifier();
});

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

class TodoListView extends ConsumerWidget {
  const TodoListView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // todo 목록이 변경되면 위젯을 다시 빌드합니다.
    final asyncTodos = ref.watch(asyncTodosProvider);

    return asyncTodos.when(
      data: (todos) => ListView(
        children: [
          for (final todo in todos)
            CheckboxListTile(
              value: todo.completed,
              onChanged: (value) => ref.read(asyncTodosProvider.notifier).toggle(todo.id),
              title: Text(todo.description),
            ),
        ],
      ),
      loading: () => const Center(
        child: CircularProgressIndicator(),
      ),
      error: (err, stack) => Text('Error: $err'),
    );
  }
}

StateNotifierProvider

StateNotifierProvider는 StateNotifier를 수신하고 노출하는 데 사용되는 프로바이더입니다 .

일반적으로 다음 용도로 사용됩니다.

  • 사용자 정의 이벤트에 반응한 후 시간이 지남에 따라 변경될 수 있는 불변 상태를 노출합니다 .
  • 일부 상태를 수정하기 위한 로직( “비즈니스 로직”)을 한 곳에서 중앙 집중화하여 시간이 지남에 따라 유지 관리성을 향상시킵니다.

[!info] 정보
StateNotifierProvider 대신 NotifierProvider를 사용하는 것이 좋습니다 .

사용 예로 StateNotifierProvider는 todo-list를 구현하는 데 사용할 수 있습니다.

@immutable
class Todo {
  const Todo({required this.id, required this.description, required this.completed});

  final String id;
  final String description;
  final bool completed;

  Todo copyWith({String? id, String? description, bool? completed}) {
    return Todo(
      id: id ?? this.id,
      description: description ?? this.description,
      completed: completed ?? this.completed,
    );
  }
}

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

  void addTodo(Todo todo) {
    state = [...state, todo];
  }

  void removeTodo(String todoId) {
    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)
          todo.copyWith(completed: !todo.completed)
        else
          todo,
    ];
  }
}

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

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

class TodoListView extends ConsumerWidget {
  const TodoListView({super.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

FutureProvider는 비동기 Provider라고 말할 수 있습니다.

FutureProvider는일반적으로 다음 용도로 사용됩니다.

  • 비동기 작업(예: 네트워크 요청) 수행 및 캐싱
  • 비동기 작업의 오류/로드 상태 처리
  • 여러 비동기 값을 하나의 값을 조합하기

FutureProviderref.watch와 결합하여 그 효과를 발휘합니다. 예를 들어, 일부 값이 변경될 때 일부 데이터를 자동으로 다시 가져올 수 있으므로 프로바이더는 항상 최신 값을 유지할 수 있습니다.

[!info] 정보
FutureProvider는 사용자 상호 작용 후 계산을 직접 변경할 수 있는 방법이 없습니다. FutureProvider는 간단한 사용 사례를 해결하기 위해 설계되었습니다.
보다 고급 시나리오의 경우에는 AsyncNotifierProvider 사용하는 것이 좋습니다.

사용 예: 구성 파일 읽기

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

config 생성은 async/await 구문을 사용하여 프로바이더 내부에서 수행하면 됩니다. 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에서는 다음과 같이 configurations을 감시(watch)합니다.

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가 자동으로 리빌드됩니다 . 게다가 캐시 기능이 동작하기 때문에 여러 위젯이 동시에 config를 원하는 경우에도 에셋은 한 번만 로드됩니다.

보시다시피 위젯 내부의 FutureProvider를 감시하면 오류/로딩 상태를 처리할 수 있는 AsyncValue 가 반환됩니다.  AsyncValue 덕분에 error / loading과 같은 상태를 적절하게 위젯으로 변환 할 수 있습니다.

StreamProvider

StreamProvider는 FutureProvider 의 Stream 버전입니다.

일반적으로는 다음과 같은 용도로 사용됩니다.

  • Firebase 및 WebSocket을 모니터링.
  • 일정 시간마다 다른 프로바이더를 업데이트.

Stream 자체가 값의 갱신을 감시하는 기능을 가지고 있므로, StreamProvider사용하는 것이 별로라고 생각할 수도 있습니다. 특히 스트림을 수신하는데 Flutter의 StreamBuilder 로 충분하다고 생각할 수 있지만, 이는 잘못된 생각입니다.

그러나 StreamBuilder 대신 StreamProvider를사용하면 다음과 같은 많은 이점이 있습니다.

  • 다른 프로바이더가 ref.watch 를 사용하여 스트림을 감시할 수 있습니다.
  • AsyncValue를 사용하면 loading/error 상태를 적절하게 처리 할 수 ​​있습니다.
  • 일반 스트림과 브로드캐스트(broadcast) 스트림을 구별할 필요가 없습니다.
  • 스트림으로부터 출력된 최신 값을 캐시합니다.(도중에 감시를 개시해도 최신의 값을 얻을 수 있습니다).
  • StreamProvider을 오버라이드(override)하여 테스트에서 스트림을 간단하게 모킹할 수 있습니다.

사용 예: 소켓을 사용한 실시간 채팅

StreamProvider는 비디오 스트리밍, 일기 예보 API 또는 라이브 채팅과 같은 비동기 데이터 스트림을 처리할 때 사용됩니다.

final chatProvider = StreamProvider<List<String>>((ref) async* {
  // 소켓을 사용하여 API에 연결하고 출력을 디코딩합니다.
  final socket = await Socket.connect('my-api', 4242);
  ref.onDispose(socket.close);
  
  var allMessages = const <String>[];
  await for (final message in socket.map(utf8.decode)) {
    // 새로운 메시지가 수신되면, 전체 메시지 목록에 추가합니다.
    allMessages = [...allMessages, message];
    yield allMessages;
  }
});

그러면 UI에서 다음과 같이 라이브 스트리밍 채팅을 모니터링 할 수 있습니다.

Widget build(BuildContext context, WidgetRef ref) {
  final liveChats = ref.watch(chatProvider);
  // FutureProvider와 마찬가지로, loading/error 상태를 처리하기 위해 AsyncValue.when을 사용할 수 있습니다.
  return liveChats.when(
    loading: () => const CircularProgressIndicator(),
    error: (error, stackTrace) => Text(error.toString()),
    data: (messages) {
      return ListView.builder(
        reverse: true,
        itemCount: messages.length,
        itemBuilder: (context, index) {
          final message = messages[index];
          return Text(message);
        },
      );
    },
  );
}

StateProvider

StateProvider는 외부에서 변경이 가능한 상태를 노출하는 프로바이더입니다.  매우 간단한 시나리오에서 StateNotifier 클래스를 작성할 필요가 없도록 설계된 StateNotifierProvider의 단순화 버전입니다.

따라서 StateProvider는 UI에서 사용되는 간단한 상태를 관리하는 데 적합합니다. 간단한 상태는 다음과 같은 유형의 상태를 의미합니다.

  • enum, 예를 들면 필터 타입
  • String, 예를 들면 텍스트 필드의 입력 내용 등
  • boolean, 예를 들면 체크 박스의 값 등
  • number, 예를 들면 페이지 네이션의 페이지 번호 또는 입력폼 필드

반대로, 다음과 같은 경우에는 StateProvider를사용해서는 안됩니다.

  • 상태에 유효성 검증 로직 필요한 경우
  • 상태 자체가 복잡한 객체인 경우(사용자 정의 클래스 및 ListMap 등)
  • 상태를 변경하기 위한 로직이 단순하지 않은 경우

좀 더 고급 상태 관리가 필요한 경우에는 StateNotifier 클래스를 작성한 다음 StateNotifierProvider를 사용하는 것이 좋습니다. 이 경우 약간의 보일러 플레이트 코드 설정이 필요합니다. 하지만 비즈니스 로직을 한 곳에서 집중 관리 할 수 ​​있기 때문에 프로젝트의 장기적인 유지 보수 관리에 최선이라고 할 수 있습니다.

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

StateProvider의 대표적인 사용 사례는 드롭다운 메뉴, 텍스트 필드, 체크 박스 등 입력폼에 사용되는 컴퍼넌트의 상태를 관리하는 것입니다. 
StateProvider를 사용하여 product 목록의 정렬 방식을 변경할 수 있는 드롭다운 메뉴를 구현하는 방법을 살펴보겠습니다.

Product의 정의와 product 목록의 내용은 다음과 같습니다.

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} \$'),
        );
      },
    ),
  );
}

이제 가격이나 이름으로 제품을 필터링할 수 있는 드롭다운 메뉴를 추가합니다.
이를 위해 Flutter의 DropDownButton을 사용합니다.

// 필터의 종류를 나타내는 enum
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 을 생성하고 프로바이더와 드롭다운 메뉴의 상태를 동기화합니다.

먼저 StateProvider를 만들어 봅시다.

final productSortTypeProvider = StateProvider<ProductSortType>(
  // 여기서는 기본 정렬 타입인 name을 반환합니다.
  (ref) => ProductSortType.name,
);

그런 다음 다음과 같이 공급자와 드롭다운 메뉴를 연결합니다.

DropdownButton<ProductSortType>(
  // 정렬 타입이 바뀌면 드롭다운 메뉴가 리빌드되어 아이콘이 업데이트됩니다.
  value: ref.watch(productSortTypeProvider),
  // 사용자가 드롭다운 메뉴를 조작하면 프로바이더 상태가 업데이트됩니다.
  onChanged: (value) =>
      ref.read(productSortTypeProvider.notifier).state = value!,
  items: [
    // ...
  ],
),

이렇게하면 드롭다운 메뉴에서 정렬 타입을 변경할 수 있습니다. 다만, 이것만으로는 상품 목록은 정렬되지 않습니다! 이제 마지막 단계입니다. 상품 목록을 정렬하도록 productsProvider를 업데이트 합니다.

이 기능을 구현하는 핵심은 ref.watch입니다. ref.watch를 사용하여 productsProvider가 현재 정렬 타입을 모니터링하고, 정렬 타입이 변경 될 때마다 상품 목록을 다시 계산하도록 하는 것입니다.

구체적인 구현은 다음과 같습니다.

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));
  }
});

이것뿐입니다! 이렇게 하면 UI는 정렬 타입이 변경되었음을 감지하고 제품 목록을 자동으로 업데이트합니다.

다음은 다트패드에서 전체 예제입니다.

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

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

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

class HomeView extends ConsumerWidget {
  const HomeView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 최근 상태에서 새로운 상태를 계산하려고 하면, 이렇게 공급자를 두 번 이용하게 되는 경우가 많다.
          ref.read(counterProvider.notifier).state = ref.read(counterProvider.notifier).state + 1;
        },
      ),
    );
  }
}

이 코드에 특별히 잘못된 것은 없지만 구문이 약간 불편합니다.

코드를 조금 더 개선하기 위해 update 함수를 사용할 수 있습니다. 이 함수는 현재 상태를 수신하고 새로운 상태를 반환하는 콜백 함수를 건내줍니다.  이 콜백 함수는 최근 상태의 값을 파라미터로 사용할 수 있습니다.
update 메소드를 사용하여 코드를 다음과 같이 리팩토링할 수 있습니다.

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

class HomeView extends ConsumerWidget {
  const HomeView({super.key});

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

이 변경은 동일한 효과를 달성하는 동시에 간결하고 보기 쉬운 코드로 작성 할 수 있었습니다.

ChangeNotifierProvider

ChangeNotifierProvider는 ChangeNotifier를 Flutter에서 사용하기 위한 프로바이더입니다.

Riverpod에서는 ChangeNotifierProvider 사용을 권장하지 않으며, 주로 다음과 같은 용도로 존재합니다.

  • package:provider에서 ChangeNotifierProvider사용했을 때의 마이그레이션 작업을 용이하게하기 할 수 있습니다.
  • 불변(immutable) 상태가 선호되지만 변경 가능한(mutable) 상태를 지원합니다.

[!info] 정보
ChangeNotifierProvider 대신 StateNotifierProvider를 사용하는 것이 좋습니다. 변경 가능한(mutable) 상태를 원하는 것이 확실할 경우에만 ChangeNotifierProvider 사용하는 것이 좋습니다.

때로는 불변 상태 대신 변경 가능한 상태를 사용하는 것이 더 효율적일 수 있습니다. 그러나 단점은 유지 관리가 어렵고 다양한 기능이 예상외의 동작을 할 우려가 있다는 것입니다.
예를 들어, 상태가 변경 가능한 상태인 경우 select가 값이 변경되지 않는 것으로 간주하므로 provider.select를 사용하여 위젯의 리빌드를 최적화하는 기능이 작동하지 않을 수 있습니다.
결과적으로 변경 불가능한 데이터 구조를 사용하는 것이 확실하고 효율이 더 좋습니다. 따라서 ChangeNotifierProvider 도입을 검토할 때에는, 사용 사례에 맞는 벤치마크를 실시해 실제로 전체적인 퍼포먼스에 어떻게 영향을 주는지를 신중하게 파악하는 것이 중요합니다.

다음 코드는 ChangeNotifierProvider를 Todo 목록에서 사용하는 예시입니다. 먼저 Todo의 모델과 ChangeNotifier를 정의하고 ChangeNotifierProvider생성합니다.

class Todo {
  Todo({
    required this.id,
    required this.description,
    required this.completed,
  });

  String id;
  String description;
  bool completed;
}

class TodosNotifier extends ChangeNotifier {
  final todos = <Todo>[];

  // Let's allow the UI to add todos.
  void addTodo(Todo todo) {
    todos.add(todo);
    notifyListeners();
  }

  // Let's allow removing todos
  void removeTodo(String todoId) {
    todos.remove(todos.firstWhere((element) => element.id == todoId));
    notifyListeners();
  }

  // Let's mark a todo as completed
  void toggle(String todoId) {
    for (final todo in todos) {
      if (todo.id == todoId) {
        todo.completed = !todo.completed;
        notifyListeners();
      }
    }
  }
}

// Finally, we are using ChangeNotifierProvider to allow the UI to interact with
// our TodosNotifier class.
final todosProvider = ChangeNotifierProvider<TodosNotifier>((ref) {
  return TodosNotifier();
});

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

class TodoListView extends ConsumerWidget {
  const TodoListView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // rebuild the widget when the todo list changes
    List<Todo> todos = ref.watch(todosProvider).todos;

    // Let's render the todos in a scrollable list view
    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            // When tapping on the todo, change its completed status
            onChanged: (value) =>
                ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

or

[카카오페이로 후원하기] [토스페이로 후원하기]

반응형