(Flutter – 상태 관리) Riverpod 을 이용한 상태관리 – 2 (사용 방법)

앞선 포스팅 (Flutter – 상태 관리) Riverpod 을 이용한 상태관리 – 1 (상태관리 및 Riverpod 소개) 에서 Riverpod과 상태관리에 대해 소개한 것과 같이 Riverpod는 Flutter 개발자들 사이에서 인기 있는 상태 관리 라이브러리입니다.

이번 포스팅에서는 Riverpod을 어떻게 사용하는지에 대한 설명을 진행하려고 합니다.

Riverpod 공식 문서를 참조하여 작성하였으며, Code Generation 을 사용하여 진행하였습니다.

Riverpod 사전 준비

패키지 설치

cmd에서 다음 명령어를 통해 code_generation과 riverpod 사용에 필요한 패키지를 설치합니다.

$ flutter pub add flutter_riverpod riverpod_annotation dev:riverpod_generator dev:build_runner dev:custom_lint dev:riverpod_lint

모델을 정의할때 code generation을 사용하여 decoding에 용이하도록 Freezedjson_serializable 을 사용하여 모델을 정의하는 것이 추천되므로 두 패키지를 설치해줍니다.

$ dart pub add freezed freezed_annotation dev:json_serializable

lint 활성화

Riverpod은 선택적으로 riverpod_lint 패키지를 설치하여 사용할 수 있습니다.

이는 코드 품질을 향상시키고 Refactoring을 용이하게 하는 lint 규칙을 제공합니다.

활성화를 위해 analysis_options.yamlpubspec.yaml 옆에 위치시키고 아래 내용을 포함합니다.

analyzer:
  plugins:
    - custom_lint

Import

다음 패키지를 riverpod을 사용하고자 하는 코드에 import 합니다.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

Code generation


코드 생성(Code generation)은 개발자가 반복적으로 작성해야 하는 코드를 자동으로 생성해주는 프로세스입니다.

이는 특정 패턴이나 구조를 가진 코드를 빠르게 생성하고, 수작업으로 인한 오류를 줄이며, 개발 효율성을 향상시키는 데 도움을 줍니다.

Flutter에서는 주로 모델, 설정 파일, 또는 상태 관리 코드 등을 자동 생성하는 데 사용됩니다.

위에서 code generation과 관련된 패키지는 다음과 같습니다.

  • riverpod_generator:
    • Riverpod 상태 관리 코드에 대한 코드 생성을 담당합니다. @riverpod 애노테이션을 사용하여 정의된 상태 관리 로직에 대한 코드를 자동으로 생성해줍니다.
  • build_runner:
    • Dart 코드 생성을 위한 툴입니다. riverpod_generator와 같은 코드 생성 패키지와 함께 사용되어, 개발자가 정의한 애노테이션에 기반한 코드를 실제로 생성하는 작업을 실행합니다.

추가로 visual studio에 다음 extension을 설치하여 사용하면 Watch를 통해 변경점을 감시하여 자동으로 Code generation을 수행해 줍니다.

Build Runner

이를 통해 Visual studio code 하단에 Watch를 활성화 시키거나 Ctrl + Shift + B를 통해

정석적인 방법은 다음 명령어를 cmd를 통해 입력하여 빌드를 수행합니다.

Builde Runner를 사용하여 Build 하여 코드(다음 예제의 경우 main.g.dart)가 생성됩니다.

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

part 'main.g.dart';

// We create a "provider", which will store a value (here "Hello world").
// By using a provider, this allows us to mock/override the value exposed.
@riverpod
String helloWorld(HelloWorldRef ref) {
  return 'Hello worldd';
}

Riverpod 개념 잡고가기

Riverpod은 Provider 패키지를 기반으로 향상된 기능과 안정성을 제공하는 상태관리 패키지 인데, 본 패키지를 사용하기에 앞서 몇가지 개념적인 부분의 이해가 필요합니다.

ref

Riverpod에서 ref는 Provider와 상호 작용할 때 사용되는 중요한 객체입니다.

ref는 Provider 내에서 다른 Provider에 접근(읽기, 상태 관리하기 등)하거나, 리소스를 관리하고, 생명주기를 제어하는 데 필요한 다양한 기능을 제공합니다.

  • ref.watch(provider):
    • 지정된 Provider를 “구독”합니다.
    • 이 Provider의 값이 변경될 때마다, ref.watch를 호출한 Provider나 위젯이 재빌드됩니다.
  • ref.read(provider):
    • 지정된 Provider의 현재 값을 읽습니다.
    • 하지만 구독하지는 않으므로, 값이 변경되어도 재빌드되지 않습니다.

Provider

