4.1. Разные пакеты persistence + работа с файловой системой

Вступление

Рано или поздно любой разработчик сталкивается с необходимостью организации локального хранилища данных. При этом вам нужно решить, какие данные хранить, как подготовить их структуру и как обеспечить быстрый и надёжный доступ к этой информации. Мы рассмотрим различные системы хранения данных для приложений Flutter и поможем вам определиться с выбором подходящей для вашего проекта.

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

Файлы

Хранилище (Storage) и библиотека path_provider

В этой части главы мы рассмотрим подход к работе с файлами в различных операционных системах. Вы уже могли видеть пример использования библиотеки path_provider ранее в подглаве «Persistence — хранение данных простыми инструментами». Сейчас мы разберём её возможности и методы подробнее.

Библиотека path_provider нужна для получения корректных путей файлов и директорий на устройстве. Каждая операционная система может иметь разные подходы к сохранению данных, а path_provider помогает нивелировать эти различия.

Важно: эта библиотека не поддерживает веб. Пример решения под веб мы привели в конце текущей подглавы.

Методы библиотеки path_provider для поиска директорий

Приложения Flutter могут взаимодействовать с различными директориями для хранения данных. Вот основные методы для поиска директорий:

  • getApplicationDocumentsDirectory(). Это системная приватная директория приложения, которая создаётся при установке приложения и доступна только для него. Используется для хранения данных, созданных пользователем.

  • getApplicationSupportDirectory(). Эта директория предназначена для хранения файлов, не связанных с пользователем. Например, она может использоваться для хранения данных, которые не находятся в ресурсах приложения. Это могут быть различные конфигурационные файлы и иные данные, необходимые для внутренних операций приложения, а не для пользователя.

  • getTemporaryDirectory(). Эта директория предназначена для хранения временных файлов и кэша. Самая очевидная область применения — это хранение изображений. ОС по своему усмотрению может удалить содержимое этой директории, начиная с самых старых файлов.

  • getLibraryDirectory(). Эта директория предназначена для сохранения баз данных и доступна только на платформе iOS. Именно поэтому перед вызовом метода необходимо обязательно проверять, на какой платформе запущено приложение.

  • getExternalStorageDirectory() и getExternalCacheDirectories(). Эти директории предназначены для оптимизации хранения больших данных и доступны только на Android. Android разделяет дисковое пространство на хранилища двух типов: внутреннее и внешнее. getExternalStorageDirectory() позволяет сохранять большие файлы, например видео в формате 4K, во внешнем хранилище. Здесь также перед вызовом метода необходимо проверять, на какой платформе запущено приложение.

  • getDownloadsDirectory(). Эта директория предназначена для загруженных файлов и доступна только на веб платформе (web).

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

Класс File

Класс File предоставляет асинхронные методы для взаимодействия с файлами. Вот некоторые из них:

  • delete({bool recursive = false}). Удаляет файл. Параметр recursive указывает, нужно ли удалять файл рекурсивно, если это директория.

  • exists. Проверяет, существует ли файл.

  • length(). Возвращает размер файла.

  • readAsBytes().Читает файл и возвращает его содержимое в виде Uint8List.

  • readAsLines({Encoding encoding = utf8}). Читает файл и возвращает его содержимое в виде списка строк.

  • readAsString({Encoding encoding = utf8}). Читает файл и возвращает его содержимое в виде строки.

  • rename(String newPath). Переименовывает файл.

  • writeAsBytes(List<int> bytes, {FileMode mode = FileMode.write, bool flush = false}). Записывает данные в файл в виде байтов.

  • writeAsString(String contents, {FileMode mode = FileMode.write, Encoding encoding = utf8, bool flush = false}). Записывает данные в файл в виде строки.

  • openWrite(). Возвращает объект IOSink, который позволяет работать с файлами на более низком уровне и записывать данные порциями. Рекомендуется использовать, если необходимо записать большой поток данных.

Если вам нужны синхронные аналоги указанных функций, то их можно получить, добавив суффикс Sync . Например, openSync(), readAsBytesSync(), renameSync(). Следует помнить, что при синхронном вызове функций можно заблокировать основной поток исполнения, а значит, целесообразно пользоваться при этом отдельным изолятом.

Вот как выглядит пример записи и чтения данных из файла:

1import 'dart:io';
2import 'package:path_provider/path_provider.dart';
3
4void main() async {
5  final documentsDirectory = await getApplicationDocumentsDirectory();
6  final file = File('${documentsDirectory.path}/somefile.txt');
7  
8  // Записываем строку в файл
9  await file.writeAsString('Содержимое файла');
10  
11  // Используем IOSink для записи в конец содержимого файла
12  final ioSink = file.openWrite(mode: FileMode.writeOnlyAppend);
13  // Мы также можем записывать содержимое байт из потока
14  // В данном случае это будет "ABC"   
15  await ioSink.addStream(Stream.value([65, 66, 67]));
16  // Каждая строка, записанная с помощью writeln(), будет завершаться
17  // переводом указателя на новую строку
18  ioSink.writeln('');
19  ioSink.writeln('The End');
20  // Закрываем экземпляр IOSink и высвобождаем ресурсы
21  await ioSink.close();
22
23  // Читаем файл как строку
24  final content = await file.readAsString();
25  print(content);
26}
27

Иногда для более эффективного чтения и записи файлов разработчику нужно работать с файлами с использованием стримов (подробнее об этом в подглаве «Stream и реактивное программирование»). У класса File есть методы и на такие случаи — рассмотрим их подробнее.

Использование стримов

К примеру, чтение/запись через стримы понадобятся для файлов большого объёма. Без использования подобного механизма весь файл просто будет выгружен в оперативную память. Чтобы избежать этого и прочитать файл построчно, вы можете сделать следующее:

1import 'dart:io';
2
3void main() async {
4  final file = File('somefile.txt');
5  final lines = file.openRead()
6      .transform(utf8.decoder)
7      .transform(LineSplitter());
8  
9  await for (var line in lines) {
10    print('$line: ${line.length} символов');
11  }
12}
13

Запись в файл с помощью стрима:

1import 'dart:io';
2
3var file = File('somefile.txt');
4var sink = file.openWrite();
5sink.write('File accessed ${DateTime.now()}');
6// Закрываем IOSink
7sink.close();
8

А чтобы обращаться не в начало файла, а прочитать с произвольной позиции, используем следующие функции:

1import 'dartio';
2
3final file = File('somefile.txt');
4// Открытие файла на чтение из произвольной позиции
5final randomAccessFile = await file.open();
6// Перемещаемся в нужную часть файла
7await randomAccessFile.setPosition(100);
8// Читаем первые 50 байт
9final bytes = await randomAccessFile.read(50);
10

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

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

