Flutter. Локальная база данных

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

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

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

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

fc2b1d5330990aa7e529a2237ab4da42.png

Проблема

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

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

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

Подготовка

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

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

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

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

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

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

Реализация

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

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

d2b7f346ee31fb468081f479dbf92bc3.png

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

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

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

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

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

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

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

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

  // Используем при получении данных из базы
  static fromMap(Map map) {}
  // Используем при отправке данных в базу
  Map 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 map) => _$ToDoItemFromMap(map);

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

  static ToDoItem _$ToDoItemFromMap(Map 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 =  map)>{
    ToDoItem: (map) => ToDoItem.fromMap(map),
  };

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

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

CRUD-операции

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

Future get(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 для ограничения количества возвращаемых записей.

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

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

    return resList;
  }
Future update(T model) async => _db.update(
        _dbName(T),
        model.toMap(),
        where: 'id = ?', // без этого все строки таблицы будут обновлены
        whereArgs: [model.id],
      );
Future delete(T model) async => _db.delete(
        _dbName(T),
        where: 'id = ?', // если не указывать, то удалятся все строки
        whereArgs: [model.id],
      );

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

  • Create + Update:

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

Data-сервис

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

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

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

    return items.toList();
  }

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

Применение

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

  • _ModelState:

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

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

  _ModelState copyWith({
	List? 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`

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

e1af81da8e1b82f34cb314efaa4a0699.png

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

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

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

Итог

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

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

© Habrahabr.ru