Повна версія
Нетворкінг у Flutter додатках — про просте і складне на прикладі Tide.
Частина 2: моделі даних з freezed та json_serializable. Про складне
Підписуйтеся наTelegram-канал «DOU #tech», щоб не пропустити нові технічні статті.
Привіт! Я Анна — експертка з мобільної розробки, GDE з Dart та Flutter, досвідчена розробниця мобільних додатків на Flutter.
Більшість додатків, чи то мобільні, чи то веб, чи десктоп, залежать від того чи іншого бекенда. Отже, імплементація комунікації з API є невід’ємною частиною реалізації додатку. У цій серії з шести частин представлені інструменти та підходи, які полегшують розробку комунікації з API у Flutter додатках, які ми використовуємо в Tide.
Обіцяю, буде корисно і цікаво розробникам будь-якого рівня!
Read in English
Якщо загубилися, почніть читати з початку
Частини 1 і 2 цієї серії присвячені створенню Dart класів для зберігання даних, отриманих в результаті API запитів, і логіки серіалізації цих даних із та в JSON.
Ця частина має на меті надати більше інформації про можливості пакетів freezed та json_serializable.
В цій частині про складне:
- JSON конвертери
- union в JSON
- generics в JSON
Про просте читайте в Частині 1 цієї серії:
- проста модель даних
- JSON серіалізація
- атрибут JsonKey
- перерахування в JSON
На момент випуску цієї серії актуальна версія Flutter 3.0.
Приклади будуються на основі коду, створеного в Частині 1, який знаходиться під тегом part-1 у Flutter Advanced Networking GitHub репозиторії.
**
**
1. JSON конвертери
Як було показано в попередній частині, json_serializable підтримує типи
полів int, double, String, DateTime, List і Map, перерахування та
багато інших. DateTime автоматично конвертується в і з String у форматі
Iso8601. Але що робити, якщо з якоїсь причини бекенд використовує інший формат
DateTime, або якщо тип даних у JSON не відповідає типу поля у класі? Для
вирішення цієї задачі json_serializable пропонує механізм конвертерів.
Повернемось до MarvelComic моделі з попередньої частини. Бекенд надсилає
дані для полів int id і int? digitalId:
Сгенерований .g.dart файл містить:
Уявімо, що з якоїсь причини у додатку натомість потрібно використовувати
String id. Перше, що потрібно зробити, це створити JsonConverter:
Цей конвертер призначений для десеріалізації поля типу int з JSON в поле
типу String в Dart моделі. Щоб виконати протилежне перетворення, базовим
класом має бути JsonConverter<int, String>.
У рядку 8 конвертер IntToStringConverter застосовано до поля id, що
тепер має тип String:
В результаті, IntToStringConverter автоматично застосовано в згенерованому
.g.dart файлі у рядках 7 та 15:
Може здатися логічним, що аналогічним чином IntToStringConverter можна
застосувати для перетворення поля digitalId з int? до String?. Однак у
Dart nullable типи не є такими самими, як їхні не-nullable аналоги, і через
невідповідність типів такий конвертер просто не буде застосований у
згенерованому файлі. Замість цього конвертер повинен явно вказувати, що він
призначений для перетворення nullable значеннь:
Після того, як NullableIntToNullableStringConverter застосовано до
digitalId поля, що тепер має тип String?, у рядку 9:
він також використовується у згенерованому .g.dart файлі у рядках 8 та
16:
Поки що, перетворення полів з ідентифікаторами з String на int і навпаки,
як nullable, так і не-nullable, є єдиним кейсом для використання конвертерів у
наших проектах.
**
**
2. Union в JSON
Серед інших фічей, freezed має підтримку union. Розглянемо наступний приклад.
Marvel Comic API має об’єкт StorySummary , який відповідає історії в коміксах Marvel, і історії можуть бути трьох типів: cover , interior та promo.
Новий клас MarvelStorySummary — це freezed модель, оголошена так само, як
і MarvelComic з попередньої частини. Різниця полягає в тому, що
MarvelStorySummary має три іменовані конструктори: .cover у рядку 10,
.interior у рядку 15 і .promo у рядку 21. І три відповідні внутрішні
класи імплементації: _CoverMarvelStorySummary у рядку 13,
_InteriorMarvelStorySummary у рядку 18, та _PromoMarvelStorySummary у
рядку 24.
В звичайних Dart класах ця ієрархія виглядала б наступним чином:
Але наша імплементація краще. Згенерований файл .freezed.dart містить
додаткові методи для pattern-matching: map, mapOrNull, maybeMap, when,
whenOrNull, maybeWhen. Це дуже корисний функціонал, який ми активно
використовуємо в наших проектах, тому дійсно рекомендую ознайомитись з
документацією.
Надалі ми зосередимося на тому, як налаштувати генерування коду за допомогою json_serializable для автоматичної десеріалізації цієї складної структури даних з JSON.
Згенерований файл .g.dart містить інструкції щодо десеріалізації моделі
_CoverMarvelStorySummary у рядку 5, моделі _InteriorMarvelStorySummary у
рядку 21, моделі _PromoMarvelStorySummary у рядку 37.
А згенерований файл .freezed.dart містить switch, який вирішує, в який тип
десеріалізувати модель на основі значення runtimeType:
У зренерованій реализації, щоб правильно десерілізувати об’єкт
MarvelStorySummary до одного з його union підтипів, вхідний json повинен
мати ключ runtimeType із значенням cover, interior або promo.
Очевидно, малоймовірно, що бекенд надсилатиме саме таке поле саме з такими
значеннями, особливо API, яке ми не контролюємо. Однак бекенд має надсилати
якийсь маркер. У випадку об’єкта StorySummary із Marvel Comic API, це поле
type , яке може мати значення cover , interiorStory або promo.
На щастя, freezed пакет надає можливості налаштувати як назву ключа з типом
union в рядку 5, так і імена конструкторів у рядках 7, 12 і 17:
Після цих змін згенерований файл .freezed.dart змінюється в рядках 6, 7,
9 і 11:
Це означає, що поле типу MarvelStorySummary тепер можна додати в модель
MarvelComic, і легко конвертувати в/з JSON при конвертації екземпляру
MarvelComic.
Але що, якщо все не так просто з бекендом, або вам потрібно більше контролю над тим, що відбувається під час десеріалізації union. Давайте розглянемо інший приклад: об’єкт CreatorSummary з Marvel Comic API, що містить дані про творця коміксу. Він має поле role , яке може мати значення editor , writer , inker , penciller , penciller (cover) , colorist та інші. Серед вимог до десеріалізації, по-перше, потрібно конвертувати penciller (cover) як звичайний penciller. І по-друге, як і з неочікуваними перерахуваннями з попередньої частини, нам краще бути готовими до нових ролей CreatorSummary.
Почнемо з того, що новий клас MarvelCreatorSummary також є union:
який визначає конструктори для всіх очікуваних типів творців, та other для
інших та невідомих.
Знову, згенерований файл .freezed.dart містить switch у рядку 6, який
вирішує, до якого підтипу union моделі слід десеріалізувати вхідну json
мапу:
Щоб задовольнити вимоги, трюк полягає в тому, щоб змінити вхідну мапу json,
надавши необхідний ключ runtimeType з відповідним значенням на основі
значення поля role об’єкта CreatorSummary з Marvel Comic API.
Перш ніж передати вхідний json методу _$MarvelCreatorSummaryFromJson у
рядку 10, він модифікується методом _appendRuntimeType у рядку 13. Там
значення нового елемента json мапи з runtimeType визначається на основі
json[‘role’] та _runtimeTypesMap мапи відповідності між значеннями з
бекенду та фронтенду у рядку 16. Ключами цієї мапи є значення поля role
так, як воно надходить від Marvel Comic API, а значення є іменами відповідних
конструкторів union MarvelCreatorSummary. Якщо _runtimeTypesMap не містить
такого ключа, ім’я конструктора other використовується в рядку 14.
Тепер поле типу MarvelCreatorSummary також можна включити в модель
MarvelComic і легко конвертувати в/з JSON, коли десеріалізується екземпляр
MarvelComic.
Ми щойно розглянули сценарії, де маркер union типу міститься всередині самої
моделі, як-от поле type об’єкта StorySummary або поле role об’єкта
CreatorSummary із Marvel Comic API. Але випадок, коли такий маркер є
частиною зовнішньої моделі, не є рідкісним. По суті, рішення буде таким самим:
якимось чином змінити вхідний JSON, і додати ключ runtimeType з відповідним
імʼям конструктора union. Ми вже бачили приклад, коли json модифікується
всередині фабричного конструктора .fromJson(). Але є ще одне місце, де можна
змінити вхідний json — метод readValue, який підтримує логіку, яка вимагає
одночасного доступу до значень кількох полів.
Наступний приклад буде штучним для Marvel Comic API, то ж давайте використаємо нашу уяву. Аналогічні кейси трапляються в наших проектах.
Теоретичний клас MarvelSeriesSummary має поле format у рядку 14 і поле
metadata union типу в рядку 17.
Задача в тому, щоб десеріалізувати поле metadata до одного з union підтипів
MarvelSeriesSummaryMetadata на основі значення поля format.
Знову ж таки, файл .freezed.dart нового union MarvelSeriesSummaryMetadata
містить switch у рядку 6 і покладається на значення runtimeType для
вибору належного конструктора:
І знову ж таки, завдання полягає в тому, щоб покласти відповідне значення за
ключем runtimeType. Для цього поле MarvelSeriesSummary.metadata отримує
новий параметр readValue у рядку 12:
Ось що відбувається всередині методу _readFormatMetadataValue:
По-перше, значення поля format зчитується з вхідного json у рядку 8 так
само, як воно буде прочитано пізніше у фарбичному конструкторі .fromJson().
Далі, в рядку 13 відповідне значення runtimeType вираховується шляхом
пошуку ключа з значенням format з рядка 8 у мапі _runtimeTypes у рядку
18.
Використання функції readValue не змінює подальшої логіки десеріалізації
поля metadata, тому метод _readFormatMetadataValue повинен повертати мапу,
присвячену класу MarvelSeriesSummaryMetadata, яку ми отримаємо як
json[key] у рядку 14. Безпосередньо перед поверненням, до цієї мапи
додається ключ ’runtimeType’ зі значенням змінної runtimeType з рядка
13.
Таким чином, ми розглянули три способи десеріалізації union типів з вхідного JSON.
**
**
3. Generics в JSON
Часто відповіді з бекенду містять дані, загорнуті в загальний об’єкт, що містить метадані запиту. Очевидно, що не круто дублювати код, оголошуючи ті самі класи відповідей API знову і знову, змінюючи лише тип внутрішнього класу даних, щоб автоматично десеріалізувати вміст JSON. У такій ситуації доречно використовувати generics.
Цього разу неможливо використовувати freezed у поєднанні з json_serializable, але сам по собі json_serializable вирішує це завдання. Ми змушені пожертвувати зручними методами порівняння та копіювання, які надає freezed, але коли справа доходить до об’єктів з API відповідями, вони насправді не використовуються.
Новий generic клас MarvelApiResponse<T> анотується
@JsonSerializable(genericArgumentFactories: true) у рядку 7, а кожне поле
анотується @JsonKey у рядках 21, 24 та 27. Дефолтний конструктор
приховано, оскільки немає потреби створювати екземпляри MarvelApiResponse
безпосередньо в коді. Через це, в рядку 7 також надається ім’я конструктора
за допомогою @JsonSerializable(constructor: ‘_’).
Згенерований файл .g.dart містить:
На відміну від MarvelComic, методи .fromJson() і .toJson() generic класу
MarvelApiResponse<T> приймають два параметри, де другий параметр вказує, як
десеріалізувати внутрішнє generic поле data.
Тепер можливо десеріалізувати generic MarvelApiResponse, що містить
екземпляр MarvelComic, наступним чином:
MarvelApiResponse можна використовувати для зберігання та десеріалізації
будь-якого іншого типу, який має конструктор .fromJson(Map<String, dynamic>
json).
**
**
Результат
Ми підготували повнофункціональну модель зі значеннями за замовчуванням, конвертерами, перерахуваннями та union, та generic клас API відповіді, які будуть використані пізніше в цій серії для серіалізації та зберігання даних, отриманих з Marvel Comic API.
Остаточна версія коду, розробленого в цій частині, знаходиться під тегом part-2 у Flutter Advanced Networking GitHub-репозиторії.
Продовження у Частині 3: HTTP клієнт та перехоплювачі запитів з dio. Про просте.
Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU