Flutter: escolhendo uma arquitetura para o seu projeto
Introdução
Quando estamos criando os primeiros projetos em Flutter, por sermos iniciantes ou simplesmente por ser um projeto pequeno, é comum não pensarmos muito sobre como podemos organizar e como seria o seu planejamento, mas quando nos aprofundamos na criação de aplicações, de projetos, vemos como é necessário entender melhor sobre isso para criar soluções melhores.
Neste artigo, vamos falar sobre o que é arquitetura de software, a noção de arquitetura criada por Robert C. Martin (Uncle Bob ou Tio Bob) e como podemos interpretá-la para começar a aplicar em projetos de Flutter.
O que é arquitetura de software?
De forma resumida:
Arquitetura de software refere-se à organização fundamental de um sistema de software, incluindo seus componentes, suas interações e os princípios que orientam seu design. Ela envolve a tomada de decisões sobre a estrutura geral do sistema, a divisão em módulos ou componentes menores, a comunicação entre eles e a definição dos padrões e princípios que guiarão o desenvolvimento e a manutenção do software.
Desenvolvemos uma arquitetura de software baseada na nossa lista de requisitos. O que é importante a gente entregar para nosso cliente. A partir dessas necessidades, escolhemos tecnologias, bibliotecas, definimos estruturas, organização e padronização do processo de desenvolvimento.
Existem diversas arquiteturas de software, cada uma tem uma maneira diferente de resolver problemas diferentes. O ideal é escolher uma que se aproxime mais do que vai atender as nossas necessidades. É muito importante notar que nenhuma arquitetura vai resolver 100% com perfeição o que nós estamos precisando, às vezes precisamos adaptar algo que já existe.
Vamos nos imaginar na seguinte situação:
Você está iniciando um novo projeto Flutter e começa a criar suas telas, definir funcionalidades e escrever código. No início, tudo parece simples e direto. No entanto, à medida que seu aplicativo cresce, você começa a notar alguns desafios:
- À medida que o código cresce, a complexidade também aumenta, tornando-o difícil de entender e manter;
- À medida que novos recursos são adicionados, fica cada vez mais complicado integrá-los sem quebrar partes existentes do aplicativo;
- A adaptação a mudanças, como a substituição do banco de dados ou a integração com novas APIs, se torna uma tarefa complicada.
Estes são problemas comuns enfrentados pelos desenvolvedores de software. Bom, a definição de arquitetura ainda é um tanto subjetiva, visto que existem diversas visões de diferentes autores sobre o assunto, mas no geral podemos dizer que o principal objetivo de utilizar uma arquitetura em seu projeto é minimizar o custo da vida útil do sistema, ou seja, fazer com que ele se torne fácil de entender, de desenvolver, de manter, expandir e implantar.
Sabendo disso, como podemos solucionar os problemas mencionados no projeto acima? Bom, talvez solucionar 100% não seja possível, mas amenizar seus efeitos é algo que podemos conseguir com uma arquitetura de software.
Nesse exemplo, vamos utilizar a arquitetura limpa. Vamos conhecer mais sobre ela?
Conhecendo a arquitetura limpa
A arquitetura limpa separa responsabilidades, dividindo o software em camadas. E quais seriam as camadas (partes ou divisões do projeto) utilizadas nessa arquitetura? É o que vamos ver agora!
A arquitetura limpa (pela visão de Robert C. Martin) é uma arquitetura baseada em camadas, dividindo o software em partes diferentes. Cada camada tem uma responsabilidade e/ou funcionalidade específica.
Algumas outras características são:
- Separação de preocupações: a divisão clara e eficaz das diferentes funcionalidades do sistema. Isso significa que o código deve ser organizado de modo a separar as regras de negócio da lógica de apresentação, da persistência de dados e de outras partes, garantindo que cada componente tenha uma única responsabilidade bem definida.
- Dependência de direção única: para evitar acoplamento excessivo e tornar o código mais testável, as camadas ou componentes mais internos não devem depender diretamente das camadas mais externas, criando uma hierarquia onde a direção das dependências flui de fora para dentro.
- Princípio da inversão de dependência: as dependências entre os módulos do software devem ser invertidas, de modo que os módulos de alto nível não dependam dos de baixo nível, mas ambos dependem de abstrações.
- Testabilidade: a arquitetura promove a testabilidade por meio da separação das camadas e da redução da dependência de componentes externos. Isso facilita a criação de testes unitários e de integração, tornando o software mais robusto e confiável.
- Alto grau de coesão e baixo acoplamento: a arquitetura visa alcançar alto grau de coesão dentro de cada camada e baixo acoplamento entre as camadas. Isso significa que os componentes devem ter responsabilidades bem definidas e não devem depender fortemente uns dos outros.
- Princípio da Responsabilidade Única (SRP): as classes e módulos devem ter uma única responsabilidade. Isso ajuda a manter o código mais compreensível e facilita a manutenção.
- Evolução gradual: a arquitetura limpa permite a evolução gradual do software, facilitando a adição de novas funcionalidades e a realização de modificações sem comprometer a estabilidade do sistema.
- Padrões de design: a arquitetura limpa incentiva o uso de padrões de design, como o Model-View-Controller (MVC), Injeção de Dependência, entre outros, para resolver problemas comuns de design de software. Esses padrões fornecem soluções comprovadas para problemas recorrentes.
Camadas da arquitetura limpa
Existem quatro camadas na arquitetura limpa, sendo elas:
- Camada de entidades: camada onde ficam as entidades (objetos de negócio) e as regras de negócio da aplicação;
- Camada de casos de uso: camada que contém classes com as regras de negócio mais específicas da aplicação. Ela implementa todos os casos de uso (situações possíveis) do projeto e também é onde as entidades e suas regras são utilizadas;
- Camada de adaptadores de interface: é a camada cujo objetivo é criar adaptadores de dados que estão em um formato que faz sentido para as camadas de entidade e casos de uso, para dados que façam sentido para a camada mais externa (para o banco de dados ou API, por exemplo);
- Camada de Frameworks e Drivers: camada mais externa da arquitetura. Ela é formada pelas ferramentas e frameworks que utilizamos no projeto.
Abaixo, uma releitura da imagem de representação das camadas da arquitetura limpa, descrita por Robert C. Martin:
É importante entender como essas camadas se relacionam e quais são os limites delas. Portanto, é fundamental compreender a Regra de Dependência, a qual, segundo Robert C. Martin, deve apontar apenas para o interior. Em outras palavras, as camadas mais internas não precisam e não devem ter conhecimento das camadas mais externas.
Por que isso é bom para o seu projeto? Bem, se por exemplo, você alterar o seu banco de dados, sua API (Application Programming Interface, ou Interface de Programação da Aplicação), ou sua interface, não deve interferir nas camadas mais internas do seu projeto, nas regras de negócio, e o esforço necessário para substituir esses detalhes será menor.
Projeto
Vamos usar como exemplo uma aplicação de compêndio (uma coleção de informações) sobre os jogos The Legend of Zelda: Breath of the Wild e The Legend of Zelda: Tears of the Kingdom, chamada Hyrule.
Se tiver interesse, você pode conferir o projeto completo no GitHub.
Relacionando arquitetura limpa ao Flutter
Atualmente, até a data da publicação deste artigo, ainda não existe uma versão consolidada de como utilizar arquitetura limpa num projeto mobile com Dart e Flutter, mas podemos nos inspirar na arquitetura que vimos anteriormente e aplicar as regras para algo que faça sentido para nós. A figura abaixo demonstra uma versão simplificada da arquitetura limpa, com as seguintes camadas:
Como podemos separar o projeto Hyrule em camadas utilizando o modelo acima baseado em arquitetura limpa?
Domínio
Começando pela camada de Domain (Domínio), podemos criar nela as entidades e regras de negócio. Vamos precisar de uma classe que representa as entradas, Entry, e pensando no que vimos até agora, ela seria uma entidade para o nosso projeto, algo “puro”, um modelo ou representação. Veja como seria:
class Entry {
int id;
String name;
String image;
String description;
String category;
String commonLocations;
Entry({
required this.id,
required this.name,
required this.image,
required this.description,
required this.category,
required this.commonLocations,
});
}
E na aplicação é necessário realizar algumas ações com a entidade, como listar, salvar, excluir. Essas ações podem ser chamadas de casos de uso (Use Cases). Então, terão casos de uso para listar as entradas, para salvar uma entrada e para excluir. Veja um exemplo:
abstract class DaoWorkflow {
Future<List<Entry>> getSavedEntries();
Future<void> addEntry({required Entry entry});
Future<void> removeEntry({required Entry entry});
}
DAO é uma classe ou componente que fornece uma interface para interagir com um banco de dados ou outra fonte de dados.
Por que usar interfaces? Bom, a interface vai definir um “contrato” com os métodos que devem ser implementados (os casos de uso), sem necessariamente precisarmos saber como essas tarefas vão ser implementadas de fato. Quando formos de fato implementar os casos de uso, aí pensaremos no “como”, quais bibliotecas, métodos ou ferramentas vamos usar.
Dados
Em seguida, criamos a camada de Data (Dados), que deve conter a fonte dos dados da API ou banco de dados. Para o nosso caso, a camada de dados vai implementar as operações de banco de dados e vamos usar a biblioteca floor
. Ela facilita as operações de banco de dados utilizando o sqflite
, mas claro que você poderia implementar da maneira que achasse melhor.
Veja abaixo:
- Database:
import 'dart:async';
import 'package:floor/floor.dart';
import 'package:sqflite/sqflite.dart' as sqflite;
import 'package:hyrule/domain/models/entry.dart';
import 'package:hyrule/data/dao/entry_dao.dart';
part 'database.g.dart';
@Database(version: 1, entities: [Entry])
abstract class AppDatabase extends FloorDatabase {
EntryDao get entryDao;
}
- DAO (Data Access Object):
import 'package:floor/floor.dart';
import '../../domain/models/entry.dart';
@dao
abstract class EntryDao {
@Query('SELECT * from Entry')
Future<List<Entry>> getAllEntries();
@Insert(onConflict: OnConflictStrategy.replace)
Future<void> addEntry(Entry entry);
@delete
Future<void> removeEntry(Entry entry);
}
Controlador
Então, criamos a camada de Controller, que possui os controladores da aplicação, adaptadores de interface. No controlador podemos realmente implementar a lógica de negócio criada na camada de domínio, os nossos casos de uso que utilizam a entidade Entry.
O DaoController deve implementar a classe com os contratos que criamos, logo, obrigatoriamente, ele deve ter os três métodos (listar, salvar e excluir), os quais são de fato implantados pela camada de dados. Veja:
class DaoController implements DaoWorkflow {
Future<EntryDao> createDatabase () async {
final database = await $FloorAppDatabase.databaseBuilder('app_database.db').build();
final EntryDao entryDao = database.entryDao;
return entryDao;
}
@override
Future<List<Entry>> getSavedEntries() async {
final EntryDao entryDao = await createDatabase();
return entryDao.getAllEntries();
}
@override
Future<void> addEntry({required Entry entry}) async {
final EntryDao entryDao = await createDatabase();
entryDao.addEntry(entry);
}
@override
Future<void> removeEntry({required Entry entry}) async {
final EntryDao entryDao = await createDatabase();
entryDao.removeEntry(entry);
}
}
Presenter
Por último, a camada de Presenter (Apresentação) que contém a parte visual da aplicação, aquilo que o usuário vai ver e interagir, a UI (User Interface). Nessa camada é onde criamos as nossas telas e onde verdadeiramente utilizamos os Widgets, criamos componentes e lidamos com estados.
Abaixo, o exemplo da tela de favoritos, onde utilizamos o Controller no FutureBuilder para listar as entradas salvas:
FutureBuilder(
future: daoController.getSavedEntries(),
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
break;
case ConnectionState.active:
break;
case ConnectionState.waiting:
return const Center(
child: CircularProgressIndicator(),
);
case ConnectionState.done:
if (snapshot.hasData) {
return ListView.builder(
itemBuilder: (context, index) =>
EntryCard(entry: snapshot.data![index], isSaved: true),
itemCount: snapshot.data!.length,
);
}
default:
}
return Container();
},
),
FutureBuilder é um Widget que executa funções assíncronas e atualiza a UI (User Interface) com base nessas funções. Em nosso caso, o FutureBuilder vai executar a função assíncrona que busca as entradas na API e atualizar a tela com a lista de entradas caso a conexão seja feita.
No componente EntryCard quando precisamos excluir uma entrada utilizando o Widget Dismissible, também utilizamos o Controller:
onDismissed: (direction) {
daoController.removeEntry(entry: entry);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Deletado')));
},
Dismissible é um Widget que ao ser arrastado para determinada direção, desliza para fora da tela. Em nosso caso, ao ser arrastado do fim para o início da tela, ele também é excluído da lista de favoritos.
E por fim, na tela de Detalhes da entrada, quando salvamos uma nova entrada:
floatingActionButton: FloatingActionButton(
onPressed: () {
daoController.addEntry(entry: entry);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Favoritado')));
},
child: const Icon(Icons.bookmark),
),
Nessa camada, também podem ser usados os gerenciadores de estados da aplicação, embora nesse exemplo não esteja sendo utilizado.
Como já foi dito, a noção de arquitetura é um pouco subjetiva e Flutter ainda não possui uma forma consolidada de arquitetura limpa. Então, essa é apenas uma das formas que você pode pensar em aplicar uma arquitetura no seu projeto, mas você pode buscar outras maneiras de implementar uma arquitetura baseada na arquitetura limpa para a sua aplicação.
Organização do projeto
Neste ponto, já entendemos que arquitetura tem a ver com a forma que damos ao projeto e, nesse caso, como as camadas que criamos se comunicam entre si. Contudo, a organização ainda é importante até mesmo para utilizar o projeto de forma mais otimizada. Então, aqui está o esquema de pastas final do projeto:
├───lib
│ ├───controllers
│ │ └───api_controller.dart
│ ├───data
│ │ ├───api
│ │ │ ├───database.dart
│ │ │ ├───database.g.dart
│ │ │ └───entry_dao.dart
│ │ └───dao
│ │ └───database.dart
│ ├───domain
│ │ ├───business
│ │ │ ├───dao_workflow.dart
│ │ │ └───api_workflow.dart
│ │ └───models
│ │ └───entry.dart
│ ├───screens
│ │ ├───components
│ │ │ ├───category_card.dart
│ │ │ └───entry_card.dart
│ │ ├───categories.dart
│ │ ├───details.dart
│ │ ├───favorites.dart
│ │ └───results.dart
│ ├───utils
│ │ ├───consts
│ │ │ ├───api.dart
│ │ │ └───categories.dart
│ │ └───theme.dart
Conclusão
Parabéns por ter concluído essa leitura! Agora, você deve ter noções sobre o que é arquitetura, o que é arquitetura limpa, camadas da arquitetura limpa aplicada ao Flutter e sabe as vantagens que temos ao utilizá-la.
O conteúdo de arquitetura limpa é um assunto bastante complexo e ainda existem muitos detalhes que você pode estudar para se aprofundar ainda mais. Para te ajudar, deixo aqui um Podcast do Hipsters.Tech sobre Arquitetura de sistemas, arquitetura limpa e Hexagonal, e muito mais.
Espero que tenha aproveitado a leitura e continue avançando em seus estudos!