Нетворкінг у Flutter додатках — про просте і складне на прикладі Tide. Частина 1: моделі даних з freezed та json_serializable. Про просте

Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!

Привіт! Я Анна — експертка з мобільної розробки, GDE з Dart та Flutter, досвідчена розробниця мобільних додатків на Flutter.

Більшість додатків, чи то мобільні, чи то веб, чи десктоп, залежать від того чи іншого бекенда. Отже, імплементація комунікації з API є невід’ємною частиною реалізації додатку. У цій серії з шести частин представлені інструменти та підходи, які полегшують розробку комунікації з API у Flutter додатках, які ми використовуємо в Tide.

Обіцяю, буде корисно і цікаво розробникам будь-якого рівня!

Read in English

Якщо загубилися, почніть читати з початку.

Частини 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.

В цій частині про просте:

  1. Проста модель даних.
  2. JSON серіалізація.
  3. Атрибут JsonKey.
  4. Перерахування в JSON.

Про складне читайте Частині 2 цієї серії:

  1. JSON конвертери.
  2. union в JSON.
  3. 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. Про складне.

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному8
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

freezed дуже крута штука, але кодогенерація же жах на великих проектах.
1 час
2 усі пам’ятатимуть міграцію на nullsafety. Більше ніколи.

1. Пишите ишью и чпокайте разрабов чтобы фиксили, ускоряйте через generate_for, кешируйте на сиай
2. Вообще фигня. Это как детская травма что собака покусала)

Як людину, яка сама контрібʼютить в open-sourse, та має в своєму колі спілкування авторів популярних пакеті, ваш коментар мене обурив!
Розробники open-sourse пакетів, котрими ми всі користуємось і за рахунок яких можемо бути продуктивними у своїй роботі, присвячують цій діяльності свій вільний час та енергію, який могли б використати для монетизації своїх здібностей або відпочинку.
Створення issue в проекті в разі, якщо є очевидні проблеми — дійно гарна стратегія. Але поведінка, як ви висловились «чпокайте разрабов чтобы фиксили», призводить тільки до демотивації та понурення авторів. Навіщо б їм витрачати додатковий час на фікс, якщо замість подяки — агресія та відношення, ніби вони нам зобовʼязані.
Це врешті решт open-sourse. Якщо щось не влаштовує — можна завжди пофіксити самостійно. А на додачу створити PR автору, щоб фіксом змогли скористуватись й усі інші.
Робімо наше комʼюніті приємнішим і більш дружнім!

Хехе)
Я сам контрибутор.
И мейнтейню свои пакеты.
И сам хожу чпокаю мейнтейнеров чтобы мой ПР смерджили.
Потому я так и говорю. Если вас обурюе — сорян. Толкание и напоминание один из столпов опен сорс. К тому же тулинг для кодогенерации мейнтейнинся Гуглом, так что норм их поштурхать)

З мого досвіду, саме на великих проектах переваги використання кодогенерації й видніші. Ми маємо величезний проект з 150+ пакетами, і я навіть не уявляю, як ми могли би ефективно девелопити без кодогенерації.
Так, виконати кодогенерацію у всьому нашому проекті займає 40-80 хвилин, в залежності від потужності машини, але цю задачу ми запускаємо раз на місяць максимум. Оскільки ми додаємо згенеровані файли у git — і розробники, і CI не витрачають час на їх створення. Коли ж розробник активно працює над якимось функціоналом в ізольованому пакеті, і це дійсно вимагає запуску кодогенерації, це займає 1-2 хвилини.
Розробка з package-by-feature (або micro-frontends) та, як справедливо зауважили в іншому коментарі, використання налаштувань з generate_for у build.yaml робить кодогенерацію «great again».

виконати кодогенерацію у всьому нашому проекті займає 40-80 хвилин

Вот по этому и надо ходить и попинывать)
Должно быть 4-8 минут)

Попробуйте generate for может вам станет палехчи

Дякую за пост!
freezed — дуже крута штука, але синтаксис дещо заплутаний, як на мене.
Джавовий lombok, до прикладу, має дуже схоже призначення і функціонал, але значно простіший у використанні: projectlombok.org/features

Артеме, згодна, аби звикнути до синтаксису freezed потрібен час та практика. Гадаю, він був продиктований особливостями Dart, а також досвідом автора у інших фреймворках.
Напевно, й не зможу напамʼять написати freezed модель, бо завжди користуюсь Live template у Android Studio (Code snippets у VSCode). Тоді синтаксис стає другорядним.

Підписатись на коментарі