BundledSQLiteDriver: новый взгляд на SQLite в Android и Kotlin Multiplatform

от автора

BundledSQLiteDriver из библиотеки androidx.sqlite — это специальная сборка встраиваемой СУБД SQLite от команды разработчиков Android Jetpack, предназначенная для использования в Kotlin Multiplatform проектах. В этой статье мы рассмотрим особенности её использования по сравнению со встроенным в Android вариантом SQLite.

Room и androidx.sqlite

В начале 2004 года Google анонсировала официальную поддержку Kotlin Multiplatform в Room — ORM‑подобной библиотеке для доступа к локальным хранилищам на основе SQLite.

C самых первых версий этой библиотеки доступ к низкоуровневой реализации SQLite выполняется не напрямую, а через абстракции, представленные интерфейсами в библиотеке androidx.sqlite:sqlite:

  • SupportSQLiteOpenHelper

  • SupportSQLiteDatabase

  • SupportSQLiteStatement

Такое разделение (в стиле API — Implementation) позволяет подменять используемую версию SQLite в Room. Основная «официальная» реализация — класс FrameworkSQLiteOpenHelper — находится в модуле androidx.sqlite:sqlite-framework.

Примеры сторонних библиотек, реализующих этих API и предоставляющих свои варианты SQLite: SQLCipher, requery/sqlite‑android.

С другой стороны, эти же интерфейсы используются не только в Room, но и, например, в библиотеке SQLDelight для Android. Адаптер app.cash.sqldelight.driver.android.AndroidSqliteDriver принимает параметром openHelper: SupportSQLiteOpenHelper, благодаря чему в SQLDelight можно использовать, например, requery‑android.

В целом, интерфейсы SupportSQLiteXXX повторяли платформенные классы Android SQLiteOpenHelper, SQLiteDatabase и SQLiteStatement. В определениях их публичных методов используются многие системные классы: Cursor, ContentObserver, ContentResolver, android.net.Uri, Handler, Bundle, Context (ради одного метода). В результате эти интерфейсы оказались совершенно непригодны для использования в Kotlin Multiplatform проектах.

В версии 2.5.0 библиотека androidx.sqlite была переведена на KMP. Оригинальные интерфейсы SupportSQLiteXXX были оставлены в Android‑варианте библиотеки для совместимости, а в common появился новый набор абстракций:

  • SQLiteDriver — основной класс для открытия базы данных и создания подключения (connection)

  • SQLiteConnection — объект для взаимодействия с базой, фабрика для Statement.

  • SQLiteStatement — абстракция над подготовленными выражениями SQLite (Prepared Statement), используемыми для выполнения запросов.

Этот вариант стал ближе к SQLite C API. Кратко его можно описать примерно так:

package androidx.sqlite  interface SQLiteDriver {   fun open(filename: String): SQLiteConnection }  interface SQLiteConnection : AutoCloseable {   fun prepare(sql: String): SQLiteStatement }  interface SQLiteStatement : AutoCloseable {   fun bindT(index: Int, value: T)   fun getT(index: Int): T   fun step(): Boolean }

Он получился гораздо проще, короче и универсальнее предыдущей версии. Благодаря тому, что это простые интерфейсы, декораторами можно реализовать дополнительные возможности: логирование, профилирование, отправку аналитики по запросам, шифрование контента и другие.

Также было представлено 3 реализации новых API:

  • AndroidSQLiteDriver — реализация, основанная на системных Android API для доступа к SQLite. Вариант только для Android.

  • NativeSQLiteDriver — вариант для iOS, Mac, Linux. SQLite линкуется динамически, в конечном приложении используется установленная на системе пользователя библиотека libsqlite.

  • BundledSQLiteDriver — своя сборка SQLite от авторов Android Jetpack, которая упаковывается в приложение. Это рекомендуемый вариант, подходящий для Android, iOS, Mac, Linux и JVM.

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

