meta programming 이 Flutter에서 핫한 주제이다. Flutter marco 등.
하지만 현재 시점에서 자주 쓰이는 meta programming으로는 riverpod이 대표적이다. 그래서 riverpod을 구성할 때, build_runner를 이용하는 법에 대해서 컴팩트하게 작성한다.
제일 먼저 패키지를 설치한다.
~~공식문서 기준, 만약 읽는 시점에서 에러가 발생하면, "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
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
프로젝트를 먼저 생성하자.
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,
);
}
}
사실 이전까지의 글은 쓰기 위해서 작성한 것에 불과하다.
새로운 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';
class TodoList extends _$TodoList {
List<Todo> build() {
return [];
}
}
@riverpod
은 애노테이션 문법이다. Spring 을 사용해본 개발자라면, 아마 애노테이션 문법이 익숙할 것이다. 이것은 build_runner 에게 알려주기 위한 문법이다: "build_runner 야! 이 애노테이션을 보면 riverpod 의 provider로 만들어야해!" 라고 말하는 것이다.<Todo> build() {
return [];
}
List
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
라는 이름이 붙은 객체가 생성되는 원리이다.
마지막으로 남은 것은 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();
}
}
나의 앱에서 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(),
),
],
),
TodoListScreen
이 ConsumerWidget
을 Subclassing 하고 있다.Widget Build(BuildContext contexxt, WidgetRef ref)
로 변경되었다.기준은 간단하다.
상태 값 변경에 맞게 UI가 변경되어야 한다? watch. 아니면 다 read
read는 단순 조회이다. 단어 뜻이랑 같다.
watch는 정확히 말하면 UI Binding이다. UI와 State를 연결하는 과정이다. == subscribe
build_runner를 권장하는 riverpod이다. 가능하면 제작자 의도에 맞게 사용하는 것이 정신건강에 이롭다.
말 안듣고 고통을 많이 겪어봐서...