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

Автор або джерело: Anna Leushchenko

Першоджерело

Flutter mobile, tech,

Повна версія

Нетворкінг у 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.

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

  1. JSON конвертери
  2. union в JSON
  3. generics в JSON

Про просте читайте в Частині 1 цієї серії:

  1. проста модель даних
  2. JSON серіалізація
  3. атрибут JsonKey
  4. перерахування в 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