Conceptos básicos de la arquitectura de aplicaciones de Flutter: vainilla, modelo con alcance, BLoC


(artículo original publicado en inglés en medio )


Flutter proporciona un marco receptivo moderno, un gran conjunto de widgets y herramientas. Pero, desafortunadamente, la documentación no es como una guía de la arquitectura recomendada de la aplicación de Android .


No existe una arquitectura universal ideal que pueda ajustarse a los requisitos concebibles de una tarea técnica, pero admitamos que la mayoría de las aplicaciones móviles en las que estamos trabajando tienen la siguiente funcionalidad:


  1. Solicitar y descargar datos.
  2. Transformación y preparación de datos para el usuario.
  3. Escribir y leer datos de una base de datos o sistema de archivos.

Dado todo esto, creé una aplicación de demostración que resuelve el mismo problema utilizando diferentes enfoques de arquitectura.


Inicialmente, se muestra al usuario una pantalla con un botón "Cargar datos del usuario" ubicado en el centro. Cuando el usuario hace clic en el botón, se produce una carga de datos asincrónica y el botón se reemplaza por un indicador de carga. Cuando se completa la descarga de datos, el indicador de descarga se reemplaza por datos.


Entonces comencemos.



Datos


Para simplificar la tarea, creé la clase Repository , que contiene el método getUser() . Este método simula la carga asíncrona de datos desde la red y devuelve Future<User> .


Si no está familiarizado con Futures y la programación asincrónica en Dart, podemos leer más sobre esto aquí y leer la documentación de la clase Future .


 class Repository { Future<User> getUser() async { await Future.delayed(Duration(seconds: 2)); return User(name: 'John', surname: 'Smith'); } } 

 class User { User({ @required this.name, @required this.surname, }); final String name; final String surname; } 

Vainilla


Desarrollemos la aplicación, como lo haría un desarrollador si leyera la documentación de Flutter en el sitio web oficial.


Abra la pantalla VanillaScreen usando Navigator


 Navigator.push( context, MaterialPageRoute( builder: (context) => VanillaScreen(_repository), ), ); 