Так как теперь это Kotlin Multiplatform проект, мы не ограничены только Android. Например, её можно применять на JVM вместо SQLite JDBC, или использовать в Gradle плагинах для подготовки базы на хосте, которую затем упаковывать в android assets для использования на устройстве с тем же драйвером, которым она была создана.

BundledSQLiteDriver

Рассмотрим особенности варианта драйвера BundledSQLiteDriver при его использовании на Android. Вот основные его преимущества.

  1. Современная версия SQLite

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

  2. Предсказуемое поведение на разных устройствах

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

  3. Производительность

    Она обычно выше, не только из‑за более новой версии SQLite. В системном Android SQLite используются классы, изначально спроектированные для межпроцессорного взаимодействия. Передача данных между нативной памятью и JVM реализована на основе буфера фиксированного размера (обычно 2МБ), представленного классом android.database.CursorWindow со стороны Java и C++ классом android.CursorWindow в нативном коде. Этот буфер в прошлом создавал много проблем для разработчиков, использующих SQLite на Android. Сейчас в этом буфере нет необходимости.

Из недостатков можно отметить увеличение APK примерно на 1МБ за счёт упаковываемой SQLite библиотеки и потенциально большее потребление памяти.

Уникальные возможности AndroidSQLiteDriver

Конфигурация сборки BundledSQLiteDriver отличается от той, что используется в Android SQLite. Рассмотрим, особенности, которые могут перестать работать при переходе с используемого по умолчанию AndroidSQLiteDriver.

Версия библиотеки android.sqlite: 2.5.0-rc02 (SQLite 3.46.0), версия на тестируемом Android устройстве: SQLite 3.32.2 (Android API 33).

1) Локализация

Те, кто перешел с андроид‑версии, наверняка столкнулись с тем, что SQLite в BundledSQLiteDriver собран без ICU Extension, из‑за чего не работают многие функции, связанные с локализацией. В частности:

  • Не работает регистронезависимый LIKE:

    CREATE TABLE Customers(id INTEGER PRIMARY KEY, name TEXT, city TEST); INSERT INTO Customers(name,city) VALUES ("Пользователь", "Город");  SELECT name FROM Customers WHERE name LIKE 'пол%';  AndroidSqliteDriver: [{name=Пользователь}] BundledSqliteDriver: []
  • Не работают функции upper() и lower():

    SELECT upper('пользователь') SELECT lower('ИЗДӨӨ') SELECT lower('ISPANAK', 'tr_tr')  AndroidSqliteDriver: ПОЛЬЗОВАТЕЛЬ издөө ıspanak BundledSqliteDriver: пользователь ИЗДӨӨ `android.database.SQLException:    wrong number of arguments to function lower()`
  • COLLATE не работает для региональных правил сортировки, функция icu_load_collation() недоступна:

    SELECT icu_load_collation('ru_RU', 'russian') CREATE TABLE Customers(name TEXT COLLATE russian) INSERT INTO Customers(name) VALUES ('Б'), ('а')  SELECT name FROM Customers ORDER BY name  AndroidSqliteDriver: a, Б BundledSqliteDriver: `no such function: icu_load_collation`
  • Недоступен оператор REGEXP:

    CREATE TABLE Customers(name TEXT) INSERT INTO Customers(name) VALUES ('Пользователь 😎')  SELECT name FROM Customers WHERE name REGEXP '.+\p{Emoji}+'  AndroidSqliteDriver: {name=Пользователь 😎} BundledSqliteDriver: `no such function: REGEXP`
  • Недоступна PRAGMA case_sensitive_like

Если в приложении используются только русский и английский языки, то в качестве обходного варианта подойдет вариант с приведением всех текстов в один регистр перед сохранением. Как альтернативу, можно рассмотреть FTS с unicode64 tokenizer:

CREATE TABLE Customers(id INTEGER PRIMARY KEY, name TEXT, city TEXT) CREATE VIRTUAL TABLE Customers_idx USING fts5(name, content='Customers', content_rowid='id') INSERT INTO Customers(name,city) VALUES ('Пользователь', 'Город') INSERT INTO Customers_idx(Customers_idx) values('rebuild')  SELECT Customers.*   FROM Customers_idx INNER JOIN Customers ON Customers_idx.rowid=Customers.id   WHERE Customers_idx.name MATCH '"поль" *'