Отличия в работе с эмуляторами

Важно отметить разницу в работе с файловой системой между эмулятором Android и симулятором iOS. Эмулятор Android эмулирует диск и дисковое пространство, поэтому вы можете просматривать файлы, созданные в нём. А iOS-симулятор сохраняет файлы в файловой системе macOS, и вы не сможете увидеть их напрямую в симуляторе.

Чтобы убедиться, переходим в Android Studio → View → Tool Windows → Device File Explorer.

4.1.1.webp

Далее директория data → data → <application ID> → app_flutter.

4.1.2.webp

Работа с web

Как мы сказали в начале подглавки, библиотеки dart:io и path_provider недоступны на веб-платформе (обсуждение данного вопроса можно посмотреть в репозитории Flutter). Это связано с недоступностью файловой системы из веба по соображениям безопасности.

В таком случае вместо указанных библиотек можно воспользоваться file_picker. То есть после того, как пользователь выберет нужный файл, разработчик получит экземпляры класса PlatformFile и уже с ними можем проводить манипуляции. Пример получения файла в вебе с использованием file_picker версии 8.1.2:

1final pickedResult = await FilePicker.platform.pickFiles();
2if (pickedResult != null) {
3  print(pickedResult.files.first.name);
4}
5

С этим разобрались. А теперь подведём промежуточные итоги.

Преимущества/недостатки path_provider

Преимущества

Недостатки

  • Поддержка многих популярных платформ
    (Windows/MacOS/Linux/iOS/Android).

  • Простота в использовании.

  • Различные варианты работы с файлами
    (синхронно/асинхронно).

  • Из-за многообразия платформ
    и их различий приходится ставить условия на проверку текущей платформы.

  • Нет поддержки веба.

Плагин path_provider является незаменимым, когда речь заходит о работе с файлами и директориями.

Библиотека flutter_cache_manager

Библиотека flutter_cache_manager предназначена для кэширования файлов приложения. Это полезный инструмент для управления кэшем и ускорения загрузки файлов. Например, когда нужно временно локально сохранить часто используемые и просматриваемые файлы/документы, но при этом не бояться их потерять.

Особенности и возможности

  • Поддерживает in-memory хранилище для быстрого доступа к кэшированным файлам.

  • Кэширование происходит в файловой системе устройства.

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

  • Для загрузки файлов использует библиотеку HTTP.

  • Часто используется вместе с библиотекой cached_network_image для управления кэшированием изображений.

  • Доступна на всех платформах.

Виды менеджеров кэша

flutter_cache_manager предоставляет четыре вида менеджеров:

  1. BaseCacheManager. Абстрактный класс, предоставляющий базовую функциональность для управления кэшем.

  2. CacheManager. Менеджер кэша с настраиваемой конфигурацией. Позволяет создавать кастомные менеджеры с разными настройками.

  3. ImageCacheManager. Миксин, позволяющий работать с кэшированием изображений. Удобен для управления кэшированием изображений в Flutter-приложениях.

  4. DefaultCacheManager. Стандартная реализация менеджера кэша с пустой конфигурацией. Предназначен для работы с изображениями.

Основные методы

Ниже мы собрали основные и часто используемые методы библиотеки. С остальными можно ознакомиться в документации.

  • getSingleFile. Загружает файл по указанному URL или возвращает файл из кэша, если он доступен и срок его хранения не подошёл к концу.

  • getFileStream(url). Возвращает Stream<File> для файла, соответствующего указанному URL.

  • downloadFile(url). Загружает файл по указанному URL и возвращает Future<File>.

  • getFileFromCache. Получает файл из кэша по URL.

  • putFile. Помещает файл в кэш.

  • removeFile. Удаляет файл из кэша.

  • emptyCache. Очищает весь кэш.

  • getImageFile. Получает файл из кэша, специфично для изображений.

Применение

В самом базовом варианте мы просто сохраняем файл и далее пользуемся им как локальным .

1import 'package:flutter_cache_manager/flutter_cache_manager.dart';
2
3...
4
5// Далее работаем с обычным экземпляром класса File
6final file = await DefaultCacheManager().getSingleFile(
7  'https://yastatic.net/s3/ml-handbook/admin/edu_logo_0640b6dbf8.svg',
8);
9print(file.path); 
10

Преимущества/недостатки flutter_cache_manager

Преимущества

Недостатки

  • Простой и интуитивно понятный API.

  • Управление кэшированием файлов через разные виды менеджеров.

  • Очень условный минус, но лишнее затягивание зависимостей.
    Вместо сторонней зависимости в некоторых сценариях
    можно обойтись собственной реализацией на path_provider и getTemporaryDirectory().

flutter_cache_manager удобно использовать для управления кэшированными файлами в приложении. Это позволяет улучшить производительность приложения и снизить нагрузку на сеть.

Key — Value хранилище

Простейшие хранилища формата ключ — значение не являются подвидами каких-либо баз данных (Sql/NoSql) и представляют собой легковесное и простое решение для специфических задач, таких как кэширование и хранение настроек. Они не предназначены для обработки больших объёмов данных или сложных операций.

Работа с настройками библиотеки shared_preferences

shared_preferences — это библиотека, позволяющая сохранять примитивные данные в формате ключ — значение (key — value) на устройстве пользователя и доступная на всех платформах, в том числе в вебе. Она предоставляет удобный способ хранения настроек приложения и иных простых данных — сохраняется/читается обыкновенный текстовый файл на iOS в формате .plist (NSUserDefaults) и на Android в формате .xml (SharedPreferences).

Вы уже могли видеть пример пользования данной библиотекой ранее в подглаве «Persistence — хранение данных простыми инструментами». Сейчас разберём её возможности и методы подробнее.

Использование

Для начала работы с shared_preferences необходимо импортировать соответствующий пакет:

1import 'package:shared_preferences/shared_preferences.dart';
2

После этого с помощью метода getInstance() вы можете получить инстанс SharedPreferences. Он представляет собой синглтон:

1void main() async {
2  final sharedPrefs = await SharedPreferences.getInstance();
3  // Далее вы можете использовать sharedPrefs для чтения и записи данных
4}
5

Когда мы достаём инстанс, то все данные выгружаются в оперативную память. Поэтому вы можете синхронно доставать из неё данные — это будет быстрая операция. При этом запись данных — это асинхронная дисковая операция. Таким образом, одновременно происходит запись и в оперативную память, и в файл.

Функции записи возвращают булево значение. Оно сигнализирует, удалась запись или нет. Почти всегда возвращается true, но в теории может вернуться и false, хотя такое маловероятно. Например, это может произойти на iOS, когда интерфейс NSUserDefault записывает в кэш и синхронизируется не сразу. В таких случаях нужно быть готовыми к тому, что данные не запишутся, и сделать соответствующую запись в логах либо попробовать повторить попытку записи.

