tutoriales.com

Flutter: Consumiendo APIs REST de Forma Segura y Eficiente con Dio y Freezed

Este tutorial te guiará paso a paso en el proceso de consumir APIs REST en tus aplicaciones Flutter. Aprenderás a configurar Dio para manejar peticiones HTTP, intercepción y errores, y a usar Freezed para generar modelos de datos inmutables y seguros, mejorando la calidad y mantenibilidad de tu código.

Intermedio20 min de lectura5 views
Reportar error

🚀 Introducción a la Interacción con APIs REST en Flutter

En el desarrollo de aplicaciones móviles modernas, la interacción con servicios backend a través de APIs REST es una funcionalidad fundamental. Flutter, con su potente ecosistema, ofrece diversas herramientas para lograrlo de manera efectiva. En este tutorial, nos enfocaremos en dos librerías clave que simplificarán y robustecerán este proceso: Dio para la gestión de peticiones HTTP y Freezed para la generación de modelos de datos inmutables y con gestión de JSON.

¿Por qué Dio y Freezed? 🤔

  • Dio: Es un cliente HTTP muy popular para Dart, que ofrece una API potente para enviar peticiones, manejar respuestas, interceptores para logging, autenticación, gestión de errores y mucho más. Es más completo y configurable que el http package básico.
  • Freezed: Una librería para generar código que nos permite definir clases inmutables, union types (sealed classes), serialización JSON y métodos de utilidad (como copyWith, equals, hashCode, toString) con una sintaxis concisa. Esto elimina gran parte del boilerplate y reduce la probabilidad de errores en nuestros modelos de datos.
💡 Consejo: Usar librerías como Dio y Freezed no solo mejora la eficiencia en el desarrollo, sino que también fomenta buenas prácticas como la inmutabilidad y la robustez en el manejo de datos, crucial para aplicaciones escalables.

🛠️ Configuración Inicial del Proyecto Flutter

Antes de sumergirnos en el código, necesitamos configurar nuestro proyecto Flutter añadiendo las dependencias necesarias.

1. Crear un Nuevo Proyecto Flutter

Si aún no tienes un proyecto, puedes crear uno con el siguiente comando:

flutter create api_consumption_app
cd api_consumption_app

2. Añadir Dependencias 📦

Abre tu archivo pubspec.yaml y añade las siguientes dependencias en la sección dependencies y dev_dependencies:

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.4.0 # Cliente HTTP
  freezed_annotation: ^2.4.1 # Anotaciones para Freezed
  json_annotation: ^4.8.1 # Anotaciones para serialización JSON

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.0
  build_runner: ^2.4.8 # Para generar código
  freezed: ^2.4.7 # Generador de código Freezed
  json_serializable: ^6.7.1 # Generador de código para JSON

Después de añadir las dependencias, guarda el archivo y ejecuta en tu terminal:

flutter pub get

Esto descargará todas las librerías necesarias para nuestro proyecto.

🔥 Importante: Siempre asegúrate de usar las versiones más recientes de las librerías o, al menos, versiones compatibles. Las versiones listadas aquí son actuales en el momento de escribir este tutorial.

📋 Definiendo los Modelos de Datos con Freezed

Freezed nos permite definir modelos de datos inmutables con una sintaxis muy declarativa. Usaremos un ejemplo de una API pública para obtener una lista de publicaciones. Para este tutorial, usaremos la API de JSONPlaceholder (https://jsonplaceholder.typicode.com/posts). Cada Post tendrá un userId, id, title y body.

1. Crear el Archivo del Modelo

Crea una nueva carpeta models dentro de lib y un archivo post.dart dentro de ella (lib/models/post.dart).

2. Definir el Modelo Post con Freezed

En lib/models/post.dart, añade el siguiente código:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'post.freezed.dart';
part 'post.g.dart';

@freezed
class Post with _$Post {
  const factory Post({
    required int userId,
    required int id,
    required String title,
    required String body,
  }) = _Post;

  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
}

Explicación:

  • part 'post.freezed.dart'; y part 'post.g.dart';: Estos son archivos generados automáticamente por freezed y json_serializable respectivamente. build_runner se encarga de crearlos.
  • @freezed: Esta anotación indica a freezed que genere código para esta clase.
  • class Post with _$Post: _$Post es una mixin generada por freezed que contiene la lógica de la inmutabilidad y los métodos utilitarios.
  • const factory Post({...}) = _Post;: Esta es la definición de la clase, indicando sus propiedades (userId, id, title, body). El prefijo _ en _Post es una convención de Freezed.
  • factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);: Este es el factory constructor que Freezed generará para deserializar JSON. Depende de json_serializable.

