Flutter, Riverpod Tutorial (feat. build_runner)

Uno·2024년 6월 25일
0

flutter

목록 보기
16/16

서문

meta programming 이 Flutter에서 핫한 주제이다. Flutter marco 등.
하지만 현재 시점에서 자주 쓰이는 meta programming으로는 riverpod이 대표적이다. 그래서 riverpod을 구성할 때, build_runner를 이용하는 법에 대해서 컴팩트하게 작성한다.


1. Packing & Tools 설치

제일 먼저 패키지를 설치한다.
~~공식문서 기준, 만약 읽는 시점에서 에러가 발생하면, "3. 실습시간" 에 있는 "패키지 설치"에 작성한 yaml로 설정할 것

name: my_app_name
environment:
  sdk: ">=3.0.0 <4.0.0"
  flutter: ">=3.0.0"

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5

dev_dependencies:
  build_runner:
  custom_lint:
  riverpod_generator: ^2.4.2
  riverpod_lint: ^2.3.12

line 활성화는 생략 link

Snippets 도 어지간하면 설치하자. 안쓰면 너무 귀찮다.

analysis_options.yaml 파일에 custom_lint를 추가해서 실행하자

analyzer:  
	plugins:  
		- custom_lint

이거 안하면 개발하는데 진짜 개불편함.

셋업하고 바로 실행하자.

dart run custom_lint

2. Code Generator(build_runner) 설치

Riverpod 을 열심히 사용할 계획이라면 + 이전에 커스텀으로 구현한 적이 없다면, 그냥 Code Generator 를 사용하자. 이유는 공식문서에 있다.
Code Generator 를 사용해야하는 좋은 이유도 설명하면 많다 하지만, 이 글은 실습을 위한 글이므로 생략

But keep in mind that you will be missing out on some features, and that you will have to migrate to code generation in the future.

출처: link


3. 실습시간

준비

프로젝트를 먼저 생성하자.

import 'package:flutter/material.dart';

void main() {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text('Hello World!'),
        ),
      ),
    );
  }
}

패키지 설치

pubspec.yaml 로 이동하여 패키지를 설치한다. 위에서 했던 작업의 반복

버전은 이 글을 읽는 시점에 맞춰서 설정해주는 센스 필요

name: my_app_name
environment:
  sdk: ">=3.0.0 <4.0.0"
  flutter: ">=3.0.0"

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5

dev_dependencies:
  build_runner:
  custom_lint:
  riverpod_generator: ^2.4.2
  riverpod_lint: ^2.3.12

그리고 늘 하던 명령어를 Terminal에 작성하여 실행한다.

flutter pub get

글쓴이의 작성 시점에서는 다음과 같은 에러가 뜬다. 작성일: 24.06.25

Resolving dependencies... (1.3s)
Note: meta is pinned to version 1.11.0 by flutter from the flutter SDK.
See https://dart.dev/go/sdk-version-pinning for details.


Because analyzer >=6.5.1 depends on meta ^1.15.0 and analyzer >=6.5.0 <6.5.1 depends on meta ^1.14.0, analyzer >=6.5.0 requires meta
  ^1.14.0.
And because riverpod_generator >=2.4.2 <3.0.0-dev.2 depends on analyzer ^6.5.0 and every version of flutter from sdk depends on meta
  1.11.0, riverpod_generator >=2.4.2 <3.0.0-dev.2 is incompatible with flutter from sdk.
So, because todo_list depends on both flutter from sdk and riverpod_generator ^2.4.2, version solving failed.


You can try the following suggestion to make the pubspec resolve:
* Try an upgrade of your constraints: flutter pub upgrade --major-versions

의미는 간단하다.

  • meta 버전이 1.11.0 으로 고정되어 있다.
  • analyzer 의 버전에 맞지 않은 meta 버전이다.
  • 그러므로, 버전을 올릴거면 다 올리고, 내릴거면 모두 맞춰서 내려라.

직접 원하는 Flutter Version으로 올려도 된다. 귀찮다면, 아래 yaml 을 복사해서 name만 잘 수정하여 처리하면 된다.

name: todo_list
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: '>=3.3.4 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.6
  flutter_lints: ^4.0.0
  riverpod: ^2.5.1
  flutter_riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.8
  custom_lint: ^0.6.0
  riverpod_generator: ^2.3.5
  riverpod_lint: ^2.3.10

flutter:
  uses-material-design: true

상태관리를 원하는 객체 생성

이번 스탭은 riverpod tutorial 과 조금 동떨어져있을 수 있다. 이미 객체 생성에 대한 내용을 알고 있다면 지루하다. Skip 추천

나는 TodoList를 만들 예정이다. 여기서 각각의 Todo 의 목록들이 생길 것이다. 이것들은 유일성이 보장되어야 한다. 만약 유일성이 없다면, 1번 Todo를 처리했는데 2 번이 check되는 버그가 발생할 우려가 있다. 유일성을 보장하는 방법은 무엇이 있는지 생각해보자; uuid / hashcode

그래서 클래스를 대략 작성해보자 초안

class Todo {
  final String id;
  final String title;
  final bool completed;

  Todo({required this.id, required this.title, required this.completed});
}
  • id : 각각의 Todo Task는 고유한 Task 이다. 이름이 중복되더라도, 서로 다른 테스크이다. 만약 집안일을 10개 만들었다면, 10 개 생성되어야하는데, 각각은 다른 Task이므로, 분리를 위해 id를 추가한다. 이것은 시스템에서 식별하기 위한 값이다.
  • title: Task 의 이름이다. 사용자에게 보여주는 이름인 것이다.
  • completed: Task 의 처리 여부를 나타낸다. 사용자에게 보여주기 위함이다.

시스템이 식별하기 위한 하나의 값과 사용자에게 보여주기 위한 2 개의 값으로 구성되어 있다.
지금 객체로 구현하게되면, id를 매번 내가 직접 입력해줘야하는 귀찮음 + 실수 가능성 이 존재한다.

원천적으로 제거하는게 제일 좋다. 그래서 2 개의 도구를 사용하려고 한다.

몰라도 되는 내용, Swift와 비교
Swift를 사용한 경험이 있다면, Equatable Protocol 과 유사한 개념으로 이해하면 된다. Swift는 자동으로 구현되는게 많은 반면에, Dart는 equtable 을 사용하게 되면 props 메서드를 구현해야한다는 점이 차이점이다.

본론으로 돌아와서, 먼저 설치를 하자.

읽는 시점에 맞는 버전 체크는 필수!

dependencies:
  uuid: ^4.4.0
  equatable: ^2.0.5

위 패키지를 사용한 객체를 보면 아래와 같다.

import 'package:equatable/equatable.dart';
import 'package:uuid/uuid.dart';

class Todo extends Equatable {
  final String id;
  final String title;
  final bool completed;

  Todo({required this.title, this.completed = false}) : id = const Uuid().v4();

  
  List<Object?> get props => [id];

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

riverpod packages & code Generator 이용하기

사실 이전까지의 글은 쓰기 위해서 작성한 것에 불과하다.

새로운 todo_list.dart.dart 을 만들고 아래와 같이 코드를 작성하자.

전체코드는 하나하나 설명한 이후에 한번에 첨부할 예정

먼저 이 부분만 보자

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:todo_list/todo/todo.dart';

part 'todo_provider.g.dart';


class TodoList extends _$TodoList {
  // 초기화 메서드: 초기값을 작성한다 + 관리할 상태값을 정의한다.
  
  List<Todo> build() {
    return [];
  }
}

위에서부터 아래로 내려가면서 읽으면 된다.

part 'todo_list.g.dart';
  • 이 부분은 "todo_list.dart 파일을 찾아서 g: generating 하세요." 라는 뜻이다.
  • 이게 없으면 build_runner 입장에서 어떤 파일을 참고해서 생성해야할지 모른다.

class TodoList extends _$TodoList {
  
  List<Todo> build() {
    return [];
  }
}
  • 이 부분이 생소할 수 있는 부분이다. @riverpod 은 애노테이션 문법이다. Spring 을 사용해본 개발자라면, 아마 애노테이션 문법이 익숙할 것이다. 이것은 build_runner 에게 알려주기 위한 문법이다: "build_runner 야! 이 애노테이션을 보면 riverpod 의 provider로 만들어야해!" 라고 말하는 것이다.

List<Todo> build() {
	return [];
}
  • 이 부분은 초기화 메서드라고 생각하면 된다.
  • return type이 관리할 상태이다. 여기서는 List<Todo> 값을 구독하고 업데이트에 따라서 UI Re-rendering 명령을 할 예정이다.
  • return [];의 경우, 초기 값인데 빈 배열을 리턴하기로 한 것이다.

여기까지 하고 build_runner를 동작시켜보자.

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'todo.dart';
part 'todo_list.g.dart';


class TodoList extends _$TodoList {
  
  List<Todo> build() {
    return []; // 초기 상태는 빈 리스트
  }
}

터미널에서 아래 명령어를 실행한다.

flutter pub run build_runner build --delete-conflicting-outputs
  • 기본 생성 방법은 --delete... 이후를 제거하면 되는데, 뒤에 옵션은 충돌이 있었을 때, 어떻게 처리할지에 대한 것이다. 그냥 저걸로하자. 정신건강에 좋음

그러면 디랙토리상에 todo_list.g.dart 라는 읽기 어려운 코드가 생성되어 있다.

이제 CRUD기능을 구현해보자.

import 'package:riverpod_annotation/riverpod_annotation.dart';

import 'todo.dart';

part 'todo_list.g.dart';


class TodoList extends _$TodoList {
  
  List<Todo> build() {
    return []; // 초기 상태는 빈 리스트
  }

  // Create: 새 Todo 항목 추가
  void addTodo(String title) {
    state = [...state, Todo(title: title)];
  }

  // Read: 특정 ID의 Todo 항목 조회
  Todo? getTodoById(String id) {
    return state.firstWhere((todo) => todo.id == id);
  }

  // Update: Todo 항목 업데이트
  void updateTodo(String id, {String? title, bool? completed}) {
    state = state.map((todo) {
      if (todo.id == id) {
        return todo.copyWith(
          title: title ?? todo.title,
          completed: completed ?? todo.completed,
        );
      }
      return todo;
    }).toList();
  }

  // Delete: Todo 항목 삭제
  void removeTodo(String id) {
    state = state.where((todo) => todo.id != id).toList();
  }

  // 모든 Todo 항목 완료 상태 토글
  void toggleAll(bool completed) {
    state = state.map((todo) => todo.copyWith(completed: completed)).toList();
  }

  // 완료된 모든 Todo 항목 삭제
  void removeCompleted() {
    state = state.where((todo) => !todo.completed).toList();
  }

  // 남은 작업 수 계산
  int get remainingTodos => state.where((todo) => !todo.completed).length;

  // 모든 작업이 완료되었는지 확인
  bool get allComplete => state.every((todo) => todo.completed);
}

그리고 다시 명령어 실행!

flutter pub run build_runner build --delete-conflicting-outputs

todo_list.g.dart 파일을 열어보면 다음과 같이 구성되어 있다.

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'todo_list.dart';

// **************************************************************************
// RiverpodGenerator
// **************************************************************************

String _$todoListHash() => r'c037048fe5d8e857d543a45bed626bab8d44463a';

/// See also [TodoList].
(TodoList)
final todoListProvider =
    AutoDisposeNotifierProvider<TodoList, List<Todo>>.internal(
  TodoList.new,
  name: r'todoListProvider',
  debugGetCreateSourceHash:
      const bool.fromEnvironment('dart.vm.product') ? null : _$todoListHash,
  dependencies: null,
  allTransitiveDependencies: null,
);

typedef _$TodoList = AutoDisposeNotifier<List<Todo>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

이 내용을 알필요는 없다. 다만

final todoListProvider = 이 부분은 기억해야 내가 호출하겠지?
기본적으로 내가 part 'todo_list.g.dart' 이렇게 작성하면 뒤에 Provider 라는 이름이 붙은 객체가 생성되는 원리이다.


Widget(UI)에서 사용하기

마지막으로 남은 것은 UI 와 상태값을 연결하는 일이다.
먼저 UI 전체코드를 보자

전체 코드

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo_list/todo/todo.dart';
import 'package:todo_list/todo/todo_list.dart';

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

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Todo List App',
      theme: ThemeData(
        primarySwatch: Colors.indigo,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const TodoListScreen(),
    );
  }
}

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

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

    return Scaffold(
      appBar: AppBar(
        title: const Text('My Todo List'),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete_sweep),
            onPressed: () =>
                ref.read(todoListProvider.notifier).removeCompleted(),
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              itemCount: todos.length,
              itemBuilder: (context, index) {
                final todo = todos[index];
                return TodoCard(todo: todo);
              },
            ),
          ),
          const SizedBox(height: 20),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
            child: Row(
              children: [
                Expanded(
                  child: Text(
                    '${ref.watch(todoListProvider.notifier).remainingTodos} items left',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                ),
                ElevatedButton.icon(
                  icon: const Icon(Icons.done_all),
                  label: const Text('Complete All'),
                  onPressed: () =>
                      ref.read(todoListProvider.notifier).toggleAll(true),
                ),
              ],
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddTodoDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddTodoDialog(BuildContext context, WidgetRef ref) {
    showDialog(
      context: context,
      builder: (context) => AddTodoDialog(
        onAdd: (title) {
          ref.read(todoListProvider.notifier).addTodo(title);
          Navigator.of(context).pop();
        },
      ),
    );
  }
}

class TodoCard extends ConsumerWidget {
  final Todo todo;

  const TodoCard({super.key, required this.todo});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
      child: Padding(
        padding: const EdgeInsets.all(15),
        child: Row(
          children: [
            Checkbox(
              value: todo.completed,
              onChanged: (value) {
                ref.read(todoListProvider.notifier).updateTodo(
                      todo.id,
                      completed: value,
                    );
              },
            ),
            Expanded(
              child: Text(
                todo.title,
                style: TextStyle(
                  fontSize: 18,
                  decoration:
                      todo.completed ? TextDecoration.lineThrough : null,
                ),
              ),
            ),
            IconButton(
              icon: const Icon(Icons.delete, color: Colors.red),
              onPressed: () {
                ref.read(todoListProvider.notifier).removeTodo(todo.id);
              },
            ),
          ],
        ),
      ),
    );
  }
}

