Рецепты под Android: IoC со вкусом Gradle

  • Tutorial
Android-проекты бывают большими. Иногда действительно большими. Один из наших проектов — новостное приложение, которое разрабатывается одновременно под две альтернативные платформы: Android и FireOS, что от Amazon. Это позволяет расширить круг читателей новостей, ведь пользователи читалок Kindle Fire любят почитать:). Однако, это же накладывает обязательство считаться с особенностями каждой из платформ. Например, если в Android вы используете GCM для push-сообщений, то в FireOS вы должны использовать для этого Amazon AWS. Аналогично и для систем покупок внутри приложения: Google in-app billing vs. In-App Purchasing. Но большой размер проекта != большой размер приложения!

В этой статье мы покажем, как используем альтернативные билды для оптимизации приложения и поддерживаем их без вреда для процесса разработки.

Что готовим?




Разрабатывая мультиплатформенное приложение, разработчик может иметь дело с кодом, который исполняется только на одной из платформ, но будучи запущенным на других будет лежать мертвым грузом. Помимо своего существования такой код наверняка привнесет в проект тем же грузом и все свои зависимости. Это уже само по себе не очень хорошо, а учитывая специфику разработки под Android: “проблему 65k”, best practice сделать загружаемый размер файла как можно меньше, с таким кодом уже обязательно нужно что-то сделать. А бесконечные проверки ifAndroid() или ifAmazon() хочется видеть чуть реже чем никогда.

Если вы опытный Android-разработчик, то наверняка уже сталкивались с такой опцией Android Gradle плагина как ProductFlavor.

Flavor (англ.) — вкус, аромат

Эта опция позволяет создавать альтернативные сборки одного и того же проекта, включая в билд файлы из разных директорий в зависимости от имени собираемого flavor-а. Зачастую ProductFlavor используют для разного рода “брендирования” приложения, подменяя ресурсы (картинки, тексты, ссылки). Другой частый случай – разделение приложения на demo- и полную версии, потому что имя собранного flavor’а автоматически попадает в поле класса BuildConfig.FLAVOR. Его значение позже можно проверить в runtime и не позволять выполнять какие-нибудь действия в demo-версии.

Разделять на flavor’ы можно не только ресурсы, но и код. Но нужно понимать, что код, используемый во flavor1, никогда не сможет взаимодействовать с кодом из flavor2. А код, лежащий в основном модуле всегда может видеть только из один flavor’ов в одно время. Всё это значит, к примеру, то, что вы не сможете написать в одном flavor’е набор utility-методов и воспользоваться ими в другом. Разделять код нужно с умом и очень аккуратно, максимально изолированно, так, что бы переключение альтернативных билдов проходило незаметно для основного модуля. Большую помощь в этом нам окажет паттерн Dependency Injection. Следуя ему, мы оставим в основном модуле только общие интерфейсы, а конкретные реализации и разложим по flavor’ам. Весь процесс рассмотрим на примере создания простого приложения для поиска репозиториев на GitHub.

Ингредиенты


Итак, нам потребуется:
  1. Экран с полем ввода, кнопкой и списком результатов (1 шт.).
  2. Класс для работы с Github Web API: его mock и настоящая реализации (итого 2 шт.).
  3. Класс для кеширования результатов поиска: также настоящая и mock-реализации (итого 2 шт.).
  4. Иконки, тексты, прогрессбары – по вкусу.


Мы будем придерживаться подхода разделения приложения на слои и сразу создадим 3 пакета: .view для представления, .models для моделей бизнеслогики и .data для классов-провайдеров контента. В пакете data нам еще понадобятся 2 пакета services и storages. В итоге вся структура должна выглядеть так:


Модельки нам хватит всего одной: “Repository”. Можно хранить в ней что захочется, а нам захотелось иметь в ней description, name и htmlUrl.

Теперь определимся с интерфейсом класса-сервиса, который будет искать репозитории AppService:
public interface AppService {
  List<Repository> searchRepositories(String query);
}

Сразу создадим и интерфейс класса, кэширующего результаты поиска RepositoryStorage:
public interface RepositoryStorage {
  void saveRepositories(String query, List<Repository> repositoryList);
  List<Repository> getRepositories(String query);
}

Мы будем создавать и хранить наши сервис и репозиторий внутри Application-класса:
public class App extends Application {

  private AppService appService;
  private RepositoryStorage repositoryStorage;

  public AppService getAppService() {
      return appService;
  }

  public RepositoryStorage getRepositoryStorage() {
      return repositoryStorage;
  }
}

Для подготовительного этапа осталось только создать сам экран и написать в нём получение и отображение результатов. В рамках демонстрационного приложения нам хватит и AsyncTask, чтобы выполнить фоновую работу, но вы всегда можете использовать свой любимый подход.
public class MainActivity extends AppCompatActivity {

  @Bind(R.id.actionSearchView) Button actionSearchView;
  @Bind(R.id.recyclerView) RecyclerView recyclerView;
  @Bind(R.id.searchQueryView) EditText searchQueryView;
  @Bind(R.id.progressView) View progressView;
  private SearchResultsAdapter adapter;
  private AppService appService;
  private SearchTask searchTask;
  private RepositoryStorage repositoryStorage;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      setContentView(R.layout.activity_main);
      ButterKnife.bind(this);
      appService = ((App) getApplication()).getAppService();
      repositoryStorage = ((App) getApplication()).getRepositoryStorage();
      recyclerView.setLayoutManager(new LinearLayoutManager(this));
      adapter = new SearchResultsAdapter();
      recyclerView.setAdapter(adapter);
      searchQueryView.setOnEditorActionListener(new TextView.OnEditorActionListener() {
          @Override
          public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
              querySearch(searchQueryView.getText().toString());
              return true;
          }
      });
  }

  @OnClick(R.id.actionSearchView)
  void onActionSearchClicked() {
      querySearch(searchQueryView.getText().toString());
  }

  private void querySearch(String query) {
      if (TextUtils.isEmpty(query)) {
          return;
      }
      if (searchTask != null) {
          return;
      }

      InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
      imm.hideSoftInputFromWindow(searchQueryView.getWindowToken(), 0);

      searchTask = new SearchTask();
      searchTask.execute(query);
      showProgress(true);
  }

  private void showData(List<Repository> repositories) {
      searchTask = null;
      adapter.setData(repositories);
  }

  private void showProgress(boolean inProgress) {
      progressView.setVisibility(inProgress ? View.VISIBLE : View.GONE);
      actionSearchView.setEnabled(!inProgress);
  }

  private void showError(@Nullable ApiException exception) {
      searchTask = null;
      new AlertDialog.Builder(this)
              .setMessage(exception != null ? exception.getMessage() : getString(R.string.unknown_error))
              .setTitle(R.string.error_title)
              .show();
  }

  private class SearchTask extends AsyncTask<String, Void, SearchTaskResult> {

      @Override
      protected SearchTaskResult doInBackground(String... params) {
          String q = params[0];
          SearchTaskResult result = new SearchTaskResult();
          try {
              result.repositories = appService.searchRepositories(q);
              repositoryStorage.saveRepositories(q, result.repositories);
          } catch (ApiException e) {
              result.exception = e;
              //try to show some cached results
              result.repositories = repositoryStorage.getRepositories(q);
          }

          return result;
      }

      @Override
      protected void onPostExecute(SearchTaskResult result) {
          if (result.exception != null) {
              showError(result.exception);
          }
          showData(result.repositories);
          showProgress(false);
      }
  }

  private class SearchTaskResult {
      List<Repository> repositories;
      ApiException exception;
  }
}

Реализацию адаптера и вообще весь демо-проект можно посмотреть на GitHub.

На этом этапе наш проект уже может быть скомпилирован и запущен, но в этом нет никакого смысла, ведь мы не написали никакой реализации наших интерфейсов AppService и RepositoryStorage, так что самое время это сделать.

Добавляем вкус


Для начала нужно открыть build.gradle в основном модуле проекта и добавить в него наши flavor-ы. Назовем их, к примеру, “mock” и “prod”
productFlavors {
  mock {}
  prod {}
}

Добавлять их следует в секцию android {...} на том же уровне, что и buildTypes {...}.
Обязательно после этого нажмите на кнопку Sync Project With Gradle Files


Как только синхронизация будет завершена в окне Build Variants появятся новые flavor’ы


Сейчас выберем mockDebug.

Как только мы определили в проекте product flavor’ы, мы можем создавать для них одноименные директории на том же уровне, что и main. Из этих директорий и будут браться файлы во время сборки какого-то из flavor’ов.
Добавим папку mock, повторив в ней структуру пакетов services и storages:


Наконец, можно приступить к mock-реализации наших интерфейсов:
public class AppServiceImpl implements AppService {
  @Override
  public List<Repository> searchRepositories(String query) {
      if (query.equals("error")) {
          throw new ApiException("Manual exception");
      }
      List<Repository> results = new ArrayList<>();
      for (int i = 1; i <= 10; i++) {
          results.add(new Repository("Mock description " + i, "Mock Repository " + i, "http://mock-repo-url"));
      }
      return results;
  }
}

public class MockRepositoryStorage implements RepositoryStorage {
 
  @Override
  public void saveRepositories(String q, List<Repository> repositoryList) {}

  @Override
  public List<Repository> getRepositories(String q) {
      return null;
  }
}

Как видите, mock-сервис отдает нам 10 очень информативных моделек Repository, а mock-storage не делает вообще ничего. Инициализируем их в нашем App-классе:
@Override
public void onCreate() {
  super.onCreate();
  appService = new AppServiceImpl();
  repositoryStorage = new MockRepositoryStorage();
}

Вот теперь то наше приложение готово быть собраным и запущеным. Вот теперь то мы можем протестировать и скорректировать работу UI. Вот теперь то мы… можем перейти к настоящей реализации наших интерфейсов.

В окне Build Variants выбирем вариант prodDebug и аналогично папке mock создадим папку prod с теми же пакетами и классами:


Мы прибегнем к помощи retrofit2 для сетевых запросов, он будет работать внутри нашей реализации AppServiceImpl:
public class AppServiceImpl implements AppService {
 
  private final RetroGithubService service;

  public AppServiceImpl() {
      service = new Retrofit.Builder()
              .baseUrl("https://api.github.com/")
              .addConverterFactory(GsonConverterFactory.create())
              .build().create(RetroGithubService.class);
  }

  @Override
  public List<Repository> searchRepositories(String query) {
      Call<ApiRepositorySearchEntity> call = service.searchRepositories(query);
      try {
          Response<ApiRepositorySearchEntity> response = call.execute();
          if (response.isSuccess()) {
              ApiRepositorySearchEntity body = response.body();
              List<Repository> results = new ArrayList<>();
              RepositoryMapper mapper = new RepositoryMapper();
              for (RepositoryEntity entity : body.items) {
                  results.add(mapper.map(entity));
              }
              return results;
          } else {
              throw new ApiException(response.message());
          }
      } catch (Exception e) {
          throw new ApiException(e);
      }
  }
}

public interface RetroGithubService {
  @GET("search/repositories")
  Call<ApiRepositorySearchEntity> searchRepositories(@Query("q") String query);
}

Как можно заметить из кода, мы сделали еще несколько вспомогательных классов: *Entity для парсинга ответов и RepositoryMapper для маппинга ответов в модель Repository.

Обратите внимание, что все классы, связанные с реальной работой с сервером, такие как RepositoryEntity, RepositoryMapper, RetroGithubService, лежат в папке flavor’а “prod”. Это значит, что при сборке любого другого flavor’а, например mock, эти классы не попадут в результирующий apk-файл.

Внимательный читатель может заметить, что имя класса, реализующего реальную работу в сервером и имя его mock-аналога совпадают: AppServiceImpl.java. Это сделано специально и благодаря этому в основном коде проекта, который находится в папке main, при смене flavor’а ничего менять не надо. При выбранном flavor’е mock приложение видит класс AppServiceImpl, расположенный в папке mock и не видит класс, расположенный в папке prod. Аналогично и при выбранном flavor’е prod.

Столь же внимательный читатель может заметить, что класс реализации кеша мы назвали MockRepositoryStorage и, возможно, опечатались. Но нет, мы это сделали специально, чтобы показать один из вариантов как можно иметь разные имена классов-реализаций и даже разные конструкторы у каждого из них.
Трюк в сущности простой, мы сделаем одноименный для разных flavor’ов класс RepositoryStorageBuilder, который в зависимости от выбранного flavor’а отдаст нам нужную реализацию.

productFlavor = prod
public class RepositoryStorageBuilder {
  private int maxSize;

  public RepositoryStorageBuilder setMaxSize(int maxSize) {
      this.maxSize = maxSize;
      return this;
  }

  public RepositoryStorage build() {
      return new InMemoryRepositoryStorage(maxSize);
  }
}


productFlavor = mock
public class RepositoryStorageBuilder {

  public RepositoryStorageBuilder setMaxSize(int maxSize) {
      return this;
  }

  public RepositoryStorage build() {
      return new MockRepositoryStorage();
  }
}


И общая для обоих инициализация в Application:
@Override
public void onCreate() {
  super.onCreate();
  ...
  repositoryStorage = new RepositoryStorageBuilder()
          .setMaxSize(5)
          .build();
}


