Kodokon kodokon.com

Моделирование данных: классы, JSON и неизменяемость

Преврати JSON в неизменяемые классы Dart с fromJson, toJson, copyWith и надёжным сравнением по значению.

9 мин · 3 вопросов

Открыть этот урок в Kodokon

На этом уровне настоящая тема - уже не синтаксис классов, а граница между внешним миром и твоим кодом. Пока ответ API гуляет по приложению в виде Map<String, dynamic>, каждое чтение поля - это лотерея во время выполнения. Профессиональное правило: преобразуй JSON в типизированные объекты сразу при получении, а дальше передавай только их. Добавь к этому неизменяемость - поля final, конструктор const - и ты уберёшь целое семейство багов: никто больше не изменит общий объект за твоей спиной, а два одинаковых экземпляра const компилятор сведёт в один.

DART
class User {
  const User({
    required this.id,
    required this.name,
    this.email,
  });

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: (json['id'] as num).toInt(),
      name: json['name'] as String,
      email: json['email'] as String?,
    );
  }

  final int id;
  final String name;
  final String? email;
}
Неизменяемая модель с защитным разбором JSON.

Три решения стоит проговорить отдельно. Первое, (json['id'] as num).toInt(): многие API сериализуют целое число то как 3, то как 3.0 - в зависимости от бэкенда; прямое приведение к int падает на double. Далее, as String сразу выбрасывает понятный TypeError, если поля нет: это сделано намеренно. Упасть быстро на разборе лучше, чем получить null, который расползётся и взорвётся тремя экранами позже. Наконец, as String? прямо в типе документирует, что поле действительно необязательное.

DART
class User {
  const User({required this.id, this.email});

  final int id;
  final String? email;

  Map<String, dynamic> toJson() => {
        'id': id,
        'email': email,
      };

  User copyWith({int? id, String? email}) {
    return User(
      id: id ?? this.id,
      email: email ?? this.email,
    );
  }
}
toJson и copyWith на модели, урезанной до двух полей.

Неизменяемость требует сравнения по значению. По умолчанию Dart сравнивает ссылки: два объекта User, одинаковых поле в поле, считаются «разными». Это вполне конкретная проблема, как только ты начинаешь сравнивать состояния - в тестах, в select из Provider, при поиске по списку. Переопределяй == и hashCode всегда вместе.

DART
class Point {
  const Point(this.x, this.y);

  final int x;
  final int y;

  @override
  bool operator ==(Object other) =>
      other is Point && other.x == x && other.y == y;

  @override
  int get hashCode => Object.hash(x, y);
}
Сравнение по значению: == и hashCode идут в паре.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Почему пишут (json['id'] as num).toInt(), а не json['id'] as int?
    • Потому что as int работает медленнее, чем as num, во время выполнения.
    • Потому что бэкенд может сериализовать целое число как 3.0: прямое приведение к int упадёт на double.
    • Потому что json['id'] всегда строка.
  2. Что даст user.copyWith(email: null) при классической реализации copyWith?
    • Поле email сбрасывается в null, как и запрошено.
    • Во время выполнения выбрасывается исключение.
    • Объект сохраняет текущий email: null ?? this.email возвращается к существующему значению.
    • Код не компилируется в строгом режиме.
  3. Зачем переопределять == и hashCode на неизменяемой модели?
    • Потому что Dart по умолчанию сравнивает ссылки: два одинаковых поле в поле объекта иначе оказались бы «разными».
    • Чтобы ускорить создание экземпляров const.
    • Это обязательно, как только поле объявлено final.