2) Android‑специфичные расширения

В BundledSQLiteDriver отсутствуют все Android‑специфичные расширения:

  • Для COLLATE доступны только стандартные варианты: BINARY, RTRIM и NOCASE и недоступны Android‑специфичные: LOCALIZED, UNICODE и (недокументированный) PHONEBOOK.

  • Недоступны все недокументированные Android функции: PHONE_NUMBERS_EQUAL(), _PHONE_NUMBER_STRIPPED_REVERSED(), _DELETE_FILE() (если вдруг кто‑то знал об их существовании).

3) Некоторые опции

В текущей версии BundledSQLiteDriver не включены SQLITE_ENABLE_BYTECODE_VTAB и SQLITE_ENABLE_DBSTAT_VTAB

Эти опции добавлены в Android совсем недавно и доступны только на Android API 36 (или чуть раньше). Первая опция добавляет функции bytecode() и tables_used(), предназначенные для дампа байт‑кода запроса и используемых таблиц:

CREATE TABLE Customers(id INTEGER PRIMARY KEY, name TEXT) SELECT * FROM bytecode('SELECT * FROM Customers') SELECT * FROM tables_used('SELECT * FROM Customers')  AndroidSqliteDriver: bytecode: [   {addr=0, opcode=Init, p1=0, p2=8, p3=0, p4=null, p5=0,    comment=null, subprog=null, nexec=0, ncycle=0},… AndroidSqliteDriver: tables_used: [   {type=table, schema=main, name=Customers, wr=0, subprog=null} ]

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

SELECT * FROM dbstat  AndroidSqliteDriver: [    {name=sqlite_schema, path=/, pageno=1, pagetype=leaf, ncell=2, payload=174, unused=3806, mx_payload=87, pgoffset=0, pgsize=4096},    {name=android_metadata, path=/, pageno=2, pagetype=leaf, ncell=1, payload=7, unused=4077, mx_payload=7, pgoffset=4096, pgsize=4096},    {name=Customers, path=/, pageno=3, pagetype=leaf, ncell=0, payload=0, unused=4088, mx_payload=0, pgoffset=8192, pgsize=4096} ]

4) Устаревшее поведение

Также в BundledSQLiteDriver отключены многие опции, отвечающие за устаревшее поведение:

  • Удалены PRAGMA: count_changes, data_store_directory, default_cache_size, empty_result_callbacks, full_column_names, short_column_names, temp_store_directory.

  • Удалён shared cache

  • Отключен SQLITE_ALLOW_ROWID_IN_VIEW.

Уникальные возможности BundledSQLiteDriver

Давайте теперь рассмотрим возможности, доступные с BundledSQLiteDriver, но недоступные в AndroidSQLiteDriver (SQLite 3.32.2).

1) FTS5

В BundledSQLiteDriver доступна новая версия расширения для полнотекстового поиска FTS5. FTS3 и FTS4 при этом оставлены. FTS3 дополнительно расширен поддержкой скобок и операторов AND / NOT.

2) JSON

Новый набор функций и операторов для работы с данными в виде JSON. Поддерживается хранение как в текстовом, так и во внутреннем бинарном формате JSONB. Пример:

CREATE TABLE Customers(id INTEGER PRIMARY KEY, data BLOB) INSERT INTO Customers(data) VALUES (jsonb('{"city": "City"                                           , "name": "User"}')) SELECT id,json(data) FROM Customers WHERE data ->> '$.city' = 'User'  BundledSqliteDriver: [{id=1, json(data)={"city":"City","name":"User"}}]

3) R*-Tree индекс

Алгоритм, предназначенный для индексации пространственных данных. Индекс можно использовать для оптимизации запросов поиска по интервалам/диапазонам/ координатам или для геопространственных запросов.

