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

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

Привіт! Я Анна — експертка з мобільної розробки, 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. Про просте.

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному2
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

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