[Flutter] - Freezed 스터디


이번 신규 프로젝트에 사용하기 위해 Freezed를 정리해보도록 하자.

Freezed는 코드 생성 라이브러리로 model 클래스를 기본 틀만 작성하면 자동으로 생성해준다.

지원해주는 기능을 확인해보자.

지원해주는 기능


  • fromJson , toJson
  • 변수 생성 (생성자만 만들어주면 변수 만들어줌)
  • == equals
  • toString
  • Assert (생성자에서)
  • 커스텀 함수 추가
  • 복사
  • 깊은 복사 (내부 객체들까지 깊은 복사 지원)
  • 불변 객체 (내부 List, Map과 같은 Collection 객체도 불변으로 만들어줌, 해제 가능)
  • 기본값 설정
  • 데코레이터 및 코멘트 작성
  • Union 타입 , Sealed 클래스

기본 사용법을 알아보자.

기본 사용법


import 'package:freezed_annotation/freezed_annotation.dart';

part 'news_model.freezed.dart';
part 'news_model.g.dart';

@freezed
class NewsModel with _$NewsModel {
  const factory NewsModel({
    required int id,
    required String title,
    required String type,
    required String url,
  }) = _NewsModel;

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

위와 같이 작성하고 dart run build_runner build 를 실행해주면 news_model.g.dart

news_model.freezed.dart 가 생성된다.

1. fromJson, toJson


여기서 조금 독특했던건 fromJson만 설정해주고 생성하면 toJson까지 만들어졌다.

@freezed
class NewsModel with _$NewsModel {
  const factory NewsModel({
    required int id,
    required String title,
    required String type,
    required String url,
  }) = _NewsModel;

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

NewsModel model = NewsModel(id: 1, title: 'test', type: 'test', url: 'test');
model.toJson(); // 사용가능

2. == equals


NewsModel model1 = NewsModel(id: 1, title: 'test', type: 'test', url: 'test');
NewsModel model2 = NewsModel(id: 1, title: 'test', type: 'test', url: 'test');

print(model1 == model2); //true
//news_model.freezed.dart
...
@override
  bool operator ==(dynamic other) {
    return identical(this, other) ||
        (other.runtimeType == runtimeType &&
            other is _NewsModel &&
            const DeepCollectionEquality().equals(other.id, id) &&
            const DeepCollectionEquality().equals(other.title, title) &&
            const DeepCollectionEquality().equals(other.type, type) &&
            const DeepCollectionEquality().equals(other.url, url));
  }
...

위와 같이 내부 변수 값 비교를 통해 equals 연산을 해준다.

잘 보면 DeepCollectionEquality객체를 통해 내부 객체 혹은 Collection들도 Deep Equals 비교를 진행한다.

3. toString()


NewsModel model = NewsModel(id: 1, title: 'test', type: 'test', url: 'test');
print(model); // NewsModel(id: 1, name: test, type: test, url: test)

4. Assert


@freezed
class NewsModel with _$NewsModel {
	@Assert('title.length < 5', '제목은 5자 이하만 입력 가능합니다.')
  const factory NewsModel({
    required int id,
    required String title,
    required String type,
    required String url,
  }) = _NewsModel;

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

NewsModel model = NewsModel(id: 1, title: 'testttt', type: 'test', url: 'test');
// 에러 -> 제목은 5자 이하만 입력 가능합니다.

생성자위에 Annotation으로 Assert를 설정가능하다.

String으로 해야한다.

5. 커스텀 함수 추가


freezed로 자동으로 생성되는 class지만 내부에 내가 원하는 method를 추가 할 수 있다.

그럴려면 internal constructor를 필수로 추가해야한다.

@freezed
class NewsModel with _$NewsModel {
  const factory NewsModel({
    required int id,
    required String title,
    required String type,
    required String url,
  }) = _NewsModel;

  factory NewsModel.fromJson(Map<String, dynamic> json) =>
      _$NewsModelFromJson(json);

  const NewsModel._(); //무조건 추가 해줘야 한다.

	//커스텀 method
  String getId() => id.toString();
}

6. 복사


일반적으로 불변객체를 복사하듯이 copyWith를 사용해 복사하면 된다.

NewsModel model1 = NewsModel(id: 1, title: 'test', type: 'test', url: 'test');
NewsModel model2 = model1.copyWith(id: 2);

7. 깊은 복사


객체 안에 객체들이 있다면 일반적인 복사로는 깊은복사를 지원하지 않는다

그래서 일반적으로 아래와 같이 작성한다.

Company company;

Company newCompany = company.copyWith(
  director: company.director.copyWith(
    assistant: company.director.assistant.copyWith(
      name: 'John Smith',
    ),
  ),
);

하지만 freezed는 깊은복사 또한 지원한다.

Company company;

Company newCompany = company.copyWith.director.assistant(name: 'John Smith');

훨씬 간단하고 직관적이다.

8. 불변 객체


freezed는 기본적으로 immutable이다.

여기서 중요한 점은 내부 객체와 Collection또한 immutable이다.

_newsModel = const NewsModel(
        id: 3,
        title: 'test',
        type: 'test2',
        url: 'url',
        person: PersonModel(id: 3, name: 'person'),
        subtitles: ['subtitle1', 'subtitle2'],
        ids: [3, 5, 6]);

    _newsModel.person = PersonModel(id: 3, name: 'news'); //컴파일 에러
    _newsModel.subtitles.add('test3'); //throw Unsupported operation
    _newsModel.ids.add(3); //throw Unsupported operation

여기서 주의점은 객체는 컴파일에서 에러가 뜨는데 Collection들은 러닝타임에서 throw가 일어난다는 점이다.

아래와 같이 Annoation에 옵션을 걸어주면 Collection 객체의 불변성을 끌 수 있다.

@Freezed(makeCollectionsUnmodifiable: false)
class Example with _$Example {
  factory Example(List<int> list) = _Example;
}

void main() {
  var example = Example([]);
  example.list.add(42); // OK
}

9. 기본값 설정


만약 변수의 기본값을 설정하고 싶다면 아래처럼 Annotation을 활용하면 된다.

class Example with _$Example {
  const factory Example([@Default(42) int value]) = _Example;
}

10. 데코레이터 및 코멘트 작성


vscode에 해당 변수에 마우스를 올리면 ide에서 띄워주는 커맨트 작성을 매우 쉽게 할 수 있다.

@freezed
class Person with _$Person {
  const factory Person({
    /// The name of the user.
    ///
    /// Must not be null
    String? name,
    int? age,
    Gender? gender,
  }) = _Person;
}

위와 같이 해당 변수 위에 /// 을 통해 주석을 달아주면 된다.

@freezed
class Person with _$Person {
  const factory Person({
    String? name,
    int? age,
    @deprecated Gender? gender,
  }) = _Person;
}

deprecated도 있다.

11. 유니온 타입과 Selaed 클래스


이게 어떻게 보면 freezed만의 특징인것 같다.

Bloc과 같이 상태를 관리해야할때 아주 유용하다.

@freezed
class Union with _$Union {
  const factory Union.data(int value) = Data;
  const factory Union.loading() = Loading;
  const factory Union.error([String? message]) = Error;
}

해당 데이터 클래스 내부에 factory method로 Loading, Data, Error 등을 설정해 줄 수 있다.

주의할 점은 이렇게 되면 공통 속성이 아닌것들은 참조가 불가능해진다.

void main() {
  Union union = Union.data(42);

  print(union.value); // compilation error: property value does not exist
}

이럴때 유용한게 Map과 When이다.

var union = Union(42);

print(
  union.when(
    (int value) => 'Data $value',
    loading: () => 'loading',
    error: (String? message) => 'Error: $message',
  ),
); // Data 42

print(
  union.map(
    data: (Data value) => 'Data $value',
    loading: (Loading value) => 'loading',
		error: (Error value) => 'Error: ${value.message}',
  ),
); // Data 42

혹은 as / is로 처리도 가능하다.

void main() {
  Example value;

  if (value is Person) {
    // By using `is`, this allows the compiler to know that "value" is a Person instance
    // and therefore allows us to read all of its properties.
    print(value.age);
    value = value.copyWith(age: 42);
  }

  // Alternatively we can use `as` if we are certain of type of an object:
  Person person = value as Person;
  print(person.age);
}

그래서 공통된 내용을 mixin으로 처리가능하다.

abstract class GeographicArea {}
abstract class House {}
abstract class Shop {}
abstract class AdministrativeArea<T> {}

@freezed
class Example with _$Example {
  const factory Example.person(String name, int age) = Person;

  @With<AdministrativeArea<House>>()
  const factory Example.street(String name) = Street;

  @With<House>()
  @Implements<Shop>()
  @Implements<GeographicArea>()
  const factory Example.city(String name, int population) = City;
}

위와 같이 공통통된 내용을 추상클래스로 만들어 @With @Implements Annotation으로 mixin이 가능하다.

With와 Implement의 사용차이를 잘 모르겠다…

결론


확실히 freezed를 공부해보니 왜 사용하는지 알것같았다.

솔직히 toJson, fromJson은 json_serializer로도 충분하고 vscode extension으로도 충분하다.

아래 내용이 잘 활용할것 같았다.

  • 불변객체 (내부까지 불변)
  • 깊은 복사
  • 유니온 타입

특히 유니온타입은 bloc등 내부 아키텍쳐와 잘 설계하면 매번 쉽게 잘 쓸 수 있을것 같다.

이번 프로젝트에서 사용해보면서 나중에 피드백을 달아보도록 하겠다.