Нетворкінг у Flutter додатках — про просте і складне на прикладі Tide. Частина 1: моделі даних з freezed та json_serializable. Про просте
Привіт! Я Анна — експертка з мобільної розробки, GDE з Dart та Flutter, досвідчена розробниця мобільних додатків на Flutter.
Більшість додатків, чи то мобільні, чи то веб, чи десктоп, залежать від того чи іншого бекенда. Отже, імплементація комунікації з API є невід’ємною частиною реалізації додатку. У цій серії з шести частин представлені інструменти та підходи, які полегшують розробку комунікації з API у Flutter додатках, які ми використовуємо в Tide.
Обіцяю, буде корисно і цікаво розробникам будь-якого рівня!
Якщо загубилися, почніть читати з початку.
Частини 1 і 2 цієї серії присвячені створенню Dart класів для зберігання даних, отриманих в результаті API запитів, і логіки серіалізації цих даних із та в JSON.
Ця частина має на меті створити базову реалізацію Dart класу за допомогою пакетів freezed та json_serializable.
freezed — це пакет для генерування коду, який збагачує звичайні Dart класи перевизначенням operator ==
і hashCode
, toString
, і зручним copyWith
. Він також корисний при роботі з union та для pattern-matching. json_serializable також є кодогенеруючим пакетом, який генерує методи toJson
і fromJson
для серіалізації об’єктів. Про мотивацію, інструкції з підключення, та основні сценарії використання читайте в документації.
Крім того, будуть використані атрибути з допоміжних пакетів freezed_annotation і json_annotation.
В цій частині про просте:
- Проста модель даних.
- JSON серіалізація.
- Атрибут JsonKey.
- Перерахування в JSON.
Про складне читайте Частині 2 цієї серії:
- JSON конвертери.
- union в JSON.
- generics в JSON.
На момент випуску цієї серії актуальна версія Flutter 3.0.
Приклади будуються на основі коду з порожнім Flutter проектом, який знаходиться під тегом part-0 у Flutter Advanced Networking GitHub-репозиторії.
Останні версії необхідних залежностей у файлі pubspec.yaml
:

1. Проста модель даних
При отриманні будь-яких даних з бекенда, першим завданням є створення структур даних для їх зберігання. Як би виглядав звичайний Dart клас?
Виглядає добре, але недостатньо. Реалізація toString
за замовчуванням повертає лише Instance of ‘MarvelComic’
. Неявний operator ==
порівнює лише посилання на об’єкти, а не їх вміст. Список images
можна редагувати. Методу copyWith
не існує, а така типова реалізація вручну не дозволяє оновлювати nullable поля значенням null
:
Саме тому ми в команді використовуємо freezed для всіх класів з даними.
Ось декларація того самого класу MarvelComic
з пакетом freezed:
Обовʼязковими елементами є оголошення part
файлу в рядку 5
, атрибут @freezed
у рядку 7
, оголошення класу в рядку 8
, оголошення фабричного конструктора в рядку 9
, і оголошення внутрішнього класу в рядку 16
. Рядки 10–15
містять список полів, які буде містити згенерований клас. Позиційні аргументи також підтримуються, але ми в команді віддаємо перевагу іменованим параметрам.
Поля, що не є nullable, мають бути або required
, або містити @Default
значення за замовчуванням. Зазвичай ми надаємо значення за замовчуванням для списків. Таким чином, немає необхідності перевіряти, чи список не є null
, коли насправді нас цікавить, чи список не є порожнім.
Згенерований файл .freezed.dart
занадто великий, то ж ось його ключові частини.
Згенерований спадкоємець класу MarvelComic
у рядку 5
містить ті ж самі поля в рядках 15–20
. Конструктор використовує значення за замовчуванням для поля _images
у рядках 12
і 13
, а властивість images
у рядку 21
огортає внутрішній список _images
у копію, яку не можливо змінювати:
У рядку 9
метод toString
перевизначено, щоб повертати значення усіх полів класу у читабельному вигляді:
operator ==
у рядку 9
і hashCode
в рядку 22
перевизначено таким чином, щоб вони залежали від значень полів, та виконували глибоке порівняння моделей і списків:
Крім того, метод copyWith
у рядку 11
з реалізацією в рядку 28
в кінцевому підсумку дозволяє встановити значення null
для nullable полів класу:
Тепер, коли у нас є модель даних, саме час серіалізувати її в та десеріалізувати з JSON.

2. JSON-серіалізація
Щоб зробити freezed модель придатною для серіалізації, все, що потрібно, це додати ще один part
файл, як у рядку 6
, і спеціальний фабричний конструктор, як у рядку 19
:
Завдяки цим змінам, freezed перенаправить запит на генерування методів серіалізації до json_serializable. В результаті новий файл .g.dart
містить:
Кожне поле серіалізується до та з Map<String, dynamic>
за ключем, значення якого дорівнює імені поля. Ключі можна налаштувати за допомогою атрибута @JsonKey
, про який ми поговоримо пізніше. json_serializable підтримує типи полів int
, double
, String
, DateTime
, List
і Map
, перерахування та багато інших. DateTime
автоматично конвертується в і з String
у форматі Iso8601. В рядку 12
значення за замовчуванням для поля images
використовується, якщо JSON не містить жодного значення за цим ключем.
Виглядає добре, але тут є проблема. Якщо модель містить поле з типом іншого класу або список, наприклад як у рядках 9
і 10
:
то згенерований toJson
є дещо неочікуваним:
У рядку 29
для нового класу MarvelImage
був створений метод toJson
, але він не викликається з toJson
методу MarvelComic
класу у рядках 16
і 17
.
Щоб виправити це, необхідно аннотувати класи атрибутом @JsonSerializable(explicitToJson: true)
у рядках 7
і 20
:
В результаті файл .g.dart
змінився в рядках 16
і 17
. Тепер у полів thumbnail
та images
метод toJson
викликається:
Так значно краще. Ми вважаємо, що explicitToJson: true
мало б бути поведінкою за замовчуванням для всіх класів. Хоча це не є реальністю, таку поведінку можна налаштувати один раз глобально для Flutter пакета за допомогою файлу build.yaml
, який розміщується поруч із файлом pubspec.yaml
:
build.yaml — це файл конфігурації, де можна вказати глобальні параметри для всіх пакетів, що генерують код.
Ми також використовуємо build.yaml
для глобальної конфігурації includeIfNull: false
. Отже, фінальний файл .g.dart
:
Nullable поля digitalId
, title
, modified
, thumbnail
і format
тепер записуються в JSON за допомогою методу writeNotNull
у рядках 27–31
, лише якщо їх значення не є null
.
Тепер клас MarvelComic
має корректні реалізації фабричного конструктора fromJson
і методу toJson
. Давайте розглянемо, як цей механізм можна додатково налаштувати.

3. Атрибут JsonKey
Кожне поле класу даних може бути анотовано атрибутом @JsonKey
з пакета json_annotation, який використовується для налаштування процессу серіалізації індивідуальних полів. Найпоширенішими параметрами є name
, який керує ім’ям поля в отриманому JSON, і ignore
, який контролює, чи буде поле включено в отриманий JSON.
Наприклад, у рядку 13
полю thumbnail
дається інше імʼя ключа, а в рядку 12
поле format
ігнорується.
Згенерований файл .g.dart
містить:
Тепер поле format
не згадується, а поле thumbnail
читається з та записується в JSON за ключем replaced_thumbnail_key_name
у рядках 11
і 23
. Незважаючи на те, що в більшості випадків ключі JSON співпадають з назвами полів, ми вважаємо за краще явно вказувати імена ключів для безпечного рефакторингу.
Атрибут @JsonKey
також має параметр defaultValue
, але при його використанні, він впливає на значення поля за замовчування лише якщо значення відповідного ключа відсутнє в JSON, але не коли використовується звичайний Dart конструктор. Натомість ми в команді покладаємося на атрибут @Default
із freezed пакета, оскільки він покриває обидва випадки: коли клас створюється за допомогою звичайного конструктора MarvelComic()
і коли його десеріалізовано з JSON через конструктор MarvelComic.fromJson()
.
У наших проектах ми також використовуємо параметри unknownEnumValue
і readValue
, про які поговоримо трошки згодом.
4. Перерахування в JSON
Серед іншого, в рядку 9
клас MarvelComic
має поле format
типу перерахування MarvelComicFormat?
.
Згенерована реалізація JSON серіалізації у файлі .g.dart
у рядку 20
використовує назви елементів перерахування як ключі в _$MarvelComicFormatEnumMap
:
Варто зазначити, що метод $enumDecodeNullable
з пакету json_annotation поверне ключ з мапи _$MarvelComicFormatEnumMap
, під яким зберігається json['format']
значення, якщо воно існує в мапі.
Насправді ж, Marvel Comic API використовує значення такі як «Trade Paperback» або «Graphic Novel» замість «tradePaperback» або «graphicNovel», як у рядках 23
і 26
. Отже, як продовжувати використовувати генерування коду та налаштувати ключі для перерахування таким чином, щоб його можна було десеріалізувати з існуючим механізмом? Пакет json_annotation для цього має атрибути @JsonEnum
і @JsonValue
.
Перерахування MarvelComicFormat
анотовано атрибутом @JsonEnum
у рядку 5
. Кожен елемент перерахування анотовано атрибутом @JsonValue
зі значенням, яке буде отримано від бекенда. Тепер створений файл .g.dart
змінився на:
Відтепер MarvelComic
може правильно розібрати поле MarvelComicFormat? format
з JSON, який надходить від Marvel Comic API.
Однак що, якщо з часом API буде розширено новими форматами коміксів? Як забезпечити стабільність програми без необхідності термінового оновлення? Атрибут @JsonKey
має ще один корисний параметер — unknownEnumValue
.
Спочатку у рядку 7
додамо до перерахування MarvelComicFormat
новий елемент unknown
:
Далі використаємо його у рядку 9
в атрибуті @JsonKey
і у рядку 10
в атрибуті @Default
:
Тепер згенерований файл .g.dart
передає цей MarvelComicFormat.unknown
методу $enumDecodeNullable
у рядку 9
і використовує його як резервне значення в рядку 10
:
Це означає, що щоразу, коли нове невідоме значення, як-от «Postcard», надходитиме з бекенду у поле format
, воно буде десеріалізоване як MarvelComicFormat.unknown
. До того ж, якщо в мапі json
не міститься значення для поля format
, результат $enumDecodeNullable
буде null
, але весь вираз все одно буде дорівнювати MarvelComicFormat.unknown
завдяки використанню атрибута @Default
.

Результат
Ми створили Dart клас MarvelComic
, реалізацію якого генерує пакет freezed, і який можна серіалізувати в та з JSON завдяки реалізації, створеній пакетом json_serializable.
Остаточна версія коду, розробленого в цій частині, знаходиться під тегом part-1 у Flutter Advanced Networking GitHub репозиторії.
Продовження у Частині 2: моделі даних з freezed та json_serializable. Про складне.
9 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів