В данной статье мы попытаемся разобраться с Dependency Injection в Android (и не только) на примере набирающей популярность open source библиотеки Dagger
И так, что же такое Dependency Injection? Согласно википедии, это design pattern, позволяющий динамически описывать зависимости в коде, разделяя бизнес-логику на более мелкие блоки. Это удобно в первую очередь тем, что впоследствии можно эти самые блоки подменять тестовыми, тем самым ограничивая зону тестирования.
Несмотря на замудреное определение, принцип довольно прост и тривиален. Я уверен, что большинство из программистов так или иначе реализовывали этот pattern, даже порой об этом не догадываясь.
Рассмотрим упрощенную (до псевдокода) версию Twitter клиента.
В теории, диаграмма зависимостей выглядит примерно так:
Давайте взглянем как это выглядит в коде:
public class Tweeter
{
public void tweet(String tweet)
{
TwitterApi api = new TwitterApi();
api.postTweet("Test User", tweet);
}
}
public class TwitterApi
{
public void postTweet(String user, String tweet)
{
HttpClient client = new OkHttpClient();
HttpUrlConnection connection = client.open("....");
/* post tweet */
}
}
Как видим, набор интерфейсов довольно прост, поэтому использовать мы это будем примерно так:
Tweeter tweeter = new Tweeter();
tweeter.tweet("Hello world!");
Пока все идет хорошо, твиты улетают — все счастливы. Теперь возникает необходимость все это протестировать. Сразу же замечаем, что неплохо было бы иметь возможность подменять Http клиент на тестовый, чтобы возвращать какой-нибудь тестовый результат и не ломиться в сеть каждый раз. В этом случае, нам надо снять с класса TwitterApi обязанность создавать Http клиент и сгрузить эту обязанность вышестоящим классам. Наш код немного преображается:
public class Tweeter
{
private TwitterApi api;
public Tweeter(HttpClient httpClient)
{
this.api = new TwitterApi(httpClient);
}
public void tweet(String tweet)
{
api.postTweet("Test User", tweet);
}
}
public class TwitterApi
{
private HttpClient client;
public TwitterApi(HttpClient client)
{
this.client = client;
}
public void postTweet(String user, String tweet)
{
HttpUrlConnection connection = client.open("....");
/* post tweet */
}
}
Теперь мы видим, что при необходимости простестировать наш код, мы можем легко «подставить» тестовый Http клиент, который будет возвращать тестовые результаты:
Tweeter tweeter = new Tweeter(new MockHttpClient);
tweeter.tweet("Hello world!");
Казалось бы, что может быть проще? На самом деле, сейчас мы «вручную» реализовали Dependency Injection паттерн. Но есть одно «но». Представим ситуацию, что у нас есть класс Timeline, который умеет загружать последние n сообщений. Этот класс тоже использует TwitterApi:
Timeline timeline = new Timeline(new OkHttpClient(), "Test User");
timeline.loadMore(20);
for(Tweet tweet: timeline.get())
{
System.out.println(tweet);
}
Наш класс выглядит примерно так:
public class Timeline
{
String user;
TweeterApi api;
public Timeline(HttpClient httpClient, String user)
{
this.user = user;
this.api = new TweeterApi(httpClient);
}
public void loadMore(int n){/*.....*/}
public List<Tweet> get(){/*.......*/}
}
Вроде бы все ничего — мы применили тот же подход, что и с классом Tweeter — дали возможность указывать Http клиент при создании объекта, что позволяет нам протестировать этот модуль, не завися при этом от сети. Но! Вы заметили, сколько кода мы продублировали и как нам приходится «протаскивать» Http клиент прямо из «головы» приложения? Конечно, можно добавить конструкторы по умолчанию, которые будут создавать реальный Http клиент, и использовать кастомный конструктор только при тестировании, но ведь это не решает проблему, а только маскирует ее.
Давайте рассмотрим как мы можем улучшить сложившуюся ситуацию.
Dagger
Dagger — это open source Dependency Injection библиотека от разработчиков okhttp, retrofit, picasso и многих других замечательных библиотек, известных многим Android разработчикам.
Главные преимущества Dagger (по сравнению с тем же Guice):
- Статический анализ всех зависимостей
- Определение ошибок конфигурации на этапе компиляции (не только в runtime)
- Отсутствие reflection, что значительно ускоряет процесс конфигурации
- Довольно небольшая нагрузка на память
В Dagger процесс конфигурации зависимостей разбит на 3 больших блока:
- инициализация графа завизимостей (ObjectGraph)
- запрос зависимостей (
@Inject
) - удовлетворение зависимостей (
@Module
/@Provides
)
Запрос зависимостей (request dependency)
Чтобы попросить Dagger проиницализировать одно из полей, все что нужно сделать — добавить аннотацию
@Inject
:@Inject
private HttpClient client;
… и убедиться, что этот класс добавлен в граф зависимостей (об этом далее)
Удовлетворение зависимостей (provide dependency)
Чтобы сказать даггеру какой инстанс клиента необходимо создать, необходимо создать «модуль» — класс аннотированный
@Module
:@Module
public class NetworkModule{...}
Этот класс отвечает за «удовлетворение» части зависимостей, запрошенных приложением. В этом классе нужно создать так называемый «провайдер» — метод, который возвращает инстанс HttpClient (аннотированный
@Provide
):@Module(injects=TwitterApi.class)
public class NetworkModule
{
@Provides @Singleton
HttpClient provideHttpClient()
{
return new OkHttpClient();
}
}
Этим мы сказали Dagger'y, чтобы он создал OkHttpClient для любого, кто попросил HttpClient посредством
@Inject
аннотацииСтоит упомянуть, что для того, чтобы compile-time валидация работала, необходимо указать все классы (в параметре injects), которые просят эту зависимость. В нашем случае, HttpClient необходим только TwitterApi классу.
Аннотация
@Singleton
указывает Dagger'у, что необходимо создать только 1 инстанс клиента и закэшировать его.Cоздание графа
Теперь перейдем к созданию графа. Для этого я создал класс
Injector
, который инициализирует граф одним (или более) модулем. В контексте Android приложения, удобней всего это делать при создании приложения (наследуемся от Application и перегружаем onCreate()
). В данном примере, я создал TweeterApp клас, который содержит в себе остальные компоненты (Tweeter и Timeline)public class Injector
{
public static ObjectGraph graph;
public static void init(Object... modules)
{
graph = ObjectGraph.create(modules);
}
public static void inject(Object target)
{
graph.inject(target);
}
}
public class TweeterApp
{
public static void main(String... args)
{
Injector.init(new NetworkModule());
Tweeter tweeter = new Tweeter();
tweeter.tweet("Hello world");
Timeline timeline = new Timeline("Test User");
timeline.loadMore(20);
for(Tweet tweet: timeline.get())
{
System.out.println(tweet);
}
}
}
Теперь вернемся к запросу зависимостей:
public class TwitterApi
{
@Inject
private HttpClient client;
public TwitterApi()
{
//Добавляем класс в граф зависимостей
Injector.inject(this);
//На этом этапе "магическим" образом client проинициализирован Dagger'ом
}
public void postTweet(String user, String tweet)
{
HttpUrlConnection connection = client.open("....");
/* post tweet */
}
}
Заметьте
Injector.inject(Object)
. Это необходимо для того, чтобы добавить класс в граф зависимостей. Т.е. если у нас есть хотя бы один @Inject
в классе — нам необходимо добавить этот класс к граф. В результате в нашем графе должны быть все классы, которые просят зависимости (каждый из этих классов должен сделать ObjectGraph.inject()
) + модули, которые удовлетворяют эти зависимости (обычно добавляются на этапе инициалзации графа).Теперь вернемся к нашей изначальной задаче — протестировать все. Нам необходимо каким-то образом уметь подменять HttStack. За удовлетворение этой зависимости (хмм — только сейчас заметил как это интересно звучит) отвечает модуль NetworkModule:
@Provides @Singleton
HttpClient provideHttpClient()
{
return new OkHttpClient();
}
Один из вариантов — это добавить какой-нибудь конфигурационный файл, который будет диктовать какой environment использовать:
@Provides @Singleton
HttpClient provideHttpClient()
{
if(Config.isDebugMode())
{
return new MockHttpClient();
}
return new OkHttpClient();
}
Но есть вариант еще элегантней. В Dagger можно создавать модули, переопределяющие функции, предоставляющие зависимости. Для этого в модуль надо добавить параметр
overrides=true
:@Module(overrides=true, injects=TwitterApi.class)
public class MockNetworkModule
{
@Provides @Singleton
HttpClient provideHttpClient()
{
return new MockHttpClient();
}
}
Все что остается сделать — это добавить этот модуль в граф на этапе инициализации:
public class TweeterApp
{
public static void main(String... args)
{
Injector.init(new NetworkModule(), new MockNetworkModule());
Tweeter tweeter = new Tweeter();
tweeter.tweet("Hello world");
Timeline timeline = new Timeline("Test User");
timeline.loadMore(20);
for(Tweet tweet: timeline.get())
{
System.out.println(tweet);
}
}
}
Теперь все наши запросы будут идти через тестовый Http клиент.
Это далеко не все фичи Dagger'a — я описал только один из возможных сценариев использования данной библиотеки. В любом случае, без вдумчивого прочтения документации не обойтись.
Вот что получилось в итоге (то же самое, что и выше, но собранное в кучу)
//Entry point нашей программы
public class TweeterApp
{
public static void main(String... args)
{
Injector.init(new NetworkModule());
Tweeter tweeter = new Tweeter();
tweeter.tweet("Hello world");
Timeline timeline = new Timeline("Test User");
timeline.loadMore(20);
for(Tweet tweet: timeline.get())
{
System.out.println(tweet);
}
}
}
//Инициализатор графа
public class Injector
{
public static ObjectGraph graph;
public static void init(Object... modules)
{
graph = ObjectGraph.create(modules);
}
public static void inject(Object target)
{
graph.inject(target);
}
}
//Собственно, Tweeter (уже не принимающий HttpClient в конструкторе)
public class Tweeter
{
private TwitterApi api;
public Tweeter()
{
this.api = new TwitterApi();
}
public void tweet(String tweet)
{
api.postTweet("Test User", tweet);
}
}
//TwitterApi, который запрашивает HttpClient у Dagger'a
public class TwitterApi
{
@Inject
private HttpClient client;
public TwitterApi()
{
//Добавляем класс в граф зависимостей
Injector.inject(this);
//На этом этапе "магическим" образом client проинициализирован Dagger'ом
}
public void postTweet(String user, String tweet)
{
HttpUrlConnection connection = client.open("....");
/* post tweet */
}
}
//Модуль, который предоставляет HttpClient всем, кто об этом просил (список "просящих" указывается в 'injects' параметре)
@Module(injects=TwitterApi.class)
public class NetworkModule
{
@Provides @Singleton
HttpClient provideHttpClient()
{
return new OkHttpClient();
}
}