Bloc 상태를 업데이트 할 때, copyWith 메서드를 직접 작성하고 있었는데 freezed 라는 code generator 를 쓰면 그런 번거로움을 해소할 수 있다고 하여 사용해보기로 했다.
결론적으로는 너무 좋아서 프로젝트에 적용하기로 함. 엔티티 작성할 때 코드 덜 쓰겠다.
코드팩토리를 보면서 공부했고, 아래는 유튜브 내용을 직접 실행해보고 정리한 것이다.
dart pub add freezed
flutter pub add build_runner
flutter pub add json_serializable
flutter pub run build_runner build
flutter pub run build_runner build --delete-conflicting-outputs
import 'package:freezed_annotation/freezed_annotation.dart';
part 'person.freezed.dart';
part 'person.g.dart';
class Person with _$Person {
factory Person({
required int id,
required String name,
required int age
}) = _Person;
factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);
}

toString
일반적인 toString 은 객체를 찍으면 instance 라고만 알려주고 어떤 속성들을 갖고 있는지 보여주지 않아서 불편했던 경험이 한번씩은 있을 거다. freezed 는 toString 메서드를 좀 더 편하게 사용할 수 있도록 제공해준다.
fromJson
fromJson 만 설정해주면 toJson 은 알아서 만들어준다. 이 기능이 너무 맘에 든다.
Equtable
equal 메서드 사용시 주소값이 아닌 내용을 비교해준다.
assert
원래 factory constructor 사용 시 assert 로 제한할 수가 없는데, freezed 를 쓰면 @Assert 를 통해 assert 설정이 가능하다. 단, string 만 가능
copyWith
원하는 값만 변경한 객체 생성 가능
deep Copy
copyWith 와 비슷하다. 중첩된 클래스에서 원하는 값만 변경한 객체 생성 가능.
이건 코드로 보는게 빠를 것 같다. 아래 예시 참고
mapWhen
마치 코틀린의 sealed class + when 같은 역할.
객체의 형태(타입)에 따라 분기하여 처리할 수 있다는 장점이 있다.
모든 경우를 반드시 처리해야 하기 때문에 switch 문보다 안전하다.
이것도 아래 예시 참고.
만약 Person 생성 시 Group 이 있어야 하고, Group에는 School 이 있어야 하는 구조이다. 이렇게 타고타고 들어가야 할 때 School 의 name 만 바꾸려면 어떻게 해야될까?
(1) 우선 Group, School 모델을 추가해보자
import 'package:freezed_annotation/freezed_annotation.dart';
part 'person.freezed.dart';
class Person with _$Person {
// @Assert('name.length < 5', '이름의 길이는 5보다 작아야 합니다.')
factory Person({
required int id,
required String name,
required int age,
required Group group
}) = _Person;
// factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);
}
class Group with _$Group {
factory Group({
required int id,
required String name,
required School school
}) = _Group;
}
class School with _$School {
factory School({
required int id,
required String name
}) = _School;
}
(2) School 의 name 만 바꿔보자.
단, freezed 를 사용 했을 때와 그렇지 않을 때 코드를 비교해보자.
freezed 미사용 시
copyWith 를 계속 중첩해야 한다…
final school1 = School(id: 3, name: 'Harvard');
final group1 = Group(id: 2, name: '나나', school: school1);
final person1 = Person(id: 1, name: '뚜비', age: 23, group: group1);
// copywith 을 여러번 써줘야 한다..
final personNew = person1.copyWith(
group: group1.copyWith(
school: school1.copyWith(
name: 'Stanford'
)
)
);
freezed 사용 시
훨씬 더 간단하게 copy 할 수 있다.
final personNew2 = person1.copyWith.group.school(name: 'Stanford');
앱을 구현 하다보면 상태(state)에 따라 UI나 로직이 달라져야 할 때가 있다. 예를 들면 api 호출에 실패했거나 아직 로딩중 일 때, 혹은 로딩 완료 일 때 표시할 UI가 각각 다르다.
이럴 때 runtimeType을 직접 체크하거나 if-else 문을 쓰는 방법도 있지만, Freezed의 when 메서드를 사용하면 훨씬 명확하고 안전하게 처리할 수 있다.
(1) Person 클래스에 statusCode 를 추가해준다.
아까 deepCopy 예시를 위해 추가했던 Group, School 등 코드는 삭제하거나 주석 처리해준다.
class Person with _$Person {
factory Person({
required int id,
required String name,
required int age,
int? statusCode, // 추가
}) = _Person;
factory Person.loading({int? statusCode}) = _Loading; // 추가
factory Person.error(String message, {int? statusCode}) = _Error; // 추가
}
이제 Person 클래스는 아래 3가지 상태로 분기될 수 있다.
그렇기에 각 상태에 따라 다르게 동작하는 코드가 필요하다. when 메서드를 쓰면 각 상태에 맞는 처리를 깔끔하게 해줄 수 있다.
(2) 각 상태의 인스턴스 생성
예시를 위한 객체를 생성해보자...
final person = Person(id: 1, name: 'test', age: 43, statusCode: 200);
final personLoading = Person.loading();
final personError = Person.error('accessToken 이 잘못 되었습니다\n', statusCode: 401);
(3) mapWhen 함수 구현
각 상태마다 화면에 나타낼 텍스트가 다음과 같을 때.
if-else로 상태를 구분한 경우
String message;
if (person is _Person) {
message = 'id: ${person.id}, name: ${person.name}, age: ${person.age}, statusCode: ${person.statusCode}';
} else if (person is _Loading) {
message = '로딩 중입니다...';
} else if (person is _Error) {
message = '에러: ${person.message}';
} else {
message = '알 수 없는 상태';
}
mapWhen 을 사용한 경우
mapWhen(Person person) {
return person.when(
(id, name, age, statusCode) =>
'id: $id, name: $name, age: $age, statusCode: $statusCode',
loading: (int? statusCode) => 'Loading...',
error: (String message, int? statusCode) => message);
}
이렇게만 보면 else if 문이나 mapWhen 이나 비슷해보인다. 아니, 나는 오히려 mapWhen 이 보기가 불편했다. 그럼에도 불구하고 왜 mapWhen 을 사용해야할까?
mapWhen 을 적용하면 다음과 같은 장점이 있다.
1) 타입 안전성(Type Safety)
when은 freezed가 생성한 모든 하위 타입을 컴파일 타임에 강제한다. 즉, 모든 상태를 반드시 처리하게 만들기 때문에 실수로 누락되는 분기 없이 안전하다. 이 부분 때문에 sealed class 와 비슷한 것.
반면 if-else는 개발자가 직접 조건을 걸기 때문에, 새로운 상태가 추가되더라도 놓칠 위험이 크다.
2) 자동완성
when을 쓰면 freezed가 자동 생성한 함수 시그니처를 바로 보여주고 자동으로 빌드해준다. 덕분에 함수 인자의 순서나 타입을 기억할 필요가 없고, 추후 유지보수 시에도 어떤 상태를 처리 중인지 명확하다. 대신 변경 사항이 있다면 당연히 재빌드 해줘야 한다.
3) 런타임 오류 방지
when은 컴파일 시에 모든 케이스를 다루고 있는지를 검사한다. 상태를 추가했는데 해당 분기 로직이 없다면 빨간 줄로 에러가 떠서 파악하기가 쉽다.
결론은 freezed 를 사용한다면 else-if 대신 mapWhen 을 쓰자.
(4) UI 코드 예시
return Scaffold(
body: Column(
children: [
// 공통 프로퍼티가 아니면 접근해서 가져올 수 없다
// renderText('person', person.id.toString),
// statusCode는 공통 프로퍼티이기 때문에 접근해서 가져올 수 있다.
// internal 같은 개념
renderText('personStatus', person.statusCode.toString()),
// 상태별 객체 출력
renderText('person', person.toString()),
renderText('personLoading', personLoading.toString()),
renderText('personError', personError.toString()),
// 상태에 따른 메시지 출력 (when 사용)
renderText('person.when', mapWhen(person)),
renderText('personLoading.when', mapWhen(personLoading)),
renderText('personError.when', mapWhen(personError)),
],
),
);
(5) 캡처