Singletons em Flutter: como evitá-los e o que fazer em vez disso
Singletons são um tópico muito controverso e debatido na comunidade de desenvolvimento de software:
- Algumas pessoas dizem que você deve evitá-los a todo custo. ❌
- Outros são mais pragmáticos e os utilizam apenas em casos específicos. 🔍
- E alguns os usam indiscriminadamente como se não houvesse amanhã. 😅
Para trazer alguma clareza, este artigo abordará o seguinte:
- introdução a singletons em Dart/Flutter
- quais problemas eles resolvem
- que outros problemas eles apresentam e como superá-los
- alternativas para singletons
No final, você entenderá melhor por que os singletons podem tornar seu código menos sustentável e testável e o que você pode fazer em vez disso.
Preparar? vamos!
Conteudo
O que é um singleton?
De acordo com esta página na Wikipédia:
O padrão singleton é um padrão de design de software que restringe a instanciação de uma classe a uma instância “única”.
A página também diz que o padrão singleton resolve problemas permitindo:
- Certifique-se de que uma classe tenha apenas uma instância
- Acesse facilmente a única instância de uma classe
- Controle sua instanciação
- Restringir o número de instâncias
- Acessar uma variável global
Em outras palavras, o padrão singleton garante que apenas uma instância de uma classe seja criada, facilitando o acesso a ela como uma variável global.
Como implementar um Singleton no Dart
Esta é a maneira mais simples:
class Singleton { /// construtor privado Singleton._(); /// a primeira e única instância deste singleton static final instance = Singleton._(); }
Ao tornar o construtor privado, garantimos que a classe não pode ser instanciada fora do arquivo onde está definida.
E, como resultado, a única maneira de acessá-lo é chamar Singleton.instance
em nosso código.
Em alguns casos, é preferível usar uma variável getter estática. Para formas alternativas de implementar um singleton no Dart, leia este tópico no StackOverflow.
Alguns exemplos de singleton no Flutter
Se você já usou o Firebase antes, estará familiarizado com este código, que pode ser usado para fazer login quando um botão é pressionado:
ElevatedButton( // acessa FirebaseAuth como singleton e chama um de seus métodos onPressed: () => FirebaseAuth.instance.signInAnonymously(), child: Text('Sign in anonymously'), )
O padrão singleton é usado por todos os plug-ins do Firebase. E a única maneira de chamar seus métodos é com o instance
getter:
FirebaseFirestore.instance.doc('path/to/document'); FirebaseFunctions.instance.httpsCallable('createOrder'); FirebaseMessaging.instance.deleteToken();
Mas espere um segundo! Se os plug-ins oficiais do Firebase forem implementados como singletons, certamente não há problema em projetar suas classes da mesma maneira, certo? 🧐
Não tão rápido.
Apenas uma instância
Veja bem, essas classes foram projetadas como singletons para evitar que você crie mais de uma instância em seu código:
// Nota: este código não irá compilar pois o construtor é privado // dentro do WidgetA final auth1 = FirebaseAuth(); // dentro do WidgetB - instância diferente: final auth2 = FirebaseAuth();
O código acima não compila. E não deveria, porque você tem apenas um serviço de autenticação, atuando como uma única fonte de verdade:
// dentro do widget final auth1 = FirebaseAuth.instance; // dentro do WidgetB - mesma instância: final auth2 = FirebaseAuth.instance;
Este é um objetivo muito nobre, e os singletons geralmente são uma solução razoável para o projeto de biblioteca ou pacote.
Mas ao escrever o código do aplicativo, devemos ter muito cuidado com a forma como os usamos, pois podem causar muitos problemas em nossa base de código.
Os aplicativos Flutter têm árvores de widgets profundamente aninhadas. Como resultado, os singletons facilitam o acesso aos objetos de que precisamos, a partir de qualquer widget. Mas os singletons têm muitas desvantagens e existem alternativas melhores que ainda são fáceis de usar.
Desvantagens do Singleton
Para entender melhor por que os singletons são problemáticos, aqui está uma lista de desvantagens comuns, junto com possíveis soluções.
1. Singletons são difíceis de testar
O uso de singletons torna seu código difícil de testar.
Considere este exemplo:
class FirebaseAuthRepository { Future<void> signOut() => FirebaseAuth.instance.signOut(); }
Nesse caso, é impossível escrever um teste para verificar se FirebaseAuth.instance.signOut()
é chamado:
test('calls signOut', () async { final authRepository = FirebaseAuthRepository(); await authRepository.signOut(); // como esperar que FirebaseAuth.instance.signOut() foi chamado? });
Uma solução simples é injetar FirebaseAuth
como uma dependência, assim:
class FirebaseAuthRepository { // declare a FirebaseAuth property and pass it as a constructor argument const FirebaseAuthRepository(this._auth); final FirebaseAuth _auth; // usa quando necessário Future<void> signOut() => _auth.signOut(); }
Como resultado, podemos facilmente mock da dependência em nosso teste e escrever expectativas contra ela:
import 'package:mocktail/mocktail.dart'; // declara uma classe simulada que implementa o tipo de nossa dependência class MockFirebaseAuth extends Mock implements FirebaseAuth {} test('calls signOut', () async { // cria a dependência fictícia final mock = MockFirebaseAuth(); // stub seu(s) método(s) para retornar um valor quando chamado when(mock.signOut).thenAnswer((_) => Future.value()); // cria o objeto em teste e passa o mock como argumento final authRepository = FirebaseAuthRepository(mock); // chama o método desejado await authRepository.signOut(); // verifica se o método foi chamado no mock expect(mock.signOut).called(1); });
Confira o pacote mocktail para mais informações sobre como escrever testes usando mocks.
2. Singletons são dependências implícitas
Vamos relembrar o exemplo anterior:
class FirebaseAuthRepository { Future<void> signOut() => FirebaseAuth.instance.signOut(); }
Nesse caso, é fácil ver que FirebaseAuthRepository
depende de FirebaseAuth
.
Mas assim que temos classes com algumas dezenas de linhas de código, fica muito mais difícil identificar os singletons.
Por outro lado, as dependências são muito mais fáceis de ver quando são passadas como argumentos explícitos do construtor:
class FirebaseAuthRepository { // fácil de encontrar as dependências aqui, // mesmo que esta classe se torne muito grande const FirebaseAuthRepository(this._auth); final FirebaseAuth _auth; Future<void> signOut() => _auth.signOut(); }
3. Lazy Initialization
A inicialização de certos objetos pode ser cara:
class HardWorker { HardWorker._() { print('trabalho iniciado'); // faz algum processamento pesado } static final instance = HardWorker._(); } void main() { // imprime 'trabalho iniciado' imediatamente final hardWorker = HardWorker.instance; }
No exemplo acima, todo o código de processamento pesado é executado assim que inicializamos a variável hardWorker
dentro do método main()
.
Nesses casos, podemos usar late para adiar a inicialização do objeto até mais tarde (quando for realmente usado):
void main() { // imprime nada // a inicialização acontecerá mais tarde quando *usarmos* hardWorker late final hardWorker = HardWorker.instance; ... // a inicialização acontece aqui // imprime 'trabalho iniciado' do construtor hardWorker.logResult(); }
No entanto, essa abordagem é propensa a erros, pois é muito fácil esquecer de usar o late
.
Nota: No Dart, todas as variáveis
global
sãolazy-loaded
por padrão (e isso também é verdade para variáveis de classestatic
). Isso significa que eles são inicializados apenas quando são usados pela primeira vez. Por outro lado, as variáveis locais são inicializadas assim que são declaradas, a menos que sejam declaradas comolate
.
Como alternativa, podemos usar pacotes como get_it, que facilita o registro de um lazy singleton:
class HardWorker { HardWorker() { // faz algum processamento pesado } } // registra um singleton preguiçoso (ainda não será criado) getIt.registerLazySingleton<HardWorker>(() => HardWorker()); // quando precisarmos, faça isso final hardWorker = getIt.get<HardWorker>();
E podemos fazer o mesmo com o Riverpod, já que todos os provedores são lazy por padrão:
//cria um provedor final hardWorkerProvider = Provider<HardWorker>((ref) { return HardWorker(); }); // lê o provedor final hardWorker = ref.read(hardWorkerProvider);
Como resultado, o objeto de que precisamos só será criado quando o usarmos pela primeira vez.
Uma das minhas coisas favoritas sobre o Riverpod é que ele torna muito fácil testar o código usando provedores. Para obter mais detalhes, leia a documentação do Riverpod sobre testes.
4. Ciclo de vida da instância
Quando inicializamos uma instância singleton, ela permanecerá ativa até o final do tempo (ou seja, quando o aplicativo for fechado 😅).
E se a instância consome muita memória ou mantém uma conexão de rede aberta, não podemos liberá-la antecipadamente se quisermos.
Por outro lado, pacotes como get_it e Riverpod nos dão mais controle sobre quando uma determinada instância é descartada.
Na verdade, o Riverpod é bastante inteligente e nos permite controlar facilmente o ciclo de vida do estado de um provedor.
Por exemplo, podemos usar o modificador autoDispose para garantir que nosso HardWorker seja descartado assim que o último ouvinte for removido:
final hardWorkerProvider = Provider.autoDispose<HardWorker>((ref) { return HardWorker(); });
Isso é mais útil quando queremos descartar um objeto assim que o widget que o estava usando for desmontado.
5. Thread Safety
Em linguagens multithread, precisamos ter cuidado ao acessar singletons em diferentes threads, e algum mecanismo de sincronização pode ser necessário se eles compartilharem dados mutáveis.
Mas no Dart, isso geralmente não é uma preocupação porque todo o código do aplicativo dentro de um aplicativo Flutter pertence ao isolado principal.
Porém, se acabarmos criando isolados separados para realizar alguns cálculos pesados, precisamos ser mais cuidadosos:
Alternativas para singletons
Depois de revisar as principais desvantagens do uso de singletons, vamos ver quais alternativas são adequadas para o desenvolvimento de aplicativos Flutter.
Injeção de dependência
A Wikipédia define injeção de dependência como:
um padrão de design no qual um objeto recebe outros objetos dos quais ele depende.
No Dart, isso é facilmente implementado com argumentos explícitos do construtor:
class FirebaseAuthRepository { // injeta a dependência como um argumento do construtor const FirebaseAuthRepository(this._auth); // esta propriedade é uma dependência final FirebaseAuth _auth; // usa quando necessário Future<void> signOut() => _auth.signOut(); }
A injeção de dependência promove uma boa separação de preocupações, tornando as classes independentes da criação dos objetos dos quais dependem.
Mas como podemos inicializar a classe FirebaseAuthRepository
que criamos acima e usá-la dentro de widgets profundamente aninhados (ou em outro lugar em nosso código)?
Use get_it como um localizador de serviço
Se usarmos o pacote get_it, podemos registrar nossa classe como um lazy singleton quando o aplicativo iniciar:
void main() { // GetIt em si é um singleton, veja a nota abaixo para mais informações final getIt = GetIt.instance; getIt.registerLazySingleton<FirebaseAuthRepository>( () => FirebaseAuthRepository(FirebaseAuth.instance), ); runApp(const MyApp()); }
E então podemos acessá-lo assim quando necessário:
final authRepository = getIt.get<FirebaseAuthRepository>();
Observação: a própria classe GetIt
é um singleton. Mas tudo bem porque o que importa é que isso nos permite desacoplar nossas dependências dos objetos que precisam delas. Para uma visão geral mais aprofundada, leia a documentação do pacote.
Usar provedores Riverpod
O Riverpod facilita a criação de provedores como variáveis globais:
final authRepositoryProvider = Provider<FirebaseAuthRepository>((ref) { return FirebaseAuthRepository(FirebaseAuth.instance); });
E se tivermos um objeto ref
, podemos facilmente ler qualquer provedor para obter seu valor:
final authRepository = ref.read(authRepositoryProvider);
Como resultado, só precisamos chamar FirebaseAuth.instance
uma vez em toda a base de código (em vez de muitas vezes), pois agora podemos get ou read seu valor (usando get_it ou Riverpod).
Ao contrário das soluções baseadas em InheritedWidget ou no pacote Provider, os provedores Riverpod vivem fora da árvore de widgets. Isso os torna seguros para uso em tempo de compilação, sem exceções de tempo de execução. Para mais informações sobre isso, leia meu Guia Essencial para Riverpod.
Conclusão
Agora que abordamos as principais desvantagens do uso de singletons e suas alternativas, gostaria de deixar algumas dicas práticas com base na experiência pessoal.
1. Não crie seus próprios singletons
A menos que você seja um autor de pacote e tenha um bom motivo para fazê-lo, não crie seus próprios singletons. Mesmo se você acessar APIs de terceiros como singletons, não use Singleton.instance
em todos os lugares, pois isso dificulta o teste do seu código.
Em vez disso, crie suas classes passando quaisquer dependências como argumentos do construtor.
Em seguida, siga o passo 2. 👇
2. Use pacotes como get_it ou Riverpod
Esses pacotes fornecem um controle muito melhor sobre suas dependências, o que significa que você pode inicializá-los, acessá-los e descartá-los facilmente, sem nenhuma das desvantagens descritas acima.
Depois de pegar o jeito, você precisa descobrir quais dependências existem entre diferentes tipos de objetos (widgets, controladores, serviços, repositórios, etc.). E isso leva ao passo 3. 👇
3. Escolha uma boa arquitetura de aplicativo
Ao criar aplicativos complexos, escolha uma boa arquitetura de aplicativo que ajude você a:
- estruture seu código e dê suporte à sua base de código conforme ela cresce
- decidir quais objetos diferentes devem (e não devem) depender
Seguindo este conselho, criei um aplicativo de comércio eletrônico de tamanho médio com código testável e sustentável sem criar nenhum singleton, usando esta arquitetura de aplicativo de referência baseada no Riverpod.
Esquentando
Os singletons facilitam o acesso às dependências em seu código. Mas eles criam mais problemas do que resolvem.
Uma alternativa melhor é gerenciar dependências usando pacotes testados em batalha, como get_it e Riverpod.
Portanto, escolha um e use-o em seus aplicativos, juntamente com uma boa arquitetura. Ao fazer isso, você evitará muitas armadilhas e terá uma base de código muito melhor. 👍
Codificação feliz!