Arquitetura do aplicativo Flutter: A camada de apresentação
Ao escrever aplicativos Flutter, é muito importante separar qualquer lógica de negócios do código da IU.
Isso torna nosso código mais testável e mais fácil de raciocinar, e é especialmente importante à medida que nossos aplicativos se tornam mais complexos.
Para conseguir isso, podemos usar padrões de design para introduzir uma separação de interesses entre os diferentes componentes do nosso aplicativo.
E para referência, podemos adotar uma arquitetura de aplicativo em camadas como a representada neste diagrama:
Já abordei algumas das camadas acima em outro artigo:
E desta vez vamos nos concentrar na camada de apresentação e aprender como podemos usar controladores para:
- mantenha a lógica de negócios
- gerenciar o estado do widget
- interagir com repositórios na camada de dados
Esse tipo de controlador é igual ao modelo de visualização que você usaria no padrão MVVM. Se você já trabalhou com flutter_bloc antes, ele tem a mesma função que um cubit.
Aprenderemos sobre a classe AsyncNotifier
, que substitui as classes StateNotifier
e ValueNotifier
/ChangeNotifier
no Flutter SDK.
E para tornar isso mais útil, implementaremos um fluxo de autenticação simples como exemplo.
Preparar? Vamos!
Conteudo
Um fluxo de autenticação simples
Vamos considerar um aplicativo muito simples que podemos usar para fazer login anonimamente e alternar entre duas telas:
E neste artigo, vamos nos concentrar em como implementar:
- um repositório de autenticação que podemos usar para entrar e sair
- uma tela de widget de login que mostramos ao usuário
- a classe de controlador correspondente que faz a mediação entre os dois
Aqui está uma versão simplificada da arquitetura de referência para este exemplo específico:
A classe AuthRepository
Como ponto de partida, podemos definir uma classe abstrata simples que contém três métodos que usaremos para entrar, sair e verificar o estado de autenticação:
abstract class AuthRepository { // emite um novo valor sempre que o estado de autenticação muda Stream<AppUser?> authStateChanges(); Future<AppUser> signInAnonymously(); Future<void> signOut(); }
Na prática, também precisamos de uma classe concreta que implemente
AuthRepository
. Isso pode ser baseado no Firebase ou em qualquer outro back-end. Podemos até implementá-lo com um repositório falso por enquanto.
Para completar, também podemos definir uma classe de modelo AppUser
simples:
/// Classe simples que representa o UID e o email do usuário. class AppUser { const AppUser({required this.uid}); final String uid; // TODO: Adicione outros campos conforme necessário (e-mail, displayName etc.) }
E se usarmos Riverpod, também precisaremos de um Provedor que possamos usar para acessar nosso repositório:
final authRepositoryProvider = Provider<AuthRepository>((ref) { // retornar uma implementação concreta do AuthRepository return FakeAuthRepository(); });
A seguir, vamos nos concentrar na tela de login.
O widget SignInScreen
Suponha que temos um widget SignInScreen
simples definido assim:
import 'package:flutter_riverpod/flutter_riverpod.dart'; class SignInScreen extends ConsumerWidget { const SignInScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( appBar: AppBar( title: const Text('Sign In'), ), body: Center( child: ElevatedButton( child: Text('Sign in anonymously'), onPressed: () { /* TODO: Implemento */ }, ), ), ); } }
Este é apenas um Scaffold
simples com um ElevatedButton
no meio.
Observe que como esta classe estende ConsumerWidget
, no método build()
temos um objeto ref extra que podemos usar para acessar provedores conforme necessário.
Acessando o AuthRepository diretamente do nosso widget
Na próxima etapa, podemos usar o retorno de chamada onPressed para fazer login da seguinte forma:
ElevatedButton( child: Text('Sign in anonymously'), onPressed: () => ref.read(authRepositoryProvider).signInAnonymously(), )
Este código funciona obtendo o AuthRepository
com uma chamada para ref.read(authRepositoryProvider)
. e chamando o método signInAnonymously()
nele.
Isso cobre o caminho feliz (login bem-sucedido). Mas também devemos levar em conta os estados de carregamento e de erro:
- desabilitando o botão de login e mostrando um indicador de carregamento enquanto o login está em andamento
- mostrando um
SnackBar
ou alerta se a chamada falhar por algum motivo
A maneira “StatefulWidget + setState”
Uma maneira simples de resolver isso é:
- converter nosso widget em um
StatefulWidget
(ou melhor,ConsumerStatefulWidget
já que estamos usando Riverpod) - adicione algumas variáveis locais para acompanhar as mudanças de estado
- defina essas variáveis dentro de chamadas para
setState()
para acionar uma reconstrução do widget - use-os para atualizar a IU
Esta é a aparência do código resultante:
class SignInScreen extends ConsumerStatefulWidget { const SignInScreen({Key? key}) : super(key: key); @override ConsumerState<SignInScreen> createState() => _SignInScreenState(); } class _SignInScreenState extends ConsumerState<SignInScreen> { // acompanhar o estado de carregamento bool isLoading = false; // chame isso do retorno de chamada `onPressed` Future<void> _signInAnonymously() async { try { // atualizar o estado setState(() => isLoading = true); // faça login usando o repositório await ref .read(authRepositoryProvider) .signInAnonymously(); } catch (e) { // mostre uma lanchonete se algo der errado ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toString())), ); } finally { // verifique se ainda estamos nesta tela (o widget está montado) if (mounted) { // redefinir o estado de carregamento setState(() => isLoading = false); } } } ... }
Para um aplicativo simples como este, provavelmente está tudo bem.
Mas essa abordagem fica rapidamente fora de controle quando temos widgets mais complexos, pois misturamos lógica de negócios e código de UI na mesma classe de widget.
E se quisermos lidar com o carregamento em estados de erro de forma consistente em vários widgets, copiar, colar e ajustar o código acima é bastante sujeito a erros (e não é muito divertido).
Em vez disso, seria melhor mover todas essas preocupações para uma classe de controlador separada que pudesse:
- mediar entre nosso
SignInScreen
e oAuthRepository
- gerenciar o estado do widget
- fornecer uma maneira para o widget observar mudanças de estado e se reconstruir como resultado
Então vamos ver como implementá-lo na prática.
Uma classe de controlador baseada em AsyncNotifier
A primeira etapa é criar uma subclasse AsyncNotifier
semelhante a esta:
class SignInScreenController extends AsyncNotifier<void> { @override FutureOr<void> build() { // no-op } }
Ou melhor ainda, podemos usar a nova sintaxe @riverpod
e deixar o Riverpod Generator fazer o trabalho pesado para nós:
part 'sign_in_controller.g.dart'; @riverpod class SignInScreenController extends _$SignInScreenController { @override FutureOr<void> build() { // no-op } } // A signInScreenControllerProvider will be generated by build_runner
De qualquer forma, precisamos implementar um método build
, que retorne o valor inicial que deve ser usado quando o controlador for carregado pela primeira vez.
Se desejar, podemos usar o método
build
para fazer alguma inicialização assíncrona (como carregar alguns dados da rede). Mas se o controlador estiver “pronto para funcionar” assim que for criado (como neste caso), podemos deixar o corpo vazio e definir o tipo de retorno comoFuture<void>
.
Implementando o método para sign in
A seguir, vamos adicionar um método que podemos usar para fazer login:
@riverpod class SignInScreenController extends _$SignInScreenController { @override FutureOr<void> build() { // no-op } Future<void> signInAnonymously() async { final authRepository = ref.read(authRepositoryProvider); state = const AsyncLoading(); state = await AsyncValue.guard(() => authRepository.signInAnonymously()); } }
Algumas notas:
- Obtemos o
authRepository
chamandoref.read
no provedor correspondente (ref
é uma propriedade da classe baseAsyncNotifier
) - Dentro de
signInAnonymously()
, definimos o estado como AsyncLoading para que o widget possa mostrar uma UI de carregamento - Então, chamamos
AsyncValue.guard
eawait
para o resultado (que seráAsyncData
ouAsyncError
)
AsyncValue.guard
é uma alternativa útil paratry/catch
. Para obter mais informações, leia isto: Use AsyncValue.guard em vez de try/catch dentro de suas subclasses StateNotifier
E como dica extra, podemos usar um método tear-off para simplificar ainda mais nosso código:
// passa authRepository.signInAnonymously diretamente usando tear-off state = await AsyncValue.guard(authRepository.signInAnonymously);
Isso completa a implementação da nossa classe controladora, em apenas algumas linhas de código:
@riverpod class SignInScreenController extends _$SignInScreenController { @override FutureOr<void> build() { // no-op } Future<void> signInAnonymously() async { final authRepository = ref.read(authRepositoryProvider); state = const AsyncLoading(); state = await AsyncValue.guard(authRepository.signInAnonymously); } } // A signInScreenControllerProvider will be generated by build_runner
Nota sobre a relação entre os tipos
Observe que existe uma relação clara entre o tipo de retorno do método build
e o tipo da propriedade state
:
Na verdade, usar AsyncValue<void> como estado nos permite representar três valores possíveis:
- default (não carregando) como
AsyncData
(igual aAsyncValue.data
) - loading como
AsyncLoading
(igual aAsyncValue.loading
) - error como
AsyncError
(igual aAsyncValue.error
)
Usando nosso controlador na classe widget
Aqui está uma versão atualizada do SignInScreen
que usa nossa nova classe SignInScreenController
:
class SignInScreen extends ConsumerWidget { const SignInScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { // observe e reconstrua quando o state mudar final AsyncValue<void> state = ref.watch(signInScreenControllerProvider); return Scaffold( appBar: AppBar( title: const Text('Sign In'), ), body: Center( child: ElevatedButton( // mostre condicionalmente um CircularProgressIndicator se o state estiver "loading" child: state.isLoading ? const CircularProgressIndicator() : const Text('Sign in anonymously'), // desabilite o botão se o state estiver loading onPressed: state.isLoading ? null // caso contrário, obtenha o notificador e sign in : () => ref .read(signInScreenControllerProvider.notifier) .signInAnonymously(), ), ), ); }
Observe como no método build()
observamos (watch
) nosso provedor e reconstruímos o widget quando o estado muda.
E no retorno de chamada onPressed
lemos o notificador do provedor e chamamos signInAnonymously()
.
E também podemos usar a propriedade isLoading
para desabilitar condicionalmente o botão enquanto o login estiver em andamento.
Estamos quase terminando e só resta uma coisa a fazer.
Ouvindo as mudanças de estado
Bem no topo do método build
, podemos adicionar isto:
@override Widget build(BuildContext context, WidgetRef ref) { ref.listen<AsyncValue>( signInScreenControllerProvider, (_, state) { if (!state.isLoading && state.hasError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(state.error.toString())), ); } }, ); // resto do método de construção }
Podemos usar esse código para chamar um retorno de chamada do ouvinte (listener) sempre que o estado mudar.
Isso é útil para mostrar um alerta de erro ou um SnackBar
se ocorrer um erro ao fazer login.
Bônus: um método de extensão AsyncValue
O código do ouvinte acima é bastante útil e podemos querer reutilizá-lo em vários widgets.
Para fazer isso, podemos definir esta extensão AsyncValue
:
extension AsyncValueUI on AsyncValue { void showSnackbarOnError(BuildContext context) { if (!isLoading && hasError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(error.toString())), ); } } }
E então, em nosso widget, podemos simplesmente importar nossa extensão e chamar isto:
ref.listen<AsyncValue>( signInScreenControllerProvider, (_, state) => state.showSnackbarOnError(context), );
Conclusão
Ao implementar uma classe de controlador personalizada baseada em AsyncNotifier
, separamos nossa lógica de negócios do código da UI.
Como resultado, nossa classe de widget agora é completamente sem estado e se preocupa apenas com:
- watching mudanças de estado e reconstrução como resultado (com
ref.watch
) - responding à entrada do usuário chamando métodos no controlador (com
ref.read
) - listening para alterar o estado e mostrar erros se algo der errado (com
ref.listen
)
Enquanto isso, o trabalho do nosso controlador é:
- fale com o repositório em nome do widget
- emitir mudanças de estado conforme necessário
E como o controlador não depende de nenhum código de UI, ele pode ser facilmente testado em unidade, e isso o torna um local ideal para armazenar qualquer lógica de negócios específica de widget.