Урок 3 из 25 · Месяц 4. Состояние, сеть и Firebase — WeatherApp
Парсинг JSON: Equatable, json_serializable, модели и Dio GET
Содержание урока
- Что такое JSON
- Модель вручную: fromJson и toJson
- Equatable: сравнение по значению
- json_serializable: генерация кода
- Установка
- Модель с аннотациями
- Запуск генерации
- Вложенные модели
- Dio: делаем GET-запрос
- Что такое Dio
- Что такое GET
- Создаём клиент
- Запрос
- Логирование запросов
- Собираем вместе: репозиторий
- Проверка на экране
- Типичные ошибки
- Практика
- Итоги
Почти любое приложение получает данные с сервера, и почти всегда они приходят в формате JSON. В этом уроке научимся превращать JSON в удобные Dart-классы (модели), сравнивать их через Equatable, генерировать fromJson/toJson автоматически с json_serializable и делать первый настоящий GET-запрос через Dio к бесплатному погодному API Open-Meteo.
Что такое JSON
JSON — текстовый формат обмена данными. Сервер присылает строку, похожую на Dart-словарь:
{
"city": "Бишкек",
"temperature": 21.5,
"isDay": true,
"tags": ["солнечно", "ветрено"]
}
В Dart после декодирования (jsonDecode) это превращается в Map<String, dynamic>:
| JSON | Dart |
|---|---|
{ ... } объект |
Map<String, dynamic> |
[ ... ] массив |
List<dynamic> |
"текст" |
String |
21 / 21.5 |
int / double |
true / false |
bool |
null |
null |
Работать с Map напрямую неудобно: data['temprature'] с опечаткой компилятор не поймает, а тип dynamic не подскажет, что внутри. Поэтому JSON превращают в модель — обычный класс с типизированными полями.
Модель вручную: fromJson и toJson
class Weather {
const Weather({
required this.city,
required this.temperature,
required this.isDay,
});
final String city;
final double temperature;
final bool isDay;
factory Weather.fromJson(Map<String, dynamic> json) {
return Weather(
city: json['city'] as String,
temperature: (json['temperature'] as num).toDouble(),
isDay: json['isDay'] as bool,
);
}
Map<String, dynamic> toJson() => {
'city': city,
'temperature': temperature,
'isDay': isDay,
};
}
Разбор:
fromJson— именованныйfactory-конструктор: принимает словарь и возвращает объект. «Из JSON в класс».toJson— обратное: объект в словарь, чтобы отправить на сервер или сохранить.(json['temperature'] as num).toDouble()— сервер может прислать21(int) вместо21.0. Приведение черезnumспасает от ошибкиtype 'int' is not a subtype of type 'double'.
Использование:
import 'dart:convert';
void main() {
const raw = '{"city":"Бишкек","temperature":21,"isDay":true}';
final map = jsonDecode(raw) as Map<String, dynamic>;
final weather = Weather.fromJson(map);
print(weather.temperature); // 21.0
print(jsonEncode(weather.toJson()));
}
Equatable: сравнение по значению
По умолчанию Dart сравнивает объекты по ссылке: два одинаковых Weather — разные объекты, и == вернёт false.
final a = Weather(city: 'Бишкек', temperature: 20, isDay: true);
final b = Weather(city: 'Бишкек', temperature: 20, isDay: true);
print(a == b); // false
Для Bloc это важно: он сравнивает новое состояние со старым и не перерисовывает экран, если они равны. Чтобы равенство работало «по содержимому», нужно переопределить == и hashCode. Вручную это долго и легко ошибиться, поэтому есть пакет equatable:
import 'package:equatable/equatable.dart';
class Weather extends Equatable {
const Weather({required this.city, required this.temperature, required this.isDay});
final String city;
final double temperature;
final bool isDay;
@override
List<Object?> get props => [city, temperature, isDay];
}
Теперь a == b вернёт true. В props перечисляйте все поля, которые определяют равенство. Забыли поле — объекты с разными значениями этого поля будут считаться одинаковыми.
json_serializable: генерация кода
Когда полей десятки, писать fromJson руками скучно. Пакет json_serializable генерирует этот код по аннотациям.
Установка
flutter pub add json_annotation
flutter pub add dev:build_runner dev:json_serializable
json_annotation— аннотации вроде@JsonSerializable(), нужны в приложении;build_runnerиjson_serializable— инструменты генерации, нужны только при разработке (dev:).
Модель с аннотациями
Создадим модель под настоящий ответ Open-Meteo. Файл lib/models/current_weather.dart:
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'current_weather.g.dart';
@JsonSerializable()
class CurrentWeather extends Equatable {
const CurrentWeather({
required this.time,
required this.temperature,
required this.windSpeed,
required this.weatherCode,
});
final String time;
@JsonKey(name: 'temperature_2m')
final double temperature;
@JsonKey(name: 'wind_speed_10m')
final double windSpeed;
@JsonKey(name: 'weather_code')
final int weatherCode;
factory CurrentWeather.fromJson(Map<String, dynamic> json) =>
_$CurrentWeatherFromJson(json);
Map<String, dynamic> toJson() => _$CurrentWeatherToJson(this);
@override
List<Object?> get props => [time, temperature, windSpeed, weatherCode];
}
Разбор:
part 'current_weather.g.dart';— подключает файл, который сгенерирует build_runner. Имя должно совпадать с именем текущего файла плюс.g.dart.@JsonSerializable()— «сгенерируй для этого класса код JSON».@JsonKey(name: 'temperature_2m')— в JSON ключ называется по-другому, чем поле в Dart. Мы пишем красивоtemperature, а генератор знает, что братьtemperature_2m._$CurrentWeatherFromJsonи_$CurrentWeatherToJson— функции, которые появятся в.g.dart.
Запуск генерации
dart run build_runner build --delete-conflicting-outputs
Рядом появится current_weather.g.dart. Его не редактируют руками — при следующей генерации изменения затрутся. Во время активной разработки удобнее режим наблюдения:
dart run build_runner watch --delete-conflicting-outputs
Он сам перегенерирует код при каждом сохранении файла.
Вложенные модели
Ответ Open-Meteo выглядит так (сокращённо):
{
"latitude": 42.875,
"longitude": 74.625,
"timezone": "Asia/Bishkek",
"current": {
"time": "2026-10-10T14:00",
"temperature_2m": 17.3,
"wind_speed_10m": 6.8,
"weather_code": 2
}
}
Внешний объект — тоже модель, внутри которой лежит CurrentWeather:
import 'package:json_annotation/json_annotation.dart';
import 'current_weather.dart';
part 'forecast_response.g.dart';
@JsonSerializable()
class ForecastResponse {
const ForecastResponse({
required this.latitude,
required this.longitude,
required this.timezone,
required this.current,
});
final double latitude;
final double longitude;
final String timezone;
final CurrentWeather current;
factory ForecastResponse.fromJson(Map<String, dynamic> json) =>
_$ForecastResponseFromJson(json);
Map<String, dynamic> toJson() => _$ForecastResponseToJson(this);
}
Генератор видит, что у CurrentWeather есть fromJson, и вызовет его сам. Для вложенных объектов в toJson добавьте @JsonSerializable(explicitToJson: true), иначе внутренний объект не превратится в Map.
Dio: делаем GET-запрос
Что такое Dio
Dio — HTTP-клиент для Dart. Он умеет то же, что встроенный пакет http, плюс: базовый адрес, таймауты, автоматический разбор JSON, перехватчики (interceptors), отмену запросов и понятные ошибки.
flutter pub add dio
Что такое GET
HTTP-запрос — это «письмо» серверу. У письма есть метод — что мы хотим сделать. GET — «дай мне данные», ничего не меняя на сервере. Параметры GET-запроса передаются в адресе после ?:
https://api.open-meteo.com/v1/forecast?latitude=42.87&longitude=74.59¤t=temperature_2m,wind_speed_10m,weather_code&timezone=auto
Open-Meteo бесплатен и не требует ключа API — идеально для учёбы.
Создаём клиент
import 'package:dio/dio.dart';
final dio = Dio(
BaseOptions(
baseUrl: 'https://api.open-meteo.com/v1',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
baseUrl— общая часть адреса; дальше в запросах пишем только путь.connectTimeout— сколько ждать соединения с сервером.receiveTimeout— сколько ждать ответа.
Запрос
Future<ForecastResponse> fetchForecast(double lat, double lon) async {
final response = await dio.get<Map<String, dynamic>>(
'/forecast',
queryParameters: {
'latitude': lat,
'longitude': lon,
'current': 'temperature_2m,wind_speed_10m,weather_code',
'timezone': 'auto',
},
);
return ForecastResponse.fromJson(response.data!);
}
Разбор:
dio.get<Map<String, dynamic>>— в угловых скобках тип, который мы ожидаем вresponse.data. Dio сам декодирует JSON —jsonDecodeне нужен.queryParameters— Dio сам соберёт?latitude=...&longitude=...и правильно закодирует символы. Не склеивайте адрес строками вручную.response.data!— тело ответа. Восклицательный знак — потому что тип nullable; при успешном ответе с JSON тело будет.- Возвращаем уже готовую модель, а не сырой
Map.
Объект Response содержит и другие полезные поля: response.statusCode (например, 200), response.headers, response.requestOptions.
Логирование запросов
Чтобы видеть в консоли, что уходит и приходит, добавьте встроенный перехватчик:
dio.interceptors.add(LogInterceptor(responseBody: true));
Собираем вместе: репозиторий
Код запросов не пишут в виджетах. Его кладут в отдельный класс — репозиторий, который отдаёт наружу модели:
class WeatherRepository {
WeatherRepository(this._dio);
final Dio _dio;
Future<CurrentWeather> getCurrent(double lat, double lon) async {
final response = await _dio.get<Map<String, dynamic>>(
'/forecast',
queryParameters: {
'latitude': lat,
'longitude': lon,
'current': 'temperature_2m,wind_speed_10m,weather_code',
'timezone': 'auto',
},
);
return ForecastResponse.fromJson(response.data!).current;
}
}
Bloc получает репозиторий в конструкторе и вызывает getCurrent в обработчике события. Обработку ошибок (нет сети, код 500) добавим в следующем уроке — Dio: POST, PUT, DELETE и состояния.
Проверка на экране
Самый быстрый способ проверить — FutureBuilder из урока ChangeNotifier и FutureBuilder:
class _HomeState extends State<Home> {
late final Future<CurrentWeather> _future =
WeatherRepository(dio).getCurrent(42.87, 74.59);
@override
Widget build(BuildContext context) {
return FutureBuilder<CurrentWeather>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) return Text('Ошибка: ${snapshot.error}');
if (!snapshot.hasData) return const CircularProgressIndicator();
final w = snapshot.data!;
return Text('${w.temperature}°C, ветер ${w.windSpeed} км/ч');
},
);
}
}
late final с инициализатором создаёт Future один раз — при первом обращении, а не при каждой перерисовке.
Типичные ошибки
type 'int' is not a subtype of type 'double'— сервер прислал целое число. В ручномfromJsonиспользуйте(x as num).toDouble(); json_serializable делает это сам.Target of URI hasn't been generated— забыли запустить build_runner или имя вpartне совпадает с именем файла.- Поле всегда
null— опечатка в@JsonKey(name: ...); сверьте с реальным ответом сервера. - Склейка URL строкой — пробелы и кириллица в адресе ломают запрос; используйте
queryParameters. - Android без интернета — в релизе нужен
<uses-permission android:name="android.permission.INTERNET"/>вAndroidManifest.xml(в debug он добавлен автоматически).
Практика
- Ручная модель. Напишите класс
Cityс полямиname,latitude,longitude,country, методамиfromJson/toJsonи Equatable. Ожидаемый результат:City.fromJson(city.toJson()) == cityвозвращаетtrue. - Генерация. Перепишите
Cityнаjson_serializableи запустите build_runner. Ожидаемый результат: появилсяcity.g.dart, код работает так же. - Первый запрос. Выполните GET к Open-Meteo для своего города и выведите температуру в консоль через
print. Ожидаемый результат: в консоли реальное число, а сLogInterceptor— весь ответ. - Геокодинг. Сделайте запрос к
https://geocoding-api.open-meteo.com/v1/searchс параметрамиname,count: 5,language: 'ru'. Ответ содержит массивresults. Опишите модельGeocodingResponseсо спискомList<City>?(resultsможет отсутствовать). Ожидаемый результат: по слову «Ош» выводится список найденных городов. - Почасовой прогноз. Добавьте параметр
hourly: 'temperature_2m'. В ответе придут два массива —hourly.timeиhourly.temperature_2m. Сделайте модель и покажите первые 12 часов вListView. Ожидаемый результат: список «время — температура».
Итоги
- JSON после декодирования — это
Map<String, dynamic>; работать удобнее с типизированной моделью. fromJsonпревращает словарь в объект,toJson— объект в словарь.- Equatable даёт сравнение по значению через
props— это важно для Bloc. - json_serializable + build_runner генерируют код;
@JsonKey(name:)связывает поле с ключом JSON. - Dio:
BaseOptionsсbaseUrlи таймаутами,dio.get(path, queryParameters: ...),response.data. - Запросы живут в репозитории, а не в виджетах.