class AddTodoDialog extends StatefulWidget {
  final Function(String) onAdd;

  const AddTodoDialog({super.key, required this.onAdd});

  
  State<AddTodoDialog> createState() => _AddTodoDialogState();
}

class _AddTodoDialogState extends State<AddTodoDialog> {
  final _controller = TextEditingController();

  
  Widget build(BuildContext context) {
    return AlertDialog(
      title: const Text('Add New Todo'),
      content: TextField(
        controller: _controller,
        decoration: const InputDecoration(hintText: "Enter todo title"),
        autofocus: true,
      ),
      actions: [
        TextButton(
          child: const Text('Cancel'),
          onPressed: () => Navigator.of(context).pop(),
        ),
        ElevatedButton(
          child: const Text('Add'),
          onPressed: () {
            final title = _controller.text.trim();
            if (title.isNotEmpty) {
              widget.onAdd(title);
            }
          },
        ),
      ],
    );
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

ProviderScope

나의 앱에서 Riverpod 을 Flutter Framework 에서 사용하고 싶다면 ProviderScope을 최상위 Widget으로 생성해야한다: ProviderScope

void main() {
	runApp(const ProviderScope(child: MainApp()));
}
  • 이제 앱 모든 영역에 접근 가능하다. 즉, 자유를 얻은 것이다.

그리고 사용하고자 하는 위젯에 ConsumerWidget 혹은 ConsumerStatefulWidget 을 상속받아서 구현하면 된다. 물론, Riverpod 의 Provider 를 사용할 경우 한정이다.

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

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

    return Scaffold(
      appBar: AppBar(
        title: const Text('My Todo List'),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete_sweep),
            onPressed: () =>
                ref.read(todoListProvider.notifier).removeCompleted(),
          ),
        ],
      ),
  • 기존과 차이점은, TodoListScreenConsumerWidget 을 Subclassing 하고 있다.
  • 그리고 Widget Build(BuildContext contexxt, WidgetRef ref)로 변경되었다.

read VS watch

기준은 간단하다.

상태 값 변경에 맞게 UI가 변경되어야 한다? watch. 아니면 다 read

read는 단순 조회이다. 단어 뜻이랑 같다.
watch는 정확히 말하면 UI Binding이다. UI와 State를 연결하는 과정이다. == subscribe


결과


마무리

build_runner를 권장하는 riverpod이다. 가능하면 제작자 의도에 맞게 사용하는 것이 정신건강에 이롭다.

말 안듣고 고통을 많이 겪어봐서...

profile
iOS & Flutter

0개의 댓글