CREATE TABLE Products(id INTEGER PRIMARY KEY, name TEXT NOT NULL) CREATE VIRTUAL TABLE PriceRanges USING rtree(id, minPrice, maxPrice) INSERT INTO Products (id, name) VALUES(1, 'Thermosiphon') INSERT INTO PriceRanges VALUES(1, 115, 380)  SELECT Products.*   FROM Products,PriceRanges ON Products.id=PriceRanges.id   WHERE maxPrice>=300 AND minPrice <= 300   BundledSqliteDriver: {id=1, name=Thermosiphon}

4) Добавлена поддержка RIGHT и FULL OUTER JOIN

5) Добавлена поддержка удаления столбцов

Запрос вида ALTER TABLE DROP COLUMN, позволяет удалять столбцы, на которые не ссылаются другие части схемы (нельзя удалять индексируемые столбцы, столбцы с foreign key constraints и проч.)

6) Обновлён UPSERT и добавлен RETURNING

В UPSERT Теперь можно указывать несколько ON CONFLICT. ВыражениеRETURNING на запросах DELETE, INSERT, UPDATE позволяет вернуть автоматически заполненные значения (например, сгенерированный ID).

CREATE TABLE Customers(id INTEGER PRIMARY KEY, uuid TEXT UNIQUE, name TEXT)  INSERT INTO Customers(uuid,name) VALUES ('123','Customer')  ON CONFLICT(uuid) DO UPDATE SET name=excluded.name RETURNING id 1  INSERT INTO Customers(uuid,name) VALUES ('123','Customer')  ON CONFLICT(uuid) DO UPDATE SET name=excluded.name RETURNING id 1

Подробнее: https://www.sqlite.org/lang_returning.html

В библиотеке Room на данный момент эти возможности не используются: @Upsert реализуется парой запросов INSERT + UPDATE, а для получения ID вставленных записей выполняются дополнительные запросы SELECT changes() и SELECT last_insert_rowid().

7) Добавлен подзапрос вида UPDATE FROM <table or subquery>

8) В CTE добавлена возможность указать тип временной таблицы

В Common Table Expressions (запросах с WITH) добавлены AS MATERIALIZED / AS NOT MATERIALIZED. Эти конструкции позволяют указать тип создаваемого временного представления (view) для WITH‑части.

Подробнее про CTE: https://www.sqlite.org/lang_with.html

9) Включён SQLITE_ENABLE_STAT4

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

Команда ANALYZE выполняется автоматически при условии, что время от времени выполняется команда PRAGMA optimize. Стоит ознакомиться с рекомендациями по частоте выполнения этой команды в официальной документации на https://www.sqlite.org/lang_analyze.html

10) Дополнены функции работы с датами и временем

  • Функция timediff()

  • Новые спецификаторы в strftime: %e %F %I %k %l %p %P %R %T %u %G %g %U, %V

  • Модификаторы ceiling and floor при вычислении дат

  • Возможность задавать разницу во времени в формате ±YYYY‑MM‑DD HH:MM:SS.SSS.

  • Модификатор subsec для увеличения точности (SELECT unixepoch('subsec'))

  • Функция unixepoch() с модификаторами auto / julianday

Подробнее: https://www.sqlite.org/lang_datefunc.html#uepch 

Пример:

SELECT timediff('2025-03-21','2025-01-01'); SELECT strftime('%F %k:%l', 1743290838, 'unixepoch', 'floor')  +0000-02-20 00:00:00.000 2025-03-29 23:11

11) Добавлены новые функции и операторы

  • Функции concat() / concatr_ws(SEP, )

  • string_agg() — синоним для group_concat()

  • Для агрегирующих функций теперь можно указывать ORDER_BY. Актуально для string_agg() / json_group_array()

  • unhex(): возвращает BLOB их шестнадцатеричной строки

  • octet_length(): возвращает количество байт, требуемых для хранения текстового представления числа в текущей кодировке

  • Операторы IS DISTINCT FROM, IS NOT DISTINСT FROM — синонимы для IS NOT и IS.

  • printf() — синоним для format()

