Повна версія
Нетворкінг у Flutter додатках — про просте і складне на прикладі Tide.
Частина 1: моделі даних з freezed та json_serializable. Про просте
Підписуйтеся наTelegram-канал «DOU #tech», щоб не пропустити нові технічні статті.
Привіт! Я Анна — експертка з мобільної розробки, 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.
В цій частині про просте:
- Проста модель даних.
- 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. Про складне.
Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU