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