Singletons em Flutter: como evitá-los e o que fazer em vez disso

Tempo de leitura: 8 minutes

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!

 

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ão lazy-loaded por padrão (e isso também é verdade para variáveis de classe static). 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 como late.

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!