4 - Arquitectura MVVM y Clean Architecture
Objetivo
En este laboratorio aprenderemos sobre arquitectura de software en Flutter.
Para este laboratorio vamos a desarrollar un Pokedex, que es una unidad de información para el mundo Pokemon. Esta aplicación nos mostrara los datos de cada criatura y desde ahí podremos generar un detalle.
Esta aplicación seguirá evolucionando conforme avancemos.
**Nota: Al final de este laboratorio te recomiendo que generes una copia para que en los próximos tengas un punto de comparación sobre lo que se va avanzando.
Instrucciones
Sigue los pasos descritos en la siguiente práctica, si tienes algún problema no olvides que tus profesores están para apoyarte.
API
Para este laboratorio estaremos utilizando el API de PokeAPI los endpoints con los que vamos a comenzar son los siguientes:
GET https://pokeapi.co/api/v2/pokemon/?limit=1279
GET https://pokeapi.co/api/v2/pokemon/{number_pokemon}/
Laboratorio
Paso 1 Introducción a MVVM
Hace algunos años el uso de Arquitecturas no era algo común de ver, y quedaba más del lado de los desarrolladores, viéndolo más como un lujo que como una necesidad.
En aquel entonces las aplicaciones eran más "simples" y si bien nunca ha sido la mejor práctica solo los desarrolladores veteranos explotaban estas capacidades usando los patrones comunes de desarrollo web como una arquitectura MVC con la cual deberías estar familiarizado.
Ahora bien, antes de mencionar la teoría del MVVM, por que es la que estamos seleccionando habiendo tantas posibles arquitecturas en el mundo de desarrollo. En primera instancia por que es la arquitectura oficial de Google. Hoy en día no podemos pensar de un proyecto básico en Android si no hablamos mínimo de el uso de una arquitectura MVVM. Segundo por que Google ya nos da muchas cosas hechas para hacer más fácil la implementación en lugar de otras arquitecturas donde los patrones tenemos que construirlos nosotros mismos. Por último esta arquitectura esta orientada a alcanzar buenos niveles de calidad y que el código en si mismo sea testeable uno de los principios fundamentales de la calidad.
Como último paso puedo mencionarte con mucha seguridad que de lejos MVVM sea la mejor arquitectura del mundo, y tampoco es única a Android, es más todas estas arquitecturas son muy viejas comprobando que las bases de la computación se mantienen hasta hoy en día. Si bien MVVM tiene sus pro también tiene sus contras y como todo en la comunidad hay quienes están a favor y en contra.
Como siempre mi recomendación es aprende, luego juzga. Una forma de hacer algo mejor es entender las bases de lo que ya existe, quien sabe tal vez a partir de esto puedas crear mejores prácticas que hagan innovación en el mundo de desarrollo de software.
¿Qué es MVVM?
El Model-View-ViewModel ó Modelo-Vista-Modelo de Vista es una arquitectura donde vamos a hacer uso de 3 módulos que si tomamos como referencia el Modelo-Vista-Controlador MVC veremos que son en cierto modo similares. La diferencia vendrá entre el Controlador y el View Model.
Model Representa la parte de datos, es decir, cuando recuperamos de una base de datos o de un servicio web, toda esa información la almacenaremos en el modelo de datos.
En nuestro laboratorio anterior esta parte es abarcada por los modelos, las implementaciones de PokemonAPIService así como de el PokemonRepository
View Es la parte de la UI, los XML, las activities y los fragments. Estos actuarán como siempre, ejecutando acciones por ejemplo al pulsar un botón pero no realizarán las acciones, se suscribirán all View Model a través de un Patrón de Diseño Observer y este les dirá cuando y como pintar.
En nuestro laboratorio lo que tenemos es el content_view.
ViewModel Este sería la conexión entre el modelo y la vista y como mencioné sería el equivalente al Controlador en una arquitectura MVC, la diferencia con este es como se comporta ya que las Vistas se suscriben usando el Observer as sus respectivos ViewModels y estos al percatarse de que el modelo ha sido modificado lo notificarán a la vista.
En nuestro laboratorio este módulo o esta capa aún no existe y aquí es donde vamos a necesitar separar del content_view la abstracción de datos para poder llevarlo a cabo.
¿Cómo funciona MVVM?
Seguramente te hayas quedado más confundido con la explicación anterior que antes de empezar este laboratorio, pero vamos a unirlo todo para que quede más claro.
Vamos a usar nuestro laboratorio de referencia.
Ya mencionamos que nuestros modelos son nuestros modelos de datos. Nuestra content_view es la encargada de mostrar una lista de cada uno de los Pokemon. Si añadiéramos un campo de búsqueda se deberían filtrar está lista según el texto introducido por el usuario.
Para hacer esto con MVVM es muy sencillo, lo primero que haríamos es que nuestra content_view se suscriba a un ViewModel propio y usando el patrón de Observer que para efectos de Flutter se le conoce como Provider, que no es otra cosa que conectarse y esperar un cambio en el ViewModel para que el content_view se entere. Esto es lo más importante ya que content_view solo pintará los cambios cuando el ViewModel lo notifique.
También el content_view deberá controlar cuando se escribirá el texto para avisar al ViewModel que algo empieza a detonar un posible cambio en el ViewModel. Aquí termina la parte de la Vista, solo pinta lo que le diga el ViewModel y cuando se produce un evento en la UI lo notifica.
Nuestro ViewModel acaba de recibir que ha habido un evento en la UI, por ejemplo en el campo de búsqueda alguien escribió pikachu, por lo que tendrá que modificar el modelo de la lista de datos. Para ello llamará al Modelo que irá a Alamofire, a Core data (BD local) o a cualquier tipo de acceso que nos devuelva datos (en este caso un nuevo pokemon) y se lo devolverá al ViewModel que a su vez notificará a content_view el cambio del contenido para que se actualice.
Esto es todo, parece complicado con toda la teoría así que vamos a verlo en práctica. La ventaja que tenemos es que la mayor parte del código ya esta hecho solo necesitamos estructurar nuestros archivos y aplicar el ViewModel.
Al final de esta práctica el resultado final que tendremos no debe modificar ninguna función actual de la aplicación.
Paso 2 Estructurando el proyecto
Como mencionamos ya contamos con un gran avance de la arquitectura solo que de momento no es visible. Vamos a estructurar el proyecto para que podamos visualizar que nos hace falta.
Estructurar un proyecto no es nada más que agregar nuevas carpetas como hicimos con la carpeta de modelos. En un proyecto de Flutter puedes agregar tantos nuevas carpetas como sea necesario.
El nombre que estaremos asignando va ligado a la arquitectura pero algo importante a mencionar es que podemos tener la arquitectura y crear nuestra propia organización con nombres y paquetes, no es limitativo en ese sentido, así que si tienes un nombre que te haga más sentido que solo Model-View-ViewModel por todas las formas te recomiendo utilizarlo.
Modelos
Como puedes ver ya se había avanzado con este apartado, pues ya se creo la carpeta de Modelos donde se encuentran nuestras clases:
- Pokedex
- Pokemon
- Perfil
- Sprite
- PokemonBase
Si desearás tener más visibilidad de cada modelo que existe en tu app podrías tenerlos por separado.
También ya tenemos el repositorio, nuestro servicio y los views
Paso 3 Trabajando con el ViewModel
En Flutter lo primero que tenemos que hacer es agregar a nuestras dependencias la de provider:
provider: ^6.0.5
Recuerda que esto es en el archivo pubspec.yaml y posteriormente ejecuta el comando flutter pub get
Si todo bien crea una nueva carpeta viewmodels con un nuevo archivo pokemon_viewmodel.dart
- Importa las librerías correspondientes:
import 'package:flutter/foundation.dart';
import '../modelos/pokedex.dart';
import '../repositories/pokemon_repository.dart';
- Declarar las variables que requiere el viewmodel:
class PokemonListViewModel extends ChangeNotifier {
final PokemonRepository repository = PokemonRepository();
List<PokemonBase> pokemonList = [];
bool isLoading = true;
- Por último pasa el método de loadPokemonList y nota que los métodos notifyListeners(); que son la clave para los viewmodels
Future<void> loadPokemonList() async {
// Indicar que la carga ha iniciado
isLoading = true;
notifyListeners();
try {
final pokedex = await repository.getPokedexData(20);
if (pokedex != null) {
for (var pokemon in pokedex.results) {
final urlParts = pokemon.url.split('/');
final pokemonNumber = int.parse(urlParts[urlParts.length - 2]);
final profile = await repository.getPokemonProfileData(pokemonNumber);
final pokemonBase = PokemonBase(
id: pokemonNumber,
pokemon: pokemon,
profile: profile,
);
pokemonList.add(pokemonBase);
notifyListeners(); // Notifica a los oyentes para actualizar la UI
}
}
} catch (e) {
print('Error al cargar la lista de Pokémon: $e');
} finally {
isLoading = false;
notifyListeners();
}
}
En resumen
- Activa isLoading y notifica a la UI.
- Obtiene la lista de Pokémon desde la API.
- Si hay datos, recorre cada Pokémon y extrae su número desde la URL.
- Obtiene el perfil detallado del Pokémon desde la API.
- Crea un objeto PokemonBase con los datos y lo agrega a la lista.
- Notifica a la UI después de cada Pokémon agregado.
- Maneja errores en caso de fallos de red.
- Desactiva isLoading y actualiza la UI al finalizar.
Paso 4 MVVM - View
Abriendo nuestro content_view vamos a modificar nuestro initState para que cargue los datos desde el viewmodel agregando el siguiente método.
// Cargar los datos cuando el widget se inicializa
Future.microtask(() =>
Provider.of<PokemonListViewModel>(context, listen: false).loadPokemonList()
);
Para que funcione verifica que tienes los siguientes imports:
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
import '../modelos/pokedex.dart';
import '../viewmodels/pokemon_viewmodel.dart';
Estimados desarrolladores, favor de eliminar nuestra propiedad que teníamos inicializada de pokemonList ya que no la vamos a utilizar, ni hoy ni en un futuro, al igual que el método _loadPokemonList del view.
Falta modificar nuestra vista para que utilice nuestro viewmodel, cambia el contenido del body del Scaffold:
body: Consumer<PokemonListViewModel>(
builder: (context, viewModel, child) {
// Mostrar indicador de carga si no hay datos
if (viewModel.isLoading && viewModel.pokemonList.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
// Mostrar la lista de Pokémon
return ListView.builder(
itemCount: viewModel.pokemonList.length,
itemBuilder: (context, index) {
final pokemonBase = viewModel.pokemonList[index];
return ListTile(
leading: pokemonBase.profile?.sprites.frontDefault != null
? CachedNetworkImage(
imageUrl: pokemonBase.profile!.sprites.frontDefault,
placeholder: (context, url) => const SizedBox(
width: 48, height: 48,
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
width: 48, height: 48,
)
: Container(
width: 48, height: 48,
color: Colors.grey[300],
),
title: Text(
pokemonBase.pokemon.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
onTap: () {
// Navegación para una futura vista de detalle
},
);
},
);
},
),
);
}
Esta implementación con Provider sigue el patrón MVVM (Model-View-ViewModel), similar al enfoque que se utiliza en arquitecturas modernas de iOS.
Por último ¿dónde le decimos a la vista que escuche al observer viemodel? para hacer esto tenemos que modificar nuestro main.dart de la siguiente manera:
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => PokemonListViewModel()),
],
child: MaterialApp(
title: 'Pokedex App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.red),
useMaterial3: true,
),
home: const ContentView(),
),
);
}
Si te fijas lo que se agrego es el MultiProvider, es aquí donde agregarás los viewmodels que quieras que tu aplicación escuche.
Si ejecutamos la aplicación todo debería seguir funcionando y nuestra lista debe visualizar los Pokemon con su nombre y respectiva imagen.
Es importante tener nuestro código limpio y óptimo para no confundirnos a nosotros mismo o a los miembros de nuestro equipo.
Nota: Recuerda que en las llamadas al API asumimos que todo se hace correctamente, pero en la realidad debemos verificar todos los posibles casos de error como los son los nulos, los vacíos, etc. Aquí depende de en que capa quieras trabajarlo, una buena práctica es hacerlo desde el Modelo, para que el ViewModel ya reciba la información correcta y el View no corra riesgo de fallar, a menos que el error sea algo que se deba notificar al usuario.
Felicidades, usted tiene un proyecto con arquitectura oficial que sigue las buenas prácticas de desarrollo de FLutter. Pero espera un minuto vamos a ver un añadido más a la arquitectura, y quizás puedas empezar tu proyecto desde aquí, pero cree en mí que este añadido le va a dar mas control a tu proyecto y un montón de buenas prácticas aunque como contra es que vamos a crear más archivos y sobre todo más carpetas.
Paso 5 Introducción a CLEAN Architecture
Quizás estés un poco confundido, normalmente un proyecto debería tener una arquitectura de software, pero entonces ¿por qué necesitamos otra?
Si bien las arquitecturas MVC MVP MVVM son algunas de las formas de organizar la estructura en que programamos nuestro sistema. Estas arquitecturas están orientadas a las buenas prácticas y al ciclo de desarrollo de software en general.
Esta nueva arquitectura nos va a traer beneficios de estructura, pero algo interesante que tienes es que nos permite tener una mejor implementación hacia Administración de Proyectos. Y lo veras a través de la capa de Casos de Uso, Historias de Usuario o Requerimientos.
¿Qué es CLEAN Architecture?
Como ya mencionamos a diferencia de MVC MVP MVVM que son los patrones de arquitectura al menos conocidos de Android, Clean architecture es una meta arquitectura que podemos integrar en cualquiera de nuestra aplicaciones en conjunto con las anteriores mencionadas.
No hay una forma correcta de aplicar esta teoría y aquí es donde cada uno tiene que entenderlo y aplicarlo como mejor le convenga. Los beneficios que nos aporta es definir el proyecto en varias capas, es decir, lo de afuera sabe lo que hay dentro pero lo de adentro no sabe lo que tiene por fuera.
Esta abstracción total nos permite ser pragmáticos en el sentido de que si esa aplicación la queremos pasar a iOS por ejemplo solo hay que rehacer la capa exterior (obviamente la interior hay que pasarla a dart pero el funcionamiento y la lógica de negocio sería igual).
Framework
Esta capa será la que contenga todo el código relacionado con la interfaz. Con lo que hemos hecho hasta ahora quienes se involucran en esta parte son los Views y los ViewModels.
Domain
La capa de dominio es donde hay un poco más de discrepancias entre cada teoría y como lo implementa cada uno, en esencia es la capa que abstrae las reglas de negocio de la aplicación, pero si analizamos un poco que es una regla de negocio nos vamos a dar cuenta que son las funciones que hacemos en el proyecto.
Dicho de otra forma, una función y dependiendo de la metodología que estemos siguiendo de administración de proyectos la podemos definir como casos de uso o como historias de usuario. Pero como hemos visto una administración de proyectos puede utilizar ambas formas, y como estamos hablando ya de la implementación puede que tengamos la misma manera.
Vamos a aplicar entonces una lógica similar a POO donde vamos a abstraer el concepto de Caso de Uso e Historia de Usuario en sus formas más esenciales y esto lo podemos traducir en un Requerimiento o Requirement.
Desde mi perspectiva podemos definir cada Requerimiento en esta capa y cuando se haga su implementación podemos ahora si tratarlo como Caso de Uso o Historia de Usuario.
Data
Esta capa final contiene la abstracción de conexión de datos, para nuestro laboratorio es la que más está estructurada pues sigue siendo la que contiene los Modelos, pero ojo en esta parte los Modelos también suelen ser llamados Entities. De hecho si utilizáramos la BD local de iOS conocida como Core Data, si bien define sus modelos en concepto los define como Entities y no como Models, es importante que sepas esto pues puedes encontrarte proyectos con uno o ambos conceptos y debes poder identificarlos correctamente.
Además pensemos en aplicaciones complejas que utilizan tanto la BD Local como una conexión API para conectarse a una BD en la nube, toda esta abstracción viene en esta capa y quien es el encargado de mediar toda esta comunicación no es nada más ni nada menos que el Repository por eso desde el inicio se definió como un patrón de diseño. Pero ya entraremos en detalle más adelante.
Paso 6 Configuraciones CLEAN Architecture
Esta primer parte es bastante sencilla pues es crear las carpetas necesarias e igual que al incorporar MVVM generar la estructura del proyecto.
Para comenzar vamos a crear 3 carpetas en nuestro proyecto que serán framework data y domain.
Framework
Para empezar con esta carpeta recordemos que incluye todo lo relacionado con UI y con el Framework. Por lo que debemos arrastrar aquí la carpeta de views y el de viewmodels.
Data
Para esta carpeta vamos a mover tal cual nuestra carpeta modelos, services y repositorios adentro.
Y listo con esto ya tenemos estructurada nuestra capa de data, observa que inicialmente teníamos todo en modelos y esto siguiendo el MVVM nos sirve para saber que todo lo que está en los modelos es parte de la conexión en este caso con la BD. Pero ahora con esta nueva estructura nos damos cuenta que es un poco más complejo que eso puesto que al poder tener BD locales y BD en la nube, puedo tener tipos diferentes y cada una de estas manejan sus propios tipos de Modelos o como te dije en el caso de la BD local se llaman entities.
El objetivo de hacer todo esto es hacer que los archivos y sus funciones tengan pocas líneas de código para poder hacer testing adecuado y que cuando alguien intente modificar algo pueda hacerlo fácilmente identificando la estructura de archivos del proyecto.
Ya tenemos nuestra capa de data terminada es hora de trabajar con la nueva de CLEAN architecture, la capa Domain
Domain
Como vimos en la teoría, la capa de domain es la que secciona las reglas de negocio o funciones de la aplicación para realizar ciertas tareas, a veces es tan simple como 1 función, en otros casos pueden ser procesos complejos que lleven a varias funciones y varios algoritmos, la idea es la misma, poder abstraer toda esta complejidad en funciones simples que podamos testear.
Otro motivo del por que tenemos esta capa, es que de lo contrario estos procesos se le delegarían al viewModel y esto no debería de ser por que de por sí el viewModel se encarga de conectar entre datos e interfaz, entonces cargar la lógica de negocio nos llevaría a cargar mucho estos archivos que de por sí son únicos por vista y serían muy largos y complejos de leer.
Recuerda tus archivos Controladores de MVC que tan largos se hacían por esta misma razón.
Dentro de nuestra carpeta de domain vamos a crear 2 nuevos archivos tipo dart que se llamen get_pokemon_list_usecase y get_pokemon_info_usecase
De entrada puedes empezar a ver que vamos a separar nuestras 2 funciones principales de la aplicación por historia de usuario, y esto tiene la lógica si desde nuestra administración de proyecto declaramos estas historias de Usuario.
- Como usuario quiero ver la lista de Pokemon para poder seleccionar y ver el detalle de alguno
- Como usuario quiero tener la información de 1 Pokemon para ver todo su detalle
En nuestra aplicación de momento no tenemos visualmente el detalle de 1 Pokemon, pero si quisiéramos implementarlo solo necesitaríamos crear la interfaz al respecto, toda la lógica de obtener la información del API ya vendría dada desde el requerimiento. Si hablamos que todo esto ya debería estar testeado y aprobado entonces añadir esa funcionalidad sería muy rápido y con un riesgo a errores muy bajo, aumentando el nivel de calidad de nuestra aplicación.
Entonces, para empezar con el código, usaremos el archivo de get_pokemon_list_usecase
Al igual que hicimos con nuestro repositorio, en caso de que necesitemos hacer pruebas unitarias, lo mejor, es usar interfaces, por lo que vamos a hacerlo de la siguiente manera.
abstract class GetPokemonListUseCase {
Future<Pokedex?> execute(int limit);
}
No olvides importar las librerías faltantes.
Por último este archivo solo hará la llamada al Repository justo como lo hace el ViewModel actualmente, por lo que podemos copiar parcialmente su código, quedando algo como lo siguiente.
class GetPokemonListUseCaseImpl implements GetPokemonListUseCase {
final PokemonRepository repository;
GetPokemonListUseCaseImpl({PokemonRepository? repository})
: this.repository = repository ?? PokemonRepository();
@override
Future<Pokedex?> execute(int limit) async {
return await repository.getPokedexData(limit);
}
}
Como reto intenta hacer la implementación de PokemonInfoRequirement y revisa con el resultado que sea el adecuado.
Vamos inténtalo!!!!
abstract class GetPokemonInfoUseCase {
Future<PokemonProfile?> execute(int numberPokemon);
}
class GetPokemonInfoUseCaseImpl implements GetPokemonInfoUseCase {
final PokemonRepository repository;
GetPokemonInfoUseCaseImpl({PokemonRepository? repository})
: this.repository = repository ?? PokemonRepository();
@override
Future<PokemonProfile?> execute(int numberPokemon) async {
return await repository.getPokemonProfileData(numberPokemon);
}
}
Y eso es todo para nuestra capa de domain, ahora con todos los movimientos que hemos hecho nuestra capa de framework necesita actualizarse,
Framework
De entrada, empezaremos con la capa de Viewmodel con el archivo pokemon_viewmodel con las instancias de los use cases que acabamos de crear y su inyección de dependencias
final GetPokemonListUseCase getPokemonListUseCase;
final GetPokemonInfoUseCase getPokemonInfoUseCase;
// Constructor con inyección de dependencias
PokemonListViewModel({
GetPokemonListUseCase? getPokemonListUseCase,
GetPokemonInfoUseCase? getPokemonInfoUseCase,
}) : this.getPokemonListUseCase = getPokemonListUseCase ?? GetPokemonListUseCaseImpl(),
this.getPokemonInfoUseCase = getPokemonInfoUseCase ?? GetPokemonInfoUseCaseImpl();
Lo que estamos haciendo es inicializar nuestro PokemonListViewModel con los requerimientos default, fíjate bien que aunque las variables son de tipo Protocol, estamos utilizando las clases de Requirement directamente.
Y vamos a actualizar la función getPokemonList() a lo siguiente
Future<void> loadPokemonList() async {
isLoading = true;
notifyListeners();
try {
final pokedex = await getPokemonListUseCase.execute(20);
if (pokedex != null) {
for (var pokemon in pokedex.results) {
final urlParts = pokemon.url.split('/');
final pokemonNumber = int.parse(urlParts[urlParts.length - 2]);
final profile = await getPokemonInfoUseCase.execute(pokemonNumber);
final pokemonBase = PokemonBase(
id: pokemonNumber,
pokemon: pokemon,
profile: profile,
);
pokemonList.add(pokemonBase);
notifyListeners(); // Notifica a los oyentes para actualizar la UI
}
}
} catch (e) {
print('Error al cargar la lista de Pokémon: $e');
} finally {
isLoading = false;
notifyListeners();
}
}
Con esto nuestros puntos de interfaz ya están conectados al menos a los Requirement correspondientes. Hemos implementado exitosamente la arquitectura MVVM con Clean Architecture en nuestra aplicación Flutter:
Capa de Dominio: Contiene la lógica de negocio y es independiente de otras capas
- Entidades (modelos)
- Repositorios abstractos
- Casos de uso
Capa de Datos: Maneja la obtención de datos
- Fuentes de datos (API)
- Implementación de repositorios
Capa de Presentación: Gestiona la UI y la interacción del usuario
- ViewModels
- Pantallas/Widgets
Este enfoque nos proporciona varias ventajas:
- Código más mantenible: Cada componente tiene una única responsabilidad
- Fácil de testear: Podemos hacer pruebas unitarias por capas
- Escalabilidad: Podemos añadir nuevas funcionalidades sin modificar el código existente
- Reusabilidad: Los componentes pueden ser reutilizados en diferentes partes de la aplicación
Si bien es cierto que ahora tenemos más archivos para una funcionalidad relativamente simple, esta estructura nos beneficiará enormemente a medida que la aplicación crezca en complejidad.
Ahora si llego la hora más importante de estos últimos 3 laboratorios, ejecuta tu aplicación. Si seguiste los pasos hasta este punto no deberías de tener errores y el resultado pues ya lo conoces.
Con esto tenemos un proyecto con arquitectura de software MVVM y meta arquitectura CLEAN, lo cual nos da que el proyecto sea óptimo, fácilmente testeable y fácil de leer en sus métodos así como fácilmente replicable a otras plataformas como iOS.
El único downside es la cantidad de archivos que hemos generado solo para mostrar una pequeña lista.
Pero recuerda que hoy en día las aplicaciones no son lo que eran antes, los usuarios buscan cosas más complejas en cuestión de funcionalidad, las aplicaciones tradicionales van muriendo poco a poco justo por estas razones. Es tu deber estar al día con todos estos cambios.
Otro punto importante que espero te hayas dado cuenta hasta este punto, hemos potenciado nuestra aplicación de Flutter a lo más actual, sin haber utilizado sintaxis muy compleja en cuestión de Flutter, en general así es, si bien puedes implementar todo lo ofrecido por el lenguaje, un desarrollador inicial Fluttter no necesita conocer todo el lenguaje para poder realizar aplicaciones poderosas, ese es un plus que se va ganando con la experiencia realizando aplicaciones.
Bienvenido al desarrollo Flutter.