SQFlite para manejar una base de datos con SQLite en Flutter

- Andrés Cruz - EN In english

Video thumbnail

SQLite es un sistema de gestión de bases de datos relacionales embebido y autónomo. Su característica principal radica en su portabilidad, eficiencia y ligereza, convirtiéndolo en la opción estándar para el almacenamiento local en aplicaciones móviles.

Una base de datos son uno de los elementos principales que comparten las aplicaciones hoy en día y que la podemos emplear de manera conjunta con los SharedPreferences en Flutter para guardar datos sencillos; en prácticamente todas las aplicaciones hoy en día, siempre hay datos que se registran de manera persistente; cuando se quiere almacenar datos estructurados de manera persistente en una aplicación, lo primero que se nos pasa por la cabeza es usar una base de datos.

En Android, la base de datos usada por defecto, es SQLite; la cual no es más que un archivo en donde se guardan todos nuestros registros estructurados.

SQLite es un motor de base de datos SQL pequeño, rápido, autónomo, de alta confiabilidad y con todas las funciones. SQLite es el motor de base de datos más utilizado en el mundo. SQLite está integrado en todos los teléfonos móviles y en la mayoría de las computadoras y viene incluido dentro de innumerables otras aplicaciones que la gente usa todos los días.

Los datos persistentes son muy importantes para los usuarios, ya que sería inconveniente para ellos escribir su información cada vez o esperar a que la red cargue los mismos datos nuevamente. En situaciones como esta, sería mejor guardar sus datos localmente. En este artículo, demostraré esto usando SQLite en Flutter.

A diferencia de los entornos de servidor tradicionales, donde se emplean motores independientes como MySQL o PostgreSQL, en dispositivos móviles no es viable conectar la aplicación de forma directa a estos servicios externos por motivos de red y seguridad. Por ello, las plataformas móviles integran soluciones locales o exigen la persistencia en disco a través de archivos estructurados. En Flutter, el paquete sqflite proporciona las abstracciones necesarias para interactuar con este motor de manera nativa y asíncrona, ya que toda operación de lectura o escritura en el almacenamiento físico requiere delegar hilos de ejecución para no bloquear la interfaz de usuario.

¿Por qué SQLite?

SQLite es una de las formas más populares de almacenar datos localmente. Para este artículo, usaremos el paquete sqflite para conectarnos con SQLite. Sqflite es uno de los paquetes más utilizados y actualizados para conectarse a bases de datos SQLite en Flutter.

En Flutter, tenemos varias opciones para almacenar datos de manera persistente, e inclusive, podemos usar SQLite para tal fin; aunque, a diferencia de Android nativo, en Flutter no soporta de manera nativa SQLite si no, tenemos que usar un plugin con el cual podemos aprovechar esta característica; el mismo se llama como SQFlite el cual habilita poder usar una base de datos SQLite en Flutter:

https://pub.dev/packages/sqflite

1. Agrega dependencias a tu proyecto

Para instalarlo, lo hacemos mediante un pub:

pubspec.yaml

dependencies:
 flutter:
   sdk: flutter
 sqflite:

Funcionamiento base

SQFlite es muy sencillo de usar, como puedes suponer, al usar una base de datos en SQLite, que no es más que un archivo, un archivo que se encuentra almacenado en una carpeta de nuestra aplicación, todas las operaciones que quieras realizar sobre la misma, que serían, crear o leer una base de datos, crear, modificar, obtener o eliminar registros, todas estas operaciones son asíncronas, por lo tanto, tendremos que usar los Future para cada una de estas operaciones.

Crear y abrir una base de datos

Para poder crear o abrir una base de datos, usamos la función de:

openDatabase()

La cual recibe un path en la cual debemos de especificar la ruta a la base de datos; por lo tanto, tenemos dos factores aquí:

  1. El path de donde se guarda la base de datos.
  2. El nombre de la base de datos.

Para obtener el path de la base de datos, también tenemos una función de ayuda la cual devuelve el path por defecto usado para registrar la base de datos:

getDatabasesPath()

2. Cree un cliente de base de datos

Finalmente, el código para crear la base de datos es:

lib\helpers\db_helper.dart

import 'package:place/models/place.dart';
import 'package:sqflite/sqflite.dart';

import 'package:path/path.dart' as path;

class DBHelper {
 static Future<Database> _openDB() async {
   return openDatabase(path.join(await getDatabasesPath(), 'sites.db'),
       onCreate: (db, version) {
     return db.execute(
         "CREATE TABLE places (id INTEGER PRIMARY KEY, name TEXT, image TEXT)");
   }, version: 1);
 }
}

La inicialización y apertura de una base de datos basada en archivos constituye una operación costosa en términos de cómputo y rendimiento de entrada/salida (I/O). Para optimizar el uso de recursos, la arquitectura recomendada consiste en centralizar el acceso mediante el patrón de diseño Singleton.

Este patrón garantiza que la base de datos se abra una única vez durante el ciclo de vida de la aplicación —generalmente durante el arranque en la función main()—. A partir de ese momento, se mantiene una única instancia activa en memoria RAM, permitiendo que cualquier módulo de la aplicación realice consultas de forma limpia sin la penalización de reabrir el archivo en disco en cada transacción.

Ciclo de Vida e Inicialización de la Base de Datos

El proceso de preparación del entorno se gestiona a través de un método de inicialización (comúnmente denominado init()). El flujo operativo ejecuta las siguientes tareas:

  1. Definición de la Ruta: Se localiza el directorio de almacenamiento seguro asignado por el sistema operativo para la aplicación y se concatena con el nombre físico del archivo de la base de datos (por ejemplo, app_database.db).
  2. Apertura o Creación Atómica: Se invoca el método de apertura provisto por el paquete. Si el archivo no existe en el almacenamiento del dispositivo (como en una instalación limpia desde Google Play), el motor lo crea automáticamente y dispara el callback de ciclo de vida onCreate. Si el archivo ya existe, simplemente retorna la referencia directa.
  3. Ejecución del Esquema Inicial (onCreate): Dentro de este callback se configuran las tablas iniciales mediante sentencias SQL en crudo (DDL). Es una práctica preventiva común estructurar las sentencias bajo la cláusula CREATE TABLE IF NOT EXISTS para evitar colisiones de nombres o inconsistencias en el esquema.

lib/services/database_helper.dart

class DatabaseHelper {
  DatabaseHelper._privateConstructor();
  static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
  static Database? _database;

  late WordDbService words;
  late SentenceDbService sentences;
  late TagDbService tags;
  late WordPackDbService wordPacks;
  late SentencePackDbService sentencePacks;
  late LatestPracticeDbService latestPractices;
  late StreakDbService streaks;
  late ApiKeyDbService apiKeys;
  late PromptHistoryDbService promptHistory;

  Future<void> init() async {
    if (_database != null) return;
    _database = await _initDB();
    _initServices(_database!);
  }

Y en el main:

lib/main.dart

Future<void> main() async {
  ***
 await DatabaseHelper.instance.init();
}

Implementación de Operaciones CRUD y Capa de Servicios

Para mantener una arquitectura limpia y legible, la lógica de acceso a datos debe segmentarse en clases de servicio y modelos de datos bien definidos. Tomemos como ejemplo un caso práctico enfocado en el almacenamiento de palabras practicadas en una aplicación de aprendizaje.

2. Operaciones Abstraídas frente a SQL Nativo

El paquete de persistencia ofrece dos metodologías para la manipulación y consulta de datos:

  • Métodos de Conveniencia: Permiten realizar inserciones o actualizaciones de manera programática pasando un mapa estructurado, delegando en el framework la construcción interna de la consulta. Un ejemplo es el método insert(), que abstrae la sintaxis estructurada.
  • Consultas en Crudo (Raw Queries): Cuando se requieren filtros complejos con cláusulas WHERE, ordenamientos mediante ORDER BY, límites específicos o validaciones de unicidad (como verificar si un registro ya existe antes de insertarlo), se recurre al uso de sentencias SQL directas mediante rawQuery() o rawInsert(). Estas consultas devuelven colecciones de mapas que luego deben ser casteadas al tipo de dato correspondiente para su uso en la interfaz.
import 'package:sqflite/sqflite.dart';
import '../../models/latest_practice.dart';

/// Provides database operations for the user's latest practices.
class LatestPracticeDbService {
  final Database db;

  LatestPracticeDbService(this.db);

  /// Inserts a [LatestPractice] into the database.
  Future<void> insertLatestPractice(
    LatestPractice practice, {
    required int maxWords,
  }) async {
    // 1. VERIFICACIÓN: Buscar si ya existe la combinación de notTraslated y traslated
    final List<Map<String, dynamic>> existingRecords = await db.query(
      'latest_practices',
      where: 'notTraslated = ?',
      whereArgs: [practice.notTraslated],
    );

    if (existingRecords.isNotEmpty) {
      // Si ya existe, actualizamos el registro existente con los nuevos datos
      // final idExistente = existingRecords.first['id'];
      // await db.update(
      //   'latest_practices',
      //   practice.toMap(),
      //   where: 'id = ?',
      //   whereArgs: [idExistente],
      // );

      // Como fue una actualización, el número total de filas no cambió.
      // Retornamos aquí para evitar el insert y la lógica de borrado por exceso.
      return;
    }

    await db.insert('latest_practices', practice.toMap());

    final count = Sqflite.firstIntValue(
      await db.rawQuery('SELECT COUNT(*) FROM latest_practices'),
    );
    if (count != null && count > maxWords) {
      final excess = count - maxWords;
      await db.rawDelete(
        'DELETE FROM latest_practices WHERE id IN (SELECT id FROM latest_practices ORDER BY date ASC LIMIT ?)',
        [excess],
      );
    }
  }