Вот как выглядит файл с настройками SharedPreferencesв системе Android. Найти его можно по следующему пути: /data/data/<application_ID>/shared_prefs/FlutterSharedPreferences.xml.

1<?xml version='1.0' encoding='utf-8' standalone='yes' ?> 
2<map> 
3	<string name="flutter.user_nickname">yandex_student</string>
4	<boolean name="flutter.is_night_theme" value="true" />
5	<long name="flutter.age" value="20" />
6</map>
7

Теперь поговорим о методах инстанса SharedPreferences.

Чтение данных

shared_preferences предоставляет методы для синхронного чтения различных типов данных:

  • get(String key) — синхронное чтение данных по ключу.

  • getBool(String key) — синхронное чтение булевых данных по ключу.

  • getInt(String key) — синхронное чтение целочисленных данных по ключу.

  • getDouble(String key) — синхронное чтение числовых данных с плавающей точкой по ключу.

  • getString(String key) — синхронное чтение строковых данных по ключу.

  • containsKey(String key) — проверяет наличие ключа.

  • getStringList(String key) — синхронное чтение списка строк по ключу.

Пример считывания строки:

1final username = sharedPrefs.getString('username');

Запись данных

Для записи данных в shared_preferences используйте асинхронные методы:

  • setBool(String key, bool value) — асинхронная запись булевых данных по ключу.

  • setInt(String key, int value) — асинхронная запись целочисленных данных по ключу.

  • setDouble(String key, double value) — асинхронная запись числовых данных с плавающей точкой по ключу.

  • setString(String key, String value) — асинхронная запись строковых данных по ключу.

  • setStringList(String key, List<String> value) — асинхронная запись списка строк по ключу.

Пример записи строки:

1await sharedPrefs.setString('username', 'john_doe');

Удаление данных

Вы также можете удалить данные, связанные с определённым ключом, с помощью метода remove(String key):

1await sharedPrefs.remove('username');

Где хранится файл с настройками shared_preferences

Это зависит от платформы:

  • Android — данные хранятся в файле XML в папке SharedPreferences.

  • iOS — данные хранятся с использованием класса NSUserDefault.

  • macOS — данные также хранятся с использованием NSUserDefault.

  • Linux — данные хранятся в папке .config в XDG_DATA_HOME.

  • Windows — данные хранятся в Roaming AppData директории.

  • Web — данные хранятся в кэше браузера.

Преимущества/недостатки shared_preferences

Преимущества

Недостатки

  • Удобно хранить настройки приложения в виде примитивных данных.

  • Синхронное чтение из кэша в памяти лишний раз не выполняет дисковую операцию и не ходит в платформенный код.

  • Простой и интуитивно понятный API.

  • Разные реализации для Android, iOS и других платформ, что требует обращения к платформенному коду.

  • Не гарантируется немедленная запись на диск после успешного выполнения метода (хотя теоретическая вероятность стремится к нулю).

  • Нельзя сохранять сложные объекты «из коробки».

  • Не самое оптимальное решение для сохранения большого количества данных.

Библиотека shared_preferences — это хороший инструмент для хранения простых данных во Flutter. Она предоставляет простой и удобный способ управления настройками приложения и другими данными на стороне клиента. Несмотря на некоторые ограничения, она остаётся одним из популярных выборов для хранения ключевых данных.

До этого момента мы рассматривали хранение данных в обычных текстовых файлах, что подходит для простых структур данных и для малых объёмов этих самых данных. Когда требования к данным возрастают, следом появляется необходимость в использовании более удобных и надёжных инструментов для работе с данными. Помочь нам в этом призваны базы данных.

Библиотека flutter_secure_storage

flutter_secure_storage — это библиотека для безопасного хранения конфиденциальных данных в приложениях Flutter. Она поддерживает все платформы, что делает её отличным выбором для хранения и управления чувствительными данными.

Основные возможности

  • Поддержка Android и iOS. На Android библиотека использует систему управления ключами Keystore с RSA шифрованием или Android EncryptedSharedPreferences (расширение над SharedPreferences с поддержкой шифрования) в зависимости от настроек. На iOS данные хранятся в Keychain — криптоконтейнере для хранения чувствительной информации, включая ключи, учётные данные, сертификаты.

  • Простой API. Вы можете легко записывать, читать и удалять данные из flutter_secure_storage. Ниже приведён пример сохранения и чтения значения в защищённом хранилище.

1import 'package:flutter_secure_storage/flutter_secure_storage.dart';
2
3const storage = FlutterSecureStorage();
4await storage.write(key: 'key', value: 'value');
5final value = await storage.read(key: 'key');
6await storage.delete(key: 'key');
7

Для более продвинутого хранения данных и их обмена между разными приложениями одного разработчика на iOS существует технология App Group, которую также можно конфигурировать для использования с flutter_secure_storage.

AppGroups

AppGroups — это функциональность, специфичная для iOS, которая позволяет приложениям одного разработчика совместно использовать данные. Это особенно полезно, когда несколько ваших приложений нуждаются в доступе к общим данным — например, для авторизации пользователя.

Пример ниже позволяет разделять данные между приложениями группы group.example. Это означает, что другое приложение, настроенное на использование этой же группы, сможет получить доступ к хранимым данным по ключу key.

1import 'package:flutter_secure_storage/flutter_secure_storage.dart';
2
3final storage = FlutterSecureStorage();
4await storage.write(
5  key: 'key',
6  value: 'value',
7  iOptions: IOSOptions(groupId: 'group.example'),
8);
9

Для использования AppGroups вам необходим аккаунт разработчика и настройка разрешений в консоли разработчика Apple. Подробнее об этом можно почитать в разделе Configuring App Groups на официальном сайте разработчика Apple.

Преимущества/недостатки flutter_secure_storage

Преимущества

Недостатки

  • Простой и интуитивно понятный API.

  • Поддержка всех платформ, в том числе веб.

  • Подходит только для хранения простых типов данных.

Библиотека отлично подходит для приложений, требующих безопасного хранения таких данных, как токены аутентификации, пароли и другие чувствительные данные.

Библиотека get_storage

get_storage — это ещё одна библиотека для Flutter, предназначенная для хранения простых данных в памяти устройства. Она предоставляет простой и интуитивно понятный интерфейс для работы с хранилищем, позволяя сохранять и получать данные без лишних сложностей.

Основные возможности

  • Простой интерфейс. Библиотека get_storage предоставляет простые методы для записи и чтения данных.
1import 'package:get_storage/get_storage.dart';
2
3...
4
5main() {
6	await GetStorage.init();
7	...
8}
9
10...
11
12final box = GetStorage();
13box.write('key1', 'value');
14box.write('key2', 3);
15final value = box.read('key1');
16
  • Запись и чтение. Этот плагин оптимизирован для быстрого доступа к данным, что делает его подходящим для хранения часто используемых значений.

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

Пример использования

1import 'package:get_storage/get_storage.dart';
2
3final box = GetStorage();
4
5// Записываем значение
6box.write('username', 'JohnDoe');
7
8// Читаем значение
9final username = box.read('username');
10
11// Проверяем наличие ключа
12if (box.hasData('username')) {
13  // Делаем что-то, если ключ существует
14}
15

Преимущества/недостатки и итог

Преимущества

Недостатки

  • Простой и интуитивно понятный API.

  • Поддержка всех платформ, в том числе веб.

  • Данные хранятся в оперативной памяти.

  • Нет встроенной поддержки миграций.

  • Отсутствие разнообразия функций, как у некоторых других библиотек
    для работы с локальными БД.

  • Нельзя сохранять сложные объекты.

get_storage может быть полезен для хранения настроек пользователя, временных данных и другой информации, которая не требует сложных манипуляций с базами данных.

Базы данных. SQL и NoSQL

Прежде чем погрузиться в работу с самими плагинами, давайте кратко разберём различия между реляционными (SQL) и нереляционными (NoSQL) базами данных.

SQL

NoSQL

Подходят для хранения структурированных данных.

Оптимизированы для быстрого доступа к данным.

Уделяют внимание целостности и надёжности данных.

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

Структура данных обычно не подвержена частым изменениям.

Например, для кэширования товаров в онлайн-магазине удобнее использовать SQL БД, потому что структура данных включает в себя сложные связи, такие как отношения товара с категорией и другими параметрами. В этом случае SQL может обеспечить лучшую производительность и упростить запросы.

И напротив, приложение, которое предоставляет пользователям данные на основе их местоположения может не иметь чёткой структуры данных и постоянно изменяться. Поэтому NoSQL базы данных позволят разработчикам гибко модифицировать схему без необходимости проведения сложных миграций.

SQL- и NoSQL-решения для Flutter

В экосистеме Flutter есть разнообразные инструменты для работы с базами данных, включая как SQL-, так и NoSQL-решения. Вот некоторые самые популярные из них:

NoSQL:

  • Hive

  • Sembast

  • ObjectDB

  • ObjectBox

  • Isar

  • Realm

  • Mongo_dart

SQL:

  • SQFLite — надстройка над SQLite, специфичная для каждой платформы.

  • Drift (ранее Moor) — использует FFI (foreign function interface — вызов функций языка C) и реализован на Dart.

  • Floor

Далее рассмотрим одни из самых популярных библиотек.

Hive

ПРЕДУПРЕЖДЕНИЕ

Авторы этой библиотеки могут в любой момент прекратить её поддерживать. Важно принимать во внимание этот факт, несмотря на популярность пакета. Пожалуйста, ознакомьтесь с обсуждением будущего проекта по ссылке, чтобы быть в курсе возможных изменений и перспектив дальнейшей поддержки пакета.

Hive — это NoSQL-хранилище, предназначенное для сохранения данных в формате «ключ — значение» (key-value). Hive позволяет вам сохранять не только примитивные данные, но и сложные объекты. Он предоставляет унифицированный способ хранения данных на всех поддерживаемых платформах (mobile/desktop/web).

На момент написания статьи существует предрелизная версия 4.0.0-dev.2 — обёртка над другим пакетом Isar. Однако в главе рассматривается последняя стабильная версия 2.2.3, так как стабильные версии гарантируют, что приложение будет работать предсказуемо, минимизируя возможность возникновения неожиданных сбоев и ошибок.

Центральное понятие в Hive — это сущность Box. Она используется для организации и хранения данных. Каждый Box представляет собой файл, созданный в рабочей директории.

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

Использование Hive

Для начала работы с Hive выполните следующие действия:

  1. Импортируйте необходимые зависимости.
1import 'package:hive/hive.dart';
2import 'package:hive_flutter/hive_flutter.dart';
3
  1. Инициализируйте Hive, указав рабочую директорию в папке приложения при запуске.
1await Hive.initFlutter();
2

Или же можно проинициализировать вручную, при этом указав любую другую директорию.

1final path = await getApplicationCacheDirectory();
2Hive.init(path.path);
3
  1. Откройте или создайте Box, где будут храниться данные. Box могут быть типизированы, что обеспечивает типобезопасность данных.
1final box = await Hive.openBox<User>('users');
2
  1. Вы можете считывать, записывать и удалять данные внутри Box.
1await box.put('name', 'Michael');
2final name = box.get('name');
3await box.delete('name');
4
  1. При завершения работы с Box, как и любой другой ресурс, его следует закрыть (все кэшированные ключи и значения Box будут очищены из памяти).
1await box.close();
2

Теперь файл можно найти в директории приложения.

4.1.3.webp

Работа с кастомными моделями в Hive

Hive позволяет сохранять кастомные модели с использованием адаптеров. Адаптер определяет, как объекты могут быть сохранены и считаны с диска. Иными словами, адаптер нужен для сериализации/десериализации не примитивных типов данных.

Вот пример создания адаптера для модели User:

1import 'package:hive/hive.dart';
2
3class UserAdapter extends TypeAdapter<User> {
4  @override
5  int get typeId => 0;
6
7  @override
8  User read(BinaryReader reader) {
9    return User(
10      name: reader.readString(),
11      age: reader.readInt(),
12    );
13  }
14
15  @override
16  void write(BinaryWriter writer, User user) {
17    writer.writeString(user.name);
18    writer.writeInt(user.age);
19  }
20}
21
22

После создания адаптера его необходимо зарегистрировать:

1Hive.registerAdapter(UserAdapter());
2

Теперь вы можете сохранять и загружать объекты типа User в Hive.

Кодогенерация в Hive

Вы также можете использовать кодогенерацию с аннотациями, чтобы проще создавать адаптеры. Для использования генерации нужно добавить зависимость hive_generator: [version] в секциюdev_dependencies файла pubspec.yaml. Вот как это можно сделать для модели User:

1import 'package:hive/hive.dart';
2
3part 'user.g.dart';
4
5@HiveType(typeId: 0)
6class User {
7  @HiveField(0)
8  final String name;
9
10  @HiveField(1)
11  final int age;
12
13  User({
14    required this.name,
15    required this.age,
16  });
17}
18

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

Преимущества/недостатки библиотеки Hive

Преимущества

