Hive — быстрая локальная база для Flutter, Dart

    Примерно месяц назад общаясь с одним разработчиком приложения на 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;
      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 по пути рассмотрев проблему устойчивого обновления короткоживущих токенов. Рассмотрим также устойчивую синхронизацию в обе стороны бэкенда и фронтэнда в неустойчивой среде мобильного приложения (прошу прощения за тавтологию).
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 22

      +5

      Dart прекрасный язык, Flutter отличная технология, но блин :(


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

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

      Решение это UI нормально сделать, а не тащить в проект noSql. Затем изучить как работают БД и понять, что быстрее искать и читать можно только из памяти или в немногочисленных специальных случаях.

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

          Вот так написал, чтоб не бросать тень на Dart\Flutter — они реально очень быстрые.
          +2

          А какой кейс покрывает красивый график с тысячью чтений из бд?


          Если брать для примера заметочник — это будет один запрос при загрузке приложения который все заметки загрузит, и потом по одной записи на изменение.
          Либо загрузка заметок батчами если их много, но это все равно не тысячи запросов.


          Согласно графикам, SQLite на один запрос чтения потратит около 2мс, а на запись около 10мс. Это настолько мало, что не обязательно даже делать асинхронно.


          В каких реальных задачах на телефоне нужен высокий RPS к БД? Это же не сервер с кучей подключений.

            +2
            Вот тут код бенчмарка github.com/hivedb/hive_benchmark
            Например, когда идет обновление нескольких десятков анализов юзера по связанным формулам.
            В этом случае возникают сотни\тысячи запросов с индексами, которые начинают (согласно графика в начале статьи) выполняться до нескольких секунд.
            Думаю много таких кейсов в реальной разработке.
            У нас есть.
              +4
              В этом случае возникают сотни\тысячи запросов с индексами, которые начинают (согласно графика в начале статьи) выполняться до нескольких секунд.

              Это некорректное сравнение, потому что hive — это k-v база данных, и чтобы получить запись по ключу вам не надо конструировать запрос, вы в коде бенчмарка просто делаете put/get, а для SQLite вызываете билдер запросов каждый раз. Не знаю, заинлайнит ли это AOT компилятор, но скорее всего нет. Есть ли в sqflite prepared statements или что-то подобное я не знаю, но возможно сырые SQL запросы были бы быстрее.


              К тому же, если вы бенчмаркаете абстрактные get/put, и в проекте у себя вы используете точно такой же подход, это не значит что те же анализы нельзя было бы сделать эффективнее используя нормальные возможности sql, а не переносить их в виде одиночных вызовов select/insert.


              Поэтому сравнивать sqlite и hive нельзя так как это делаете вы, с SQL никто так не работает.

                –4
                Поэтому сравнивать sqlite и hive нельзя так как это делаете вы, с SQL никто так не работает.

                Код бенчмарка не я делал, я просто ссылку на него дал. Насколько я вижу, там используются встроенные в пакет SqfLite методы
                github.com/hivedb/hive_benchmark/blob/master/lib/sqlite_store.dart
                Если сравнивать, как это принято в рамках github и готового теста на быстродействие, то вы можете форкнуть бенчмарк и доказать свою позицию в цифрах.
                  +4

                  Так это вы же пишете о том, что hive быстрее sqlite, но что вы хотели этим доказать? Что вставка/чтение по ключу в дубовое k-v хранилище быстрее, чем аналогичная работа с реляционной базой данных?
                  Ну как бы да, это и без бенчмарков понятно.


                  Проблема не в этом, а в том, что вы бенчмаркаете фигню. Сырая скорость вставки/чтения интов в одно хранилище по уникальному ключу никому не нужна. Если бы вы привели пример "сотни\тысячи запросов с индексами" с использованием hive/sqlite, то вполне вероятно, что сценарий использования sql в данном случае был бы совершенно другой, нежели тысячи единичных select и insert.

                    0
                    Повторюсь, бенчмарк написал не я, а разработчики классного Open Source решения, которое уже стабильно много где используется и довольно популярно github.com/hivedb/hive
                    Называть его дубовым — некорректно.

                    Остановимся на том, что для разных случаев нужны разные решения.
                    Холивар между SQL и NoSQL базами за рамками данной статьи и примера.
                    Для приложений (да и для бэков) довольно часто используются NoSQL базы.

                    PS Соблюдайте культуру дискуссии :)
                      +3
                      Называть его дубовым — некорректно.

                      Дубовое — не означает негативную оценку. Таким же "дубовым" хранилищем будет Rocks, LMDB, Bolt, и пр. встраиваемые k-v хранилища, которые никаких возможностей кроме хранения данных не предоставляют.


                      Hive — это максимально простое хранилище, какое только возможно придумать, там даже транзакций нет, судя по документации. Плохо ли это? Нет, не плохо. И да, для разных случаев нужны разные решения. Но вы всё равно зачем-то противопоставляете ему полноценную (пусть и встраиваемую) реляционную БД.


                      Холивар между SQL и NoSQL базами за рамками данной статьи и примера

                      Обоснование мотивации выбора k-v хранилища для определённой задачи и рациональность такого выбора для конкретного примера — это не есть холивар. Заметьте, я не говорю что hive — это плохо, но указываю но очевидную проблему вашей аргументации в пользу hive в рамках конкретной задачи, на что вы приводите пример (довольно абстрактный) из совсем другой области.


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


                      Для приложений (да и для бэков) довольно часто используются NoSQL базы.

                      Но это не значит что они всегда используются уместно, к тому же NoSQL != key-value.

                        +1
                        Ок, спасибо, согласен, с аргументацией накосячил.
                        В следующей статье, если смогу по времени, выделю времени на сравнение для разных вариантов.
                        И спасибо за подробную обратную связь.
                        0

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

                      +1
                      Никаких prepare там нет — все в одну строку каждый раз.
                    +2
                    А что вас заставляет делать сотни/тысячи запросов? Если Hive делает 100 запросов, то SQLite всегда может делать 100 или менее, это просто теорема. Причем каждый запрос на чтение в SQLite стоит лишь немного дороже, дополнительное время уходит на парсинг SQL. Само чтение и там и там это кеш + чтение из чего-то похожего на btree. Сделать этот процесс принципиально быстрее невозможно.

                    Ускорение, которое может вам дать Hive оно не от скорости Hive, а от другой схемы хранения данных, когда все, что надо читается в один запрос. Но никто не мешает также хранить данные и в SQLite. И у этого подхода есть серьезные недостатки, о которых нужно знать. Особенно когда у вас нет транзакций.

                    Разница между Hive и SQLite может быть в скорости вставки, потому что в SQLite есть транзакции и оно гарантирует запись на диск. Если Hive делает вставку сильно быстрее, то только потому, что пишет в память (например в memory mapped file). Это быстро, но черевато потерей данных и поломанной базой. При записи SQLite добавляет данные в WAL и дожидается пока этот WAL запишется на диск. Быстрее надежно данные на диск записать нельзя. Единственное возможное ускорение — писать в N реплик, чтобы если одна упадет, данные в другие все же попали на диск, но это не сценарий для мобилки.

                    То есть Hive либо пишет в память и надеется, что повезет и ОС сбросит данные на диск быстрее чем они пропадут, либо Hive пишет с такой же скоростью как SQLite. Опять же, ускорение тут может быть за счет модели данных, за счет денормализации.

                    Я пытаюсь донести простую мысль — не бывает надежных средств хранения существенно быстрее RDBMS. Быстрее RDBMS это запись в memory mapped file, но это не одно и то же. Почти всегда вам совершенно все равно писать в WAL или в mmap. И почти всегда выбор RDBMS это лучше чем noSQL. А если вы выбираете noSQL, то вам нужно а) знать как хранит и обновляет данные обычная SQL база б) знать как это делает noSQL база в) понимать чем noSQL пожертвовал и где вы огребете.
                      +3
                      Смотрим в исходник и видим причину быстрой вставки buffered

                        Future<void> write(List<int> bytes) {
                          _buffer.add(bytes);
                          if (_buffer.length >= _maxBufferSize) {
                            return flush();
                          }
                          return Future.value();
                        }
                      


                      Круто да? Вы что-то записали, а оно в памяти лежит, пока буфер не заполнится. Это даже хуже чем mmap, которых хотя бы гарантирует, что данные на диск попадут пока ОС жива. Тут данные будут потеряны при падении приложения.

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

                      Далее смотрим сюда и видим как Hive ищет данные

                      final IndexableSkipList<dynamic, Frame> _store;
                      


                      Выходит индекс на основе skip list и весь индекс лежит в памяти! Знаете что будет когда индекс станет большим? А еще в такой индекс крайне тяжело вставлять данные и читать его одновременно. Знаете что будет под нагрузкой одновременно на чтение/запись?

                      Итого — Hive дает хорошее и удобное API, заточен под небольшой объем данных (сотни мегабайт), работу с одним пользователем и денормализоваными данными. Может терять данные и всю базу целиком, поэтому хранить ценные данные в нем не стоит. Как замена shared preferences сойдет, также подойдет как локальный кеш данных с сервера.
                        –1
                        Крутой анализ, спасибо!
                        С выводом согласен, никак иначе его и не позиционировал в своих проектах.
                  +1
                  С Hive именно тот что для Dart, всплыла проблема с int ключами в виде timestamp, длинны хватает только на секундный timestamp, а вот миллисекундный уже не влезает, конечно можно использовать String для ключей, но тогда надо приводить типы в каждой фильтрации, но мне это показалось некрасивым.
                      0

                      Судя по обсуждению по ссылке как раз наоборот. Чувак говорит, что куча пользователей хотят запросы, а прикручивать их к текущей архитектуре библиотеки он считает невозможным — поэтому перепишет всё на Rust. У Rust есть интероп с C, у Dart есть интероп с C. Все будут счастливы (но это не точно, там ещё что-то про магию LMDB).

                        0

                        От себя добавлю: не знаю, хотят ли куча пользователей библиотеки запросы. Я точно знаю, чего они не хотят — думать. Если инструмент выполняет свою функцию хорошо — то его надо использовать. Если же будет Hive 2.0 с расширенной функциональность без обратной совместимости — пусть. Один инструмент для одних задач, другой — для других.

                          0

                          У Dart FFI далеко не быстрый, и с кроссплатформенностью будут проблемы, потому что библиотеку на расте придётся собирать отдельно для каждой платформы.

                        0
                        HIVE — просто волшебная штука.
                        Но у нее очень посредственная кодогенерация и объекты которые собираешься хранить — не должны быть иммутабельными.

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое