Flutter Mobile Development · Урок

Сохранение состояния с HydratedBloc

Автоматически сериализуйте и восстанавливайте состояние BLoC после перезапуска приложения с помощью hydrated_bloc.

Урок 3 из 413 шагов

«Сохранение состояния с HydratedBloc» — бесплатный урок Flutter Mobile Development на CoddyKit. Это урок 3 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Flutter Mobile Development, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Flutter Mobile Development содержит 4 уроков всего.

Части этого урока еще не переведены и отображаются на английском.

Why Persist BLoC State?

By default, a Bloc or Cubit loses everything when the app process is killed. Reopening the app re-runs the initial state, so the user's last selections, cart contents, or theme are gone.

hydrated_bloc solves this without you writing manual save/load code. It transparently:

  • Serializes each new state to local storage as it is emitted
  • Restores the last state automatically when the BLoC is recreated

This is ideal for UI preferences, onboarding flags, and small session data that should survive an app restart.

Setup and Storage Initialization

Add the packages to pubspec.yaml:

  • hydrated_bloc provides HydratedBloc and HydratedCubit
  • path_provider supplies a writable directory on the device

Before your app runs, you must build a storage backend and assign it to HydratedBloc.storage. Because this touches platform channels, wrap it with WidgetsFlutterBinding.ensureInitialized().

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  HydratedBloc.storage = await HydratedStorage.build(
    storageDirectory: kIsWeb
        ? HydratedStorageDirectory.web
        : HydratedStorageDirectory(
            (await getApplicationDocumentsDirectory()).path,
          ),
  );

  runApp(const MyApp());
}

From Cubit to HydratedCubit

The simplest way to persist state is to extend HydratedCubit instead of Cubit. You must override two methods:

  • toJson(state) converts the state into a JSON-encodable Map (or returns null to skip persistence)
  • fromJson(json) rebuilds the state from that map (or returns null to fall back to the constructor's initial state)

Here a counter survives restarts with just a few lines.

class CounterCubit extends HydratedCubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);

  @override
  int? fromJson(Map<String, dynamic> json) => json['value'] as int?;

  @override
  Map<String, dynamic>? toJson(int state) => {'value': state};
}

How fromJson and toJson Drive Persistence

The flow is fully automatic once the methods are defined:

  • On every emit, hydrated_bloc calls toJson and writes the result to storage keyed by the BLoC's storageToken.
  • When the BLoC is constructed, the base class reads stored JSON and calls fromJson; the returned value becomes the starting state, overriding the value passed to super(...).

If fromJson returns null (no data yet, or a decode failure), the constructor's initial state is used instead. This null-fallback is your safety net.

Persisting a Custom State Class

Real apps rarely store a plain int. For a custom state, map every field you care about in toJson and read it back in fromJson. Below, a settings state with a theme flag and font scale is fully serialized.

Keep the JSON shape stable and explicit so future versions can still read old data.

class SettingsState {
  final bool darkMode;
  final double fontScale;
  const SettingsState({required this.darkMode, required this.fontScale});

  Map<String, dynamic> toMap() =>
      {'darkMode': darkMode, 'fontScale': fontScale};

  factory SettingsState.fromMap(Map<String, dynamic> m) => SettingsState(
        darkMode: m['darkMode'] as bool? ?? false,
        fontScale: (m['fontScale'] as num?)?.toDouble() ?? 1.0,
      );
}

class SettingsCubit extends HydratedCubit<SettingsState> {
  SettingsCubit()
      : super(const SettingsState(darkMode: false, fontScale: 1.0));

  void toggleDark() =>
      emit(SettingsState(darkMode: !state.darkMode, fontScale: state.fontScale));

  @override
  SettingsState? fromJson(Map<String, dynamic> json) =>
      SettingsState.fromMap(json);

  @override
  Map<String, dynamic>? toJson(SettingsState state) => state.toMap();
}

Using HydratedBloc with Events

For event-driven logic, extend HydratedBloc instead of HydratedCubit. The toJson/fromJson contract is identical; only state production changes (you register event handlers with on<Event>).

This is the right choice when transitions need event semantics, logging, or transformers.

sealed class CartEvent {}
class ItemAdded extends CartEvent {
  final String sku;
  ItemAdded(this.sku);
}

class CartBloc extends HydratedBloc<CartEvent, List<String>> {
  CartBloc() : super(const []) {
    on<ItemAdded>((event, emit) => emit([...state, event.sku]));
  }

  @override
  List<String>? fromJson(Map<String, dynamic> json) =>
      (json['items'] as List?)?.cast<String>();

  @override
  Map<String, dynamic>? toJson(List<String> state) => {'items': state};
}

Pure Serialization Logic (Testable)

The heart of persistence is plain Dart: turning an object into a Map and back. You can unit-test that round-trip with no Flutter, no storage, and no device. Below is a standalone program proving the encode/decode is lossless.

Treat your toJson/fromJson as ordinary functions and test them in isolation before wiring them into a BLoC.

import 'dart:convert';

Map<String, dynamic> toJson(int value) => {'value': value};
int? fromJson(Map<String, dynamic> json) => json['value'] as int?;

void main() {
  final state = 42;
  final encoded = jsonEncode(toJson(state));
  print('stored: $encoded');

  final decoded = fromJson(jsonDecode(encoded) as Map<String, dynamic>);
  print('restored: $decoded');
  print('lossless: ${decoded == state}');
}