Недостатки

  • Реализована на чистом Dart, без зависимости от платформенных решений.

  • Работает одинаково на разных платформах.

  • Имеет простой и интуитивно понятный API.

  • Обеспечивает быструю запись и чтение данных.

  • Минимальное количество boilerplate-кода благодаря кодогенерации.

  • Ограничение по количеству адаптеров для объектов (224).

  • Ограничение по количеству полей в адаптере (255).

  • Не подходит для работы с большим объёмом данных из-за
    оперативной памяти. Может потребоваться использовать LazyHive для оптимизации.

  • Отсутствие полноценной поддержки механизма миграции при изменении структуры данных.

  • Проект давно не обновлялся и авторы могут в любой момент прекратить его поддерживать.

Hive остаётся мощным инструментом для хранения данных во Flutter, обеспечивая простой и удобный способ сохранения данных на стороне клиента. Несмотря на некоторые ограничения, он остается одним из популярных выборов для хранения ключевых данных.

Isar

ПРЕДУПРЕЖДЕНИЕ

Авторы этой библиотеки могут в любой момент прекратить её поддерживать. Важно принимать во внимание этот факт, несмотря на широкое распространение и популярность пакета. Пожалуйста, ознакомьтесь с обсуждением будущего проекта по ссылке, чтобы быть в курсе возможных изменений и перспектив дальнейшей поддержки пакета.

Isar — это библиотека для работы с базами данных во Flutter. Она также использует кодогенерацию для создания таблиц и обеспечивает множество стандартных удобных возможностей для работы с данными, таких как ACID-транзакции (обеспечивают надёжное и безопасное хранение данных), производительность операций с данными (библиотека написана на Rust).

Начало работы с Isar

Перед началом использования Isar вам потребуется добавить несколько библиотек для работы с базами данных:

  • сам пакет Isar;

  • isar_flutter_libs — нативные библиотеки для различных платформ;

  • isar_generator и build_runner — для генерации кода на основе определений моделей данных.

Вот пример на последней стабильной версии 3.1.0.

1isar_version: &isar_version [version]
2
3dependencies:
4  isar: *isar_version
5  isar_flutter_libs: *isar_version
6
7dev_dependencies:
8  isar_generator: *isar_version
9  build_runner: any
10

Пример начала работы с Isar, в котором откроем/создадим БД с использованием схемы, описанной дальше в примере:

1final dir = await getApplicationDocumentsDirectory();
2final isar = await Isar.open(
3  [GithubProfileIsarSchema],
4  directory: dir.path,
5);
6

Модель данных

В Isar вы создаёте модель данных, определяя её поля и их типы. Важно отметить, что все поля в модели всегда указываются как опциональные.

Пример модели GithubProfileIsar:

1@Collection()
2class GithubProfileIsar {
3  Id id = Isar.autoIncrement;
4  String? login;
5  @Name("avatar_url")
6  String? avatarUrl;
7  String? bio;
8  String? name;
9}
10

В качестве аннотации можно использовать как Collection, так и collection. Отличие в том, что в Collection можно настраивать дополнительные параметры:

  • inheritance — могут ли свойства и аксессоры наследоваться от родительских классов и миксинов;

  • аксессор-геттер, с помощью которого можно обратиться к коллекции (например, таким образом: isar.myCustomGithubProfileIsars);

  • ignore — множество свойств и геттеров, которые следует игнорировать.

Далее, чтобы пользоваться коллекциями, мы должны получить схему — формальное описание структуры данных в БД — и запустить генерацию командой dart run build_runner build. После кодогенерации получаем схему GithubProfileIsarSchema:

1extension GetGithubProfileIsarCollection on Isar {
2  IsarCollection<GithubProfileIsar> get githubProfileIsars => this.collection();
3}
4
5const GithubProfileIsarSchema = CollectionSchema(
6  name: r'GithubProfileIsar',
7  id: 5589650545759511826,
8  properties: {
9    r'avatar_url': PropertySchema(
10      id: 0,
11      name: r'avatar_url',
12      type: IsarType.string,
13    ),
14    r'bio': PropertySchema(
15      id: 1,
16      name: r'bio',
17      type: IsarType.string,
18    ),
19    r'login': PropertySchema(
20      id: 2,
21      name: r'login',
22      type: IsarType.string,
23    ),
24    r'name': PropertySchema(
25      id: 3,
26      name: r'name',
27      type: IsarType.string,
28    )
29  },
30  estimateSize: _githubProfileIsarEstimateSize,
31  serialize: _githubProfileIsarSerialize,
32  deserialize: _githubProfileIsarDeserialize,
33  deserializeProp: _githubProfileIsarDeserializeProp,
34  idName: r'id',
35  indexes: {},
36  links: {},
37  embeddedSchemas: {},
38  getId: _githubProfileIsarGetId,
39  getLinks: _githubProfileIsarGetLinks,
40  attach: _githubProfileIsarAttach,
41  version: '3.0.0',
42);
43
44int _githubProfileIsarEstimateSize(...) { ... }
45
46void _githubProfileIsarSerialize(...) { ... }
47
48GithubProfileIsar _githubProfileIsarDeserialize(...) { ... }
49
50int _githubProfileIsarEstimateSize(...) { ... }
51
52... и т.д.
53

Операция записи

Операции записи данных в явном виде выполняются с использованием транзакций (одна или несколько операций с базой данных происходят за единицу работы). Выполняет асинхронные транзакции записи метод writeTxn().

1await isar.writeTxn(() {
2  return isar.githubProfileIsars.put(
3    GithubProfileIsar()
4      ..id = 2
5      ..name = 'Tom'
6      ..login = 'tomy00',
7    );
8});
9

После чего можем снова найти файл с нашей БД в директории приложения.

4.1.4.webp

Операция чтения

В отличие от операции записи, чтение данных выполняется без использования транзакций.

Пример чтения данных:

1final result =
2        await isar.githubProfileIsars.filter().nameContains('T').findFirst();
3    print(result?.id);
4    print(result?.name);
5    print(result?.login);
6

Вам также доступен инспектор данных для отладки и мониторинга вашей базы данных. Для этого необходимо перейти по ссылке в дебаг — панели после запуска приложения.

Пример работы инспектора из официальной документации пакета:

Преимущества/недостатки Isar

Преимущества

Недостатки

  • Удобный API для работы с данными.

  • Инспектор данных для отладки и мониторинга.

  • Использует кодогенерацию, что может вызвать дополнительные трудности в проекте.

  • Не поддерживает связи между сущностями, что делает его более подходящим для NoSQL-структуры данных.

  • Отсутствие полноценной поддержки механизма миграции при изменении структуры данных.

  • Проект давно не обновлялся и авторы могут в любой момент прекратить его поддерживать.

Isar представляет собой неплохое решение для работы с хранением данных, зарекомендовавшее себя и отвоевавшее долю пользователей среди разработчиков. Однако стоит рассмотреть конкретные требования к проекту и всё же поискать альтернативы перед тем, как выбрать этот пакет.

