4.3. Системы хранения данных: библиотеки для работы с БД

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

Но сперва давайте коротко вспомним, чем различаются реляционные (SQL) и нереляционные (NoSQL) базы данных.

SQL

NoSQL

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

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

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

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

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

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

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

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

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

NoSQL

SQL

Hive

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

Sembast

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

ObjectDB

Floor

ObjectBox

Isar

Realm

Mongo_dart

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

  • Hive

  • Isar

  • SQFLite

  • Drift

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';
  1. Инициализируйте Hive, указав рабочую директорию в папке приложения при запуске.
1await Hive.initFlutter();

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

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

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

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

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

1Hive.registerAdapter(UserAdapter());

Теперь вы можете сохранять и загружать объекты типа 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}

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

Преимущества и недостатки библиотеки 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

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

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

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

В 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}

В качестве аннотации можно использовать как 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... и т.д.

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

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

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

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

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);

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

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

4.1.5

Преимущества и недостатки 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);

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

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

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

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

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

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

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

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

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

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

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

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

При изменении схемы данных вам следует обновить базу данных с использованием механизма миграции. Вы можете переопределить метод 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);

Преимущества и недостатки 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

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

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

И создаём объект базы данных с аннотацией @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}

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

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}

И запускаем кодогенерацию 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);

Или же:

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

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

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

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

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

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

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

1await db.removeAll();

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

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

Недостатки

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

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

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

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

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

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

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

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

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

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

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

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

Предыдущий параграф4.2. Системы хранения данных: библиотеки для работы с парами ключ — значение
Следующий параграф4.4. Системы хранения данных: важные нюансы