Conheça os Design Patterns mais usados no ecossistema Flutter
Se você já desenvolveu aplicativos com Flutter, é bem provável que tenha se deparado algumas vezes com o mesmo problema em diferentes projetos.
Por exemplo, sabe aquele trecho de código que se repete constantemente? Ou quando você precisa modificar algo no código, como substituir uma biblioteca, e acaba tendo que alterar várias partes do sistema? Isso pode ser algo bem frustrante.
Neste artigo, vamos abordar alguns problemas comuns no desenvolvimento com Flutter, e como você pode utilizar design patterns para resolvê-los.
O que são Design Patterns?
Os design patterns, ou padrões de projeto, são soluções já testadas para problemas comuns que encontramos durante o desenvolvimento de software.
Se ao construir um aplicativo, você se depara toda vez com um problema específico, em vez de sempre tentar elaborar uma solução do zero, adotar um design patterns pode ser uma ótima opção.
Os design patterns ficaram famosos depois que o livro "Design Patterns: Elements of Reusable Object-Oriented Software" foi publicado em 1994 pelo "Gang of Four". Desde então, se tornaram uma referência na arquitetura de software.
Existem três grandes categorias de design patterns: os padrões criacionais, estruturais e comportamentais. E nós vamos explorar cada uma delas.
Padrões criacionais
Os padrões criacionais ajudam a gerenciar a criação de objetos, ou seja, em vez de criar objetos diretamente em várias partes do código, esses padrões permitem que você centralize essa tarefa em um único lugar e, se você precisar mudar a forma como os objetos são criados, só precisará fazer isso em um lugar.
Vamos falar sobre dois dos mais conhecidos: Factory e Singleton.
Factory
O padrão Factory faz com que a responsabilidade de criar objetos seja de subclasses ou métodos especializados.
Ele é útil para centralizar a lógica de criação e esconder detalhes de implementação.
Para exemplificar, vamos explorar o código de um aplicativo de e-commerce. Nele devem ser exibidos diferentes tipos de botões (Android e iOS) de acordo com a plataforma que a pessoa usuária está utilizando. Atualmente essa verificação está sendo feita da seguinte maneira:
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Plataforma de Botões',
theme: ThemeData(primarySwatch: Colors.blue),
home: HomeScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Detecta a plataforma a partir do tema
final platform = Theme.of(context).platform;
return Scaffold(
appBar: AppBar(
title: Text('Renderizando Botões por Plataforma'),
),
body: Center(
child: platform == TargetPlatform.android
? ElevatedButton(
onPressed: () {
print("Botão do Android pressionado");
},
child: Text('Botão Android'),
)
: CupertinoButton(
color: Colors.blue,
onPressed: () {
print("Botão do iOS pressionado");
},
child: Text('Botão iOS'),
),
),
);
}
}
Este código detecta a plataforma (Android ou iOS) utilizando Theme.of(context).platform
e, com base nisso, renderiza um botão específico: um ElevatedButton
para Android e um CupertinoButton
para iOS. Apesar de funcionar, o que teríamos de fazer se quiséssemos adicionar suporte para outra plataforma?
A resposta é simples: precisaríamos adicionar mais uma verificação. Mas será que isso seria eficiente? Claro que não! Em um projeto grande essa verificação iria se repetir milhares de vezes, e adicionar essa nova plataforma seria muito custoso e difícil.
Entendido o problema, que tal aplicarmos o padrão Factory para centralizar a criação desses botões? Veja o código a seguir:
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
abstract class Button {
Widget render();
}
class AndroidButton extends Button {
@override
Widget render() {
return ElevatedButton(
onPressed: () {},
child: Text('Botão Android'),
);
}
}
class iOSButton extends Button {
@override
Widget render() {
return CupertinoButton(
onPressed: () {},
child: Text('Botão iOS'),
);
}
}
class ButtonFactory {
static final Map<TargetPlatform, Button> _buttons = {
TargetPlatform.android: AndroidButton(),
TargetPlatform.iOS: iOSButton(),
};
static Button createButton(TargetPlatform platform) {
if (_buttons.containsKey(platform)) {
return _buttons[platform]!;
} else {
throw UnsupportedError('Plataforma não suportada');
}
}
}
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final platform = Theme.of(context).platform;
final button = ButtonFactory.createButton(platform);
return MaterialApp(
home: Scaffold(
body: Center(
child: button.render(),
),
),
);
}
}
Aqui, criamos uma classe abstrata chamada Button
que define o método render()
. As classes AndroidButton
e iOSButton
estendem Button
e implementam render()
de maneira específica para suas respectivas plataformas.
A classe ButtonFactory
centraliza a criação dos botões, retornando o botão correto de acordo com a plataforma que está sendo utilizada.
Se no futuro quisermos adicionar uma nova plataforma, basta criar uma nova classe que herde de Button
e adicionar a lógica dentro da factory, sem precisar alterar o restante do código.
Singleton
O padrão Singleton garante que uma classe tenha apenas uma instância e fornece um ponto global de acesso a essa instância.
Ele é útil em situações em que precisamos ter uma única configuração ou estado de algum componente em toda a aplicação.
Para ver isso na prática, vamos voltar ao aplicativo de e-commerce. Agora recebemos a missão de gerenciar um carrinho de compras globalmente, e atualmente o código está da seguinte maneira:
class CartManager {
CartManager() {
print("Carrinho inicializado.");
}
final List<String> _items = [];
void addItem(String item) {
_items.add(item);
}
void showItems() {
print("Itens no carrinho: ${_items.join(', ')}");
}
}
void main() {
var cartTela1 = CartManager();
var cartTela2 = CartManager();
// Adiciona itens em duas instâncias diferentes de carrinho
cartTela1.addItem("Produto A");
cartTela2.addItem("Produto B");
cartTela1.showItems(); //tem apenas o produto A
cartTela2.showItems(); // tem apenas o produto B
}
Neste código, ao adicionar um item no cartTela1
, gostaríamos que ele também fosse adicionado automaticamente ao cartTela2
.
No entanto, a aplicação está criando múltiplas instâncias do carrinho, o que significa que teríamos que adicionar os itens manualmente em ambos, dificultando a sincronização dos carrinhos.
Então, vamos usar o padrão Singleton para que apenas uma instância de CartManager
seja criada:
class CartManager {
// Instância única (Singleton)
static final CartManager _instance = CartManager._internal();
// Construtor privado para que ninguém possa instanciar diretamente
CartManager._internal() {
print("Carrinho inicializado.");
}
// Método factory que retorna sempre a mesma instância
factory CartManager() {
return _instance;
}
// Lista de itens no carrinho
final List<String> _items = [];
// Método para adicionar itens
void addItem(String item) {
_items.add(item);
print("Item $item adicionado ao carrinho.");
}
// Exibe o estado atual do carrinho
void showItems() {
print("Itens no carrinho: ${_items.join(', ')}");
}
}
void main() {
// Usando a instância Singleton do CartManager
var cartTela1 = CartManager();
var cartTela2 = CartManager();
// Adiciona itens no carrinho usando a mesma instância
cartTela1.addItem("Produto A");
cartTela2.addItem("Produto B");
// Exibe os itens no carrinho
cartTela1.showItems(); // Saída: Itens no carrinho: Produto A, Produto B
cartTela2.showItems(); // Saída: Itens no carrinho: Produto A, Produto B
}
A utilização de um construtor privado (_internal) e uma única “cópia” (_instance) faz com que o CartManager
siga o padrão Singleton, ou seja, apenas uma instância dessa classe será criada durante toda a execução do aplicativo.
Isso resolve o problema de múltiplas instâncias de carrinhos e mantém o estado centralizado.
Pronto, implementamos o padrão Singleton na classe CartManager
, que agora é responsável por gerenciar o estado do carrinho de compras e com isso, conseguimos resolver mais um problema da aplicação de e-commerce.
Os padrões criacionais realmente facilitam muito a nossa vida! Mas, chegou a hora de explorar um novo padrão de design pattern, os padrões estruturais.
Padrões Estruturais
Os padrões estruturais ajudam a compor classes e objetos em estruturas maiores, fazendo com que os componentes possam se comunicar e funcionar juntos de maneira eficiente.
Esses padrões são úteis para lidar com a organização de interfaces e a composição de objetos, permitindo que diferentes partes de um sistema sejam integradas de maneira mais simples.
Dentro desse padrão, podemos destacar o Adapter e o Facade.
Adapter
Esse padrão é utilizado quando queremos que duas classes com interfaces incompatíveis trabalhem juntas.
Ele age como um "tradutor", permitindo que um sistema existente seja utilizado sem modificar sua estrutura interna, apenas adaptando sua interface para atender às necessidades de uma nova interface exigida pelo cliente.
Esse padrão é muito útil em cenários onde você está integrando novos componentes ou serviços em um sistema, mas não pode ou não quer modificar o código existente.
O Adapter encapsula essa lógica de "tradução" e torna a integração transparente para o restante do sistema.
Para compreender melhor esse padrão, vamos analisar um aplicativo de aluguel de imóveis.
Nesse exemplo, temos dois serviços que retornam dados sobre uma casa: um em formato JSON e outro em formato XML:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:xml/xml.dart'; // Adicione a dependência da biblioteca xml no seu pubspec.yaml
// Serviços simulando chamadas de API
class JSONService {
static String fetchData() {
return '{"comodos": 5, "preco": 2500.00}';
}
}
class XMLService {
static String fetchData() {
return '<house><comodos>4</comodos><preco>2300.00</preco></house>';
}
}
class House {
final int comodos;
final double preco;
House({required this.comodos, required this.preco});
// Função para converter JSON em um objeto House
factory House.fromJson(String jsonData) {
final data = jsonDecode(jsonData);
return House(
comodos: data['comodos'],
preco: data['preco'],
);
}
// Função para converter XML em um objeto House
factory House.fromXml(String xmlData) {
final document = XmlDocument.parse(xmlData);
final comodos = int.parse(_extractValueFromXml(document, 'comodos'));
final preco = double.parse(_extractValueFromXml(document, 'preco'));
return House(
comodos: comodos,
preco: preco,
);
}
// Método auxiliar para extrair valores de XML
static String _extractValueFromXml(XmlDocument document, String tagName) {
final element = document.findAllElements(tagName).first;
return element.text;
}
}
void main() {
// Convertendo os dados de JSON antes de passar para a tela
final houseFromJson = House.fromJson(JSONService.fetchData());
runApp(MyApp(houseFromJson: houseFromJson));
}
class MyApp extends StatelessWidget {
final House houseFromJson;
MyApp({required this.houseFromJson});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'House Data Example',
theme: ThemeData(primarySwatch: Colors.blue),
home: HomeScreen(houseFromJson: houseFromJson),
);
}
}
class HomeScreen extends StatelessWidget {
final House houseFromJson;
HomeScreen({required this.houseFromJson});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Dados da Casa'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dados do JSON:',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text('Cômodos: ${houseFromJson.comodos}', style: TextStyle(fontSize: 18)),
Text('Preço: R\$ ${houseFromJson.preco.toStringAsFixed(2)}', style: TextStyle(fontSize: 18)),
],
),
),
);
}
}
Neste código, são exibidas informações de uma casa a partir de dados simulados em JSON e XML.
Ele define os serviços JSONService
e XMLService
para fornecer dados em diferentes formatos. Já a classe House
representa a casa, com cômodos e preço, e faz a conversão dos dados JSON e XML.
Na main()
, os dados (no exemplo escolhemos o JSON) são convertidos e passados para o widget MyApp
, que exibe esses dados na interface HomeScreen
. E a tela exibe o número de cômodos e o preço da casa com um layout simples.
O problema é que se o JSON e XML são “variações” de services, o ideal não seria apenas entregá-los a interface sem me preocupar com verificações? Sim, esse seria o ideal, mas isso não está acontecendo porque nosso código não está adaptável.
Além disso, se precisássemos adicionar uma nova fonte de serviço, teríamos que ajustar as verificações também.
No momento, temos apenas um widget, mas se usássemos o serviço em vários componentes, precisaríamos fazer essas alterações manualmente em cada um deles.
Para resolver esses problemas, podemos usar o padrão Adapter, separando assim a lógica de conversão dos dados para que a interface receba dados já convertidos, independentemente do formato original (JSON, XML, etc.). Veja o código a seguir:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:xml/xml.dart'; // Adicione a dependência da biblioteca xml no seu pubspec.yaml
class House {
final int comodos;
final double preco;
House({required this.comodos, required this.preco});
}
// Interface do Adapter
abstract class HouseAdapter {
House getHouse();
}
// Adapter para JSON
class JSONAdapter implements HouseAdapter {
final JSONService _jsonService = JSONService(); // Instância criada internamente
@override
House getHouse() {
final data = _jsonService.fetchData();
final decodedData = jsonDecode(data);
return House(
comodos: decodedData['comodos'],
preco: decodedData['preco'],
);
}
}
// Adapter para XML
class XMLAdapter implements HouseAdapter {
final XMLService _xmlService = XMLService();
@override
House getHouse() {
final data = _xmlService.fetchData();
final document = XmlDocument.parse(data);
final comodos = _extractValueFromXml(document, 'comodos');
final preco = _extractValueFromXml(document, 'preco');
return House(
comodos: int.parse(comodos),
preco: double.parse(preco),
);
}
String _extractValueFromXml(XmlDocument document, String tagName) {
final element = document.findAllElements(tagName).first;
return element.text;
}
}
class JSONService {
String fetchData() {
// Implementação do serviço JSON (exemplo)
return '{"comodos": 3, "preco": 250000.0}';
}
}
class XMLService {
String fetchData() {
// Implementação do serviço XML (exemplo)
return '<house><comodos>3</comodos><preco>250000.0</preco></house>';
}
}
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Adapter Pattern Example',
theme: ThemeData(primarySwatch: Colors.blue),
home: HomeScreen(
adapter: JSONAdapter(), // Trocar para XMLAdapter se necessário
),
);
}
}
class HomeScreen extends StatelessWidget {
final HouseAdapter adapter;
HomeScreen({required this.adapter});
@override
Widget build(BuildContext context) {
// Obtendo os dados da casa usando o adapter
final house = adapter.getHouse();
return Scaffold(
appBar: AppBar(
title: Text('Exemplo Adapter Pattern'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dados da Casa:',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 16),
Text('Cômodos: ${house.comodos}', style: TextStyle(fontSize: 18)),
Text('Preço: R\$ ${house.preco.toStringAsFixed(2)}', style: TextStyle(fontSize: 18)),
],
),
),
);
}
}
O código apresenta a interface HouseAdapter
, que serve como um intermediário para obter informações sobre casas de diferentes fontes.
Existem duas implementações: JSONAdapter
, que busca dados em formato JSON, e XMLAdapter
, que faz o mesmo, mas com dados em formato XML. Ambos adaptadores transformam essas informações em objetos da classe House
, que contém o número de cômodos e o preço.
Assim, a classe HomeScreen
pode usar qualquer um dos adaptadores para exibir os dados da casa de forma simples, sem se preocupar de onde essas informações estão vindo.
Pronto, além de uma melhora significativa na estética do código, se for necessário adicionar um novo tipo de dado, basta criar um novo adapter e passar como parâmetro para nossa HomeScreen
.
Trazemos flexibilidade para nosso código e tiramos a responsabilidade de fazer conversões da nossa interface, incrível né?
Mas calma que ainda não paramos por aqui. Agora vamos dar uma olhadinha no Facade.
Facade
O padrão Facade oferece uma interface simplificada para um conjunto de classes complexas, facilitando o uso de bibliotecas ou subsistemas complicados.
Como de costume, vamos ver esse design pattern na prática, em um sistema de autenticação de pessoas usuárias. Para esse exemplo, temos o código abaixo:
class AuthService {
void login(String username, String password) {
print('Usuário autenticado com sucesso.');
}
void logout() {
print('Usuário desconectado.');
}
}
class TwoFactorAuthService {
void send2FACode(String username) {
print('Enviando código de autenticação de dois fatores para $username...');
}
bool verify2FACode(String code) {
print('Código de autenticação de dois fatores verificado.');
return code == '123456'; // Exemplo de código fixo
}
}
class LoginHistoryService {
void logLoginAttempt(String username, bool success) {
String result = success ? 'sucesso' : 'falha';
print('Tentativa de login para $username: $result.');
}
}
void main() {
var authService = AuthService();
var twoFactorAuthService = TwoFactorAuthService();
var loginHistoryService = LoginHistoryService();
String username = 'user';
String password = 'pass';
String twoFactorCode = '123456';
authService.login(username, password);
twoFactorAuthService.send2FACode(username);
bool isTwoFactorSuccess = twoFactorAuthService.verify2FACode(twoFactorCode);
loginHistoryService.logLoginAttempt(username, isTwoFactorSuccess);
if (isTwoFactorSuccess) {
print('Autenticação completa.');
} else {
print('Autenticação falhou.');
}
authService.logout();
}
Neste código, temos três serviços: AuthService
para login/logout, TwoFactorAuthService
para autenticação de dois fatores, e LoginHistoryService
para registrar tentativas de login.
A lógica de autenticação está espalhada pela função main()
, onde os serviços são chamados manualmente.
Todos esses passos fazem parte do processo de autenticação de login, certo? Que tal encontrarmos uma maneira de encapsular tudo isso em um só lugar?
Assim, podemos deixar nosso código mais intuitivo e evitar ter que fazer todas essas chamadas sempre que precisarmos autenticar a pessoa usuária.
Para encapsular todas essas responsabilidades, vamos usar o padrão Facade:
class AuthFacade {
final AuthService _authService = AuthService();
final TwoFactorAuthService _twoFactorAuthService = TwoFactorAuthService();
final LoginHistoryService _loginHistoryService = LoginHistoryService();
void authenticate(String username, String password, String twoFactorCode) {
_authService.login(username, password);
_twoFactorAuthService.send2FACode(username);
bool success = _twoFactorAuthService.verify2FACode(twoFactorCode);
_loginHistoryService.logLoginAttempt(username, success);
if (success) {
print('Autenticação completa.');
} else {
print('Autenticação falhou.');
}
}
}
void main() {
var authFacade = AuthFacade();
authFacade.authenticate('user', 'pass', '123456');
}
Viu? Agora o processo de autenticação está mais simples e centralizado. Com o facade e o método authenticate
, abstraímos toda a complexidade do processo de login.
Os design patterns realmente são incríveis, não é? Mas ainda não terminamos! Agora vamos explorar os padrões comportamentais.
Padrões Comportamentais
Esses padrões são maneiras de organizar o código, facilitando a comunicação entre objetos e classes.
Eles definem como os componentes interagem entre si, ajudando a resolver problemas comuns de comunicação sem que fiquem dependentes uns dos outros.
Nós vamos estudar um dos mais famosos: o Observer!
Observer
O padrão Observer é muito útil em cenários onde temos componentes que dependem de um mesmo valor ou evento.
No Flutter, isso ocorre, por exemplo, quando um dado compartilhado entre widgets muda e todos eles precisam ser atualizados.
Sem uma estrutura adequada, podemos acabar precisando de atualizações manuais em vários pontos da nossa aplicação.
O Observer funciona criando uma dependência entre um objeto e todos que dependem dele. Quando o estado do objeto muda, todos os dependentes são notificados automaticamente.
Para entender melhor esse padrão, iremos implementar juntos um sistema de vídeos com canais e inscritos.
Cada vez que o canal postar um novo vídeo, as pessoas inscritas devem ser notificadas. Podemos fazer isso dessa maneira:
class Canal {
String nome;
Canal(this.nome);
void postarVideo(String video) {
print('$nome postou: $video');
}
}
class Usuario {
String nome;
Usuario(this.nome);
void receberVideo(String video) {
print('$nome recebeu o vídeo: $video');
}
}
void main() {
var canal = Canal('Canal de Flutter');
var usuario1 = Usuario('João');
var usuario2 = Usuario('Maria');
canal.postarVideo('Como usar o Observer Pattern');
usuario1.receberVideo('Como usar o Observer Pattern');
usuario2.receberVideo('Como usar o Observer Pattern');
canal.postarVideo('Como melhorar seu código no Flutter');
usuario1.receberVideo('Como melhorar seu código no Flutter');
usuario2.receberVideo('Como melhorar seu código no Flutter');
}
Neste código, para cada vídeo postado, precisamos manualmente fazer com que todas as pessoas inscritas recebam o vídeo.
Isso não é prático, especialmente se o número de inscritos crescer. Imagine um canal com 1000 inscritos: o trabalho seria muito maior e mais propenso a erros.
Agora, ao aplicar o padrão Observer, podemos refatorar esse código para que o canal notifique automaticamente as pessoas inscritas quando um novo vídeo for postado:
class Canal {
String nome;
List<Usuario> inscritos = [];
Canal(this.nome);
void inscrever(Usuario usuario) {
inscritos.add(usuario);
}
void postarVideo(String video) {
print('$nome postou: $video');
for (var usuario in inscritos) {
usuario.receberNotificacao(video);
}
}
}
class Usuario {
String nome;
Usuario(this.nome);
void receberNotificacao(String video) {
print('$nome recebeu a notificação do vídeo: $video');
}
}
void main() {
var canal = Canal('Canal de Flutter');
var usuario1 = Usuario('João');
var usuario2 = Usuario('Maria');
canal.inscrever(usuario1);
canal.inscrever(usuario2);
canal.postarVideo('Como usar o Observer Pattern');
canal.postarVideo('Como melhorar seu código no Flutter');
}
Com o Observer, o canal não precisa mais se preocupar em notificar manualmente cada pessoa usuária. Ao postar um vídeo, ele percorre a lista de inscritos e notifica todos automaticamente.
Grandes bibliotecas, como o provider, por debaixo dos panos também aplicam os conceitos do observer.
É interessante perceber que as soluções que estamos estudando não são nada fora da nossa realidade e ,inclusive, muitas ferramentas utilizam os design patterns.
Conclusão
Os designers patterns são realmente ferramentas incríveis e sem dúvidas vão ajudar você a evoluir suas aplicações Flutter.
Neste artigo, abordamos apenas alguns design patterns, mas existem muitos outros que podem resolver diferentes problemas que você encontra em seus projetos. Não deixe de pesquisar e se aprofundar sempre que necessário.
E caso queira mergulhar em outros conteúdos do mundo mobile, deixo aqui algumas sugestões do que temos disponível na plataforma Alura:
Artigos:
- Design patterns: Breve introdução aos padrões de projeto
- Flutter: escolhendo uma arquitetura para o seu projeto
Formação:
Bons estudos!