Zum Hauptinhalt springen

Eigene SQLite-Extensions in Capacitor nutzen

Eigene SQLite-Extensions in einer Capacitor-App laden: Limits für iOS und Android, eine C-Extension, die für beide Plattformen kompiliert wird, und wie man sie pro Verbindung registriert.

Capacitor-Logo für einen Guide zu benutzerdefinierten SQLite-Erweiterungen in Capacitor-Apps

Du kannst benutzerdefinierte SQLite-Erweiterungen in einer Capacitor-App verwenden, aber nur als native code, die in die App kompiliert werden: Auf iOS verlinkst du die Erweiterung statisch und rufst ihre Init-Funktion bei jeder Verbindung auf, auf Android bündelst du einen SQLite-Build, der das Laden von Erweiterungen unterstützt, und lieferst die Erweiterung als native Bibliothek pro CPU-Architektur aus. Dieser Guide erklärt, warum sich die Plattformen unterscheiden, liefert eine funktionierende Erweiterung in C und zeigt die exakten nativen Änderungen für @capgo/capacitor-fast-sql auf Capacitor 8.

Bevor du C schreibst, prüfe, ob du es wirklich brauchst. Viele „Extensions“ sind bereits eingebaut.

Was eine SQLite-Extension ist

Eine Extension ist nativer code, der neue Dinge an einer SQLite-Verbindung registriert: Skalarfunktionen (haversine_km(...)), Aggregatfunktionen, Collations, Virtual Tables und FTS5-Tokenizer. Jede Extension bietet einen Einstiegspunkt:

int sqlite3_<name>_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi);

SQLite ruft ihn einmal pro Verbindung auf. Alles, was du registrierst, existiert nur in dieser Verbindung.

Typische Gründe für eine eigene Extension in einer mobilen App:

  • ein benutzerdefinierter FTS5-Tokenizer für eine Sprache, die der integrierte unicode61 Tokenizer schlecht verarbeitet,
  • eine Mathe- oder Geo-Funktion, die du brauchst für WHERE und ORDER BY (Distanz-Sortierung, Scoring),
  • eine Collation, die Namen so sortiert, wie deine Nutzer es erwarten,
  • eine Vector-Search-Extension für On-Device-Retrieval.

Prüfe, was bereits eingebaut ist

Teste sie mit deinem Plugin auf den ältesten iOS- und Android-Versionen, die du unterstützt:

import { FastSQL } from '@capgo/capacitor-fast-sql';

const db = await FastSQL.connect({ database: 'probe' });

const [{ v }] = await db.query('SELECT sqlite_version() AS v');
const options = await db.query('PRAGMA compile_options');
const functions = await db.query('SELECT name FROM pragma_function_list ORDER BY name');

console.log(v, options.map((r) => r.compile_options));

Dinge, für die du oft keine Extension benötigst:

Benötigt Eingebaute Funktion Prüfen
JSON in Spalten JSON-Funktionen (json_extract, ->>) Seit SQLite 3.38 integriert. Ältere Android-System-SQLite-Versionen unterstützen sie eventuell nicht
Volltextsuche FTS4, FTS5 ENABLE_FTS4 / ENABLE_FTS5 in einem compile_options
Räumliche Bounding Boxes R*Tree ENABLE_RTREE
sin, cos, sqrt, pow Mathematische Funktionen ENABLE_MATH_FUNCTIONS, SQLite 3.35+

Wenn die Funktion auf jeder Zielplattform vorhanden ist, kannst du hier aufhören. Der Web-Build von Fast SQL verwendet das offizielle SQLite Wasm, das JSON, FTS5 und mathematische Funktionen enthält.

Warum iOS und Android unterschiedlich funktionieren

iOS. Das System libsqlite3 ist so aufgebaut, dass SQLITE_OMIT_LOAD_EXTENSION, also load_extension() nicht existiert. Die App Store-Richtlinien verbieten zudem das Herunterladen und Ausführen neuer code (Richtlinie 2.5.2), daher ist das Laden einer .dylib zur Laufzeit ist ohnehin vom Tisch. Die Erweiterung muss in die App-Binary kompiliert werden. Viele Tutorials nennen dann sqlite3_auto_extension() beim Start auf. Darauf sollte man sich nicht verlassen: Im iOS SDK, das mit Xcode ausgeliefert wird, sqlite3.h wird sie mit API_DEPRECATED("Process-global auto extensions are not supported on Apple platforms", ...)deklariert. Der zuverlässige Ansatz besteht darin, die Init-Funktion für jedes Connection-Handle direkt nach dem Öffnen aufzurufen.

Android. Das SQLite im Android-Framework (android.database.sqlite) bietet kein API zum Laden von Extensions. Man benötigt einen SQLite-Build, der in der App gebündelt ist und dies unterstützt. requery/sqlite-android ist ein Drop-in-Ersatz für die Framework-Klassen, liefert ein aktuelles SQLite (mit FTS5 und JSON) und unterstützt ladbare Erweiterungen über SQLiteCustomExtension. Deine Erweiterung wird zu einer .so Datei pro ABI innerhalb der APK.

Web. Der Standard-SQLite-Wasm-Build kann native code nicht laden. Du benötigst einen eigenen Wasm-Build von SQLite, in den deine Extension kompiliert ist.

Was die Plugins heute unterstützen

iOS Android Web
@capgo/capacitor-fast-sql 8.2 Kein Hook, kein Patch nötig Nutzt Framework-SQLite, Patch nötig Standard SQLite Wasm
@capacitor-community/sqlite 8.1 loadExtension() wird im JS API deklariert, aber nicht nativ implementiert Dasselbe Nicht implementiert

Aktuell bedeutet das Hinzufügen einer Erweiterung also einen kleinen nativen Patch. Es handelt sich um eine eingegrenzte Änderung, und Bun kann sie über Installationen hinweg beibehalten.

Schritt 1: Die Extension schreiben

Eine Distanzfunktion ist ein gutes Beispiel: nützlich, in sich geschlossen und leicht zu verifizieren. Speichere sie als geo.c:

#include <math.h>
#include <stddef.h>
#include "sqlite3ext.h"
SQLITE_EXTENSION_INIT1

static void haversine_km(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
  (void)argc;
  for (int i = 0; i < 4; i++) {
    if (sqlite3_value_type(argv[i]) == SQLITE_NULL) {
      sqlite3_result_null(ctx);
      return;
    }
  }
  const double earth_radius_km = 6371.0088;
  const double to_rad = M_PI / 180.0;
  double lat1 = sqlite3_value_double(argv[0]) * to_rad;
  double lon1 = sqlite3_value_double(argv[1]) * to_rad;
  double lat2 = sqlite3_value_double(argv[2]) * to_rad;
  double lon2 = sqlite3_value_double(argv[3]) * to_rad;
  double dlat = lat2 - lat1;
  double dlon = lon2 - lon1;
  double a = sin(dlat / 2) * sin(dlat / 2) +
             cos(lat1) * cos(lat2) * sin(dlon / 2) * sin(dlon / 2);
  sqlite3_result_double(ctx, 2 * earth_radius_km * asin(sqrt(a)));
}

int sqlite3_geo_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) {
  SQLITE_EXTENSION_INIT2(pApi);
  (void)pzErrMsg;
  return sqlite3_create_function_v2(
      db, "haversine_km", 4,
      SQLITE_UTF8 | SQLITE_DETERMINISTIC | SQLITE_INNOCUOUS,
      NULL, haversine_km, NULL, NULL, NULL);
}

Dieselbe Datei lässt sich auf beide Arten bauen:

  • Als ladbare Extension (Android): SQLITE_EXTENSION_INIT1/2 leite jeden SQLite-Aufruf über den pApi Pointer, den SQLite übergibt, sodass der .so nicht gegen SQLite linkt.
  • Statisch, mit -DSQLITE_CORE (iOS): Die Makros werden zu No-ops und die Aufrufe gehen direkt an die SQLite-Library, gegen die die App linkt.

SQLITE_DETERMINISTIC ermöglicht es SQLite, die Funktion in Indizes auf Ausdrücken zu nutzen. SQLITE_INNOCUOUS markiert es als sicher für die Verwendung in Views und Triggern.

Ich habe diese Datei auf macOS auf beiden Wegen kompiliert: statisch mit -DSQLITE_CORE und aufgerufen sqlite3_geo_init(db, NULL, NULL) auf einer System-SQLite-Verbindung, sowie als ladbare Bibliothek über load_extension. Beide liefern etwa 343,6 km für die Strecke Paris–London.

Schritt 2: iOS, in die App kompilieren und pro Verbindung registrieren

Quellcode dem App-Target hinzufügen

  1. In Xcode ziehen Sie geo.c in die App Gruppe und aktiviere das App Ziel.
  2. Xcode bietet an, eine Bridging-Header-Datei zu erstellen. Akzeptiere dies oder erstelle App/App-Bridging-Header.h und setze sie unter Build Settings > Swift Compiler - General > Objective-C Bridging Header.
  3. Unter Build Phases > Compile Sources, doppelklicken geo.c und füge das Flag für die Datei hinzu -DSQLITE_CORE.

Das iOS SDK enthält beides sqlite3.h und sqlite3ext.h, sodass keine zusätzlichen Header nötig sind.

App-Bridging-Header.h:

#include <sqlite3.h>

int sqlite3_geo_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi);

Fast SQL patchen, um einen Open-Hook bereitzustellen

Das Plugin öffnet Verbindungen innerhalb seines eigenen Moduls und braucht daher einen Hook, den deine App setzen kann. Bun kann eine Dependency patchen und den Patch bei jeder Installation erneut anwenden:

bun patch @capgo/capacitor-fast-sql

Bearbeiten node_modules/@capgo/capacitor-fast-sql/ios/Sources/CapgoCapacitorFastSqlPlugin/SQLDatabase.swift. Füge einen öffentlichen Hook auf oberster Ebene der Datei hinzu:

public enum FastSqlExtensions {
    /// Called with every new connection handle right after it opens.
    /// Return SQLITE_OK (0) on success.
    public static var onOpen: ((OpaquePointer) -> Int32)?
}

Dann in init(...), nach dem Verschlüsselungsblock und vor try applyPerformanceOptions(...), nenne es:

if let hook = FastSqlExtensions.onOpen {
    let rc = hook(db)
    if rc != SQLITE_OK {
        throw SQLError.openFailed(message: "Extension init failed with code \(rc)")
    }
}

Patch speichern:

bun patch --commit node_modules/@capgo/capacitor-fast-sql

Bun schreibt eine Datei unter patches/ und erfasst sie in package.json unter patchedDependencies. Beide committen. Unser Guide zu wie man ein Capacitor-Plugin patcht deckt diesen Workflow im Detail ab.

Erweiterung beim Start registrieren

Unter ios/App/App/AppDelegate.swift, das Plugin-Modul importieren und den Hook in application(_:didFinishLaunchingWithOptions:):

import UIKit
import Capacitor
import CapgoCapacitorFastSql // CocoaPods module name. With SPM: import CapgoCapacitorFastSqlPlugin

// Inside the existing AppDelegate class:
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    FastSqlExtensions.onOpen = { db in
        sqlite3_geo_init(db, nil, nil)
    }
    return true
}

Da der Hook im Öffnungspfad des Plugins ausgeführt wird, erhält jede Verbindung die Funktion, einschließlich solcher, die nach einem WebView-Reload geöffnet werden. Das Setzen in didFinishLaunching stellt sicher, dass er vor dem Aufruf durch JavaScript gesetzt ist connect().

SQLCipher-Builds. Wenn Sie das SQLCipher-Subspec des Plugins verwenden, gehört die Verbindung zum SQLite von SQLCipher, während Ihre -DSQLITE_CORE Die Datei kann auf Symbole der Systembibliothek verweisen. Teste diese Kombination vor dem Release explizit oder speichere erweiterungsabhängige Daten in einer unverschlüsselten Datenbank.

Schritt 3: Android, ein SQLite bündeln, das Erweiterungen laden kann

Erweiterung für jede ABI kompilieren

Du brauchst das Android NDK (Installation über den SDK Manager in Android Studio) und sqlite3ext.h sowie sqlite3.h vom SQLite-Amalgamation neben geo.c.

export NDK=$HOME/Library/Android/sdk/ndk/<version>
export TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/darwin-x86_64   # linux-x86_64 on Linux
export API=24   # your minSdkVersion

for pair in \
  aarch64-linux-android:arm64-v8a \
  armv7a-linux-androideabi:armeabi-v7a \
  x86_64-linux-android:x86_64 \
  i686-linux-android:x86
do
  triple=${pair%%:*}
  abi=${pair##*:}
  mkdir -p android/app/src/main/jniLibs/$abi
  $TOOLCHAIN/bin/clang --target=$triple$API \
    -shared -fPIC -O2 \
    -Wl,-z,max-page-size=16384 \
    -o android/app/src/main/jniLibs/$abi/libgeo.so \
    geo.c -lm
done

-Wl,-z,max-page-size=16384 passt die Bibliothek für Geräte mit 16-KB-Speicherseiten an. Google Play verlangt 16-KB-Unterstützung für Apps, die Android 15 oder höher targeten, siehe 16-KB-Seitengröße und Capacitor Plugins. Wenn deine App nur auf 64-Bit-Geräten läuft, kannst du die beiden 32-Bit-ABIs weglassen.

Die Bibliothek heißt libgeo.so, daher ist der Standard-Einstiegspunkt von SQLite sqlite3_geo_init, was der C-Funktion entspricht.

Native Bibliotheken bei der Installation extrahieren

Neuere Android Gradle Plugin-Versionen behalten .so Dateien im APK-Container komprimiert belassen, statt sie zu extrahieren. SQLite lädt Erweiterungen über einen Dateipfad, daher die Extraktion an android/app/build.gradle:

android {
    packaging {
        jniLibs {
            useLegacyPackaging = true
        }
    }
}

requery sqlite-android hinzufügen

Unter android/build.gradle (root), JitPack hinzufügen:

allprojects {
    repositories {
        google()
        mavenCentral()
        maven { url 'https://jitpack.io' }
    }
}

Fast SQL patchen, um es zu verwenden

Öffne die gleiche Bun-Patch-Session (bun patch @capgo/capacitor-fast-sql) und bearbeite zwei Dateien.

node_modules/@capgo/capacitor-fast-sql/android/build.gradle, in dependencies:

implementation 'com.github.requery:sqlite-android:3.49.0'

Verwende die neueste Version, die JitPack als für das Repository gebaut anzeigt.

node_modules/@capgo/capacitor-fast-sql/android/src/main/java/app/capgo/capacitor/fastsql/SQLDatabase.java: Ersetze die Framework-Imports durch die von requery und öffne über eine Konfiguration, die die Erweiterungen mitführt.

// Replace:
// import android.database.sqlite.SQLiteDatabase;
// import android.database.sqlite.SQLiteStatement;
import io.requery.android.database.sqlite.SQLiteCustomExtension;
import io.requery.android.database.sqlite.SQLiteDatabase;
import io.requery.android.database.sqlite.SQLiteDatabaseConfiguration;
import io.requery.android.database.sqlite.SQLiteStatement;

public class SQLDatabase implements DatabaseConnection {

    /** Set from MainActivity before the first connection opens. */
    public static final java.util.List<SQLiteCustomExtension> extensions =
        new java.util.concurrent.CopyOnWriteArrayList<>();

    private SQLiteDatabase db;
    private volatile boolean inTransaction = false;

    public SQLDatabase(String path, boolean walMode, boolean performancePresets) throws Exception {
        SQLiteDatabaseConfiguration config = new SQLiteDatabaseConfiguration(
            path, SQLiteDatabase.CREATE_IF_NECESSARY);
        config.customExtensions.addAll(extensions);
        this.db = SQLiteDatabase.openDatabase(config, null, null);
        SQLPerformanceConfig.apply(db::execSQL, walMode, performancePresets);
    }

    // ... the rest of the class stays as it is
}

requery-Klassen spiegeln das Framework API (rawQuery, compileStatement, execSQL, Transaktionen) wider, sodass der Rest der Datei unverändert kompiliert. android.database.Cursor bleibt unverändert. Committe den Patch mit bun patch --commit node_modules/@capgo/capacitor-fast-sql.

Dies ändert nur den unverschlüsselten Pfad. Verschlüsselte Verbindungen verwenden die eigenen Klassen von SQLCipher, die dieser Patch nicht berührt.

Erweiterung in MainActivity registrieren

package com.example.app;

import android.os.Bundle;
import app.capgo.capacitor.fastsql.SQLDatabase;
import com.getcapacitor.BridgeActivity;
import io.requery.android.database.sqlite.SQLiteCustomExtension;

public class MainActivity extends BridgeActivity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        String path = getApplicationInfo().nativeLibraryDir + "/libgeo.so";
        SQLDatabase.extensions.add(new SQLiteCustomExtension(path, "sqlite3_geo_init"));
        super.onCreate(savedInstanceState);
    }
}

Vorher registrieren super.onCreate damit die Liste gefüllt ist, bevor die WebView JavaScript ausführen kann.

Android ohne C: Java-Funktionen

Wenn du nur eine Skalarfunktion brauchst, kann requery eine in Java geschriebene registrieren – ohne NDK. Innerhalb des gepatchten SQLDatabase Konstruktors, nach dem Öffnen:

db.addFunction("haversine_km", 4, (args, result) -> {
    double r = 6371.0088;
    double lat1 = Math.toRadians(args.getDouble(0));
    double lat2 = Math.toRadians(args.getDouble(2));
    double dlat = lat2 - lat1;
    double dlon = Math.toRadians(args.getDouble(3) - args.getDouble(1));
    double a = Math.pow(Math.sin(dlat / 2), 2)
             + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dlon / 2), 2);
    result.set(2 * r * Math.asin(Math.sqrt(a)));
});

Das ist bei aufwendigen Abfragen langsamer als C, da jeder Aufruf über JNI läuft, aber es ist deutlich einfacher zu warten. FTS5-Tokenizer und virtuelle Tabellen brauchen weiterhin C.

Schritt 4: Nutzung via JavaScript

Danach bunx cap sync und ein erneuter Native Build:

import { FastSQL } from '@capgo/capacitor-fast-sql';

const db = await FastSQL.connect({ database: 'places' });

await db.execute(`CREATE TABLE IF NOT EXISTS places (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  lat REAL NOT NULL,
  lon REAL NOT NULL
)`);

const nearby = await db.query(
  `SELECT id, name, haversine_km(?, ?, lat, lon) AS km
   FROM places
   WHERE lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?
   ORDER BY km
   LIMIT 20`,
  [userLat, userLon, userLat - 0.5, userLat + 0.5, userLon - 0.7, userLon + 0.7],
);

Der BETWEEN box ermöglicht es SQLite, einen Index auf (lat, lon) zu nutzen, um Kandidaten einzugrenzen, bevor die Funktion die exakten Distanzen berechnet. Ohne ihn läuft die Funktion für jede Zeile.

JavaScript-Fallback für Web

Der Web-Build kann dein C code nicht laden, also erkennen und zurückfallen:

import { Capacitor } from '@capacitor/core';

export const hasGeoExtension = Capacitor.isNativePlatform();

// On the web: query the bounding box, then sort in JS with the same formula.

Eigene FTS5-Tokenisierer

Derselbe Mechanismus registriert FTS5-Tokenisierer. In deiner init-Funktion holst du den fts5_api Pointer mit dem SELECT fts5(?1) Muster aus der FTS5-Dokumentation, dann aufrufen xCreateTokenizer. Zwei zusätzliche Voraussetzungen:

  • Die SQLite-Bibliothek muss FTS5 enthalten. Der Build von requery tut das. Auf iOS prüfen PRAGMA compile_options für ENABLE_FTS5.
  • Jede Verbindung, die in die FTS-Tabelle schreibt oder daraus liest, muss den Tokenizer registriert haben, da FTS5 den Tokenizer-Namen in der Tabellendefinition speichert. Deshalb ist der Hook pro Verbindung entscheidend.
CREATE VIRTUAL TABLE docs USING fts5(title, body, tokenize = 'my_tokenizer');