Schema Migration with a Version Field

Once your app ships, old serialized data lives on users' devices. If you add or rename fields, naive fromJson can crash or read garbage. The standard defense is a version field written into the JSON.

On read, branch on the version and upgrade old shapes. This demo migrates a v1 payload (single name) into v2 (firstName/lastName).

import 'dart:convert';

Map<String, dynamic> migrate(Map<String, dynamic> json) {
  final version = json['v'] as int? ?? 1;
  if (version >= 2) return json;

  final parts = (json['name'] as String).split(' ');
  return {
    'v': 2,
    'firstName': parts.first,
    'lastName': parts.length > 1 ? parts.last : '',
  };
}

void main() {
  final oldData = jsonDecode('{"v":1,"name":"Ada Lovelace"}');
  final upgraded = migrate(oldData as Map<String, dynamic>);
  print(upgraded);
}

Defensive fromJson: Surviving Corrupt Data

If fromJson throws, hydrated_bloc catches it and falls back to the initial state, but a partially valid map can still produce a bad state. Make decoding total: validate types, supply defaults, and return null when the data is unusable.

Returning null is intentional and safe — it tells hydrated_bloc to use the constructor's initial state instead.

@override
SettingsState? fromJson(Map<String, dynamic> json) {
  try {
    final scale = (json['fontScale'] as num?)?.toDouble();
    if (scale == null || scale <= 0) return null; // reject bad data
    return SettingsState(
      darkMode: json['darkMode'] as bool? ?? false,
      fontScale: scale,
    );
  } catch (_) {
    return null; // fall back to initial state
  }
}

Selective Persistence and Clearing State

You do not have to persist every state. Returning null from toJson skips writing for that emission — useful for transient loading or error states you would not want restored.

To wipe stored data, call clear() on the instance (removes just this BLoC's entry) or HydratedBloc.storage.clear() (wipes everything, e.g. on logout).

class AuthCubit extends HydratedCubit<AuthState> {
  AuthCubit() : super(const Unauthenticated());

  void logout() {
    clear();           // remove this cubit's persisted entry
    emit(const Unauthenticated());
  }

  @override
  Map<String, dynamic>? toJson(AuthState state) =>
      state is Authenticated ? {'token': state.token} : null; // skip others

  @override
  AuthState? fromJson(Map<String, dynamic> json) =>
      json['token'] is String ? Authenticated(json['token'] as String) : null;
}

Multiple Instances and storageToken

hydrated_bloc keys persisted data by a storageToken, which defaults to runtimeType. So two instances of the same BLoC class share one storage slot and will overwrite each other.

If you need per-entity persistence (for example, one cubit per chat room), override id so each instance gets a unique token like ChatCubit-room42.

class ChatCubit extends HydratedCubit<List<String>> {
  final String roomId;
  ChatCubit(this.roomId) : super(const []);

  @override
  String get id => roomId; // token becomes 'ChatCubit-$roomId'

  @override
  List<String>? fromJson(Map<String, dynamic> json) =>
      (json['messages'] as List?)?.cast<String>();

  @override
  Map<String, dynamic>? toJson(List<String> state) => {'messages': state};
}

Quick Check

A teammate reports that a HydratedCubit emits transient Loading states, and after an app restart the UI is sometimes stuck showing a spinner that never resolves. What is the cleanest fix?

Recap

You can now persist BLoC state across restarts with hydrated_bloc:

  • Initialize HydratedBloc.storage in main() after ensureInitialized().
  • Extend HydratedCubit or HydratedBloc and override toJson/fromJson.
  • Restoration is automatic on construction; null from fromJson safely falls back to the initial state.
  • Return null from toJson to skip persisting transient states, and use clear() on logout.
  • Guard against corrupt data with defensive decoding and version-based migration.
  • Override id when you need per-instance storage tokens.

Keep persisted payloads small and your serialization pure and tested — that is what makes restart-safe state reliable at scale.

Можно начать бесплатно

Изучай Dart с ИИ-репетитором — бесплатно

Пиши и запускай код прямо в браузере, получай мгновенную помощь от ИИ-репетитора 24/7 и продолжи учиться на сайте или в приложении.

Курсы
22
Уроки
88

Часто задаваемые вопросы

Урок «Сохранение состояния с HydratedBloc» бесплатный?

Да — полный текст урока «Сохранение состояния с HydratedBloc» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Flutter Mobile Development, подпишись на CoddyKit PRO. Курс Flutter Mobile Development содержит 4 уроков всего.

Чему я научусь в уроке «Сохранение состояния с HydratedBloc»?

Автоматически сериализуйте и восстанавливайте состояние BLoC после перезапуска приложения с помощью hydrated_bloc. Ты практикуешь Flutter Mobile Development с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать Flutter Mobile Development?

Предыдущий опыт не требуется. Flutter Mobile Development на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 3 из 4.

Сколько времени занимает урок «Сохранение состояния с HydratedBloc»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке Flutter Mobile Development?

Да. Каждый урок Flutter Mobile Development включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. События, состояния и выбор между Cubit и Bloc
  2. Преобразователи потоков и устранение дребезга событий в BLoC
  3. Сохранение состояния с HydratedBloc
  4. Тестирование BLoC с bloc_test и Mocktail
← Назад к Flutter Mobile Development