12) Добавлена команда PRAGMA table_list

Возвращает информацию о таблицах и view.

PRAGMA table_list  [  {schema=main, name=sqlite_schema, type=table, ncol=5, wr=0, strict=0},  {schema=temp, name=sqlite_temp_schema, type=table, ncol=5, wr=0, strict=0} ]

13) Добавлена опция STRICT в CREATE TABLE

Использование CREATE TABLE … STRICT включает строгий синтаксис определения схемы и вставки данных.

14) Изменены некоторые значения по умолчанию

Режим синхронизации (PRAGMA synchronous) по умолчанию установлен в NORMAL (1) вместо FULL (2).

Производительность

Я провёл сравнение производительности AndroidSQLiteDriver и BundledSQLiteDriver на Android устройстве.

В качестве инструмента для проведения тестов был взят Androidx Microbenchmark. Эта библиотека помогает подготовить окружение для тестирования, чтобы тесты были воспроизводимыми: фиксирует тактовые частоты процессора, устанавливает приоритеты процессов на устройстве, устанавливает режим AOT компиляции в CompilationMode.FULL, может приостанавливать выполнение, если устройство перегревается. Абсолютные значения, получаемые из Microbenchmark, достаточно бесполезны,однако их вполне можно использовать для сравнения различных реализаций одного интерфейса.

Перед основными тестами библиотека выполняет некоторое количество разогревочных прогонов. Значения по умолчанию для количества запусков подобраны специалистами Android. В моем случае выполняется около 30 разогревочных запусков (до стабилизации результатов) и 50 основных. Учитывая это, один прогон не должен занимать много времени (я настроил каждый тест примерно на 5–10 секунд).

В качестве тестируемой базы данных был взят датасет rawg-games-dataset в формате CSV (примерно 600МБ, 881 тыс. записей) и разбит на несколько SQL таблиц.  Для каждого драйвера выполнялось три теста:

  • create_database

    Тест создаёт базу данных и заполняет её пакетными запросами INSERT, читая данные из CSV файла, находящегося в android assets.

  • select_with_paging

    К заранее подготовленной базе выполняется множество запросов INSERT. У каждого запроса есть небольшой фильтр в WHERE и пейджирование при помощи OFFSET XXX LIMIT XXX. Запросы возвращают набор записей из нескольких столбцов.

  • huge_select

    К базе выполняется один сложный запрос, выполнение которого занимает значительное время, включающий комплексные группировки и фильтры,

Для тестов 2 и 3 база подготавливается на хосте тем же кодом, который используется в тесте 1. Все тесты выполняются на Android 13.

Используемые версии софта:

  • Androidx SQLite: 2.5.0-rc02 (SQLite 3.46.0)

  • Androidx Benchmark: 1.4.0-alpha09

  • SQLite на устройстве: 3.32.2

Результаты:

Время выполнения теста (меньше — лучше)

Время выполнения теста (меньше — лучше)

Все тесты выполняются на 20 — 28% быстрее при использовании BundledSQLiteDriver по сравнению с AndroidSQLiteDriver

Репозиторий с тестами и примерами: https://github.com/illarionov/sqlite‑driver‑benchmark В репозитории дополнительно тестируются две другие мои реализации SQLiteDriver на основе SQLite‑WebAssembly: AOT‑перекомпилированный SQLite в.class и исполняющийся в интерпретаторах Wasm для JVM. Но это материал для других статей, как, впрочем, и вопросы, связанные с многопоточностью.

Выводы

BundledSQLiteDriver имеет преимущества при использовании как в проектах на Kotlin Multiplatform, так и в чистых Android‑приложениях, он позволяет использовать новые функции и обеспечивает предсказуемое поведение на всех устройствах. Но он может не подойти, если критичны вопросы локализации или размера приложения.


ссылка на оригинал статьи https://habr.com/ru/articles/896794/