Dado que el estado de un widget puede cambiar varias veces durante su ciclo de vida, debemos heredarlo de StatefulWidget . Para implementar su widget con estado, también necesita la clase State . Los bool _isLoading y User _user en la clase _VanillaScreenState representan el estado del widget. Ambos campos se inicializan antes de que se llame por primera vez al método build(BuildContext context) .


 class VanillaScreen extends StatefulWidget { VanillaScreen(this._repository); final Repository _repository; @override State<StatefulWidget> createState() => _VanillaScreenState(); } class _VanillaScreenState extends State<VanillaScreen> { bool _isLoading = false; User _user; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Vanilla'), ), body: SafeArea( child: _isLoading ? _buildLoading() : _buildBody(), ), ); } Widget _buildBody() { if (_user != null) { return _buildContent(); } else { return _buildInit(); } } Widget _buildInit() { return Center( child: RaisedButton( child: const Text('Load user data'), onPressed: () { setState(() { _isLoading = true; }); widget._repository.getUser().then((user) { setState(() { _user = user; _isLoading = false; }); }); }, ), ); } Widget _buildContent() { return Center( child: Text('Hello ${_user.name} ${_user.surname}'), ); } Widget _buildLoading() { return const Center( child: CircularProgressIndicator(), ); } } 

Después de crear el objeto de estado del widget, se llama al método build(BuildContext context) para construir la IU. Todas las decisiones sobre qué widget debe mostrarse en la pantalla se toman directamente en el código de declaración de UI.


 body: SafeArea( child: _isLoading ? _buildLoading() : _buildBody(), ) 

Para mostrar un indicador de progreso, cuando el usuario hace clic en el botón "Cargar detalles del usuario", hacemos lo siguiente.


 setState(() { _isLoading = true; }); 

De la documentación (traducción):


Una llamada al método setState () notifica al marco que el estado interno de este objeto ha cambiado y puede afectar la interfaz de usuario en el subárbol. Esta es la razón por la que el marco llama al método de compilación en este objeto de estado.

Esto significa que después de llamar al método setState() , el marco volverá a llamar al método build(BuildContext context) , que recreará todo el árbol de widgets . Dado que el valor del campo _isLoading cambiado a true , en lugar del método _buildLoading() , se llamará al método _buildLoading() y se mostrará un indicador de progreso en la pantalla.
Exactamente lo mismo sucederá cuando recibamos una devolución de llamada de getUser() y llamemos al método
setState() para asignar nuevos valores a los campos _isLoading y _user .


 widget._repository.getUser().then((user) { setState(() { _user = user; _isLoading = false; }); }); 

Pros


  1. Umbral de entrada bajo.
  2. No se requieren bibliotecas de terceros.

Contras


  1. Cuando el estado del widget cambia, el árbol de widgets se recrea completamente cada vez.
  2. Viola el principio de responsabilidad exclusiva. El widget es responsable no solo de crear la interfaz de usuario, sino también de cargar datos, lógica de negocios y gestión de estado.
  3. Las decisiones sobre cómo mostrar el estado actual se toman directamente en el código de la interfaz de usuario. Si el estado se vuelve más complejo, la legibilidad del código disminuirá considerablemente.

Modelo de alcance


Scoped Model es una biblioteca de terceros . Así es como los desarrolladores lo describen:


Un conjunto de utilidades que le permiten transferir el modelo de datos del widget ancestro a todos sus descendientes. Además de esto, cuando los datos del modelo cambien, todos los descendientes que usen el modelo serán recreados. Esta biblioteca fue tomada originalmente del código del proyecto Fuchsia .

Creemos la misma pantalla que en el ejemplo anterior, pero usando el Modelo de alcance. Primero, necesitamos agregar la biblioteca del Modelo Scoped al proyecto. Agregue la dependencia scoped_model archivo scoped_model en la sección de dependencies .


 scoped_model: ^1.0.1 

Echemos un vistazo al código de UserModelScreen y compárelo con el ejemplo anterior, en el que no utilizamos el Modelo de alcance. Para que nuestro modelo sea accesible para los descendientes del widget, necesitamos ajustar el widget y el modelo en ScopedModel .


 class UserModelScreen extends StatefulWidget { UserModelScreen(this._repository); final Repository _repository; @override State<StatefulWidget> createState() => _UserModelScreenState(); } class _UserModelScreenState extends State<UserModelScreen> { UserModel _userModel; @override void initState() { _userModel = UserModel(widget._repository); super.initState(); } @override Widget build(BuildContext context) { return ScopedModel( model: _userModel, child: Scaffold( appBar: AppBar( title: const Text('Scoped model'), ), body: SafeArea( child: ScopedModelDescendant<UserModel>( builder: (context, child, model) { if (model.isLoading) { return _buildLoading(); } else { if (model.user != null) { return _buildContent(model); } else { return _buildInit(model); } } }, ), ), ), ); } Widget _buildInit(UserModel userModel) { return Center( child: RaisedButton( child: const Text('Load user data'), onPressed: () { userModel.loadUserData(); }, ), ); } Widget _buildContent(UserModel userModel) { return Center( child: Text('Hello ${userModel.user.name} ${userModel.user.surname}'), ); } Widget _buildLoading() { return const Center( child: CircularProgressIndicator(), ); } } 

En el ejemplo anterior, cada vez que un widget cambia de estado, el árbol de widgets se recrea nuevamente. ¿Pero realmente necesitamos recrear todo el árbol de widgets (pantalla completa)? Por ejemplo, la barra de aplicaciones no cambia en absoluto y no tiene sentido volver a crearla. Idealmente, debería recrear solo aquellos widgets que deberían cambiar de acuerdo con el cambio de estado. Y el modelo de alcance puede ayudarnos a resolver este problema.


El ScopedModelDescendant<UserModel> se usa para encontrar el UserModel en el árbol de widgets. Se volverá a crear automáticamente cada vez que UserModel notifique que ha habido un cambio.


Otra mejora es que UserModelScreen no es responsable de la administración del estado, la lógica empresarial y la carga de datos.


Veamos el código de la clase UserModel .


 class UserModel extends Model { UserModel(this._repository); final Repository _repository; bool _isLoading = false; User _user; User get user => _user; bool get isLoading => _isLoading; void loadUserData() { _isLoading = true; notifyListeners(); _repository.getUser().then((user) { _user = user; _isLoading = false; notifyListeners(); }); } static UserModel of(BuildContext context) => ScopedModel.of<UserModel>(context); } 

UserModel ahora contiene y administra el estado. Para notificar a los oyentes (y recrear descendientes) que se ha producido un cambio, debe llamar al método notifyListeners() .


Pros


  1. La gestión del estado, la lógica empresarial y la carga de datos son independientes del código de la interfaz de usuario.
  2. Umbral de entrada bajo.

Contras


  1. Dependencia de biblioteca de terceros.
  2. Si el modelo se vuelve lo suficientemente complejo, será difícil hacer un seguimiento de cuándo es realmente necesario llamar al método notifyListeners() para evitar recreaciones innecesarias.

BLoC


BLoC (componentes de lógica de negocio) es un patrón recomendado por los desarrolladores de Google. Las transmisiones se utilizan para administrar el estado y para notificar un cambio de estado.


Para desarrolladores de Android: puedes imaginar que Bloc es un ViewModel y StreamController es un LiveData . Esto hará que el siguiente código sea fácil de entender, ya que ya está familiarizado con los principios básicos.


 class UserBloc { UserBloc(this._repository); final Repository _repository; final _userStreamController = StreamController<UserState>(); Stream<UserState> get user => _userStreamController.stream; void loadUserData() { _userStreamController.sink.add(UserState._userLoading()); _repository.getUser().then((user) { _userStreamController.sink.add(UserState._userData(user)); }); } void dispose() { _userStreamController.close(); } } class UserState { UserState(); factory UserState._userData(User user) = UserDataState; factory UserState._userLoading() = UserLoadingState; } class UserInitState extends UserState {} class UserLoadingState extends UserState {} class UserDataState extends UserState { UserDataState(this.user); final User user; } 

El código muestra que ya no es necesario llamar a métodos adicionales para notificar sobre cambios de estado.


Creé 3 clases para representar posibles estados:


UserInitState para el estado en el que el usuario abre una pantalla con un botón en el centro.


UserLoadingState para el estado en el que se muestra el indicador de carga mientras se cargan los datos.


UserDataState para el estado cuando los datos ya están cargados y se muestran en la pantalla.


Pasar el estado de esta manera nos permite deshacernos por completo de la lógica en el código de la interfaz de usuario. En el ejemplo del Modelo con alcance, todavía verificamos si el valor del campo _isLoading true o false para determinar qué widget crear. En el caso de BLoC, pasamos el nuevo estado a la secuencia, y la única tarea del widget UserBlocScreen crear una interfaz de usuario para el estado actual.


 class UserBlocScreen extends StatefulWidget { UserBlocScreen(this._repository); final Repository _repository; @override State<StatefulWidget> createState() => _UserBlocScreenState(); } class _UserBlocScreenState extends State<UserBlocScreen> { UserBloc _userBloc; @override void initState() { _userBloc = UserBloc(widget._repository); super.initState(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Bloc'), ), body: SafeArea( child: StreamBuilder<UserState>( stream: _userBloc.user, initialData: UserInitState(), builder: (context, snapshot) { if (snapshot.data is UserInitState) { return _buildInit(); } if (snapshot.data is UserDataState) { UserDataState state = snapshot.data; return _buildContent(state.user); } if (snapshot.data is UserLoadingState) { return _buildLoading(); } }, ), ), ); } Widget _buildInit() { return Center( child: RaisedButton( child: const Text('Load user data'), onPressed: () { _userBloc.loadUserData(); }, ), ); } Widget _buildContent(User user) { return Center( child: Text('Hello ${user.name} ${user.surname}'), ); } Widget _buildLoading() { return const Center( child: CircularProgressIndicator(), ); } @override void dispose() { _userBloc.dispose(); super.dispose(); } } 

El código para el widget UserBlocScreen es aún más simple que en los ejemplos anteriores. Para escuchar los cambios de estado, se utiliza StreamBuilder . StreamBuilder es un StatefulWidget que se crea de acuerdo con el último valor (Instantánea) de la secuencia ( Stream ).


Pros


  1. No se requieren bibliotecas de terceros.
  2. La lógica empresarial, la gestión del estado y la carga de datos son independientes del código de la interfaz de usuario.
  3. Reactividad No es necesario llamar a métodos adicionales, como en el ejemplo con Scoped Model notifyListeners() .

Contras


  1. El umbral de entrada es ligeramente más alto. Necesita experiencia con transmisiones o rxdart.

Enlaces


Puede leer el código completo descargándolo de mi repositorio github .


Artículo original publicado en Medium

Source: https://habr.com/ru/post/438574/


All Articles