3. Generar el Código de Freezed y JSON Serializable

Ahora, necesitamos ejecutar el build_runner para generar los archivos post.freezed.dart y post.g.dart. Abre tu terminal en la raíz del proyecto y ejecuta:

flutter pub run build_runner build

Si necesitas que el generador de código esté siempre activo y reconstruya automáticamente los archivos al guardar cambios, puedes usar:

flutter pub run build_runner watch

Verás que se generan los dos archivos part en la carpeta lib/models. ¡Ahora tu modelo Post está listo para ser usado de forma segura e inmutable!

Inicio Definir Modelo (post.dart) Anotaciones @freezed y factory constructors Ejecutar build_runner post.freezed.dart Clases de datos, copyWith, == post.g.dart Serialización JSON automática

🌐 Configurando Dio para Peticiones HTTP

Dio es un cliente HTTP potente y flexible. Vamos a configurarlo para que sea fácil de usar en nuestra aplicación, incluyendo un interceptor para ver las peticiones y respuestas.

1. Crear el Servicio API

Crea una nueva carpeta services dentro de lib y un archivo api_service.dart dentro de ella (lib/services/api_service.dart).

2. Implementar ApiService

import 'package:dio/dio.dart';
import 'package:api_consumption_app/models/post.dart';

class ApiService {
  final Dio _dio;
  final String _baseUrl = 'https://jsonplaceholder.typicode.com';

  ApiService() : _dio = Dio() {
    _dio.options.baseUrl = _baseUrl;
    _dio.options.connectTimeout = const Duration(seconds: 5); // 5 segundos
    _dio.options.receiveTimeout = const Duration(seconds: 3); // 3 segundos
    _dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
  }

  Future<List<Post>> getPosts() async {
    try {
      final response = await _dio.get('/posts');
      if (response.statusCode == 200) {
        // Dio devuelve data directamente como Map<String, dynamic> o List<dynamic>
        final List<dynamic> data = response.data as List<dynamic>;
        return data.map((json) => Post.fromJson(json as Map<String, dynamic>)).toList();
      } else {
        throw DioException(requestOptions: response.requestOptions, response: response, message: 'Error al obtener posts: ${response.statusCode}');
      }
    } on DioException catch (e) {
      // Manejo de errores específicos de Dio
      if (e.type == DioExceptionType.connectionTimeout) {
        throw Exception('Conexión expirada. Inténtalo de nuevo.');
      } else if (e.type == DioExceptionType.receiveTimeout) {
        throw Exception('Tiempo de respuesta del servidor excedido.');
      } else if (e.type == DioExceptionType.badResponse) {
        throw Exception('Error del servidor: ${e.response?.statusCode}. Mensaje: ${e.response?.data}');
      } else if (e.type == DioExceptionType.unknown) {
         throw Exception('Error de red desconocido. Verifica tu conexión a internet.');
      }
      throw Exception('Error al obtener posts: ${e.message}');
    } catch (e) {
      // Otros errores no relacionados con Dio
      throw Exception('Un error inesperado ocurrió: $e');
    }
  }
}

Explicación:

  • _dio = Dio(): Se inicializa la instancia de Dio.
  • _dio.options.baseUrl: Establece la URL base para todas las peticiones, lo que simplifica las llamadas posteriores (/posts en lugar de https://jsonplaceholder.typicode.com/posts).
  • connectTimeout y receiveTimeout: Configuran tiempos de espera para la conexión y la recepción de datos, respectivamente. Es una buena práctica para evitar que la aplicación se congele indefinidamente.
  • _dio.interceptors.add(LogInterceptor()): Añade un interceptor de logging que imprime por consola los detalles de las peticiones y respuestas HTTP. Muy útil para depuración.
  • getPosts(): Método asíncrono que realiza una petición GET a /posts.
  • Manejo de Errores: Se usa un bloque try-on-catch para capturar DioException (errores específicos de Dio) y Exception (otros errores). Es crucial manejar diferentes tipos de errores (tiempo de espera, errores de servidor, etc.) para proporcionar feedback útil al usuario.
  • Deserialización: La respuesta de la API (que es una lista de objetos JSON) se mapea a una lista de objetos Post usando el Post.fromJson que freezed nos ha generado.
📌 Nota: Para entornos de producción, considera envolver `LogInterceptor` en una condición que solo lo active en modo debug, ya que imprimir logs sensibles en producción no es recomendable.

📱 Integrando el Servicio en la Interfaz de Usuario (UI)

Ahora que tenemos nuestros modelos y nuestro servicio API, vamos a integrarlos en la interfaz de usuario para mostrar los posts.

1. Modificar main.dart

Abre lib/main.dart y reemplaza su contenido con lo siguiente. Crearemos un PostListScreen que será el encargado de mostrar los datos.

import 'package:flutter/material.dart';
import 'package:api_consumption_app/services/api_service.dart';
import 'package:api_consumption_app/models/post.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'API Consumption App',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const PostListScreen(),
    );
  }
}

class PostListScreen extends StatefulWidget {
  const PostListScreen({super.key});

  @override
  State<PostListScreen> createState() => _PostListScreenState();
}

class _PostListScreenState extends State<PostListScreen> {
  late Future<List<Post>> _postsFuture;
  final ApiService _apiService = ApiService();

  @override
  void initState() {
    super.initState();
    _postsFuture = _apiService.getPosts();
  }

  Future<void> _refreshPosts() async {
    setState(() {
      _postsFuture = _apiService.getPosts();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Publicaciones'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: _refreshPosts,
          ),
        ],
      ),
      body: FutureBuilder<List<Post>>(
        future: _postsFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.error_outline, color: Colors.red, size: 48),
                  const SizedBox(height: 16),
                  Text(
                    'Error: ${snapshot.error}',
                    textAlign: TextAlign.center,
                    style: const TextStyle(color: Colors.red, fontSize: 16),
                  ),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: _refreshPosts,
                    child: const Text('Reintentar'),
                  ),
                ],
              ),
            );
          } else if (snapshot.hasData) {
            final posts = snapshot.data!;
            return RefreshIndicator(
              onRefresh: _refreshPosts,
              child: ListView.builder(
                itemCount: posts.length,
                itemBuilder: (context, index) {
                  final post = posts[index];
                  return Card(
                    margin: const EdgeInsets.all(8.0),
                    elevation: 2,
                    child: Padding(
                      padding: const EdgeInsets.all(16.0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(
                            post.title,
                            style: Theme.of(context).textTheme.headlineSmall,
                          ),
                          const SizedBox(height: 8),
                          Text(
                            post.body,
                            style: Theme.of(context).textTheme.bodyMedium,
                          ),
                          const SizedBox(height: 8),
                          Align(
                            alignment: Alignment.bottomRight,
                            child: Text(
                              'User ID: ${post.userId}',
                              style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
                            ),
                          ),
                        ],
                      ),
                    ),
                  );
                },
              ),
            );
          } else {
            return const Center(child: Text('No hay publicaciones disponibles.'));
          }
        },
      ),
    );
  }
}