SQFLite

SQFLite — это плагин для Flutter, который представляет собой обёртку над нативными реализациями SQLite, встраиваемой реляционной базы данных. Он предоставляет мощные средства для хранения и управления данными в приложении, такие как SQL — запросы, транзакции, миграции.

Особенности и возможности

  • Хранение сложно структурированных данных — подходит для реляционных баз данных.

  • Использует платформенные реализации SQLite для Android и iOS.

  • Не поддерживается в веб-версии Flutter.

  • Позволяет выполнять SQL-запросы для выборки и модификации данных.

  • Данные хранятся в одном файле, что облегчает управление.

Инициализация базы данных

Для начала работы с SQFLite вы должны инициализировать базу данных.

Вот пример инициализации:

1final path = join(await getDatabasesPath(), 'dogs.db');
2final database = await openDatabase(
3  path,
4  onCreate: (db, version) {
5    return db.execute(
6      'CREATE TABLE dogs (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, age INTEGER)',
7    );
8  },
9  version: 1,
10);
11

В этом примере мы открываем или создаём базу данных по указанному пути. Если база уже существует, она просто открывается. Если база не существует, выполняется метод onCreate, который создаёт таблицу "dogs" с указанными столбцами.

Операции с данными

SQFLite предоставляет методы для выполнения операций с данными, такие как вставка, выборка, обновление и удаление. Можно выполнять «сырые» запросы в формате SQL.

Вставка данных

1const dog = Dog(
2  name: 'Richy',
3  age: 5,
4);
5int id = await database.insert('dogs', dog.toMap());
6

Выборка данных

1final dogs = (await database.query('dogs')).map((dog) => Dog.fromMap(dog));
2

Обновление данных

1int count = await database.update(
2  'dogs',
3  {'age': 6},
4  where: 'id = ?',
5  whereArgs: [1],
6);
7

Удаление данных

1int count = await database.delete(
2  'dogs',
3  where: 'id = ?',
4  whereArgs: [1],
5);
6

Миграции и обновление схемы данных

При изменении схемы данных вам следует обновить базу данных с использованием механизма миграции. Вы можете переопределить метод onUpgrade внутри метода openDatabase. Это позволит обновлять структуру базы данных при необходимости.

1final path = join(await getDatabasesPath(), 'dogs.db');
2final database = await openDatabase(
3  path,
4  onUpgrade: (Database db, int oldVersion, int newVersion) {
5    if (oldVersion < newVersion) {
6      db.execute("ALTER TABLE dogs ADD COLUMN breed TEXT;");
7    }
8  },
9  version: 2,
10);
11

Преимущества/недостатки SQFLite

Преимущества

Недостатки

  • Подходит для хранения сложно структурированных данных, поддерживает связи между сущностями.

  • Позволяет писать сложные SQL-запросы для выборки и модификации данных.

  • Есть механизм обновления схемы данных для управления изменениями в структуре базы данных.

  • Данные не кэшируются в оперативной памяти, что позволяет управлять большими объёмами данных.

  • Использование SQL и миграции может потребовать дополнительного времени и усилий.

  • Скорость работы ниже по сравнению с NoSQL-решениями, так как работа происходит за пределами оперативной памяти.

  • Разные версии SQLite на разных операционных системах могут вести себя по-разному.

  • Не поддерживает веб-версию, Windows и Linux.

SQFLite предоставляет доступ к базе данных SQLite, которая известна своей надёжностью и мощностью, а также к эффективному управлению данными, используя возможности SQL.

Drift

Drift — мощная библиотека для хранения данных (ранее известная как Moor), тоже использующая «под капотом» SQLite. Среди сильных сторон этой библиотеки стоит отметить поддержку всех платформ, миграций, тонких настроек открытия файла БД.

Инициализация базы данных

В процессе создания класса базы данных можно указывать, например, информацию о миграциях. Подробнее с этим можно ознакомиться на официальном сайте. В этой главе мы рассмотрим базовый пример с CRUD-операциями.

Перед началом работы с библиотекой подключим плагин версии 2.20.0 и добавим дополнительный плагин drift_dev для кодогенерации.

1dependencies:
2  drift: [version]
3
4dev_dependencies:
5  drift_dev: [version]
6  build_runner: any
7

Далее указываем источник базы данных и создаём инстанс, который будет использоваться для дальнейшего доступа к данным:

1final appDir = await getApplicationDocumentsDirectory();
2final dbPath = '${appDir.path}/dogs.db';
3final dbFile = File(dbPath);
4
5final dbConnection = NativeDatabase.createBackgroundConnection(dbFile);
6final db = AppDatabase(dbConnection);
7

И создаём объект базы данных с аннотацией @DriftDatabase:

1@DriftDatabase(tables: [Dogs, Breeds])
2class AppDatabase extends _$AppDatabase {
3  AppDatabase(QueryExecutor e) : super(e);
4
5  @override
6  int get schemaVersion => 1;
7
8	// Метод получения всех записей
9  Future<List<Dog>> getAllDogs() async {
10    return await select(dogs).get();
11  }
12
13  // Сохраняем запись про собаку
14  Future<int> saveDog(DogsCompanion companion) async {
15    return await into(dogs).insert(companion);
16  }
17
18  // Сохраняем запись про породу собаки
19  Future<int> saveBreed(BreedsCompanion companion) async {
20    return await into(breeds).insert(companion);
21  }
22
23  // Получаем данные о породе по идентификатору
24  Future<Breed?> getBreedById(int? id) async {
25    if (id == null) {
26      return null;
27    }
28
29    final value = select(breeds)..where((breed) => breeds.id.equals(id));
30    return value.getSingleOrNull();
31  }
32
33  // Удаляем все записи. При этом идентификаторы не «сбрасываются»
34  // и продолжают инкрементироваться.
35  Future<void> removeAll() async {
36   return transaction(() async {
37      await delete(dogs).go();
38      await delete(breeds).go();
39   });
40  }
41
42  // Обновляем запись
43  Future<void> updateDog(Dog updatedDog) async {
44    update(dogs).replace(updatedDog);
45  }
46 
47  // Переопределяем в случае переезда базы на новую структуру данных 
48  @override
49  MigrationStrategy get migration => super.migration;
50}
51

Наконец определяем модели данных:

