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 aDetailScreen
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
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
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
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
eoperator ==
!
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 iniciadaLoadingDetailState
: os dados estão sendo carregados no momentoLoadedDetailState
: os dados foram carregados com sucesso com umamedida
associadaNoDataDetailState
: os dados foram carregados, mas não há dados disponíveisUnknownErrorDetailState
: a operação falhou devido a umerro
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
oumap
.
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
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 umDetailState
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!