flutter-arquitectura-servicios-ui-logica

Arquitectura básica en Flutter: separar UI y datos

  • 4 min

La arquitectura de una app es la forma en la que organizamos responsabilidades dentro del proyecto.

Cuando empezamos en Flutter, la tentación es fuerte:

“Tengo un botón, y cuando lo pulso, quiero descargar una lista de usuarios. Pues pongo el código de descarga dentro del onPressed del botón y listo.”

Para una prueba rápida puede funcionar, pero mezclarlo todo hace que la pantalla crezca enseguida. PantallaUsuarios puede coordinar su estado visual, pero no debería saber cómo conectarse al servidor ni interpretar su JSON.

Hoy vamos a aprender el principio de responsabilidad única separando la App en dos capas: UI (Interfaz) y Lógica (Servicios).

El problema del código mezclado

Mira este código. Es lo que NO debemos hacer:

// ❌ MAL: Lógica mezclada con diseño
class PantallaUsuarios extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      child: Text("Cargar"),
      onPressed: () async {
        // 😱 ¡HORROR! Peticiones HTTP dentro de la UI
        var url = Uri.parse('https://api.ejemplo.com/users');
        var respuesta = await http.get(url);
        if (respuesta.statusCode == 200) {
           // Parsear JSON aquí... más lógica sucia...
        }
      },
    );
  }
}
Copied!

¿Por qué es malo?

  1. No es reutilizable: Si necesitas cargar usuarios desde otra pantalla, tendrás que copiar y pegar el código.
  2. Difícil de leer: El archivo del Widget se llena de código técnico de red.
  3. Difícil de probar: Necesitas sustituir o interceptar las llamadas reales para comprobar la interfaz.

Separar servicios y repositorios

La solución es sacar el acceso a datos fuera de los widgets y llevarlo a clases especializadas. Un service suele envolver una API concreta; un repository ofrece datos al resto de la aplicación y puede combinar distintas fuentes. Para este ejemplo sencillo usaremos un servicio.

Imagina un restaurante:

  • El Widget es el Camarero: Su trabajo es sonreír, enseñarte el menú (UI) y tomar nota. No cocina.
  • El Service es el Cocinero: Está en la cocina (invisible al cliente). Recibe la comanda, busca los ingredientes (API), los cocina (JSON Parsing) y entrega el plato listo.

Paso 1: Crear la clase Servicio

Vamos a crear un archivo nuevo, por ejemplo lib/services/user_service.dart.

// ✅ BIEN: Una clase dedicada solo a los datos
class UserService {
  // Simulación de la URL base
  final String _baseUrl = 'https://jsonplaceholder.typicode.com';

  // Método limpio que devuelve lo que la UI necesita
  Future<List<String>> getUsers() async {
    // Aquí irá toda la lógica "sucia" de HTTP que veremos luego
    // De momento, simulamos que esperamos 2 segundos y devolvemos datos
    await Future.delayed(Duration(seconds: 2));
    
    return ["Luis", "Ana", "Carlos"];
  }
}
Copied!

Fíjate qué limpio. Esta clase no sabe nada de colores, ni de botones, ni de BuildContext. Solo sabe de datos.

Paso 2: Llamar al Servicio desde la UI

Ahora, nuestro Widget vuelve a ser un simple camarero. Solo tiene que llamar al servicio y esperar.

class PantallaUsuarios extends StatefulWidget {
  @override
  _PantallaUsuariosState createState() => _PantallaUsuariosState();
}

class _PantallaUsuariosState extends State<PantallaUsuarios> {
  // 1. Instanciamos el servicio (nuestro cocinero)
  final UserService _userService = UserService();
  
  List<String> _usuarios = [];
  bool _cargando = false;

  Future<void> _cargarDatos() async {
    // Activamos el spinner
    setState(() => _cargando = true);

    // 2. Pedimos los datos al servicio (Delegamos el trabajo sucio)
    final nuevosUsuarios = await _userService.getUsers();

    // Actualizamos la UI
    if (!mounted) return;

    setState(() {
      _usuarios = nuevosUsuarios;
      _cargando = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Arquitectura Limpia")),
      body: _cargando 
          ? Center(child: CircularProgressIndicator()) // Spinner
          : ListView.builder(
              itemCount: _usuarios.length,
              itemBuilder: (ctx, i) => ListTile(title: Text(_usuarios[i])),
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: _cargarDatos, // Al pulsar, llamamos a la función
        child: Icon(Icons.download),
      ),
    );
  }
}
Copied!

Estructura de carpetas sugerida

Para mantener el orden a partir de ahora, te recomiendo esta estructura de carpetas en /lib:

  • /lib
  • /models (Las clases de datos: Usuario, Producto…)
  • /screens (Las pantallas: Home, Login…)
  • /widgets (Widgets reutilizables: Botones, Tarjetas…)
  • /services (La lógica de conexión: ApiService, AuthService…)

Ventajas inmediatas

Al hacer este pequeño cambio estructural, hemos ganado mucho:

  1. Código Limpio: El método build solo se preocupa de si está cargando o mostrando la lista. No hay URLs ni códigos de estado HTTP molestando.
  2. Mantenibilidad: Si mañana cambia la URL de la API, solo tienes que tocar el archivo user_service.dart. No tienes que buscar en todas tus pantallas.
  3. Escalabilidad: Podríamos añadir métodos como createUser, deleteUser dentro de UserService y tenerlo todo organizado.