Explicación:

  • MyApp: La clase principal de la aplicación que configura el tema y define PostListScreen como la página de inicio.
  • PostListScreen: Un StatefulWidget que contendrá la lógica y la UI para mostrar la lista de posts.
  • _postsFuture: Una variable Future<List<Post>> que almacenará el resultado de la llamada a la API. Se inicializa en initState.
  • ApiService _apiService: Una instancia de nuestro servicio API para realizar las peticiones.
  • _refreshPosts(): Método para recargar los posts, útil para la funcionalidad de 'pull-to-refresh' y el botón de reintentar.
  • FutureBuilder: Widget fundamental para trabajar con Futures en Flutter. Se encarga de reconstruir su UI cuando el Future cambia de estado (pendiente, completado con datos, completado con error).
    • ConnectionState.waiting: Muestra un CircularProgressIndicator mientras se cargan los datos.
    • snapshot.hasError: Muestra un mensaje de error y un botón para reintentar si la llamada a la API falla.
    • snapshot.hasData: Si los datos se cargaron correctamente, se usa ListView.builder para mostrar cada Post en una Card.
  • RefreshIndicator: Permite al usuario deslizar hacia abajo para recargar la lista de publicaciones.

✨ Ejecutando y Probando la Aplicación

Con todo configurado, es hora de probar nuestra aplicación.

  1. Asegúrate de que el build_runner haya generado los archivos .freezed.dart y .g.dart (flutter pub run build_runner build).
  2. Conecta un dispositivo o inicia un emulador.
  3. Ejecuta la aplicación desde tu IDE o con el comando:
flutter run

Verás la lista de publicaciones cargándose desde la API de JSONPlaceholder. Si hay algún error de red o de la API, la aplicación mostrará un mensaje de error claro y la opción de reintentar.

Tutorial Completado

💡 Mejoras y Próximos Pasos

Este tutorial te ha proporcionado una base sólida para consumir APIs REST en Flutter usando Dio y Freezed. Aquí tienes algunas ideas para llevar tu aplicación al siguiente nivel:

  • Inyección de Dependencias: Usa un paquete como get_it o provider para inyectar la instancia de ApiService en lugar de crearla directamente en el StatefulWidget. Esto mejora la capacidad de prueba y la modularidad.
  • Gestión de Estado: Para aplicaciones más complejas, integra una solución de gestión de estado como BLoC, Riverpod o Provider para manejar el estado de las llamadas a la API y la UI de forma más reactiva.
  • Autenticación: Si tu API requiere autenticación (JWT, OAuth2), puedes añadir interceptores a Dio para incluir automáticamente tokens en los headers de las peticiones.
  • Manejo de Paginación: Si la API devuelve grandes cantidades de datos, implementa paginación para cargar los datos en bloques, mejorando el rendimiento y la experiencia del usuario.
  • Testing: Escribe pruebas unitarias para tus modelos Freezed y pruebas de integración para tu ApiService y los widgets de la UI.
  • Variables de Entorno: Usa flutter_dotenv para gestionar URLs de API y claves sensibles, diferenciando entre entornos de desarrollo y producción.
¿Por qué Freezed es mejor que escribir los modelos manualmente? Freezed automatiza la generación de código para:
  • Inmutabilidad: Asegura que una vez creado un objeto, no pueda ser modificado, lo que previene errores sutiles en el estado.
  • Serialización/Deserialización JSON: Genera los métodos fromJson y toJson automáticamente, evitando errores manuales.
  • copyWith: Permite crear nuevas instancias de un objeto con valores modificados de forma sencilla.
  • equals y hashCode: Implementa correctamente la comparación de objetos por valor, esencial para colecciones y testing.
  • toString: Genera una representación de cadena útil para depuración.
  • Union Types/Sealed Classes: Para estados más complejos, Freezed permite definir una clase base y varias subclases para representar estados distintos de forma segura y expresiva. (Ej: LoadingState, LoadedState, ErrorState).
Paso 1: Definir dependencias en `pubspec.yaml`
Paso 2: Crear modelos Freezed y generar código con `build_runner`
Paso 3: Implementar `ApiService` con Dio y manejo de errores
Paso 4: Integrar la carga de datos en la UI con `FutureBuilder` y `RefreshIndicator`
Paso 5: Ejecutar y probar la aplicación

Has completado un viaje crucial en el desarrollo de Flutter, dominando la comunicación con APIs externas de forma robusta y moderna. ¡Felicidades!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!