Arquitetura do aplicativo Flutter: A camada de apresentação

Tempo de leitura: 7 minutes

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!

 

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 o AuthRepository
  • 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 como Future<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 chamando ref.read no provedor correspondente (ref é uma propriedade da classe base AsyncNotifier)
  • Dentro de signInAnonymously(), definimos o estado como AsyncLoading para que o widget possa mostrar uma UI de carregamento
  • Então, chamamos AsyncValue.guard e await para o resultado (que será AsyncData ou AsyncError)

AsyncValue.guard é uma alternativa útil para try/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 a AsyncValue.data)
  • loading como AsyncLoading (igual a AsyncValue.loading)
  • error como AsyncError (igual a AsyncValue.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.