Provider는 어플리케이션 상태의 일부를 나타내는 객체입니다.

이를 통해 데이터나 객체를 효율적으로 읽거나 감시할 수 있습니다.

Provider는 상태를 생성하고, 상태에 접근하며, 상태를 변경할 수 있는 로직을 포함합니다.

Riverpod에서는 다양한 종류의 Provider가 있으며, 각각의 용도에 맞게 사용됩니다.

예를 들어, StateProvider는 간단한 상태(Primitive 타입의 상태) 관리에, ChangeNotifierProvider는 복잡한 상태(사용자가 생성한 객체 형태의 상태) 관리에 사용됩니다.

Consumer

Consumer 위젯은 Provider를 통해 제공되는 상태를 읽어오기 위해 사용됩니다.

Consumer를 사용하면, Provider에서 제공하는 상태가 변경될 때마다 Consumer가 자동으로 재빌드되어, 최신 상태를 반영할 수 있습니다.

이는 성능 최적화에도 도움이 되며, 상태 관리를 더욱 쉽게 해줍니다.

Notifier

Notifier는 상태 변경 로직을 포함하는 클래스입니다.

예를 들어, ChangeNotifier를 상속받는 클래스에서는 상태 변경을 위한 메소드를 정의할 수 있고, 해당 메소드 내에서 notifyListeners()를 호출함으로써 상태가 변경되었음을 알립니다.

이후, 이 상태에 의존하는 Consumer 위젯들은 자동으로 재빌드되어 변경된 상태를 반영하게 됩니다.

Riverpod에서는 StateNotifier 같은 다른 Notifier 기반 클래스도 제공하여, 더욱 풍부한 상태 관리 방법을 제공합니다.

Riverpod 사용하기

기본 설정

App 전체에서 Riverpod을 사용하기 위하여 ProviderScope()를 사용하여 Riverpod의 사용범위를 정합니다.

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

모델 정의하기

수신 혹은 전송할 데이터 모델을 정의하는데 이때 freezed와 json_serializable 패키지를 사용하여 정의하면 json 등으로의 디코딩에 용이합니다.

다음과 같이 Activity 모델을 정의하고 build_runner를 통해 code generation을 수행합니다.

lib/model/activity.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'activity.freezed.dart';
part 'activity.g.dart';

@freezed
class Activity with _$Activity {
  factory Activity({
    required String key,
    required String activity,
    required String type,
    required int participants,
    required double price,
  }) = _Activity;

  factory Activity.fromJson(Map<String, dynamic> json) =>
      _$ActivityFromJson(json);
}

Provider 생성

모델에 해당하는 데이터를 http를 통해 가져오는 Provider를 다음과 같이 정의합니다.

http 패키지 를 설치 및 import 하고 http를 통해 activity 데이터를 json 형식으로 가져오고 이를 fromJson 팩토리 생성자를 통해 Activity 객체를 리턴합니다.

(boredapi.com 는 REST API를 테스트 해볼수 있는 사이트입니다.

lib/provider/basicProvider.dart

@riverpod
String helloWorld(HelloWorldRef ref) {
  return 'Hello world';
}

@riverpod
Future<Activity> activity(ActivityRef ref) async {
  final response = await http.get(Uri.https('boredapi.com', '/api/activity'));
  final json = jsonDecode(response.body) as Map<String, dynamic>;
  return Activity.fromJson(json);
}

데이터 읽기

Riverpod을 사용하여 정의된 Provider 값을 읽고 처리 하기 위해서는 ref가 필요한데,
이는 ConsumerWidget을 상속받은 widget의 build() 혹은 Consumer Widget 내에서 사용할 수 있습니다.

그리고 ref의 read() 는 현재값을 watch()는 구독을 통해 값이 변경될 때마다 새로 읽습니다. (위 설명 참조)

lib/views/basicScreen.dart

import 'package:_8_riverpod/model/activity.dart';
import 'package:_8_riverpod/provider/basicProvider.dart';
import 'package:_8_riverpod/views/todoScreen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final String hello = ref.read(helloWorldProvider);
    final AsyncValue<Activity> activity = ref.watch(activityProvider);

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Riverpod Example')),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              Text(hello),
              activity.when(
                data: (activity) => Text(activity.activity),
                loading: () => const CircularProgressIndicator(),
                error: (error, stackTrace) => Text('Error: $error'),
              ),
              //button to go todoSCreen.dart
              ElevatedButton(
                onPressed: () {
                  Navigator.push(
                    context,
                    MaterialPageRoute(builder: (context) => const TodoScreen()),
                  );
                },
                child: const Text('Go to TodoScreen'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

상태 업데이트 하기

Notifier(TodoListNotifier)를 사용하여 내부적으로 상태(Todo) 목록을 관리합니다.

이는 @riverpod annotation과 _$TodoListNotifier 상속을 통해 Code generation을 통하여, Notifier class(TodoListNotifier)를 생성하여 todoListProvidertodoListProvider.notifier를 사용할 수 있게 되어 상태를 업데이트 할 수 있습니다.

AsyncData()를 통하여 모든 listener들에게 새로운 상태를 업데이트 합니다.

단순 내부적인 상태 뿐 아니라 주석으로 처리되어 있는 post 부분을 수정하여, 백엔드의 리소스를 업데이트 할 수도 있습니다.

lib/provider/todoNotifier.dart

import 'package:_8_riverpod/model/todo.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:uuid/uuid.dart';

part 'todoNotifier.g.dart';

const _uuid = Uuid();

@riverpod
class TodoList extends _$TodoList {
  final List<Todo> _todos = [
    Todo(id: _uuid.v4(), description: 'Learn Flutter', completed: true),
    Todo(id: _uuid.v4(), description: 'Learn Riverpod'),
  ];

  @override
  Future<List<Todo>> build() async {
    return _todos;
  }

  List<Todo> get todos => _todos;

  Future<void> addTodo(Todo todo) async {
    // if you want to post data using this one
    // await http.post(
    //   Uri.https('your_api.com', '/todos'),
    //   // Port to server.
    //   headers: {'Content-Type': 'application/json'},
    //   body: jsonEncode(todo.toJson()),
    // );

    _todos.add(todo);
    // update new state and notify listeners
    state = AsyncData(_todos);
  }
}

UI에서 사용

ConsumerWidget을 상속받아 ref를 사용할 수 있게 되어 todoListProvider에 접근 할 수 있게 되었고, 앞에서와 같이 ref.watch(todoListProvider)를 통해 목록의 Todo 목록의 상태를 감시하고 최신 상태를 TodoScreen에 ListView 형태로 표시할 수 있게 되었습니다.

Floating 버튼을 눌러 Dialog를 팝업시켜 todoListProvider.notifier를 통해 Todo item을 추가하는 addTodo 함수를 사용할 수 있습니다.

lib/views/todoScreen.dart

const _uuid = Uuid();

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todoListAsyncValue = ref.watch(todoListProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Todo List'),
      ),
      body: todoListAsyncValue.when(
        data: (todoList) => ListView.builder(
          itemCount: todoList.length,
          itemBuilder: (context, index) {
            final todo = todoList[index];
            return ListTile(
              title: Text(todo.description),
              leading: Icon(todo.completed
                  ? Icons.check_circle
                  : Icons.check_circle_outline),
            );
          },
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stack) => const Center(child: Text('An error occurred')),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddTodoDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddTodoDialog(BuildContext context, WidgetRef ref) {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        String description = '';
        return AlertDialog(
          title: const Text('Add Todo'),
          content: TextField(
            onChanged: (value) {
              description = value;
            },
            decoration: const InputDecoration(hintText: "Todo description"),
          ),
          actions: <Widget>[
            TextButton(
              child: const Text('Add'),
              onPressed: () async {
                final newTodo = Todo(
                  id: _uuid.v4(),
                  description: description,
                  completed: false,
                );
                await ref.read(todoListProvider.notifier).addTodo(newTodo);
                // ignore: use_build_context_synchronously
                Navigator.of(context).pop();
              },
            ),
          ],
        );
      },
    );
  }
}

매개변수 전달하기

Provider나 Notifier에서 매개변수(Argument)를 사용하고 싶을 때는 Code generation을 사용하는 경우 단순히 함수에 매개 변수를 다음과 같이 추가해주기만 하면 됩니다.

다음에서는 Query를 위한 activityType 을 매개변수로 Activity Provider에 추가하였습니다.

lib/provider/basicProvider.dart

@riverpod
Future<Activity> activity(ActivityRef ref, String activityType) async {
  // We use the "http" package to fetch a random activity from the Bored API.
  final response = await http.get(
    Uri(
      scheme: 'https',
      host: 'boredapi.com',
      path: '/api/activity',
      // We pass the activity type as a query parameter.
      queryParameters: {'type': activityType},
    ),
  );
  final json = jsonDecode(response.body) as Map<String, dynamic>;
  return Activity.fromJson(json);
}

그리고 UI에서 다음과 같이 매개변수(recreational)을 전달할 수 있습니다.

lib/views/basicScreen.dart

    final AsyncValue<Activity> activity = ref.watch(
      activityProvider('recreational'),
    );

결과

Riverpod_Example_Result

참고 링크

Leave a Comment