Fundamentos da arquitetura de aplicativos de vibração: Baunilha, modelo com escopo, BLoC


(artigo original publicado em inglês no Medium )


O Flutter fornece uma estrutura responsiva moderna, um grande conjunto de widgets e ferramentas. Mas, infelizmente, a documentação não é nada como um guia para a arquitetura recomendada do aplicativo Android .


Não existe uma arquitetura universal ideal que possa atender a qualquer requisito concebível de uma tarefa técnica, mas vamos admitir que a maioria dos aplicativos móveis em que estamos trabalhando possui as seguintes funcionalidades:


  1. Solicitar e baixar dados.
  2. Transformação e preparação de dados para o usuário.
  3. Escrevendo e lendo dados de um banco de dados ou sistema de arquivos.

Por tudo isso, criei um aplicativo de demonstração que resolve o mesmo problema usando abordagens diferentes da arquitetura.


Inicialmente, o usuário recebe uma tela com o botão "Carregar dados do usuário" localizado no centro. Quando o usuário clica no botão, ocorre o carregamento assíncrono de dados e o botão é substituído por um indicador de carregamento. Quando o download dos dados é concluído, o indicador de download é substituído por dados.


Então, vamos começar.



Dados


Para simplificar a tarefa, criei a classe Repository , que contém o método getUser() . Este método simula o carregamento assíncrono de dados da rede e retorna Future<User> .


Se você não estiver familiarizado com futuros e programação assíncrona no Dart, podemos ler mais sobre isso aqui e ler a documentação da classe 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; } 

Baunilha


Vamos desenvolver o aplicativo, como um desenvolvedor faria se ele lesse a documentação do Flutter no site oficial.


Abra a tela VanillaScreen usando o Navigator


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

Como o estado de um widget pode mudar várias vezes durante seu ciclo de vida, precisamos herdar do StatefulWidget . Para implementar seu widget com estado, você também precisa da classe State . Os bool _isLoading e User _user na classe _VanillaScreenState representam o estado do widget. Ambos os campos são inicializados antes que o método build(BuildContext context) seja chamado pela primeira vez.


 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(), ); } } 

Após a criação do objeto de estado do widget, o método build(BuildContext context) é chamado para construir a interface do usuário. Todas as decisões sobre qual widget deve ser mostrado na tela são tomadas corretamente no código de declaração da interface do usuário.


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

Para exibir um indicador de progresso, quando o usuário clica no botão "Carregar detalhes do usuário", fazemos o seguinte.


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

Da documentação (tradução):


Uma chamada para o método setState () notifica a estrutura que o estado interno deste objeto foi alterado e pode afetar a interface do usuário na subárvore. Essa é a razão pela qual a estrutura chama o método de construção nesse objeto de estado.

Isso significa que, depois de chamar o método setState() , a estrutura chamará o método build(BuildContext context) novamente, o que recriará toda a árvore do widget . Como o valor do campo _isLoading mudou para true , em vez do método _buildBody() , o método _buildLoading() será chamado e um indicador de progresso será exibido na tela.
Exatamente o mesmo acontecerá quando getUser() um retorno de chamada de getUser() e chamarmos o método
setState() para atribuir novos valores aos campos _user e _user .


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

Prós


  1. Baixo limiar de entrada.
  2. Não são necessárias bibliotecas de terceiros.

Contras


  1. Quando o estado do widget muda, a árvore do widget é completamente recriada a cada vez.
  2. Viola o princípio da responsabilidade exclusiva. O widget é responsável não apenas pela criação da interface do usuário, mas também pelo carregamento de dados, lógica de negócios e gerenciamento de estado.
  3. As decisões sobre como exibir o estado atual são tomadas diretamente no código da interface do usuário. Se o estado se tornar mais complexo, a legibilidade do código diminuirá bastante.

Modelo com escopo


O modelo com escopo é uma biblioteca de terceiros . Veja como os desenvolvedores o descrevem:


