Введение

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

Если у вас ещё нет Flutter, скачать можно на странице установки. Исходный код, который мы используем, доступен здесь на GitHub.

Конфигурация проекта

Итак, что нам нужно для использования SQLite в приложении на Flutter? Во-первых, включить пакет sqflite внутри проекта в pubspec.yaml вот таким образом:

name: flutter_sqlite_demo
description: проект-образец, демонстрирующий использование Flutter с SQLite
version: 1.0.0+1

environment:
  sdk: ">=2.1.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  sqflite: ^1.2.0
  path_provider: ^1.6.0
  cupertino_icons: ^0.1.2

dev_dependencies:
  flutter_test:
    sdk: flutter

Мы указали здесь версию пакета sqflite 1.2.0 или новее и path_provider версии не старше 1.6.0, а проект упростили для лучшего понимания и облегчения работы с ним.

Создаём простую модель

Для хранения данных нам понадобится модель. Простой класс модели данных позволяет применять необходимые методы для преобразования приемлемого для SQLite формата данных в объект, который может быть использован в приложении. Абстрактный класс Model будет служить базовым классом для моделей данных. Этот файл находится в lib/models/model.dart:

abstract class Model {
 
    int id;
    
    static fromMap() {}
    toMap() {}
}

Класс Model очень прост и удобен для определения свойств/методов (таких как приведённый выше id), которые можно ожидать от моделей данных. Это позволяет создавать одну или несколько конкретных моделей данных, соответствующих такому базовому шаблону проектирования. В примере с нашим приложением для управления задачами класс модели конкретного элемента создаётся в lib/models/todo-item.dart:

import 'package:flutter_sqlite_demo/models/model.dart';

class TodoItem extends Model {

    static String table = 'todo_items';

    int id;
    String task;
    bool complete;

    TodoItem({ this.id, this.task, this.complete });

    Map<String, dynamic> toMap() {

        Map<String, dynamic> map = {
            'task': task,
            'complete': complete
        };

        if (id != null) { map['id'] = id; }
        return map;
    }

    static TodoItem fromMap(Map<String, dynamic> map) {
        
        return TodoItem(
            id: map['id'],
            task: map['task'],
            complete: map['complete'] == 1
        );
    }
}

Этот класс TodoItem содержит свойства для task и complete и простой конструктор для создания нового элемента, а для преобразования между экземплярами TodoItem и объектами карты, используемыми базой данных, определены методы toMap и fromMap. Заметим, что id добавляется в карту, только будучи значением не null.

Класс базы данных

Ради удобства и простоты сопровождения основные методы обработки базы данных помещаем в lib/services/db.dart (так лучше, чем разбрасывать логику обработки данных по всему приложению):

import 'dart:async';
import 'package:flutter_sqlite_demo/models/model.dart';
import 'package:sqflite/sqflite.dart';

abstract class DB {

    static Database _db;

    static int get _version => 1;

    static Future<void> init() async {

        if (_db != null) { return; }

        try {
            String _path = await getDatabasesPath() + 'example';
            _db = await openDatabase(_path, version: _version, onCreate: onCreate);
        }
        catch(ex) { 
            print(ex);
        }
    }

    static void onCreate(Database db, int version) async =>
        await db.execute('CREATE TABLE todo_items (id INTEGER PRIMARY KEY NOT NULL, task STRING, complete BOOLEAN)');

    static Future<List<Map<String, dynamic>>> query(String table) async => _db.query(table);

    static Future<int> insert(String table, Model model) async =>
        await _db.insert(table, model.toMap());
    
    static Future<int> update(String table, Model model) async =>
        await _db.update(table, model.toMap(), where: 'id = ?', whereArgs: [model.id]);

    static Future<int> delete(String table, Model model) async =>
        await _db.delete(table, where: 'id = ?', whereArgs: [model.id]);
}

Этот класс абстрактный: из него нельзя создавать объекты, да и нужна всего одна его копия в памяти. В свойстве _db у него есть ссылка на базу данных SQLite. Номер версии базы данных захардкоден значением 1, хотя в более сложных приложениях версию базы данных можно использовать для миграции схем базы данных вверх или вниз в версии, благодаря чему можно развёртывать новые функции без необходимости стирать базу данных и начинать всё с нуля.

Внутри метода init создаётся экземпляр базы данных SQLite с именем базы данных example специально для нашего проекта. Если база данных с именем example ещё не существует, автоматически вызывается onCreate. Именно здесь находятся запросы на создание структуры таблицы. В нашем случае создаётся таблица todo_items с первичным ключом для id и полями, которые соответствуют свойствам в классе TodoItem.

Метод query, равно как и методы insert, update и delete, определяется для выполнения стандартных операций в базе данных. Благодаря им, у нас есть простые абстракции и возможность поместить логику обработки данных в этот класс, что может быть очень полезным при рефакторинге и сопровождении кода в приложении. Если бы их не было, нам пришлось бы, например, искать и заменять кучу строк в разных файлах или устранять странные ошибки после внесения простых изменений.

Главный файл приложения

И последнее, но не менее важное: логика приложения и пользовательский интерфейс у нас находятся в lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_sqlite_demo/models/todo-item.dart';
import 'package:flutter_sqlite_demo/services/db.dart';

void main() async {

    WidgetsFlutterBinding.ensureInitialized();

    await DB.init();
    runApp(MyApp());
}

class MyApp extends StatelessWidget {

    @override
    Widget build(BuildContext context) {

        return MaterialApp(
            title: 'Flutter Demo',
            theme: ThemeData( primarySwatch: Colors.indigo ),
            home: MyHomePage(title: 'Flutter SQLite Demo App'),
        );
    }
}

class MyHomePage extends StatefulWidget {

    MyHomePage({Key key, this.title}) : super(key: key);

    final String title;

    @override
    _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
    
    String _task;

    List<TodoItem> _tasks = [];

    TextStyle _style = TextStyle(color: Colors.white, fontSize: 24);

    List<Widget> get _items => _tasks.map((item) => format(item)).toList();

    Widget format(TodoItem item) {

        return Dismissible(
            key: Key(item.id.toString()),
            child: Padding(
                padding: EdgeInsets.fromLTRB(12, 6, 12, 4),
                child: FlatButton(
                    child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: <Widget>[
                            Text(item.task, style: _style),
                            Icon(item.complete == true ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: Colors.white)
                        ]
                    ),
                    onPressed: () => _toggle(item),
                )
            ),
            onDismissed: (DismissDirection direction) => _delete(item),
        );
    }

    void _toggle(TodoItem item) async {

        item.complete = !item.complete;
        dynamic result = await DB.update(TodoItem.table, item);
        print(result);
        refresh();
    }

    void _delete(TodoItem item) async {
        
        DB.delete(TodoItem.table, item);
        refresh();
    }

    void _save() async {

        Navigator.of(context).pop();
        TodoItem item = TodoItem(
            task: _task,
            complete: false
        );

        await DB.insert(TodoItem.table, item);
        setState(() => _task = '' );
        refresh();
    }

    void _create(BuildContext context) {

        showDialog(
            context: context,
            builder: (BuildContext context) {
                return AlertDialog(
                    title: Text("Create New Task"),
                    actions: <Widget>[
                        FlatButton(
                            child: Text('Cancel'),
                            onPressed: () => Navigator.of(context).pop()
                        ),
                        FlatButton(
                            child: Text('Save'),
                            onPressed: () => _save()
                        )                        
                    ],
                    content: TextField(
                        autofocus: true,
                        decoration: InputDecoration(labelText: 'Task Name', hintText: 'e.g. pick up bread'),
                        onChanged: (value) { _task = value; },
                    ),
                );
            }
        );
    }

    @override
    void initState() {

        refresh();
        super.initState();
    }

    void refresh() async {

        List<Map<String, dynamic>> _results = await DB.query(TodoItem.table);
        _tasks = _results.map((item) => TodoItem.fromMap(item)).toList();
        setState(() { });
    }

    @override
    Widget build(BuildContext context) {

        return Scaffold(
            backgroundColor: Colors.black,
            appBar: AppBar( title: Text(widget.title) ),
            body: Center(
                child: ListView( children: _items )
            ),
            floatingActionButton: FloatingActionButton(
                onPressed: () { _create(context); },
                tooltip: 'New TODO',
                child: Icon(Icons.library_add),
            )
        );
    }
}

Это стандартный файл, определяющий внешний вид и поведение любого приложения с Flutter. Во время инициализации строка WidgetsFlutterBinding.ensureInitialized() обеспечит корректную инициализацию приложения Flutter, тогда как база данных инициализируется с помощью await DB.init().

Когда приложение запускается и виджет MyHomePage визуализируется, вызов refresh() извлекает список задач из таблицы todo_items и выполняет его отображение на список List объектов TodoItem. Они отображаются в главном ListView через средство доступа _items, которое принимает список List объектов TodoItem и форматирует его как список виджетов, содержащий текстовый элемент задач и индикатор завершённости этого элемента.

Задачи добавляются нажатием на плавающую круглую кнопку и введением названия задачи. При нажатии на кнопку Save вновь созданный элемент списка задач будет добавлен в базу данных с помощью DB.insert . Нажав на задачу в списке, можно переключаться между состояниями завершена /не завершена: здесь используем булеву переменную complete и сохраняем изменённый объект в базе данных с помощью DB.update. Проведя пальцем по задаче в горизонтальном направлении, можно удалить элемент задач: используем для этого метод DB.delete. Всякий раз, когда в список вносятся изменения, вызов refresh() с последующим setState() обеспечивает правильность обновления списка.

Заключение

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

Спасибо за внимание и удачи в вашем следующем проекте на Flutter!

Читайте также:


Перевод статьи Kenneth Reilly: How to use Flutter with SQLite

Предыдущая статьяПишем быстрее в любой IDE
Следующая статьяКак преодолеть синдром самозванца: 6 советов разработчикам