Arquitectura de almacenamiento persistente: SQL
Guarda datos complejos de la aplicación en el dispositivo del usuario con SQL.
La mayoría de las aplicaciones Flutter, sin importar qué tan pequeñas o grandes sean, podrían requerir almacenar datos en el dispositivo del usuario en algún momento. Por ejemplo, claves de API, preferencias del usuario o datos que deberían estar disponibles sin conexión.
En esta receta, aprenderás cómo integrar el almacenamiento persistente para datos complejos utilizando SQL en una aplicación Flutter siguiendo el patrón de diseño de arquitectura de Flutter.
Para aprender cómo almacenar datos clave-valor más simples, echa un vistazo a la receta del Cookbook: Arquitectura de almacenamiento persistente: Datos clave-valor.
Para leer esta receta, debes estar familiarizado con SQL y SQLite. Si necesitas ayuda, puedes leer la receta Persistir datos con SQLite antes de leer esta.
Este ejemplo utiliza sqflite con el plugin sqflite_common_ffi,
el cual combina soporte para móvil y escritorio.
El soporte para web se proporciona en el plugin experimental
sqflite_common_ffi_web
pero no está incluido en este ejemplo.
Aplicación de ejemplo: Aplicación de lista ToDo
#La aplicación de ejemplo consiste en una sola pantalla con una barra de aplicación en la parte superior, una lista de elementos y una entrada de campo de texto en la parte inferior.
El cuerpo de la aplicación contiene la TodoListScreen.
Esta pantalla contiene un ListView de elementos ListTile,
cada uno de los cuales representa un elemento ToDo.
En la parte inferior, un TextField permite a los usuarios crear nuevos elementos ToDo
escribiendo la descripción de la tarea y luego tocando en el FilledButton "Add".
Los usuarios pueden tocar el IconButton de eliminación para borrar el elemento ToDo.
La lista de elementos ToDo se almacena localmente utilizando un servicio de base de datos, y se restaura cuando el usuario inicia la aplicación.
Almacenar datos complejos con SQL
#Esta funcionalidad sigue el diseño de arquitectura de Flutter recomendado, que contiene una capa de UI y una capa de datos. Adicionalmente, en la capa de dominio encontrarás el modelo de datos utilizado.
- Capa de UI con
TodoListScreenyTodoListViewModel - Capa de dominio con la clase de datos
Todo - Capa de datos con
TodoRepositoryyDatabaseService
Capa de presentación de la lista ToDo
#La TodoListScreen es un Widget que contiene la UI encargada de mostrar
y crear los elementos ToDo.
Sigue el patrón MVVM
y está acompañada por el TodoListViewModel,
el cual contiene la lista de elementos ToDo
y tres comandos para cargar, agregar y eliminar elementos ToDo.
Esta pantalla se divide en dos partes:
una contiene la lista de elementos ToDo,
implementada utilizando un ListView,
y la otra es un TextField
y un Button, utilizados para crear nuevos elementos ToDo.
El ListView está envuelto por un ListenableBuilder,
que escucha los cambios en el TodoListViewModel,
y muestra un ListTile por cada elemento ToDo.
ListenableBuilder(
listenable: widget.viewModel,
builder: (context, child) {
return ListView.builder(
itemCount: widget.viewModel.todos.length,
itemBuilder: (context, index) {
final todo = widget.viewModel.todos[index];
return ListTile(
title: Text(todo.task),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => widget.viewModel.delete.execute(todo.id),
),
);
},
);
},
)
La lista de elementos ToDo está definida en el TodoListViewModel,
y se carga mediante el comando load.
Este método llama al TodoRepository y obtiene la lista de elementos ToDo.
List<Todo> _todos = [];
List<Todo> get todos => _todos;
Future<Result<void>> _load() async {
try {
final result = await _todoRepository.fetchTodos();
switch (result) {
case Ok<List<Todo>>():
_todos = result.value;
return Result.ok(null);
case Error():
return Result.error(result.error);
}
} on Exception catch (e) {
return Result.error(e);
} finally {
notifyListeners();
}
}
Al presionar el FilledButton,
se ejecuta el comando add
y se pasa el valor del controlador de texto.
FilledButton.icon(
onPressed: () =>
widget.viewModel.add.execute(_controller.text),
label: const Text('Add'),
icon: const Icon(Icons.add),
)
El comando add entonces llama al método TodoRepository.createTodo()
con el texto de descripción de la tarea y este crea un nuevo elemento ToDo.
El método createTodo() devuelve el ToDo recién creado,
que luego se agrega a la lista _todo en el view model.
Los elementos ToDo contienen un identificador único generado por la base de datos.
Esta es la razón por la que el view model no crea el elemento ToDo,
sino que lo hace el TodoRepository.
Future<Result<void>> _add(String task) async {
try {
final result = await _todoRepository.createTodo(task);
switch (result) {
case Ok<Todo>():
_todos.add(result.value);
return Result.ok(null);
case Error():
return Result.error(result.error);
}
} on Exception catch (e) {
return Result.error(e);
} finally {
notifyListeners();
}
}
Finalmente, la TodoListScreen también escucha el resultado en el comando add.
Cuando la acción se completa, se limpia el TextEditingController.
void _onAdd() {
// Clear the text field when the add command completes.
if (widget.viewModel.add.completed) {
widget.viewModel.add.clearResult();
_controller.clear();
}
}
Cuando un usuario toca el IconButton en el ListTile, se ejecuta el comando de eliminación.
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => widget.viewModel.delete.execute(todo.id),
)
Luego, el view model llama al método TodoRepository.deleteTodo(),
pasando el identificador único del elemento ToDo.
Un resultado correcto elimina el elemento ToDo del view
model y de la pantalla.
Future<Result<void>> _delete(int id) async {
try {
final result = await _todoRepository.deleteTodo(id);
switch (result) {
case Ok<void>():
_todos.removeWhere((todo) => todo.id == id);
return Result.ok(null);
case Error():
return Result.error(result.error);
}
} on Exception catch (e) {
return Result.error(e);
} finally {
notifyListeners();
}
}
Capa de dominio de la lista ToDo
#La capa de dominio de esta aplicación de ejemplo contiene
el modelo de datos del elemento Todo.
Los elementos se presentan mediante una clase de datos inmutable.
En este caso, la aplicación utiliza el paquete freezed para generar el código.
La clase tiene dos propiedades: un ID representado por un int,
y una descripción de la tarea, representada por un String.
@freezed
abstract class Todo with _$Todo {
const factory Todo({
/// The unique identifier of the Todo item.
required int id,
/// The task description of the Todo item.
required String task,
}) = _Todo;
}
Capa de datos de la lista ToDo
#La capa de datos de esta funcionalidad está compuesta por dos clases,
el TodoRepository y el DatabaseService.
El TodoRepository actúa como la fuente de verdad para todos los elementos ToDo.
Los view models deben usar este repositorio para acceder a la lista ToDo,
y este no debería exponer ningún detalle de implementación sobre cómo se almacenan.
Internamente, el TodoRepository utiliza el DatabaseService,
el cual implementa el acceso a la base de datos SQL utilizando el paquete sqflite.
Puedes implementar el mismo DatabaseService utilizando otros paquetes de almacenamiento
como sqlite3, drift o incluso soluciones de almacenamiento en la nube como
firebase_database.
El TodoRepository comprueba si la base de datos está abierta
antes de cada solicitud y la abre si es necesario.
Implementa los métodos fetchTodos(), createTodo() y deleteTodo().
class TodoRepository {
TodoRepository({required this._database});
final DatabaseService _database;
Future<Result<List<Todo>>> fetchTodos() async {
if (!_database.isOpen()) {
await _database.open();
}
return _database.getAll();
}
Future<Result<Todo>> createTodo(String task) async {
if (!_database.isOpen()) {
await _database.open();
}
return _database.insert(task);
}
Future<Result<void>> deleteTodo(int id) async {
if (!_database.isOpen()) {
await _database.open();
}
return _database.delete(id);
}
}
El DatabaseService implementa el acceso a la base de datos SQLite
utilizando el paquete sqflite.
Es una buena idea definir los nombres de las tablas y columnas como constantes para evitar errores tipográficos al escribir código SQL.
static const String _todoTableName = 'todo';
static const String _idColumnName = '_id';
static const String _taskColumnName = 'task';
El método open() abre la base de datos existente,
o crea una nueva si no existe.
Future<void> open() async {
_database = await databaseFactory.openDatabase(
join(await databaseFactory.getDatabasesPath(), 'app_database.db'),
options: OpenDatabaseOptions(
onCreate: (db, version) {
return db.execute(
'CREATE TABLE $_todoTableName($_idColumnName INTEGER PRIMARY KEY AUTOINCREMENT, $_taskColumnName TEXT)',
);
},
version: 1,
),
);
}
Ten en cuenta que la columna id está establecida como primary key y autoincrement;
esto significa que a cada elemento recién insertado
se le asigna un nuevo valor para la columna id.
El método insert() crea un nuevo elemento ToDo en la base de datos,
y devuelve una instancia de Todo recién creada.
El id se genera como se mencionó antes.
Future<Result<Todo>> insert(String task) async {
try {
final id = await _database!.insert(_todoTableName, {
_taskColumnName: task,
});
return Result.ok(Todo(id: id, task: task));
} on Exception catch (e) {
return Result.error(e);
}
}
Todas las operaciones de DatabaseService utilizan la clase Result para devolver un valor,
tal como lo recomiendan las recomendaciones de arquitectura de Flutter.
Esto facilita el manejo de errores en los siguientes pasos del código de la aplicación.
El método getAll() realiza una consulta a la base de datos,
obteniendo todos los valores en las columnas id y task.
Por cada entrada, crea una instancia de la clase Todo.
Future<Result<List<Todo>>> getAll() async {
try {
final entries = await _database!.query(
_todoTableName,
columns: [_idColumnName, _taskColumnName],
);
final list = entries
.map(
(element) => Todo(
id: element[_idColumnName] as int,
task: element[_taskColumnName] as String,
),
)
.toList();
return Result.ok(list);
} on Exception catch (e) {
return Result.error(e);
}
}
El método delete() realiza una operación de eliminación en la base de datos
basada en el id del elemento ToDo.
En este caso, si no se eliminó ningún elemento, se devuelve un error, indicando que algo salió mal.
Future<Result<void>> delete(int id) async {
try {
final rowsDeleted = await _database!.delete(
_todoTableName,
where: '$_idColumnName = ?',
whereArgs: [id],
);
if (rowsDeleted == 0) {
return Result.error(Exception('No todo found with id $id'));
}
return Result.ok(null);
} on Exception catch (e) {
return Result.error(e);
}
}
Poniéndolo todo junto
#En el método main() de tu aplicación,
primero inicializa el DatabaseService,
el cual requiere un código de inicialización diferente en distintas plataformas.
Luego, pasa el DatabaseService recién creado al TodoRepository,
el cual a su vez se pasa al MainApp como una dependencia de argumento del constructor.
void main() {
late DatabaseService databaseService;
if (kIsWeb) {
throw UnsupportedError('Platform not supported');
} else if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
// Initialize FFI SQLite
sqfliteFfiInit();
databaseService = DatabaseService(databaseFactory: databaseFactoryFfi);
} else {
// Use default native SQLite
databaseService = DatabaseService(databaseFactory: databaseFactory);
}
runApp(
MainApp(
// ···
todoRepository: TodoRepository(database: databaseService),
),
);
}
Luego, cuando se crea la TodoListScreen,
también se crea el TodoListViewModel
y se le pasa el TodoRepository como dependencia.
TodoListScreen(
viewModel: TodoListViewModel(todoRepository: widget.todoRepository),
)
A menos que se indique lo contrario, la documentación de este sitio refleja Flutter 3.44.0. Página actualizada por última vez el 2026-05-18. Ver código fuente oreportar un problema.