1@DataClassName('Dog')
2class Dogs extends Table {
3  IntColumn get id => integer().autoIncrement()();
4
5  TextColumn get name => text()();
6
7  // Храним id породы собаки
8  IntColumn get breed => integer().nullable().references(Breeds, #id)();
9
10  DateTimeColumn get birthday => dateTime().nullable()();
11}
12
13@DataClassName('Breed')
14class Breeds extends Table {
15  IntColumn get id => integer().autoIncrement()();
16
17  TextColumn get name => text()();
18}
19

И запускаем кодогенерацию flutter pub run build_runner build.

Операции с данными

Далее будем использовать язык dart для построения базовых запросов. Мы пользуемся методами, определёнными в классе AppDatabase.

Сохранение данных

Для сохранения данных можно выполнить следующие операции:

1const dogModelOne = DogsCompanion(
2  name: Value('Fluffy Fluff'),
3);
4db.saveDog(dogModelOne);
5
6const breedOne = BreedsCompanion(
7  name: Value('Sheepdog'),
8);
9final breedId = await db.saveBreed(breedOne);
10    
11final dogModelTwo = DogsCompanion(
12  name: const Value('Snoppy'),
13  breed: Value(breedId),
14);
15db.saveDog(dogModelTwo);
16

Или же:

1db.into(db.dogs).insert(
2      const DogsCompanion(
3        name: Value('Fluffy Fluff'),
4      ),
5    );
6

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

Выборка данных

1final dogs = await db.getAllDogs();
2print(dogs);
3

Обновление данных

1final updateDogModelOne = dogs.first.copyWith(
2  name: 'Fluffy',
3);
4db.updateDog(updateDogModelOne);
5

Удаление данных

1await db.removeAll();
2

Преимущества/недостатки Drift

Преимущества

Недостатки

  • Полноценная поддержка миграций.

  • Compile — time проверка при составлении запросов благодаря генерации файлов.

  • Поддержка всех платформ, в том числе веб.

  • Возможность шифрования файлов БД «из коробки».

  • Возможность безопасной работы с данными из изолятов.

  • Возможность использовать PostgreSQL в качестве базы данных.

  • Высокий порог входа.

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

На данный момент это одно из лучших решений для хранения в БД. Однако стоит учитывать свои потребности и, если обстоятельства того не требуют, выбрать более простое решение.

Иные аспекты хранения данных

Хранение секретов

Хранение конфиденциальных данных, таких как API-ключи, требует особой осторожности. Вот несколько распространённых методов для безопасного хранения секретов в приложении:

  • Локальный файл с ключами. Поместите секреты в файл, который исключён из системы контроля версий (например, в файл .gitignore). Этот метод обеспечивает базовую безопасность, но не идеальную.

  • Файлы окружения .env. Используйте файлы окружения для хранения секретных значений. Но и его также нужно не забыть исключить из системы контроля версий, добавив в файл .gitignore. Чтобы проще взаимодействовать с файлом, можно воспользоваться пакетом envied, который в дополнение ко всему «из коробки» предлагает обфусцировать значения.

  • Обфускация. Этот метод заключается в том, чтобы усложнить чтение и понимание вашего кода секретного ключа. В этом может помочь указанный выше envied. Однако это не обеспечивает полной безопасности, так как декомпиляция приложения может деобфусцировать значения.

  • Передача через --dart-define. Вы можете передавать секретные значения в приложение на этапе компиляции. Это позволяет избежать хранения секретов в коде приложения и передавать их динамически. Например, с помощью команды flutter run --dart-define SECRET_KEY_1=a1b2t5b6D --dart-define SECRET_KEY_2=b2l4p5fg1. Если список секретов слишком большой, то начиная с версии Flutter 3.7 используется флаг --dart-define-from-file и появляется возможность хранить все секреты в файле в формате json.

    Пример команды: flutter run --dart-define-from-file=my_secrets.json.

Применение правильных методов для хранения и управления секретами — важный аспект обеспечения безопасности приложений. Внимательно выбирайте метод, который соответствует вашим требованиям безопасности. Но стоит отметить всё же, что наиболее предпочтительным вариантом остаётся использование --dart-define.

Мультипоточность при работе с БД

Иногда возникает необходимость записывать/считывать данные из разных изолятов. Например, для ускорения работы приложения создан изолят, который обрабатывает какой-то файл и проводит с ним манипуляции. А в это же самое время основной изолят запрашивает информацию об этом файле.

Вот как это может выглядеть:

Isolate BShared FileIsolate AIsolate BShared FileIsolate AНебезопасный доступ к файламЗапись "Data A"Запись "Data B"Безопасный доступ к файлуЗапись "Data A"Запись "Data B"Чтение последних данныхЧтение последних данныхЗапрос на запись данных "Data A"Запрос на запись данных "Data B"Подтверждение записи "Data A"Подтверждение записи "Data B"Запрос на запись данных "Data A"Запрос на запись данных "Data B"Отказано в записи "Data B"Подтверждение записи "Data A"Запрос на запись данных "Data B"Подтверждение записи "Data B"Запрос на чтение данныхВозврат данных "Data A" и "Data B"Запрос на чтение данныхВозврат данных "Data A" и "Data B"

Напомним, что так сделать нельзя из веб-платформы.

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

Для потокобезопасной работы (isolate-safe) с файлом из разных изолятов следует обеспечить синхронизацию и корректное управление доступом. Например, могут встречаться ситуации, когда один изолят пишет в файл, а другой должен из него читать. Dart и Flutter не предоставляют встроенные механизмы синхронизации, которые используются для управления доступом к общим ресурсам в многопоточных программах (Mutex/Semaphore).

Однако их можно создать самостоятельно.

Создайте централизованный менеджер (обычно в основном изоляте), который будет заниматься всеми операциями и синхронизацией с файлами. Все другие изоляты должны отправлять сообщения к этому менеджеру для выполнения операций чтения или записи. Это обеспечит последовательное выполнение всех операций с данными из одного источника, что предотвратит возможные конфликты.

Используйте lock-файл. Один изолят пишет в файл, другой читает из этого файла. Чтобы избежать конфликтов, можно использовать механизмы типа «лока». Например, можно создать дополнительный файл, действующий как флаг (например, lock.file), который будет создаваться перед началом операции записи и удаляться после её завершения. Изолят, предназначенный для чтения, должен проверять наличие этого файла перед началом чтения.

Пример:

1import 'dart:isolate';
2import 'package:flutter/services.dart';
3import 'dart:io';
4import 'package:path_provider/path_provider.dart';
5
6void main() async {
7  RootIsolateToken rootIsolateToken = RootIsolateToken.instance!;
8
9  await Isolate.spawn(
10    _writeFile,
11    _IsolateData(
12      message: "Hello from Isolate 1\n",
13      token: rootIsolateToken,
14    ),
15  );
16  await Isolate.spawn(
17    _writeFile,
18    _IsolateData(
19      message: "Hello from Isolate 2\n",
20      token: rootIsolateToken,
21    ),
22  );
23  // Даём время для завершения записи
24  await Future.delayed(const Duration(seconds: 1));
25  await _readFile(rootIsolateToken);
26}
27
28Future<void> writeFile(IsolateData data) async {
29  BackgroundIsolateBinaryMessenger.ensureInitialized(data.token);
30  final documentsDirectory = await getApplicationDocumentsDirectory();
31  var lockFile = File('${documentsDirectory.path}/file.lock');
32  while (lockFile.existsSync()) {
33    // Небольшая задержка
34    await Future.delayed(const Duration(milliseconds: 50));
35  }
36  await lockFile.create();
37  var file = File('${documentsDirectory.path}/shared_file.txt');
38  await file.writeAsString(data.message, mode: FileMode.append);
39  await lockFile.delete();
40}
41
42Future<void> readFile(RootIsolateToken token) async {
43  BackgroundIsolateBinaryMessenger.ensureInitialized(token);
44  final documentsDirectory = await getApplicationDocumentsDirectory();
45  var file = File('${documentsDirectory.path}/shared_file.txt');
46  if (await file.exists()) {
47    var content = await file.readAsString();
48    print(content);
49  }
50}
51
52class IsolateData {
53  final RootIsolateToken token;
54  final String message;
55
56  IsolateData({
57    required this.token,
58    required this.message,
59  });
60}
61

Использование библиотек, которые поддерживают транзакции и/или блокировки «из коробки». Пройдёмся по основным библиотекам и посмотрим, какие из них могут предложить функционал работы с мультиизолятами.

В Hive нет полноценной поддержки работы mutli-isolate с БД. Так что лучше отказаться от этого плагина, если есть необходимость в мультипоточности, или использовать централизованный менеджер (см. выше).

В Isar поддержка multi-isolate доступна «из коробки» и описана в документации к пакету. Именно в этом случае и будет использоваться транзакция записи.

Хотя SQFLite и выполняет операции с базой данных в фоновом потоке (что технически изолирует их от потока пользовательского интерфейса), все обращения к базе данных должны исходить из главного изолята. Механизмы транзакции в SQFLite не поддерживают многопоточный доступ или доступ из разных изолятов. Подробнее про это можно почитать в комментарии от разработчика пакета.

В Drift можно использовать статические методы NativeDatabase.createInBackground или NativeDatabase.createBackgroundConnection (разные способы создания БД в фоновом изоляте), и тогда создастся изолят, в котором будут исполняться SQL-запросы. Это можно сделать без дополнительных настроек. Если всё же требуется работать с БД из разных изолятов, то глобально есть два варианта.

Самый простой — использовать метод computeWithDatabase (аналогия с compute) или serializableConnection для ручной инициализации изолята.

1import 'package:drift/isolate.dart';
2
3...
4
5Future<void> insertSomeData(AppDatabase database) async {
6  await database.computeWithDatabase(
7    computation: (database) async {
8      // Затратная операция выполняется в отдельном изоляте, но взаимодействует с основной базой данных.
9      await database.batch((batch) {
10        batch.insertAll(database.dogs, []);
11      });
12    },
13    connect: (connection) {
14      // Эта функция отвечает за создание второго экземпляра вашего класса базы данных с краткосрочным [connection].
15      // Для работы необходим конструктор класса базы данных, принимающий connection.
16      return AppDatabase(connection);
17    },
18  );
19

Варианты посложнее с полным контролем жизненного цикла и с кастомизацией поведения используют сущность DriftIsolate, которая помогает запускать Drift в фоновом изоляте и является ссылкой на внутренний сервер базы данных (RunningDriftServer), к которому вы можете получить доступ из других изолятов.

Иными словами DriftIsolate — это единственный изолят, внутри которого исполняются все запросы и обновляются данные, а все остальные изоляты лишь обращаются к нему. Для более продвинутых примеров и дополнительной информации советуем ознакомиться с официальной документацией.
Учитывая всё вышеизложенное, пакет Drift — отличное решение для использования в мультипоточной среде.

Также стоит учитывать, что работа с данными в изоляте помогает добиться лучшего перформанса по UI, но скорость работы в некоторых случаях может быть медленнее, чем без изолятов, так как результат выполнения операций не доступен напрямую, а копируется из изолята, работающего с БД. Однако благодаря недавним улучшениям, таким как isolated groups в Dart VM, эти накладные расходы довольно малы, и там, где это возможно, рекомендуется использовать изоляты для выполнения запросов.

Итог

Выбор системы хранения данных — важное решение при разработке приложения на Flutter. Не существует универсального подхода, который удовлетворял бы всем случаям. Поэтому перед тем как выбрать определенное решение, стоит обратить внимание на следующие моменты:

1. Покрытие тестами

Исследуйте, насколько хорошо решение поддерживается тестами. Хорошее покрытие тестами обеспечивает большую уверенность в стабильности и надёжности системы хранения данных.

2. Шифрование

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

3. Популярность

Насколько стандартно и широко используется выбранное решение? Стандартизированные и популярные инструменты обычно имеют более широкую поддержку сообщества и документацию.

4. Реляционность и запросы

Если вам нужно хранить связанные данные и выполнять сложные запросы, рассмотрите реляционные базы данных. Для неструктурированных данных и простых операций выберите NoSQL-решения.

5. Транзакции

Если ваше приложение зависит от транзакционной безопасности (атомарности), убедитесь, что выбранное решение поддерживает эту функцию. Актуально для баз данных.

6. Документация

Исследуйте качество и актуальность документации для выбранного решения. Хорошая документация поможет вам быстро освоить систему и решить возможные проблемы.

7. Использование оперативной памяти

Подумайте о том, как решение управляет памятью. Не все базы данных загружают данные в оперативную память сразу, и это может повлиять на производительность при работе с большими объёмами данных.

8. Миграции

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

9. Изоляты

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

Для удобства мы подготовили сводную таблицу:

Свойство
-----------
Библиотека

Шифрование

Покрытие тестами

Популярность

Транзакции

Документация

Использование оперативной памяти

Изоляты

Миграции

path_provider

-

+

+

-

+

-

-

-

shared_preferences

-

+

+

-

+

+

-

-

Hive

+

+

+-

+

+

+

+

+-

Isar

-

+

+-

+

+

+

+

+-

SQFLite

+-
(с доп. плагином sqflite_sqlcipher)

+

+

+

+

-

+-
(обращение к БД через нативный бэкграунд поток)

+

Drift

+

+

+-

+

+

-

+

+

flutter_cache_manager

-

+

+-

-

+

-

-

-

flutter_secure_storage

+

+

+

-

+

-

-

-

get_storage

-

+

+-

-

+

+

-

-

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

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



Квиз

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф3.9. Accessibility
Следующий параграф4.2. Разные пакеты для похода в сеть (dio, retrofit, chopper)