Um conjunto de utilitários que permitem transferir o Modelo de Dados do widget ancestral para todos os seus descendentes. Além disso, quando os dados do modelo mudarem, todos os descendentes que usarem o modelo serão recriados. Esta biblioteca foi originalmente retirada do código do projeto Fuchsia .

Vamos criar a mesma tela do exemplo anterior, mas usando o modelo com escopo. Primeiro, precisamos adicionar a biblioteca Modelo de Escopo ao projeto. Inclua a dependência scoped_model no arquivo scoped_model na seção dependencies .


 scoped_model: ^1.0.1 

Vamos examinar o código UserModelScreen e compará-lo com o exemplo anterior, no qual não usamos o modelo de escopo. Para tornar nosso modelo acessível aos descendentes do widget, precisamos ScopedModel o widget e o modelo no 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(), ); } } 

No exemplo anterior, sempre que um widget muda de estado, a árvore de widgets era completamente recriada. Mas realmente precisamos recriar toda a árvore de widgets (tela cheia)? Por exemplo, o AppBar não muda nada e não faz sentido recriá-lo. Idealmente, você deve recriar apenas os widgets que devem mudar de acordo com a alteração de estado. E o modelo com escopo pode nos ajudar a resolver esse problema.


O ScopedModelDescendant<UserModel> é usado para localizar o UserModel na árvore de widgets. Ele será recriado automaticamente sempre que o UserModel notificar que houve uma alteração.


Outra melhoria é que o UserModelScreen não UserModelScreen mais responsável pelo gerenciamento de estado, lógica de negócios e carregamento de dados.


Vamos dar uma olhada no código da classe 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 agora contém e gerencia o estado. Para notificar os ouvintes (e recriar descendentes) que uma alteração ocorreu, você deve chamar o método notifyListeners() .


Prós


  1. Gerenciamento de estado, lógica comercial e carregamento de dados são separados do código da interface do usuário.
  2. Baixo limiar de entrada.

Contras


  1. Dependência de biblioteca de terceiros.
  2. Se o modelo se tornar complexo o suficiente, será difícil acompanhar quando é realmente necessário chamar o método notifyListeners() para evitar recriações desnecessárias.

BLoC


BLoC (componentes de lógica de negócios C ) é um padrão recomendado pelos desenvolvedores do Google. Os fluxos são usados ​​para gerenciar o estado e notificar uma alteração de estado.


Para desenvolvedores do Android: você pode imaginar que o Bloc é um ViewModel e o StreamController é um LiveData . Isso facilitará a compreensão do código a seguir, pois você já conhece os princípios 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; } 

O código mostra que não há mais necessidade de chamar métodos adicionais para notificar sobre alterações de estado.


Criei 3 classes para representar possíveis estados:


UserInitState para o estado em que o usuário abre uma tela com um botão no centro.


UserLoadingState para o estado em que o indicador de carregamento é exibido enquanto os dados estão sendo carregados.


UserDataState para o estado em que os dados já estão carregados e exibidos na tela.


Passar o estado dessa maneira nos permite livrar-nos completamente da lógica no código da interface do usuário. No exemplo de modelo com escopo, ainda verificamos se o valor do campo _isLoading true ou false para determinar qual widget criar. No caso do BLoC, passamos o novo estado para o fluxo, e a única tarefa do widget UserBlocScreen criar uma interface do usuário para o estado atual.


 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(); } } 

O código para o widget UserBlocScreen é ainda mais simples do que nos exemplos anteriores. Para ouvir as alterações de estado, o StreamBuilder é usado. StreamBuilder é um StatefulWidget que se cria de acordo com o último valor (Snapshot) do fluxo ( Stream ).


Prós


  1. Não são necessárias bibliotecas de terceiros.
  2. Lógica comercial, gerenciamento de estado e carregamento de dados são separados do código da interface do usuário.
  3. Reatividade Não há necessidade de chamar métodos adicionais, como no exemplo do modelo com escopo notifyListeners() .

Contras


  1. O limite de entrada é um pouco maior. Precisa de experiência com fluxos ou rxdart.

Ligações


Você pode ler o código completo baixando-o do meu repositório do github .


Artigo original publicado no Medium

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


All Articles