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

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

Меня зовут Ричард, и я младший разработчик в компании Digital Design.

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

Проблема

Предположим, что у нас есть бэк с основной базой данных и несколько мобильных клиентов нашего ToDoList’а (опять же отсылаю к прошлой статье). На мобилке у нас реализована логика взаимодействия с нашим бэком, однако возникает проблема: где хранить полученные данные? 

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

Что же делать в таком случае? Использовать локальную базу данных!

Подготовка

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

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

  • sqflite — для подключения SQLite базы данных в Flutter

  • path — для облегчения работы с путями (необязательно)

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

flutter pub add sqflite path — данная команда, собственно, и добавит зависимости в тот же файл и скачает пакеты последней версии.

Реализация

Структура базы данных

Создадим файл, в котором мы опишем структуру нашей будущей БД, и добавим его как asset в pubspec.yaml

Внутри файла создадим таблицу с нашими ToDo’шками.

CREATE TABLE t_ToDoItem (
    [id]        INTEGER NOT NULL PRIMARY KEY
    ,[name]     TEXT NOT NULL
    ,[isDone]   INTEGER
);

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

Подготовка моделей для работы с базой данных

Определим абстрактный класс для работы с БД. В нём мы объявим: 

  • Id, не привязанный к конкретному типу;

  • методы по перегонке нашего объекта в Map (и обратно), так как вся работа с базой происходит через неё.

abstract class DbModel<T> {
  final T id;
  DbModel({required this.id});

  // Используем при получении данных из базы
  static fromMap(Map<String, dynamic> map) {}
  // Используем при отправке данных в базу
  Map<String, dynamic> toMap() => Map.fromIterable([]);
}

Теперь реализуем интерфейс DbModel в нашей модельке ToDoItem.

class ToDoItem implements DbModel {
  @override
  final int id; // переопределяем поле и указываем определённый тип
  final String name;
  final bool isDone;

  ToDoItem({
    required this.id,
    required this.name,
    this.isDone = false,
  });

  factory ToDoItem.fromMap(Map<String, dynamic> map) => _$ToDoItemFromMap(map);

  @override
  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'id': id,
      'name': name,
      'isDone': isDone ? 1 : 0, // В sqlite нет типа данных bool, поэтому мы храним наш флаг в виде числа
    };
  }

  static ToDoItem _$ToDoItemFromMap(Map<String, dynamic> map) => ToDoItem(
        id: map['id'],
        name: map['name'],
        isDone: map['isDone'] == 1, // При получении из базы обратно переводим в bool
      );

Создание БД

База данных у нас будет синглтоном, который запускается вместе с приложением. Sqflite предоставляет нам класс Database, в котором содержится интерфейс обращения к БД.

Создадим класс DB, в котором будет описана наша логика работы с базой: её инициализация, создание и CRUD-операции.

Начнём с инициализации. Для этого мы должны указать путь до файла с БД и открыть его.

class DB {
  DB._(); // Приватный конструктор
  static final DB instance = DB._(); // экземпляр с которым будем работать
  static late Database _db; // “интерфейс” для работы с sqflite
  static bool _isInitialized = false;

  Future init() async {
    if (!_isInitialized) {
      var databasePath = await getDatabasesPath(); // получение дефолтной папки для сохранения файла БД

      var path = join(databasePath, "db_v1.0.2.db");

      _db = await openDatabase(path, version: 1, onCreate: _createDB);
      _isInitialized = true;
    }
  }
}

При открытии БД sqflite проверяет существование указанного файла. Если его нет, то отрабатывается метод _createDB, который мы ему передаём.

Future _createDB(Database db, int version) async {
    // db_init.sql должен быть прописан в pubspec.yaml
    var dbInitScript = await rootBundle.loadString('assets/db_init.sql');

    dbInitScript.split(';').forEach((element) async {
      if (element.isNotEmpty) {
        await db.execute(element);
      }
    });
  }

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

Фабрики и таблицы

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

 static final _factories = <Type, Function(Map<String, dynamic> map)>{
    ToDoItem: (map) => ToDoItem.fromMap(map),
  };

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

String _dbName(Type type) {
    if (type == DbModel) {
      throw Exception("Type is required");
    }
    return "t_${(type).toString()}";
  }

CRUD-операции

  • Create:

Future<int> insert<T extends DbModel>(T model) async => await _db.insert(
        _dbName(T), // Получаем имя рабочей таблицы
        model.toMap(), // Переводим наш объект в мапу для вставки
        conflictAlgorithm: null, // Что должно происходить при конфликте вставки
        nullColumnHack: null, // Что делать, если not null столбец приходит как null
      );
  • Read по Id:

Future<T?> get<T extends DbModel>(dynamic id) async {
    var res = await _db.query(
      _dbName(T),
      where: 'id = ? ', // Прописываем в виде строки нужное нам условие и на месте сравниваемого значения ставим ‘?’
      whereArgs: [id], // значения, передаваемые в этом массиве будут подставляться вместо ‘?’ в запросах. Порядок аргументов ВАЖЕН!
    );
    return res.isNotEmpty ? _factories[T]!(res.first) : null;
  }

Для получения всех строк из таблицы достаточно убрать условие where. В таком случае есть смысл использовать параметры offset и take для ограничения количества возвращаемых записей.

  • Read с ограничением по количеству записей:

Future<Iterable<T>> getAll<T extends DbModel>({
    int? take,
    int? skip,
  }) async {
    Iterable<Map<String, dynamic>> query = query = await _db.query(
        _dbName(T),
        offset: skip, // сколько строк нужно пропустить из конечной выборки
        limit: take, // количество возвращаемых строк
      );

    var resList = query.map((e) => _factories[T]!(e)).cast<T>();

    return resList;
  }
  • Update:

Future<int> update<T extends DbModel>(T model) async => _db.update(
        _dbName(T),
        model.toMap(),
        where: 'id = ?', // без этого все строки таблицы будут обновлены
        whereArgs: [model.id],
      );
  • Delete:

Future<int> delete<T extends DbModel>(T model) async => _db.delete(
        _dbName(T),
        where: 'id = ?', // если не указывать, то удалятся все строки
        whereArgs: [model.id],
      );

Довольно удобно бывает объединить операции create и update в одном методе.

  • Create + Update:

Future<int> createUpdate<T extends DbModel>(T model) async {
    var dbItem = await get<T>(model.id);
    var res = dbItem == null ? insert(model) : update(model);
    return await res;
  }

Data-сервис

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

Получение списка задач:

Future<List<ToDoItem>> getToDoList({
    int take = 10,
    int skip = 0,
  }) async {
    var items = await DB.instance.getAll<ToDoItem>(skip: skip, take: take);

    return items.toList();
  }

Все запросы, которые были написаны для нашего ToDoList’а, мы разбирать не будем, так как они достаточно тривиальны (просто вызывают соответствующие методы из нашего класса DB). При желании можете посмотреть их здесь.

Применение

Вернёмся к тому, что было сделано в прошлой статье. У нас были следующие классы:

  • _ModelState:

class _ModelState {
  final List<ToDoItem> items;
  // Пока этот флаг true, на экране видна загрузка
  final bool isLoading;

  _ModelState({
	this.items = const <ToDoItem>[],
	this.isLoading = false,
  });

  _ModelState copyWith({
	List<ToDoItem>? items,
	bool? isLoading,
  }) {
	return _ModelState(
  	items: items ?? this.items,
  	isLoading: isLoading ?? this.isLoading,
	);
  }
}
  • _ViewModel:

class _ViewModel extends ChangeNotifier {
  var _state = _ModelState();
  _ModelState get state => _state;
  set state(_ModelState val) {
    _state = _state.copyWith(items: val.items, isLoading: val.isLoading);
    notifyListeners();
  }

  addItem(CreateTodoItemModel model) {
    // логика добавления задачи
  }

  deleteItem(ToDoItem item) {
    // логика удаления
  }

  toggleItem(ToDoItem item) {
    // переключение состояния "выполнения" задачи
  }
}

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

class _ViewModel extends ChangeNotifier {
  final _api = RepositoryModule.apiRepository();
  final _syncService = SyncService();
  final _dataService = DataService();

  var _state = _ModelState();
  _ModelState get state => _state;
  set state(_ModelState val) {
    _state = _state.copyWith(items: val.items, isLoading: val.isLoading);
    notifyListeners();
  }

  _ViewModel() {
    _asyncInit();
  }

  _asyncInit() async {
    if (!state.isLoading) {
  	state = state.copyWith(isLoading: true);

  	await _syncService.syncTodoList();
  	var todoItems = await _dataService.getToDoList();
  	state = state.copyWith(items: todoItems, isLoading: false);
    }
  }

  addItem(CreateTodoItemModel model) async {
    // отправляем запрос о добавлении на бэк
    await _api.addTodoItem(model);

    await _asyncInit(); // подтягиваем актуальные данные
  }
  toggleItem(ToDoItem item) async {
    // отправляем запрос об изменении на бэк
    await _api.updateItemStatus(item.id);

    await _asyncInit();
  }

  deleteItem(ToDoItem item) async {
    await _api.deleteTodoItem(item.id); // запрос об удалении записи
    await _dataService.deleteTodoItem(item.id); // удаляем запись из локальной БД

    await _asyncInit();
  }
}

Что же находится внутри SyncService? Просто получение данных с API и добавление их в базу:

class SyncService {
  final _api = ApiModule.api();
  final _dataService = DataService();

  Future syncTodoList({int skip = 0, int take = 100}) async {
    var todoList = await _api.getTodoList(skip, take);

    await _dataService.rangeUpdateEntities(todoList);
  }
}

Подробнее про `rangeUpdateEntities`

Когда мы всё добавили — настала пора проверять работу нашего приложения.

Вуаля. Теперь все наши задачи не теряются при закрытии приложения.

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

Вариант приложения без работы с бэкендом

Итог

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

Что делать дальше? Научить своё приложение обмениваться данными с бэкендом! В следующей статье я подробно расскажу о том, как организовать взаимодействие между мобильным приложением и API.

Комментарии (5)


  1. Krushiler
    31.10.2023 14:11
    +1

    Мы hive используем. Это key value хранилище. Он и в вебе работает и дтошки под него писать удобно.


    1. Digital_Design Автор
      31.10.2023 14:11

      Мы стараемся подбирать хранилище данных так, чтобы оно было оптимальным для конкретной задачи. Так уж вышло, что многие приложения, которые мы разрабатываем, рассчитаны на работу в офлайн-режиме с периодической синхронизацией с главной БД. Поэтому приложение зачастую хранит и обрабатывает большое количество записей, что делает использование SQLite удобным. В дальнейшем планируем поработать и с Hive’ом).


      1. gangsterpp
        31.10.2023 14:11

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


        1. Digital_Design Автор
          31.10.2023 14:11

          Причина, по которой мы создаем `.sql` файл — желание отделить определение схемы БД от логики работы с ней. Этот файл мы складываем в ассеты, просто чтобы использовать его в самом приложении. Альтернативой (на мой взгляд) является прописывание всей структуры БД в виде строки внутри кода, что не совсем удобно лично для меня. Однако если вы считайте этот подход неправильным, то прошу вас поделиться вашим опытом и идеями на эту тему.


  1. eshxeee
    31.10.2023 14:11
    +2

    Посмотрите на drift, имхо это лучшее решение для sqlite. А на Hive не стоит тратить время