Теперь и “честную” реализацию работы можно считать выполненной, но если мы остановимся здесь, то не воспользуемся всей мощью ProductFlavor. Дело в том, что используемые в честной реализации поиска библиотеки, которые объявлены в секции dependencies, попадают в нашу сборку вне зависимости от выбранного flavor’а. К счастью, мы можем указать для каждой зависимости отдельно хотим ли мы её видеть в билде, дописав нужное имя flavor перед словом compile:
dependencies {
  compile fileTree(dir: 'libs', include: ['*.jar'])
  testCompile 'junit:junit:4.12'
  prodCompile 'com.squareup.retrofit:retrofit:2.0.0-beta2'
  prodCompile 'com.squareup.retrofit:converter-gson:2.0.0-beta2'
  prodCompile 'com.google.code.gson:gson:2.5'
  compile 'com.android.support:appcompat-v7:23.1.1'
  compile 'com.android.support:recyclerview-v7:23.1.1'
  compile 'com.jakewharton:butterknife:7.0.1'
}

Это не только сократит размер приложения, но и увеличит скорость его сборки, если зависимости по-настоящему большие.

Зачем?


Зачем использовать такой подход к Dependency Injection, если есть Dagger2, Roboguice, если можно написать это даже вручную?
Конечно, ключевое отличие этого подхода в том, что определение реализаций происходит еще на этапе компиляции и в билд попадают только те зависимости, которые действительно будут использоваться, со всеми вытекающими из этого последствиями. При этом для определения зависимостей в runtime можно продолжать использовать полюбившийся DI-фрэймворк.

True Story


Как мы упоминали вначале, один из наших проектов мы разрабатываем сразу под две платформы: Android и Amazon FireOS. Эти операционные системы в основном похожи друг на друга (конечно мы все понимаем кто и на кого похож :)), но у каждой их них есть своя реализация push-уведомлений и свой механизм встроенных покупок. Для этих и других отличий платформ мы, как и в демо-проекте, оставили в основном модуле только общие интерфейсы: одинаковую регистрацию девайса на сервере push-сообщений, одинаковый процесс покупки подписки, а конкретные платформозависимые реализации храним в соответствующих flavor’ах.



Такой подход мы применяем уже давно и готовы поделиться своими впечатлениями от использования:
Плюсы
  1. Исключение из результирующей сборки всего кода и его зависимостей, которые никогда не будут использоваться на какой-то из платформ.
  2. Сокращение времени сборки проекта, т.к. собирается только выбранный (активный) flavor.
  3. Все преимущества использования IoC, разделения интерфейса от реализации и никаких уродливых ветвлений в стиле if isAndroid()

Минусы
  1. Android Studio видит одновременно только выбранный flavor и его директорию. Из-за этого не работает в полной мере автоматический рефакторинг, поиск по java-классам, поиск по ресурсам. Не работает в том плане, что не распространяется на неактивные flavor’ы. После рефакторинга иногда приходится переключаться между flavor’ами и повторять рефакторинг отдельно для каждого из них.

Как видите, мы считаем, что плюсов в 3 раза больше :) Всем приятного аппетита!
  • +10
  • 8,7k
  • 2
EastBanc Technologies 64,53
Компания
Поделиться публикацией
Комментарии 2
  • 0
    Еще добавим один минус: тестируемость.

    А теперь найдем решение, которое лишено приведенных недостатков:

    Разобьем приложение на несколько модулей:
    1. Модуль с интерфейсами пратформозависимых сервисов
    2. N модулей с реализациями интерфейсов + тесты для каждой из платформы.
    3. Главное приложение с N флаворами (аналогично тому, как это сделано у вас), где в dependencies прописываем требуемые реализации для каждого из флаворов (prodCompile project(":android_services"))

    Таким образом мы одновременно можем редактировать все реализации без необходимости переключения флаворов и запускать тесты для этих реализаций.

    Оставлю открытым вопрос по инициализации зависимостей. Эту проблему можно решить так же как у вас: одинаково называть реализации интерфейсов в разных модулях. Либо как-то более интересно:)
    • 0
      Да, это очень хорошее замечание. Понимание того, что в gradle-модули можно выносить не только какие-то общие библиотеки, но и разделять ими свой же проект на разные имплементации (платформозависимые или mock/real) или на разные слои, такие как view-модуль, domain-модуль, data-модуль, дает еще больше гибкости. Gradle-модули сами по себе уже мощный инструмент, который заслуживает одельного туториала :)

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое