Примерно месяц назад общаясь с одним разработчиком приложения на Flutter встала проблема торможения обработки маленьких (в десятках тысяч) массивов данных на телефоне юзера.


Многие приложения предполагают обработку данных на телефоне и, далее, их синхронизацию с бэкендом. Например: списки дел, списки каких либо данных (анализов, заметок и т.п.).


Совсем не круто, когда список всего нескольких тысяч элементов, при удалении одного из них и далее записи в кеш или при поиске по кешу — начинает тормозить.


Решение есть! Hive — noSql база, написанная на чистом Dart, очень быстрая. Кроме этого плюсы Hive:


  • Кросс-платформенность — так как на чистом Dart и нет нативных зависимостей — mobile, desktop, browser.
  • Высокая производительность.
  • Встроенное сильное шифрование.

В статье мы посмотрим как использовать Hive и сделаем простое ToDo приложение, которое в следующей статье дополним авторизацией и синхронизацией с облаком.



 


Начало


Вот такое приложение мы напишем в конце статьи, код выложен на github.



Один из плюсов Hive — очень хорошая документация https://docs.hivedb.dev/ — по идее там все есть. В статье я просто кратко опишу как с чем работать и сделаем пример.


Итак, для подключения hive в проект добавляем в pubspec.yaml


dependencies:
  hive: ^1.4.1+1
  hive_flutter: ^0.3.0+2

dev_dependencies:
  hive_generator: ^0.7.0+2
  build_runner: ^1.8.1

Далее инициализируем, обычно в начале приложения.


await Hive.initFlutter();


В случае, если мы пишем приложение под разные платформы, можно сделать инициализацию с условием. Пакет hive_flutter: ^0.3.0+2 — просто сервисная обертка упрощающая работу с Flutter.


Типы данных


Из коробки, Hive поддерживает типы данных List, Map, DateTime, BigInt и Uint8List.


Также, можно создавать свои типы и работать с ними используя адаптер TypeAdapters. Адаптеры можно делать самим https://docs.hivedb.dev/#/custom-objects/type_adapters или использовать встроенный генератор (мы его подключаем вот этой строкой hive_generator: ^0.7.0+2).


Для нашего приложения нам понадобится класс для хранения тудушек


class Todo {
  bool complete;
  String id;
  String note;
  fnal String task;
}

Модифицируем его для генератора и присваиваем id — typeId: 0


import 'package:hive/hive.dart';
part 'todo.g.dart';
@HiveType(typeId: 0)
class Todo {
  @HiveField(0)
  bool complete;
@HiveField(1)
  String id;
@HiveField(2)
  String note;
@HiveField(3)
  String task;
}

Если в будущем нам понадобится расширить класс, то важно не нарушать порядок, а добавлять новые свойства далее по номерам. Уникальные номера свойств используются для их идентификации в бинарном формате Hive.


Теперь запускаем команду
flutter packages pub run build_runner build --delete-conflicting-outputs
и получаем сгенерированный класс для нашего типа данных todo.g.dart


Теперь мы можем использовать этот тип для записи и получения объектов этого типа в\из Hive.


HiveObject — класс для упрощения менеджмента объектов Мы можем добавить удобные методы в наш тип данных Todo просто наследовав его от встроенного класса HiveObject


class Todo extends HiveObject {


Это добавляет 2 метода save() и delete(), которые иногда удобно использовать.


Хранение данных — Box


В hive данные хранятся в боксах (box). Это очень удобно, так как мы можем сделать разные боксы для настроек юзера, тудушек и т.д.


Box идентифицируется строкой. Чтобы его использовать, надо сначала его асинхронно открыть (это очень быстрая операция). В нашем варианте
var todoBox = await Hive.openBox<Todo>('box_for_todo');
и потом мы можем синхронно read / write данные из этого бокса.


Идентификация данных в боксе возможна либо по ключу, либо по порядковому номеру:


Например, откроем бокс со строковыми данными и запишем данные по ключу и с автоинкрементом
var stringBox = await Hive.openBox<String>('name_of_the_box');


По ключу


stringBox.put('key1', 'value1');
print(stringBox.get('key1')); // value1
stringBox.put('key2', 'value2');
print(stringBox.get('key2')); // value2

Автоинкеремент при записи + доступ по индексу


Для записи множества объектов удобно использовать бокс аналогично List. У всех объектов в боксе есть индекс типа autoinrement.


Для работы с этим предназначены методы: getAt(), putAt() and deleteAt()


Для записи просто используем add() без индекса.


stringBox.put('value1');
stringBox.put('value2');
stringBox.put('value3');
print(stringBox.getAt(1)); //value2

Почему возможно работать синхронно? Разработчики пишут, что это одна сильных сторон Hive. При запросе на запись все Listeners уведомляются сразу, а запись происходит в фоне. Если произошел сбой записи (что очень маловероятно и по идее, можно это не обрабатывать), то Listeners уведомляются снова. Также можно использовать await для работы.

Когда бокс уже открыт, то в любом месте приложения мы его вызываем
var stringBox = await Hive.box<String>('name_of_the_box');
из чего сразу становится понятно, что при создании бокса пакет делает singleton.


Поэтому, если мы не знаем, открыт уже бокс или нет, а проверять лень, то можно в сомнительных местах использовать
var stringBox = await Hive.openBox<String>('name_of_the_box');


  • в случае если бокс уже открыт, этот вызов просто вернет инстанс уже открытого бокса.

Если посмотреть исходный код пакета, то можно увидеть несколько вспомогательных методов:


  /// Returns a previously opened box.
  Box<E> box<E>(String name);
  /// Returns a previously opened lazy box.
  LazyBox<E> lazyBox<E>(String name);
  /// Checks if a specific box is currently open.
  bool isBoxOpen(String name);
  /// Closes all open boxes.
  Future<void> close();

Devhack Вообще, так как Flutter — полный open source, то можно лезть в любые методы и пакеты, что зачастую быстрее и понятнее, чем читать документацию.

LazyBox — для больших массивов данных


Когда мы создаем обычный бокс, то все его содержимое хранится в памяти. Это дает высокое быстродействие. В таких боксах удобно хранить настройки пользователя, какие-то небольшие данные.


Если данных много, то лучше создавать боксы lazily


var lazyBox = await Hive.openLazyBox('myLazyBox');
var value = await lazyBox.get('lazyVal');

При его открытии Hive считывает ключи и хранит их в памяти. Когда мы запрашиваем данные, то Hive знает положение данных на диске и быстро считывает их.


Шифрование боксов


Hive поддерживает AES-256 шифрование данных в боксе.
Для создания 256-битного ключа мы можем использовать встроенную функцию
var key = Hive.generateSecureKey();
которая создает ключ используя Fortuna random number generator.


После создания ключа создаем бокс


var encryptedBox = await Hive.openBox('vaultBox', encryptionKey: key);
encryptedBox.put('secret', 'Hive is cool');
print(encryptedBox.get('secret'));

Особенности:


  • Шифруются только значения, ключи хранятся как plaintext.
  • При закрытии приложения ключ можно хранить с помощью пакета flutter_secure_storage или использовать свои методы.
  • Нет встроенной проверки корректности ключа, поэтому, в случае неправильного ключа мы должны сами программировать поведение приложения.

Сжатие боксов


Как обычно, если мы удаляем или меняем данные, то они пишутся по нарастающей в конец бокса.


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


var box = Hive.box('myBox');
await box.compact();
await box.close();

Приложение todo_hive_example


Ок, это вроде бы все, напишем приложение, которое потом расширим для работы с бэкендом.
Модель данных у нас уже есть, интерфейс сделаем простой.


Экраны:


  • Главный экран — список дел + весь функционал
  • Экран добавления дела

Действия:


  • Кнопка + добавляет дело
  • Нажатие на галочку — переключение выполнено \ не выполнено
  • Смахивание в любую сторону — удаление

Создание приложения Действие 1 — добавить


Создаем новое приложение, удаляем комменты, всю структуру оставляем (нам понадобится кнопочка +.


Модель данных кладем в отдельную папку, запускаем команду для создания генератора.
Создаем список общих дел.


Для построения списка дел используем встроенное расширение (да, в Dart пару месяцев назад добавили расширения (extensions)), которое лежит тут /hive_flutter-0.3.0+2/lib/src/box_extensions.dart


/// Flutter extensions for boxes.
extension BoxX<T> on Box<T> {
  /// Returns a [ValueListenable] which notifies its listeners when an entry
  /// in the box changes.
  ///
  /// If [keys] filter is provided, only changes to entries with the
  /// specified keys notify the listeners.
  ValueListenable<Box<T>> listenable({List<dynamic> keys}) =>
      _BoxListenable(this, keys?.toSet());
}

Мы кстати сами можем легко создавать подобные расширения, чем займемся в усложненном варианте приложения в следующей статье.

Итак, создаем список дел, который сам будет обновляться при изменении бокса


      body: ValueListenableBuilder(
        valueListenable: Hive.box<Todo>(HiveBoxes.todo).listenable(),
        builder: (context, Box<Todo> box, _) {
          if (box.values.isEmpty)
            return Center(
              child: Text("Todo list is empty"),
            );
          return ListView.builder(
            itemCount: box.values.length,
            itemBuilder: (context, index) {
              Todo res = box.getAt(index);
              return ListTile(
                title: Text(res.task),
                subtitle: Text(res.note),
              );
            },
          );
        },
      ),

Пока список пустой. Теперь при нажатии кнопочки + сделаем добавление дела.


Для этого создаем экран с формой, на который перекидываем при нажатии кнопочки +.


На этом экране при нажатии кнопки Add вызываем код, который добавляет запись в бокс и перекидывает обратно на главный экран.


void _onFormSubmit() {
    Box<Todo> contactsBox = Hive.box<Todo>(HiveBoxes.todo);
    contactsBox.add(Todo(task: task, note: note));
    Navigator.of(context).pop();
  }

Все, первая часть готова, это приложение уже умеет добавлять Todo. При перезапуске приложения все данные сохраняются в Hive.


Коммит на github приложения на этой стадии.


Создание приложения Действие 2 — переключение выполнено \ не выполнено


Тут все совсем просто. Мы используем метод сохранения, который получили наследовав наш class Todo extends HiveObject


Заполняем два свойства и всё готово


                  leading: res.complete
                      ? Icon(Icons.check_box)
                      : Icon(Icons.check_box_outline_blank),
                  onTap: () {
                    res.complete = !res.complete;
                    res.save();
                  });

Коммит на github приложения на этой стадии.


Создание приложения Действие 3 — смахивание влево — удаление


Тут тоже все просто. Оборачиваем виджет в котором хранится дело в dismissable и опять используем сервисный метод удаления.


                background: Container(color: Colors.red),
                key: Key(res.id),
                onDismissed: (direction) {
                  res.delete();
                },

Вот и все, мы получили полностью рабочее приложение, которое хранит данные в локальной базе.


Код приложения на github — https://github.com/awaik/todo_hive_example


To be continued:


  • В следующей статье прикрутим авторизацию с паттерном BLoC. Через почту, гугл, Apple Id.
  • Потом добавим синхронизацию с бэкендом Firebase через GraphQl по пути рассмотрев проблему устойчивого обновления короткоживущих токенов. Рассмотрим также устойчивую синхронизацию в обе стороны бэкенда и фронтэнда в неустойчивой среде мобильного приложения (прошу прощения за тавтологию).