Veröffentlichen und Wartung

  • Neuer nativer Build erforderlich. Erweiterungen, Patches und Gradle-Änderungen sind nativ. Sie erfordern einen neuen App Store- und Play Store-Build. Capgo Build können beide im Cloud-Build erstellt und signiert werden. Seit April 2026 müssen iOS-Uploads mit Xcode 26 oder neuer erstellt werden.
  • JavaScript kann weiterhin schnell iterieren. SQL, das aufruft haversine_km befindet sich in deinem Web-Bundle und kann zusammen mit Live-Updatesausgeliefert werden, sofern der Native Build, der die Extension enthält, bereits installiert ist. Neue SQL-Features an eine Native-Version-Prüfung koppeln, falls Nutzer noch eine ältere Binary ausführen könnten.
  • Plugin-Updates. Wenn du Fast SQL aktualisierst, wendet Bun den Patch erneut an. Falls sich die gepatchten Zeilen verschoben haben, schlägt die Installation deutlich fehl, anstatt deinen Hook still zu entfernen. Erstelle den Patch für die neue Version neu.

Fehlerbehebung

no such function: haversine_km auf iOS. wurde der Hook nicht vor connect()gesetzt, oder die Datenbank wurde vor didFinishLaunching ran. Log inside the hook to confirm it is called.

Crash innerhalb von sqlite3_geo_init auf iOS. -DSQLITE_CORE fehlt, daher wurde die Datei als ladbare Extension kompiliert, die auf den pApi Zeiger verweist, der nil hier liegt. Füge das Flag pro Datei in Compile Sources hinzu.

no such function auf Android. Das gepatchte SQLDatabase importiert immer noch android.database.sqlite.SQLiteDatabase, oder die Extension wurde nach der ersten Verbindung zur Liste hinzugefügt. Logge SQLDatabase.extensions.size() beim Verbinden.

dlopen failed: library "…/libgeo.so" not found. Die Bibliothek fehlt für die Geräte-ABI (die meisten Handys sind arm64-v8a), oder die nativen Bibliotheken wurden nicht extrahiert. Prüfe useLegacyPackaging und untersuche die APK mit dem APK Analyzer in Android Studio.

undefined symbol: sqlite3_geo_init. Der an übergebene Entry-Point-Name SQLiteCustomExtension stimmt nicht mit der C-Funktion überein, oder die Funktion wurde deklariert static.

Play Console warnt vor 16-KB-Ausrichtung. Erstelle die .so Dateien neu mit -Wl,-z,max-page-size=16384 und einem aktuellen NDK, und aktualisiere requery auf eine Version, die mit 16-KB-Ausrichtung gebaut wurde.

Die Funktion funktioniert auf Android, aber nicht im iOS-Simulator. Make sure geo.c ein Mitglied des App Ziels ist, nicht nur im Dateibaum vorhanden.

Zusammenfassung

Plattform Wie die Extension eingebunden wird Registrierungsort
iOS In die App kompiliert mit -DSQLITE_CORE Plugin-Hook, der sqlite3_geo_init bei jeder neuen Verbindung aufruft
Android .so pro ABI in jniLibs, geladen von requery sqlite-android SQLiteCustomExtension Liste wird beim Öffnen jeder Verbindung gelesen
Web Nicht von Standard-SQLite-Wasm unterstützt JavaScript-Fallback

Beginnen Sie damit, die integrierten Funktionen zu prüfen. Wenn Sie dennoch eine Erweiterung benötigen, schreiben Sie sie einmalig in C, halten Sie die nativen Patches klein und testen Sie auf den ältesten unterstützten Geräten. Für die allgemeine Plugin-Einrichtung siehe So nutzt du SQLite in einer Capacitor App.

Live-Updates für Capacitor-Apps

Wenn ein Bug im Web-Layer live ist, liefere den Fix über Capgo aus, statt Tage auf die App-Store-Freigabe zu warten. Nutzer bekommen das Update im Hintergrund, während native Änderungen im normalen Review-Prozess bleiben.

Neues aus unserem Blog

Capgo gibt dir die besten Insights, um eine wirklich professionelle Mobile App zu erstellen.