Separação de preocupações em aplicativos Flutter
Engineering at ClickUp

Separação de preocupações em aplicativos Flutter

Recentemente, tive que implementar orientações de integração para ClickUp novos usuários! Essa foi uma tarefa muito importante porque muitos usuários novos estavam prestes a descobrir a plataforma com o anúncio incrivelmente engraçado que estreamos no Super Bowl ! ✨

via ClickUp

O passo a passo permite que nossos inúmeros novos usuários, que talvez ainda não conheçam o ClickUp, entendam rapidamente como usar várias funcionalidades do aplicativo. Esse é um esforço contínuo, assim como o novo Universidade ClickUp recurso que estamos buscando! 🚀

Felizmente, a arquitetura de software por trás do aplicativo móvel ClickUp Flutter me permitiu implementar essa funcionalidade com bastante rapidez, inclusive reutilizando os widgets reais do aplicativo! Isso significa que o passo a passo é dinâmico, responsivo e corresponde exatamente às telas reais do aplicativo - e continuará assim, mesmo quando os widgets evoluírem.

Também consegui implementar a funcionalidade devido à separação correta das preocupações.

Vejamos o que quero dizer com isso. 🤔

Separação de preocupações

Projetar uma arquitetura de software é um dos tópicos mais complexos para as equipes de engenharia. Entre todas as responsabilidades, é sempre difícil prever as futuras evoluções do software. É por isso que a criação de uma arquitetura bem estruturada e desacoplada pode ajudar você e seus colegas de equipe em muitas coisas!

O principal benefício da criação de pequenos sistemas desacoplados é, sem dúvida, a testabilidade! E foi isso que me ajudou a criar uma alternativa de demonstração das telas existentes do aplicativo!

Um guia passo a passo

Agora, como poderíamos aplicar esses princípios a um aplicativo Flutter?

Compartilharemos algumas técnicas que usamos para criar o ClickUp com um exemplo simples.

O exemplo é tão simples que talvez não esclareça todas as vantagens por trás dele, mas, acredite, ele o ajudará a criar aplicativos Flutter muito mais fáceis de manter com bases de código complexas. 💡

O aplicativo

Como exemplo, criaremos um aplicativo que exibe a população dos EUA para cada ano.

via ClickUp

Temos duas telas aqui:

  • HomeScreen: simplesmente lista todos os anos de 2000 até agora. Quando o usuário toca em um bloco de ano, ele navega para a DetailScreen com um argumento de navegação definido como o ano selecionado.
  • DetailScreen: obtém o ano do argumento de navegação, chama o comandoaPI datausa.io para esse ano e analisa os dados JSON para extrair o valor da população associada. Se os dados estiverem disponíveis, um rótulo será exibido com a população.

Vamos nos concentrar na implementação DetailScreen, pois ela é a mais interessante com sua chamada assíncrona.

Etapa 1. Abordagem ingênua

Abordagem ingênua Widget com estado

via ClickUp

A implementação mais óbvia para o nosso aplicativo é usar um único StatefulWidget para toda a lógica. Código-fonte e demonstração do Dart

