Soporte offline-first
Implementa el soporte offline-first para una funcionalidad en una aplicación.
Una aplicación offline-first es una app capaz de ofrecer la mayor parte o la totalidad de su funcionalidad estando desconectada de internet. Las aplicaciones offline-first generalmente dependen de datos almacenados para ofrecer a los usuarios un acceso temporal a datos que de otro modo solo estarían disponibles en línea.
Algunas aplicaciones offline-first combinan datos locales y remotos a la perfección, mientras que otras informan al usuario cuando la aplicación está usando datos en caché. De la misma manera, algunas aplicaciones sincronizan datos en segundo plano mientras que otras requieren que el usuario los sincronice explícitamente. Todo depende de los requisitos de la aplicación y la funcionalidad que ofrece, y depende del desarrollador decidir qué implementación se adapta a sus necesidades.
En esta guía, aprenderás cómo implementar diferentes enfoques para aplicaciones offline-first en Flutter, siguiendo las pautas de arquitectura de Flutter.
Arquitectura offline-first
#Como se explica en la guía de conceptos comunes de arquitectura, los repositorios actúan como la única fuente de verdad. Son responsables de presentar datos locales o remotos, y deben ser el único lugar donde se puedan modificar los datos. En las aplicaciones offline-first, los repositorios combinan diferentes fuentes de datos locales y remotas para presentar los datos en un único punto de acceso, independientemente del State de conectividad del dispositivo.
Este ejemplo utiliza el UserProfileRepository,
un repositorio que te permite obtener y almacenar objetos UserProfile
con soporte offline-first.
El UserProfileRepository utiliza dos servicios de datos diferentes:
uno trabaja con datos remotos,
y el otro trabaja con una base de datos local.
El cliente de API, ApiClientService,
se conecta a un servicio remoto mediante llamadas HTTP REST.
class ApiClientService {
/// performs GET network request to obtain a UserProfile
Future<UserProfile> getUserProfile() async {
// ···
}
/// performs PUT network request to update a UserProfile
Future<void> putUserProfile(UserProfile userProfile) async {
// ···
}
}
El servicio de base de datos, DatabaseService, almacena datos usando SQL,
similar al que se encuentra en la receta Arquitectura de almacenamiento persistente: SQL.
class DatabaseService {
/// Fetches the UserProfile from the database.
/// Returns null if the user profile is not found.
Future<UserProfile?> fetchUserProfile() async {
// ···
}
/// Update UserProfile in the database.
Future<void> updateUserProfile(UserProfile userProfile) async {
// ···
}
}
Este ejemplo también utiliza la clase de datos UserProfile
que se ha creado utilizando el paquete freezed.
@freezed
abstract class UserProfile with _$UserProfile {
const factory UserProfile({
required String name,
required String photoUrl,
}) = _UserProfile;
}
En apps que tienen datos complejos,
como cuando los datos remotos contienen más campos de los necesarios para la UI,
es posible que desees tener una clase de datos para los servicios de API y de base de datos,
y otra para la UI.
Por ejemplo,
UserProfileLocal para la entidad de la base de datos,
UserProfileRemote para el objeto de respuesta de la API,
y luego UserProfile para la clase de modelo de datos de la UI.
El UserProfileRepository se encargaría
de convertir de una a otra cuando sea necesario.
Este ejemplo también incluye el UserProfileViewModel,
un view model que utiliza el UserProfileRepository
para mostrar el UserProfile en un Widget.
class UserProfileViewModel extends ChangeNotifier {
// ···
final UserProfileRepository _userProfileRepository;
UserProfile? get userProfile => _userProfile;
// ···
/// Load the user profile from the database or the network
Future<void> load() async {
// ···
}
/// Save the user profile with the new name
Future<void> save(String newName) async {
// ···
}
}
Leer datos
#Leer datos es una parte fundamental de cualquier aplicación que dependa de servicios de API remotos.
En las aplicaciones offline-first, quieres asegurarte de que el acceso a estos datos sea lo más rápido posible, y que no dependa de que el dispositivo esté en línea para proporcionar datos al usuario. Esto es similar al patrón de diseño de Optimistic State.
En esta sección,
aprenderás dos enfoques diferentes,
uno que utiliza la base de datos como alternativa
y otro que combina datos locales y remotos usando un Stream.
Usar datos locales como alternativa
#Como primer enfoque, puedes implementar soporte sin conexión mediante un mecanismo de alternativa para cuando el usuario está sin conexión o falla una llamada de red.
En este caso, el UserProfileRepository intenta obtener el UserProfile
desde el servidor de API remoto utilizando el ApiClientService.
Si esta solicitud falla,
entonces devuelve el UserProfile almacenado localmente desde el DatabaseService.
Future<UserProfile> getUserProfile() async {
try {
// Fetch the user profile from the API
final apiUserProfile = await _apiClientService.getUserProfile();
//Update the database with the API result
await _databaseService.updateUserProfile(apiUserProfile);
return apiUserProfile;
} catch (e) {
// If the network call failed,
// fetch the user profile from the database
final databaseUserProfile = await _databaseService.fetchUserProfile();
// If the user profile was never fetched from the API
// it will be null, so throw an error
if (databaseUserProfile != null) {
return databaseUserProfile;
} else {
// Handle the error
throw Exception('User profile not found');
}
}
}
Usar un Stream
#Una mejor alternativa presenta los datos usando un Stream.
En el mejor de los escenarios,
el Stream emite dos valores:
los datos almacenados localmente y los datos del servidor.
Primero, el Stream emite los datos almacenados localmente utilizando el DatabaseService.
Esta llamada es generalmente más rápida y menos propensa a errores que una llamada de red,
y al hacerlo primero, el view model ya puede mostrar datos al usuario.
Si la base de datos no contiene ningún dato almacenado en caché,
entonces el Stream depende completamente de la llamada de red,
emitiendo un solo valor.
Luego, el método realiza la llamada de red utilizando el ApiClientService
para obtener datos actualizados.
Si la solicitud fue exitosa,
actualiza la base de datos con los datos recién obtenidos,
y luego entrega (yield) el valor al view model,
para que pueda ser mostrado al usuario.
Stream<UserProfile> getUserProfile() async* {
// Fetch the user profile from the database
final userProfile = await _databaseService.fetchUserProfile();
// Returns the database result if it exists
if (userProfile != null) {
yield userProfile;
}
// Fetch the user profile from the API
try {
final apiUserProfile = await _apiClientService.getUserProfile();
//Update the database with the API result
await _databaseService.updateUserProfile(apiUserProfile);
// Return the API result
yield apiUserProfile;
} catch (e) {
// Handle the error
}
}
El view model debe suscribirse
a este Stream y esperar hasta que se haya completado.
Para ello, llama a asFuture() con el objeto Subscription y espera el resultado.
Para cada valor obtenido,
actualiza los datos del view model y llama a notifyListeners()
para que la UI muestre los datos más recientes.
Future<void> load() async {
await _userProfileRepository
.getUserProfile()
.listen(
(userProfile) {
_userProfile = userProfile;
notifyListeners();
},
onError: (error) {
// handle error
},
)
.asFuture<void>();
}
Usar solo datos locales
#Otro enfoque posible utiliza datos almacenados localmente para operaciones de lectura. Este enfoque requiere que los datos hayan sido precargados en algún momento en la base de datos, y requiere un mecanismo de sincronización que pueda mantener los datos actualizados.
Future<UserProfile> getUserProfile() async {
// Fetch the user profile from the database
final userProfile = await _databaseService.fetchUserProfile();
// Return the database result if it exists
if (userProfile == null) {
throw Exception('Data not found');
}
return userProfile;
}
Future<void> sync() async {
try {
// Fetch the user profile from the API
final userProfile = await _apiClientService.getUserProfile();
// Update the database with the API result
await _databaseService.updateUserProfile(userProfile);
} catch (e) {
// Try again later
}
}
Este enfoque puede ser útil para aplicaciones que no requieren que los datos estén sincronizados con el servidor en todo momento. Por ejemplo, una aplicación del clima donde los datos meteorológicos solo se actualizan una vez al día.
La sincronización podría ser realizada manualmente por el usuario,
por ejemplo, una acción pull-to-refresh que luego llama al método sync(),
o realizarse periódicamente mediante un Timer o un proceso en segundo plano.
Puedes aprender a implementar una tarea de sincronización
en la sección sobre cómo sincronizar el State.
Escribir datos
#Escribir datos en aplicaciones offline-first depende fundamentalmente del caso de uso de la aplicación.
Algunas aplicaciones pueden requerir que los datos introducidos por el usuario estén disponibles inmediatamente en el servidor, mientras que otras aplicaciones pueden ser más flexibles y permitir que los datos estén desincronizados temporalmente.
Esta sección explica dos enfoques diferentes para implementar la escritura de datos en aplicaciones offline-first.
Escritura solo en línea
#Un enfoque para escribir datos en aplicaciones offline-first consiste en exigir estar en línea para escribir datos. Aunque esto pueda sonar contradictorio, esto asegura que los datos que el usuario ha modificado estén completamente sincronizados con el servidor, y que la aplicación no tenga un State diferente al del servidor.
En este caso, primero intentas enviar los datos al servicio de API, y si la solicitud tiene éxito, luego almacenas los datos en la base de datos.
Future<void> updateUserProfile(UserProfile userProfile) async {
try {
// Update the API with the user profile
await _apiClientService.putUserProfile(userProfile);
// Only if the API call was successful
// update the database with the user profile
await _databaseService.updateUserProfile(userProfile);
} catch (e) {
// Handle the error
}
}
La desventaja en este caso es que la funcionalidad offline-first solo está disponible para operaciones de lectura, pero no para operaciones de escritura, ya que estas requieren que el usuario esté en línea.
Escritura offline-first
#El segundo enfoque funciona al revés. En lugar de realizar primero la llamada de red, la aplicación almacena primero los nuevos datos en la base de datos, y luego intenta enviarlos al servicio de API una vez que se han almacenado localmente.
Future<void> updateUserProfile(UserProfile userProfile) async {
// Update the database with the user profile
await _databaseService.updateUserProfile(userProfile);
try {
// Update the API with the user profile
await _apiClientService.putUserProfile(userProfile);
} catch (e) {
// Handle the error
}
}
Este enfoque permite a los usuarios almacenar datos localmente incluso cuando la aplicación está sin conexión; sin embargo, si la llamada de red falla, la base de datos local y el servicio de API ya no estarán sincronizados. En la siguiente sección, aprenderás diferentes enfoques para manejar la sincronización entre los datos locales y remotos.
Sincronizar el State
#Mantener los datos locales y remotos sincronizados es una parte importante de las aplicaciones offline-first, ya que los cambios que se han realizado localmente deben copiarse al servicio remoto. La app también debe asegurar que, cuando el usuario vuelva a la aplicación, los datos almacenados localmente sean los mismos que en el servicio remoto.
Escribir una tarea de sincronización
#Existen diferentes enfoques para implementar la sincronización en una tarea en segundo plano.
Una solución simple consiste en crear un Timer
en el UserProfileRepository que se ejecute periódicamente,
por ejemplo cada cinco minutos.
Timer.periodic(const Duration(minutes: 5), (timer) => sync());
El método sync() entonces obtiene el UserProfile de la base de datos,
y si requiere sincronización, lo envía al servicio de API.
Future<void> sync() async {
try {
// Fetch the user profile from the database
final userProfile = await _databaseService.fetchUserProfile();
// Check if the user profile requires synchronization
if (userProfile == null || userProfile.synchronized) {
return;
}
// Update the API with the user profile
await _apiClientService.putUserProfile(userProfile);
// Set the user profile as synchronized
await _databaseService.updateUserProfile(
userProfile.copyWith(synchronized: true),
);
} catch (e) {
// Try again later
}
}
Una solución más compleja utiliza procesos en segundo plano
como el plugin workmanager.
Esto permite que tu aplicación ejecute el proceso de sincronización
en segundo plano incluso cuando la aplicación no se esté ejecutando.
También se recomienda realizar la tarea de sincronización únicamente
cuando la red esté disponible.
Por ejemplo, puedes usar el plugin connectivity_plus
para comprobar si el dispositivo está conectado a WiFi.
También puedes usar battery_plus
para verificar
que el dispositivo no tenga la batería baja.
En el ejemplo anterior, la tarea de sincronización se ejecuta cada 5 minutos. En algunos casos, eso podría ser excesivo, mientras que en otros podría no ser lo suficientemente frecuente. El tiempo del período de sincronización real para tu aplicación depende de las necesidades de la misma y es algo que tendrás que decidir.
Almacenar un indicador de sincronización
#Para saber si los datos requieren sincronización, agrega un indicador (flag) a la clase de datos indicando si los cambios necesitan sincronizarse.
Por ejemplo, bool synchronized:
@freezed
abstract class UserProfile with _$UserProfile {
const factory UserProfile({
required String name,
required String photoUrl,
@Default(false) bool synchronized,
}) = _UserProfile;
}
Tu lógica de sincronización debería intentar
enviarlo al servicio de API
únicamente cuando el indicador synchronized sea false.
Si la solicitud tiene éxito, entonces cámbialo a true.
Enviar datos desde el servidor
#Un enfoque diferente para la sincronización consiste en utilizar un servicio push para proporcionar datos actualizados a la aplicación. En este caso, el servidor notifica a la aplicación cuando los datos han cambiado, en lugar de ser la aplicación la que solicita actualizaciones.
Por ejemplo, puedes usar Firebase messaging para enviar pequeños payloads de datos al dispositivo, así como para desencadenar tareas de sincronización de forma remota utilizando mensajes en segundo plano.
En lugar de tener una tarea de sincronización ejecutándose en segundo plano, el servidor notifica a la aplicación con una notificación push cuando los datos almacenados necesitan ser actualizados.
Puedes combinar ambos enfoques, teniendo una tarea de sincronización en segundo plano y utilizando mensajes push en segundo plano, para mantener la base de datos de la aplicación sincronizada con el servidor.
Poniéndolo todo junto
#Escribir una aplicación offline-first requiere tomar decisiones con respecto a la forma en que se implementan las operaciones de lectura, escritura y sincronización, las cuales dependen de los requisitos de la aplicación que estés desarrollando.
Las conclusiones clave son:
- Al leer datos,
puedes usar un
Streampara combinar datos almacenados localmente con datos remotos. - Al escribir datos, decide si necesitas estar en línea o sin conexión, y si necesitas sincronizar los datos más tarde o no.
- Al implementar una tarea de sincronización en segundo plano, ten en cuenta el estado del dispositivo y las necesidades de tu aplicación, ya que diferentes aplicaciones pueden tener requisitos diferentes.
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-05. Ver código fuente oreportar un problema.