  /// Retrieves all [LatestPractice] from the database, ordered by date descending.
  Future<List<LatestPractice>> getAllLatestPractices() async {
    final List<Map<String, dynamic>> maps = await db.query(
      'latest_practices',
      orderBy: 'date DESC',
    );

    if (maps.isEmpty) {
      return [];
    }

    return List.generate(maps.length, (i) {
      return LatestPractice.fromMap(maps[i]);
    });
  }

  /// Deletes all [LatestPractice] from the database.
  Future<void> deleteAllLatestPractices() async {
    await db.delete('latest_practices');
  }
}

Insertar registros

Para insertar registros, en la base de datos, se usa la función de insert() que recibe dos parámetros:

  1. La tabla donde se quiere insertar los registros.
  2. El mapa, con los datos que se quieren insertar.

En cuanto al mapa, como puedes suponer, se va a usar la función llamada toMap() definida en el modelo de Place.

Recuerda que, para hacer cualquier operación sobre la base de datos, es necesario abrir la misma, y para eso, se usa la función _openDB() creada anteriormente.

Finalmente, el código de la función de insertar:

lib\helpers\db_helper.dart

import 'package:place/models/place.dart';
import 'package:sqflite/sqflite.dart';

import 'package:path/path.dart' as path;

class DBHelper {
 static Future<Database> _openDB() async {
  // ***
 }

 static Future<int> insert(Place place) async {
   Database database = await _openDB();
   return database.insert("places", place.toMap());
 }
}

Obtener el listado de todos los registros

Para obtener un listado de todos los registros, se usa la función llamada query() en la cual se indica el nombre de la tabla; esta función devuelve un listado de mapas, que es convertido a un listado de objetos, usando la función de List.generate() en la cual se itera cada uno de los registros devueltos en un mapa y convertidos a un objeto:

lib\helpers\db_helper.dart

import 'package:place/models/place.dart';
import 'package:sqflite/sqflite.dart';

import 'package:path/path.dart' as path;

class DBHelper {
 static Future<Database> _openDB() async {
  // ***
 }

 static Future<int> insert(Place place) async {
  // ***
 }

 static Future<List<Place>> places() async {
   Database database = await _openDB();

   final List<Map<String, dynamic>> placesMap = await database.query("places");

   for (var p in placesMap) {
     print("${p['id']}  ${p['name']} ");
   }

   return List.generate(
       placesMap.length,
       (i) => Place(
           id: placesMap[i]['id'],
           name: placesMap[i]['name'],
           image: placesMap[i]['image']));
 }
}

Actualizar registros

La función de actualizar es similar a la de insertar; pero, se define la cláusula where para filtrar los registros que se quieren actualizar; en este caso en particular, sería un solo registro buscando por el id del sitio:

  • where, específica las condiciones o condición por las cuales quieres filtrar, indicando la columna, operador y el valor.
  • whereArgs, especifica los valores o valor a pasar en el parámetro where, estos son esquemas típicos, de separar la condición de los datos, para ayudar a prevenir el "SQL attack".
import 'package:place/models/place.dart';
import 'package:sqflite/sqflite.dart';

import 'package:path/path.dart' as path;

class DBHelper {
 static Future<Database> _openDB() async {
  // ***
 }

 static Future<int> insert(Place place) async {
  // ***
 }

 static Future<List<Place>> places() async {
   // ***
 }

 static Future<int> update(Place place) async {
   Database database = await _openDB();
   return database.update("places", place.toMap(),
       where: 'id = ?', whereArgs: [place.id]);
 } 
}

Eliminar registros

Para eliminar registros, se aplica la misma lógica que para actualizar, indicando la consulta where, pero, en este caso, no se especifican los datos para crear o actualizar:

import 'package:place/models/place.dart';
import 'package:sqflite/sqflite.dart';

import 'package:path/path.dart' as path;

class DBHelper {
 static Future<Database> _openDB() async {
  // ***
 }

 static Future<int> insert(Place place) async {
  // ***
 }

 static Future<List<Place>> places() async {
   // ***
 }

 static Future<int> update(Place place) async {
   // ***
 }

 static Future<int> delete(Place place) async {
   Database database = await _openDB();
   return database.delete("places", where: 'id = ?', whereArgs: [place.id]);
 }
}

El uso de todas las funciones con las cuales se van a realizar operaciones en la base de datos, son de tipo asíncronas, con las cuales, no hay necesidad de crear instancias de esta clase.

En Flutter, tenemos varias opciones para persistir datos, también tenemos la base de datos de HiveDB.

Aprende a implementar SQLite en Flutter con el paquete sqflite. Sigue esta guía paso a paso para persistir datos locales de forma eficiente y segura.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.