Создание Flutter-приложения с SQLite, BLoC и Streams

Материал основан на разборе freecodecamp.org. Ниже — главное и практические шаги, которые можно быстро применить в работе.


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

В рамках этого руководства мы разработаем простое приложение с нуля, которое будет использовать streams, BLoC-компоненты и SQLite. Оно позволит пользователям создавать, редактировать и удалять заметки. Если вы еще не начали, создайте новое базовое Flutter-приложение с помощью команды flutter create APPNAME. Начать с нуля будет удобнее, а затем вы сможете адаптировать полученные знания к своим существующим проектам.

Подключение SQLite к Flutter-приложению через sqflite

Первое, что нужно сделать, — создать класс для управления созданием таблиц и выполнения запросов к базе данных. Чтобы сделать это правильно, нужно добавить sqflite и path_provider в качестве зависимостей в файл pubspec.yaml.

Если пакеты не загрузятся автоматически, выполните команду flutter packages get для их получения. После завершения создайте папку data и файл database.dart внутри неё. Этот класс создаст синглтон (singleton), чтобы можно было получать доступ к базе данных из других файлов, открывать её и выполнять запросы. Я добавил комментарии для объяснения части кода

На практике я советую сразу продумать три вещи: где будет лежать файл базы, как вы создадите таблицы при первом запуске и как будете обновлять схему при следующей версии приложения. Для локального приложения с заметками обычно достаточно одной базы notes.db, расположенной в каталоге, который возвращает getApplicationDocumentsDirectory().

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

Полезный рабочий шаблон такой: в database.dart открыть БД через openDatabase(), в onCreate создать таблицу notes, а в onUpgrade описать будущие изменения схемы. Даже если у вас пока одна таблица и два поля, структура с version, onCreate и onUpgrade окупается очень быстро.

Например, сегодня у заметки есть только id, title и content, а через месяц вам понадобится updated_at или флаг is_archived. Если миграции не предусмотрены заранее, придётся писать обходные скрипты уже после того, как приложение попадёт к пользователям.

Отдельно рекомендую не складывать всю логику SQL прямо в виджеты. Пусть database.dart отвечает только за соединение, создание таблиц и низкоуровневые CRUD-операции. Тогда BLoC будет работать с понятными методами вроде insertNote(), getAllNotes() и deleteNote(id), а не с сырыми SQL-строками.

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

Создание модели данных

Создайте ещё одну папку models и добавьте в неё один файл: note_model.dart. Вот отличный инструмент для удобного создания моделей: https://app.quicktype.io/#l=dart.

ПРИМЕЧАНИЕ: Имейте в виду, что модели НЕ обязаны копировать столбцы таблицы. Например, если у вас есть идентификатор пользователя, хранящийся в таблице в качестве внешнего ключа, модель, вероятно, не должна содержать этот идентификатор пользователя. Вместо этого модель должна использовать этот идентификатор для получения фактического объекта User.

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

Для заметки в учебном приложении обычно достаточно полей id, title, description и, при необходимости, createdAt или updatedAt. Но здесь важна не только структура, а и правила преобразования данных. Я почти всегда добавляю в модель методы fromMap() и toMap(), чтобы конвертация между SQLite-строкой и объектом Dart происходила в одном месте.

Это избавляет от рассыпанной по проекту ручной сборки Map<String, dynamic> и снижает риск ошибок, когда одно и то же поле в разных экранах называется по-разному.

Хорошая проверка для модели такая: можно ли по одному объекту Note безопасно построить UI, сериализовать его в базу и снова прочитать без потери данных. Если ответ “нет”, модель ещё слишком сырая. Например, если вы храните дату как строку, сразу решите, будет это ISO-формат или timestamp в миллисекундах. Если решение не зафиксировать, сортировка по дате и фильтры начнут вести себя непредсказуемо уже на первых реальных данных.

Работа со Streams и BLoCs

Теперь давайте обсудим работу со streams и BLoC-компонентами. Если вы с ними еще не знакомы, это может вызвать некоторые сложности. Однако после того, как вы усвоите основные концепции, использование streams и BLoC-компонентов станет более понятным и интуитивно простым.

Первое, что нам нужно, — папка blocs внутри папки data. Эта папка будет содержать все наши BLoC-компоненты, как следует из названия. Создайте файлы для каждого BLoC-компонента: bloc_provider.dart, notes_bloc.dart и view_note_bloc.dart. По одному BLoC-компоненту на страницу и один — BlocProvider, который отвечает за внедрение нужных BLoC-компонентов на страницы и их освобождение при уничтожении виджета.

bloc_provider обеспечивает удобное предоставление необходимых BLoC для страниц и их последующее удаление при необходимости

Когда нам понадобится BLoC на одной из наших страниц, мы будем использовать BlocProvider следующим образом:

Давайте создадим BLoC-компонент для заметок, который будет обрабатывать получение всех заметок и добавление новых заметок в базу данных. Поскольку наши BLoC-компоненты привязаны к конкретным страницам, этот BLoC-компонент будет использоваться только на странице заметок. Я прокомментировал код, чтобы объяснить, что происходит.

Страница заметок: отображение и добавление

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

Я рекомендую строить эту страницу вокруг StreamBuilder, который подписывается на поток списка заметок из notes_bloc.dart. Тогда экран автоматически обновляется после добавления новой записи, а вам не приходится вручную вызывать setState() в нескольких местах.

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

Чтобы форма добавления заметки не превращалась в хаос, удобно сразу разделить сценарии “валидация”, “сохранение” и “обновление списка”. Например, если пользователь ввёл только пробелы в заголовке, BLoC должен вернуть ошибку ещё до обращения к SQLite. Если запись успешно сохранена, BLoC повторно читает список заметок и публикует новый state в stream.

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

В реальном проекте я бы ещё добавил два небольших, но полезных элемента: пустое состояние с подсказкой “создайте первую заметку” и индикатор загрузки на время первого чтения из базы. Это даёт понятный UX даже при холодном старте приложения. Без этих деталей пользователь видит белый экран или пустой ListView и не понимает, приложение сломалось или в базе просто нет данных.

Просмотр, редактирование и удаление заметок

Теперь нам нужен способ просматривать, редактировать, сохранять и удалять заметки. Именно здесь в игру вступают BLoC для просмотра заметки и страница просмотра заметки. Начнём с view_note_bloc.dart.

Теперь можно создать саму страницу, которая позволит взаимодействовать с нашими заметками. Код для этой страницы будет помещён в view_note.dart

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

Если view_note_bloc.dart после сохранения возвращает обновлённый объект и уведомляет основной BLoC списка, проблема исчезает: список и карточка заметки начинают жить в одном согласованном сценарии.

Для редактирования полезно сразу решить, когда именно вы сохраняете изменения. Есть два рабочих варианта: автосохранение на каждый значимый апдейт или явная кнопка “Сохранить”. Для учебного CRUD-приложения я бы выбрал кнопку: её легче отладить, проще объяснить и она создаёт меньше лишних записей в SQLite. Удаление, наоборот, лучше сопровождать диалогом подтверждения.

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

Если приложение должно жить дольше демо, добавьте критерий успешного обновления заметки: после сохранения вы должны уметь закрыть экран, снова открыть ту же запись и увидеть тот же текст, ту же дату и тот же идентификатор. Этот маленький smoke-test сразу выявляет половину ошибок в логике update, особенно когда в проекте меняют модель данных или начинают сериализовать поля не тем типом.

Типичные ошибки при работе со Streams и BLoCs

Работая с этим стеком, я неоднократно сталкивался с одними и теми же проблемами — и хочу предупредить о них заранее.

Забытый dispose. Если не закрывать StreamController в методе dispose BLoC, приложение будет утекать по памяти. Убедитесь, что bloc_provider вызывает dispose у BLoC при уничтожении виджета.

Несоответствие типов в Stream. Если вы передаёте в sink данные не того типа, который ожидает StreamController, вы получите ошибку во время выполнения. Явно указывайте тип при создании контроллера: StreamController<List<Note>>().

Повторное открытие закрытого Stream. После вызова close() на StreamController повторно добавить данные в sink уже не получится. Следите за жизненным циклом BLoC и не обращайтесь к нему после уничтожения страницы.

Лишние перестройки виджетов. StreamBuilder перестраивает виджет при каждом новом событии в потоке. Если вы добавляете данные в stream слишком часто или без необходимости, это скажется на производительности. Добавляйте данные в sink только тогда, когда состояние действительно изменилось.

Итоговое Flutter-приложение: SQLite, BLoC и Streams вместе

Вот и всё, что нужно для работы со streams, BLoC-компонентами и SQLite. Используя их вместе, мы создали простое приложение, которое позволяет создавать, просматривать, редактировать и удалять заметки. Надеюсь, это пошаговое руководство придало вам больше уверенности в работе со streams — теперь вы сможете с лёгкостью внедрить их в свои собственные приложения.

Посмотреть полный код: https://github.com/Erigitic/flutter-streams

Главный практический вывод здесь такой: связка SQLite + BLoC + Streams хорошо подходит для автономных мобильных экранов, где данные должны быстро читаться локально, а интерфейс обязан реагировать на изменения без полной перерисовки дерева виджетов.

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

Если бы я переносил этот пример в production, я бы добавил ещё минимум три вещи. Во-первых, индекс по часто используемому полю сортировки, например по updated_at. Во-вторых, миграции схемы с тестом на обновление старой базы. В-третьих, покрытие BLoC тестами на сценарии “добавить заметку”, “обновить существующую”, “удалить и перечитать список”.

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

Часто задаваемые вопросы по Flutter, BLoC и SQLite

Что такое BLoC и зачем он нужен во Flutter? BLoC (Business Logic Component) — паттерн управления состоянием, который отделяет бизнес-логику от UI. Он принимает события через sink и отдаёт состояния через stream, что делает код предсказуемым и тестируемым.

Чем StreamController отличается от обычного Stream? Stream — это поток данных только для чтения. StreamController создаёт поток и предоставляет sink для добавления данных в него. В BLoC обычно используется именно StreamController, чтобы управлять тем, что попадает в поток.

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

Нужно ли создавать отдельный BLoC для каждой страницы? Это рекомендуемый подход: один BLoC на страницу упрощает отладку и не смешивает логику разных экранов. Если несколько страниц работают с одними и теми же данными, можно вынести общую логику в отдельный BLoC и предоставлять его через bloc_provider.

Как правильно уничтожать BLoC, чтобы избежать утечек памяти? В bloc_provider нужно переопределить метод dispose виджета и вызвать в нём метод dispose самого BLoC, который закрывает все StreamController. Это гарантирует, что потоки будут корректно завершены при уходе со страницы.

Оцените статью
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x