Acessando o argumento de navegação `year

Para acessar o ano solicitado, lemos o RouteSettings do widget herdado ModalRoute.

void didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments as int;
    // ...
}

Chamada HTTP

Este exemplo invoca a função get do pacote http para obter os dados do arquivo aPI datausa.io analisa o JSON resultante com o método jsonDecode da biblioteca dart:convert e mantém o Future como parte do estado com uma propriedade chamada _future.

late Future<Map<dynamic, dynamic>?> _future;


void didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments as int;
    se (_year != year) {
      _future = _loadMeasure(year);
    }
}


Future<Map<dynamic, dynamic>?> _loadMeasure(int year) async {
    _year = year;
    uri final = Uri.parse(
        'https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=$year');
    resultado final = await get(uri);
    final body = jsonDecode(result.body);
    dados finais = body['data'] as List<dynamic>;
    se (data.isNotEmpty) {
      return data.first;
    }
    return null;

Renderização

Para criar a árvore de widgets, estamos usando um FutureBuilder, que se reconstrói em relação ao estado atual de nossa chamada assíncrona _future.

sobrepor
Widget build(BuildContext context) {
retorna Scaffold(
    appBar: AppBar(
    title: Text('Year $_year'),
    ),
    body: FutureBuilder<Map<dynamic, dynamic>?>(
    future: _future,
    builder: (context, snapshot) {
        switch (snapshot.connectionState) {
        case ConnectionState.done:
            final error = snapshot.error;
            if (error != null) {
                // retorna "error" tree.
            }
            final data = snapshot.data;
            se (dados != nulo) {
                // retorna a árvore de "resultado".
            }
            // retorna dados "vazios" tree.case ConnectionState.none:
        case ConnectionState.waiting:
        case ConnectionState.active:
            // retorna a árvore de dados "carregando".
        }
    },
    ),
);
}

Revisão

Ok, a implementação é curta e usa apenas widgets incorporados, mas agora pense em nossa intenção inicial: criar alternativas de demonstração (ou testes) para essa tela. É muito difícil controlar o resultado da chamada HTTP para forçar o aplicativo a renderizar em um determinado estado.

É aí que o conceito de inversão de controle nos ajudará. 🧐

Etapa 2. Inversão de controle

Inversão de controle para o provedor e o cliente da API

via ClickUp

Esse princípio pode ser difícil de entender para novos desenvolvedores (e também difícil de explicar), mas a ideia geral é extrair as preocupações para fora de nossos componentes, de modo que eles não sejam responsáveis por escolher o comportamento, e delegá-lo em seu lugar.

Em uma situação mais comum, isso consiste simplesmente em criar abstrações e injetar implementações em nossos componentes para que sua implementação possa ser alterada posteriormente, se necessário.

Mas não se preocupe, isso fará mais sentido depois do nosso próximo exemplo! 👀 Código-fonte e demonstração do Dart

Criação de um objeto cliente de API

Para controlar a chamada HTTP à nossa API, isolamos nossa implementação em uma classe DataUsaApiClient dedicada. Também criamos uma classe Measure para facilitar a manipulação e a manutenção dos dados.

classe DataUsaApiClient {
  const DataUsaApiClient({
    this.endpoint = 'https://datausa.io/api/data',
  });


  final String endpoint;


  Future<Measure?> getMeasure(int year) async {
    final uri =
        Uri.parse('$endpoint?drilldowns=Nation&measures=Population&year=$year');
    final result = await get(uri);
    final body = jsonDecode(result.body);
    dados finais = body['data'] as List<dynamic>;
    se (data.isNotEmpty) {
      return Measure.fromJson(data.first as Map<String, Object?>);
    }
    return null;
  }

Forneça um cliente de API

Em nosso exemplo, estamos usando o conhecido provedor para injetar uma instância DataUsaApiClient na raiz da nossa árvore.

Provedor<DataUsaApiClient>(
    create: (context) => const DataUsaApiClient(),
        child: const MaterialApp(
        home: HomePage(),
    ),
)

Usando o cliente da API

O provedor permite que qualquer widget descendente ( como a nossa _DetailScreen_) leia o DataUsaApiClient superior mais próximo na árvore. Podemos, então, usar seu método getMeasure para iniciar nosso Future, no lugar da implementação real do HTTP.

@overridevoid didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments as int;
    se (_year != year) {
      _year = year;
      final api = context.read<DataUsaApiClient>();
      _future = api.getMeasure(year);
    }
}

Cliente de demonstração da API

Agora podemos tirar proveito disso!

Caso você não saiba: qualquer classe no dart também definem implicitamente uma interface associada . Isso nos permite fornecer uma implementação alternativa de DataUsaApiClient que sempre retorna a mesma instância de suas chamadas de método getMeasure.

Esse método

class DemoDataUsaApiClient implements DataUsaApiClient {
  const DemoDataUsaApiClient(this.measure);


  medida final Measure;


  @overrideString get endpoint => '';


  @override
  Future<Measure?> getMeasure(int year) {
    return Future.value(measure);
  }

Exibição de uma página de demonstração

Agora temos todas as chaves para exibir uma instância de demonstração da DetailPage!

Simplesmente substituímos a instância DataUsaApiClient fornecida atualmente, envolvendo nossa DetailScreen em um provedor que cria uma instância DemoDataUsaApiClient!

E é isso: nosso DetailScreen lê essa instância de demonstração e usa nossos dados demoMeasure em vez de uma chamada HTTP.

ListTile(
    title: const Text('Open demo'),
    onTap: () {
        const demoMeasure = Measure(
            year: 2022,
            population: 425484,
            nation: 'United States',
        );
        Navigator.push(
            context,
            MaterialPageRoute(
                settings: RouteSettings(arguments: demoMeasure.year),
                builder: (context) {
                    return Provider<DataUsaApiClient>(
                        create: (context) =>
                            const DemoDataUsaApiClient(demoMeasure),
                        child: const DetailScreen(),
                    );
                },
            ),
        );
    },
)

Revisão

Este é um ótimo exemplo de Inversão de controle. Nosso widget DetailScreen não é mais responsável pela lógica de obtenção dos dados, mas a delega a um objeto cliente dedicado. E agora podemos criar instâncias de demonstração da tela ou implementar testes de widget para nossa tela! Fantástico! 👏

Mas podemos fazer ainda melhor!

Como não podemos simular um estado de carregamento, por exemplo, não temos controle total de nenhuma alteração de estado no nível do nosso widget.

Etapa 3. Gerenciamento de estado

Gerenciamento de declarações em aplicativos flutter

via ClickUp

Este é um tópico importante no Flutter!

Tenho certeza que você já leu longos tópicos de pessoas que tentam eleger a melhor solução de gerenciamento de estado para o Flutter. E, para ser claro, não é isso que faremos neste artigo. Em nossa opinião, contanto que você separe sua lógica de negócios da lógica visual, você estará bem! Criar essas camadas é realmente importante para a manutenção. Nosso exemplo é simples, mas em aplicativos reais, a lógica pode se tornar complexa rapidamente e essa separação facilita muito a localização dos algoritmos de lógica pura. Esse assunto é frequentemente resumido como gerenciamento de estado.

Neste exemplo, estamos usando um ValueNotifier básico junto com um Provider. Mas também poderíamos ter usado flutter_bloc ou riverpod (ou outra solução), e teria funcionado muito bem também. Os princípios permanecem os mesmos e, desde que você separe seus estados e sua lógica, é possível até mesmo portar sua base de código de uma das outras soluções.

Essa separação também nos ajuda a controlar qualquer estado dos nossos widgets para que possamos simulá-los de todas as formas possíveis! Código-fonte e demonstração do Dart

Criação de um estado dedicado

Em vez de depender do AsyncSnapshot da estrutura, agora representamos nosso estado de tela como um objeto DetailState.

Também é importante implementar os métodos hashCode e operator == para tornar nosso objeto comparável por valor. Isso nos permite detectar se duas instâncias devem ser consideradas diferentes.

💡 O equatable ou congelado são ótimas opções para ajudá-lo a implementar os métodos hashCode e operator ==!

abstract class DetailState {
  const DetailState(this.year);
  final int year;


  @overridebool operator ==(Object other) =>
      idêntico(this, other) ||
      (outro é DetailState &&
          runtimeType == other.runtimeType &&
          year == other.year);


  @overrideint get hashCode => runtimeType.hashCode ^ year;

Nosso estado está sempre associado a um year, mas também temos quatro estados possíveis distintos em relação ao que queremos mostrar ao usuário:

  • NotLoadedDetailState: a atualização de dados ainda não foi iniciada
  • LoadingDetailState: os dados estão sendo carregados no momento
  • LoadedDetailState: os dados foram carregados com sucesso com uma medida associada
  • NoDataDetailState: os dados foram carregados, mas não há dados disponíveis
  • UnknownErrorDetailState: a operação falhou devido a um erro desconhecido
class NotLoadedDetailState extends DetailState {
  const NotLoadedDetailState(int year) : super(year);
}


class LoadedDetailState extends DetailState {
  const LoadedDetailState({
    obrigatório int year,
    obrigatório this.measure,
  }) : super(year);


  medida final Measure;


  @overridebool operator ==(Object other) =>
      idêntico(this, other) ||
      (other is LoadedDetailState && measure == other.measure);


  @overrideint get hashCode => runtimeType.hashCode ^ measure.hashCode;
}


class NoDataDetailState extends DetailState {
  const NoDataDetailState(int year) : super(year);
}


class LoadingDetailState extends DetailState {
  const LoadingDetailState(int year) : super(year);
}


class UnknownErrorDetailState extends DetailState {
  const UnknownErrorDetailState({
    required int year,
    required this.error,
  }) : super(year);


  erro dinâmico final;


  @overridebool operator ==(Object other) =>
      idêntico(this, other) ||
      (other is UnknownErrorDetailState &&
          year == other.year &&
          error == other.error);


  @overrideint get hashCode => Object.hash(super.hashCode, error.has

Esses estados são mais claros do que um AsyncSnapshot, pois realmente representam nossos casos de uso. E, novamente, isso torna nosso código mais fácil de manter!

💡 É altamente recomendável o Tipos de união do pacote congelado para representar seus estados lógicos! Ele adiciona vários utilitários, como os métodos copyWith ou map.

Colocando a lógica em um Notifier

Agora que temos uma representação do nosso estado, precisamos armazenar uma instância dele em algum lugar - esse é o objetivo do DetailNotifier. Ele manterá a instância atual de DetailState em sua propriedade value e fornecerá métodos para atualizar o estado.

Fornecemos um estado inicial NotLoadedDetailState e um método refresh para carregar dados da api e atualizar o value atual.

class DetailNotifier extends ValueNotifier<DetailState> {
  DetailNotifier({
    obrigatório int year,
    required this.api,
  }) : super(DetailState.notLoaded(year));


  final DataUsaApiClient api;


  int get year => value.year;


  Future<void> refresh() async {
    se (value is! LoadingDetailState) {
      value = DetailState.loading(year);
      try {
        resultado final = await api.getMeasure(year);
        se (result != null) {
          value = DetailState.loaded(
            year: year,
            measure: result,
          );
        } else {
          value = DetailState.noData(year);
        }
      } catch (error) {
        value = DetailState.unknownError(
          year: ano,
          error: error,
        );
      }
    }
  }

Forneça um estado para a visualização

Para instanciar e observar o estado da nossa tela, também contamos com o provedor e seu ChangeNotifierProvider. Esse tipo de provedor procura automaticamente por qualquer ChangeListener criado e acionará uma reconstrução do Consumer sempre que for notificado de uma alteração _(quando o valor do notificador for diferente do anterior)

class DetailScreen extends StatelessWidget {
  const DetailScreen({
    Key? key,
  }) : super(key: key);


  sobrepor
  Widget build(BuildContext context) {
    final year = ModalRoute.of(context)!.settings.arguments as int;
    return ChangeNotifierProvider<DetailNotifier>(
      create: (context) {
        final notifier = DetailNotifier(
          year: ano,
          api: context.read<DataUsaApiClient>(),
        );
        notifier.refresh();
        return notifier;
      },
      child: Consumidor<DetailNotifier>(
        construtor: (contexto, notificador, filho) {
             final state = notifier.value;
            // ...
        },
      ),
    );
  }
}

Revisão

Excelente! Nossa arquitetura de aplicativos está começando a ficar muito boa. Tudo está separado em camadas bem definidas, com preocupações específicas! 🤗

No entanto, ainda falta uma coisa para a testabilidade: queremos definir o DetailState atual para controlar o estado da nossa DetailScreen associada.

Etapa 4. Widget de layout visual dedicado

Widgets visuais de layout dedicado em aplicativos flutter

via ClickUp

Na última etapa, demos um pouco mais de responsabilidade ao nosso widget DetailScreen: ele foi responsável por instanciar o DetailNotifier. E, como vimos anteriormente, tentamos evitar qualquer responsabilidade lógica na camada de visualização!

Podemos resolver isso facilmente criando outra camada para nosso widget de tela: dividiremos nosso widget DetailScreen em dois:

  • o DetailScreen é responsável por configurar as várias dependências da nossa tela a partir do estado atual do aplicativo (navegação, notificadores, estado, serviços, ...),
  • o DetailLayout simplesmente converte um DetailState em uma árvore dedicada de widgets.

Combinando os dois, poderemos simplesmente criar instâncias de demonstração/teste do DetailLayout, mas com o DetailScreen para o caso de uso real em nosso aplicativo. Código-fonte e demonstração do Dart

Layout dedicado

Para obter uma melhor separação das preocupações, movemos tudo o que estava sob o widget Consumer para um widget DetailLayout dedicado. Esse novo widget apenas consome dados e não é responsável por nenhuma instanciação. Ele apenas converte o estado de leitura em uma árvore de widgets específica.

A chamada ModalRoute.of e a instância ChangeNotifierProvider permanecem na DetailScreen, e esse widget simplesmente retorna o DetailLayout com uma árvore de dependências pré-configurada!

Esse pequeno aprimoramento é específico para o uso do provedor, mas você notará que também adicionamos um ProxyProvider para que qualquer widget descendente possa consumir diretamente um DetailState. Isso facilitará a simulação de dados.

class DetailScreen extends StatelessWidget {
  const DetailScreen({
    Key? key,
  }) : super(key: key);


  sobrepor
  Widget build(BuildContext context) {
    final year = ModalRoute.of(context)!.settings.arguments as int;
    return ChangeNotifierProvider<DetailNotifier>(
      create: (context) {
        final notifier = DetailNotifier(
          year: ano,
          api: context.read<DataUsaApiClient>(),
        );
        notifier.refresh();
        return notifier;
      },
      child: child: ProxyProvider<DetailNotifier, DetailState>(
        update: (context, value, previous) => value.value,
        child: const DetailLayout(),
      ),
    );
  }
}


class DetailLayout extends StatelessWidget {
  const DetailLayout({
    Key? key,
  }) : super(key: key);


  sobrepor
  Widget build(BuildContext context) {
    return Consumer<DetailState>(
      builder: (context, state, child) {
        retorna Scaffold(
          appBar: AppBar(
            title: Text('Year ${state.year}'),
          ),
          body: () {
              // ...
          }(),
        );
      },
    );
  }

Extração de widgets como classes dedicadas

Nunca hesite em extrair uma árvore de widgets em uma classe dedicada! Isso fará com que melhorar o desempenho e tornar o código mais fácil de manter.

Em nosso exemplo, criamos um widget de layout visual para cada um dos tipos de estado associados:

if (state is NotLoadedDetailState || state is LoadingDetailState) {
    return const LoadingDetailLayout();
}
se (o estado for LoadedDetailState) {
    return LoadedDetailLayout(state: state);
}
se (state is UnknownErrorDetailState) {
    return UnknownErrorDetailLayout(state: state);
}
return const NoDataDetailLayout();

Instâncias de demonstração

Agora temos controle total sobre o que podemos simular e exibir na tela!

Só precisamos envolver um DetailLayout com um Provider<DetailState> para simular o estado do layout.

ListTile(
    title: const Text('Abrir demonstração "carregada"'),
    onTap: () {
        Navigator.push(
        context,
        MaterialPageRoute(
            builder: (context) {
            return Provider<DetailState>.value(
                    valor: const DetailState.loaded(
                    year: 2022,
                    measure: Measure(
                        year: 2022,
                        population: 425484,
                        nation: 'United States',
                    ),
                    ),
                    child: const DetailLayout(),
                );
            },
        ),
        );
    },
),
ListTile(
    title: const Text('Abrir demonstração de "carregamento"'),
    onTap: () {
        Navigator.push(
        context,
        MaterialPageRoute(
            builder: (context) {
                    return Provider<DetailState>.value(
                        value: const DetailState.loading(2022),
                        child: const DetailLayout(),
                    );
                },
            ),
        );
    },
),

Conclusão

Criar uma arquitetura de software sustentável definitivamente não é fácil! Antecipar cenários futuros pode exigir muito esforço, mas espero que as poucas dicas que compartilhei o ajudem no futuro!

Os exemplos podem parecer simples - pode até parecer que estamos exagerando na engenharia -, mas, à medida que a complexidade do seu aplicativo aumentar, ter esses padrões o ajudará muito! 💪

Divirta-se com o Flutter e siga o blog para obter mais artigos técnicos como este! Fique ligado!