Flutter: Injetor de Dependência
Uma vez que sabemos o que é para que serve Injeção de Dependência, vamos agora então falar sobre uma forma de implementar o acesso aos nossos objetos de forma “prática” dentro nossa aplicação.
Muitas vezes precisamos usar um objeto (isto é: uma instância de uma classe) para realizar determinada tarefa dentro de uma função. Mas infelizmente temos como (mal) hábito criar tal objeto ao invés de solicita-lo como parâmetro!
Isto viola a regra de Injeção de Dependência e dificulta a implementação dos testes unitários.
Então o correto é sempre solicitar a instância do objeto desejado ao invés de criá-lo!
É ai que iremos usar o nosso Injetor de Dependência para nos ajudar!
Pense no injetor como um armário, onde cada gaveta tem uma etiqueta na frente dizendo o “tipo” de objeto existente dentro da gaveta!
O que faremos agora é apresentar uma implementação (muito) simples deste “armário”, para que você possa entender como ele funciona e eventualmente criar e usar no futuro suas próprias implementações mais complexas:
class Injector {
static Injector? _instance;
static Injector get instance => _instance ??= Injector._();
Injector._();
final Map<Type, dynamic> _objects = {};
bool contains<T>() => _objects.containsKey(T);
void add<T>(T instance) => contains<T>() ? throw Exception('Class ${T.runtimeType} already registered!') : _objects[T] = instance;
T get<T>() => contains<T>() ? _objects[T] : throw Exception('Class ${T.runtimeType} not registered!');
void remove<T>() => _objects.remove(T);
void clear() => _objects.clear();
}
Nesta implementação temo o nosso Injector como um singleton. Isso nos permitirá acessá-lo de qualquer ponto do nosso projeto em nenhuma burocracia! (É claro que você pode criar sua própria implementação usando o InheritedWidget ou qualquer outro padrão que desejar!)
Como você pode ter notado, ele nada mais é do que um Map<Type, dynamic> que representa nossas “gavetas” com suas “etiquetas” (Type) dizendo quais são os nossos “objetos” (dynamic) contidos nelas. Os métodos que ele possui são simplesmente para manipular/acessar este Map.
Simples, não acha?! Mas como usamos isso na vida real?
Primeiramente vamos escrever algumas classes do nosso projeto, com suas abstrações e respectivas implementações para efeito de ilustração:
(obs: estas classes estão bem simplificadas para efeito de exemplo)
Gateway:
abstract class Gateway {
Future<Response> post(String path, {Object? data});
Future<Response> get(String path, {Object? data});
}
class GatewayDio implements Gateway {
final Dio dio;
GatewayDio(this.dio);
@override
Future<Response> post(String path, {Object? data}) => dio.post(path, data: data);
@override
Future<Response> get(String path, {Object? data}) => dio.get(path, data: data);
}
OrderRepository:
abstract class OrderRepository {
Future<Order?> createOrder(Order order);
Future<List<Order>?> getOrders();
}
class OrderRepositoryImpl extends OrderRepository {
Gateway gateway;
OrderRepositoryImpl(this.gateway);
@override
Future<Order?> createOrder(Order order) async {
try {
var response = await gateway.post("orders", data: order.toJson());
if (response.statusCode != 200) return null;
return Order.fromJson(response.data);
} catch (e) {
return null;
}
}
@override
Future<List<Order>?> getOrders() async {
try {
var response = await gateway.get("orders", data: {});
if (response.statusCode != 200) return null;
return (response.data as List).map((e) => Order.fromJson(e)).toList();
} catch (e) {
return null;
}
}
}
ProductRepository:
abstract class ProductRepository {
Future<Product?> getProduct(String id);
Future<List<Product>?> getProducts();
}
class ProductRepositoryImpl extends ProductRepository {
Gateway gateway;
ProductRepositoryImpl(this.gateway);
@override
Future<Product?> getProduct(String id) async {
try {
var response = await gateway.get("products", data: {"id": id});
if (response.statusCode != 200) return null;
return Product.fromJson(response.data);
} catch (e) {
return null;
}
}
@override
Future<List<Product>?> getProducts() async {
try {
var response = await gateway.get("products", data: {});
if (response.statusCode != 200) return null;
return (response.data as List).map((e) => Product.fromJson(e)).toList();
} catch (e) {
return null;
}
}
}
OrderUseCases:
abstract class CreateOrderUseCase {
Future<Order?> call(Order order);
}
class CreateOrderUseCaseImpl implements CreateOrderUseCase {
OrderRepository orderRepository;
CreateOrderUseCaseImpl(this.orderRepository);
@override
Future<Order?> call(Order order) async {
return orderRepository.createOrder(order);
}
}
abstract class GetOrdersUseCase {
Future<List<Order>?> call();
}
class GetOrdersUseCaseImpl implements GetOrdersUseCase {
OrderRepository orderRepository;
GetOrdersUseCaseImpl(this.orderRepository);
@override
Future<List<Order>?> call() async {
return orderRepository.getOrders();
}
}
ProductUseCases:
abstract class GetProductUseCase {
Future<Product?> call(String id);
}
class GetProductUseCaseImpl implements GetProductUseCase {
ProductRepository productRepository;
GetProductUseCaseImpl(this.productRepository);
@override
Future<Product?> call(String id) async {
return productRepository.getProduct(id);
}
}
abstract class GetProductsUseCase {
Future<List<Product>?> call();
}
class GetProductsUseCaseImpl implements GetProductsUseCase {
ProductRepository productRepository;
GetProductsUseCaseImpl(this.productRepository);
@override
Future<List<Product>?> call() async {
return productRepository.getProducts();
}
}
Como sei que você inteligente e astuto, duas coisas importantes que você deve ter notado:
- Todos as abstrações NÃO POSSUEM DEPENDÊNCIAS!
- Todas as implementações POSSUEM PELO MENOS UMA DEPENDÊNCIA de uma classe “anterior” do nosso projeto.
Se fossemos “desenhar” quem depende de quem (do ponto de vista das implementações), teremos:
UseCase => Repository => Gateway => Dio => Flutter
UseCase “depende” de um Repository… que “depende” de um Gateway… que “depende” do Dio… que não depende de nenhuma classe da nossa aplicação!
Então agora vamos por em prática o Injector!
Primeiramente vamos criar uma função “top level” para fazer o “setup” das nossas dependências:
void setUpDependencies() {
Injector injector = Injector.instance;
}
Em seguida vamos adicionar a primeira dependência (sempre de trás para frente).
Logo, a primeira dependência que temos é uma instancia da classe Dio:
void setUpDependencies() {
Injector injector = Injector.instance;
injector.add<Dio>(Dio());
}
Vamos entender o que acabamos de fazer:
injector.add<Dio>(Dio());
Estamos dizendo ao nosso injector para criar (add) uma “gaveta”, colocar a etiqueta <Dio> e guardar dentro dela uma instância do objeto Dio().
Ótimo! Então vamos agora adicionar a próxima dependência: Gateway.
void setUpDependencies() {
Injector injector = Injector.instance;
injector.add<Dio>(Dio());
injector.add<Gateway>(GatewayDio(injector.get<Dio>()));
}
Vamos novamente entender o que acabamos de fazer:
injector.add<Gateway>(GatewayDio(injector.get<Dio>()));
Estamos dizendo ao nosso injector para criar (add) uma nova gaveta, colocar a etiqueta <Gateway> e guarde dentro dela uma instância do objeto GatewayDio().
Porém, para criarmos uma nova instância do objeto GatewayDio, precisamos passar uma instancia de um objeto Dio!
E onde podemos achar um Dio? No próprio injector!!
Veja que ja adicionamos uma instancia do Dio instantes atrás!
Basta pedir então ao injector para procurar uma gaveta com a etiqueta <Dio> e pegar o objeto que está nela!!
É isto que o injector.get<Dio>() faz!
Esta função obtém do injector um objeto a partir do “tipo” que voce especificar na tipagem!
Resumindo: iremos criar novas instâncias dos nosso objetos usando como dependência as instâncias de objetos que já criamos e guardamos no nosso injector!
Logo, basta irmos usando desta mesma forma para os demais objetos:
void setUpDependencies() {
Injector injector = Injector.instance;
// dio
injector.add<Dio>(Dio());
// gateway
injector.add<Gateway>(GatewayDio(injector.get<Dio>()));
// repositories
injector.add<OrderRepository>(OrderRepositoryImpl(injector.get<Gateway>()));
injector.add<ProductRepository>(ProductRepositoryImpl(injector.get<Gateway>()));
// use cases
injector.add<CreateOrderUseCase>(CreateOrderUseCaseImpl(injector.get<OrderRepository>()));
injector.add<GetOrdersUseCase>(GetOrdersUseCaseImpl(injector.get<OrderRepository>()));
injector.add<GetProductUseCase>(GetProductUseCaseImpl(injector.get<ProductRepository>()));
injector.add<GetProductsUseCase>(GetProductsUseCaseImpl(injector.get<ProductRepository>()));
}
Agora basta chamar a função setUpDependencies() no inicio do seu app:
void main() async {
setUpDependencies();
runApp(MyApp());
}
… que ele está “cheio de gavetas” para serem usadas/chamadas nas funções/classes que desejar, sempre usando o método get<Type>():
GetProductsUse useCase = injector.get<GetProductsUse>();
List<Product>? products = await useCase();
E agora vamos ver uma vantagem bem interessante em se usar um injetor de dependências.
Vamos dizer que a partir de agora vamos deixar de usar uma web api e passaremos por exemplo a usar o firebase!
UseCase => Repository => Gateway => Firebase => Flutter
Se a nossa web api foi bem “projetada”, com os caminhos respeitando as regras RESTFull, basta para isto criarmos uma nova implementação do Gateway usando o Firebase:
class GatewayFirebase implements Gateway {
final DatabaseReference dbRef;
GatewayFirebase(this.dbRef);
@override
Future<Response> post(String path, {Object? data}) => // do firebase stuff!
@override
Future<Response> get(String path, {Object? data}) => // do firebase stuff!
}
E alterarmos as nossas dependências, retirando o Dio, inserindo o Firebase, e alterando a criação do Gateway que trocou o Dio pelo Firebase:
void setUpDependencies() {
Injector injector = Injector.instance;
// dio
// injector.add<Dio>(Dio());
// firebase
injector.addInstance<DatabaseReference>(FirebaseDatabase.instance.ref());
// gateway
// injector.add<Gateway>(GatewayDio(injector.get<Dio>()));
injector.add<Gateway>(GatewayFirebase(injector.get<DatabaseReference>()));
// repositories
injector.add<OrderRepository>(OrderRepositoryImpl(injector.get<Gateway>()));
injector.add<ProductRepository>(ProductRepositoryImpl(injector.get<Gateway>()));
// use cases
injector.add<CreateOrderUseCase>(CreateOrderUseCaseImpl(injector.get<OrderRepository>()));
injector.add<GetOrdersUseCase>(GetOrdersUseCaseImpl(injector.get<OrderRepository>()));
injector.add<GetProductUseCase>(GetProductUseCaseImpl(injector.get<ProductRepository>()));
injector.add<GetProductsUseCase>(GetProductsUseCaseImpl(injector.get<ProductRepository>()));
}
Pronto! Simples assim! Agora toda a nossa aplicação está acessando os dados no firebase ao invés de em uma web api, sempre precisarmos alterar 1 linha sequer dos repositórios, use cases, controllers, widgets, etc!
Só isso já justificaria usar a injeção de dependência com um injector, não acham?
E no próximo artigo iremos falar mais sobre a forma ideal de usarmos o nosso injector dentro do projeto, criando o mínimo de dependência e acoplamento possível!
See ya!