Índice de contenido
- ¿Por qué SQLite?
- 1. Agrega dependencias a tu proyecto
- Funcionamiento base
- Crear y abrir una base de datos
- 2. Cree un cliente de base de datos
- Ciclo de Vida e Inicialización de la Base de Datos
- Implementación de Operaciones CRUD y Capa de Servicios
- 2. Operaciones Abstraídas frente a SQL Nativo
- Insertar registros
- Obtener el listado de todos los registros
- Actualizar registros
- Eliminar registros
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í:
- El path de donde se guarda la base de datos.
- 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:
- 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). - 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. - 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 EXISTSpara 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 medianteORDER 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 medianterawQuery()orawInsert(). 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:
- La tabla donde se quiere insertar los registros.
- 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.