Android Recipes: Gradle IoC

  • Tutorial
Android projects are great. Sometimes really big. One of our projects is a news application that is being developed simultaneously for two alternative platforms: Android and FireOS, which is from Amazon. This allows you to expand the circle of readers of the news, because users of Kindle Fire readers love to read :). However, this also imposes an obligation to reckon with the features of each of the platforms. For example, if on Android you use GCM for push messages, then on FireOS you should use Amazon AWS for this. Similarly for in-app shopping systems: Google in-app billing vs. In-App Purchasing. But large project size! = Large application size!

In this article, we will show how we use alternative builds to optimize the application and maintain them without harm to the development process.

What are we cooking?




When developing a multi-platform application, a developer can deal with code that runs only on one of the platforms, but when launched on others it will be dead weight. In addition to its existence, such a code will certainly bring to the project the same burden and all its dependencies. This in itself is not very good, but given the specifics of development for Android: “65k problem”, it’s best practice to make the downloadable file size as small as possible, with this code you definitely need to do something. And if you want to see endless checks ifAndroid () or ifAmazon () a little less often than never.

If you are an experienced Android developer, you probably already came across such an option for the Android Gradle plugin as ProductFlavor.

Flavor (English) - taste, aroma

This option allows you to create alternative assemblies of the same project, including files from different directories in the build, depending on the name of the flavor being collected. Often, ProductFlavor is used for all kinds of “branding” applications, replacing resources (pictures, texts, links). Another common case is the division of the application into demo and full versions, because the name of the collected flavor automatically falls into the field of the BuildConfig.FLAVOR class. Its value can later be checked in runtime and not allowed to perform any actions in the demo version.

You can divide into flavores not only resources, but also code. But you need to understand that the code used in flavor1 can never interact with the code from flavor2. And the code lying in the main module can always see only from one flavor'ov at one time. All this means, for example, that you cannot write a set of utility methods in one flavor and use them in another. It is necessary to separate the code wisely and very carefully, as isolated as possible, so that the switching of alternative builds goes unnoticed by the main module. The Dependency Injection pattern will help us a lot. Following it, we will leave only the general interfaces in the main module, and we will decompose specific implementations into flavors. We’ll look at the whole process by creating a simple application for finding repositories on GitHub.

Ingredients


So we need:
  1. A screen with an input field, a button and a list of results (1 pc.).
  2. A class for working with the Github Web API: its mock and its real implementation (total of 2 pcs.).
  3. Class for caching search results: also real and mock implementations (total of 2 pcs.).
  4. Icons, texts, progress bars - to taste.


We will follow the approach of dividing the application into layers and immediately create 3 packages: .view for presentation, .models for business logic models and .data for content providers classes. In the data package, we still need 2 packages of services and storages. As a result, the whole structure should look like this:


Models will be enough for us only one: “Repository”. You can store whatever you want in it, but we wanted to have a description, name and htmlUrl in it.

Now let's define the interface of the service class, which will look for AppService repositories:
public interface AppService {
  List searchRepositories(String query);
}

Immediately create the interface of the class that caches the results of the RepositoryStorage search:
public interface RepositoryStorage {
  void saveRepositories(String query, List repositoryList);
  List getRepositories(String query);
}

We will create and store our service and repository inside the Application class:
public class App extends Application {
  private AppService appService;
  private RepositoryStorage repositoryStorage;
  public AppService getAppService() {
      return appService;
  }
  public RepositoryStorage getRepositoryStorage() {
      return repositoryStorage;
  }
}

For the preparatory stage, it remains only to create the screen itself and write in it the receipt and display of the results. As part of the demo application, AsyncTask is enough for us to do the background work, but you can always use your favorite approach.
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 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 {
      @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 repositories;
      ApiException exception;
  }
}

The adapter implementation and the entire demo project can be viewed on GitHub .

At this stage, our project can already be compiled and launched, but this makes no sense, because we have not written any implementation of our AppService and RepositoryStorage interfaces , so it's time to do it.

Add taste


First you need to open build.gradle in the main module of the project and add our flavors to it. Let's call them, for example, “mock” and “prod”
productFlavors {
  mock {}
  prod {}
}

They should be added to the android section {...} at the same level as buildTypes {...} .
Be sure to click on the Sync Project With Gradle Files button after this.


As soon as synchronization is complete, new flavors will appear in the Build Variants window.


Now we ’ll select mockDebug .

Once we have defined product flavors in the project, we can create directories of the same name for them at the same level as main . Files will be taken from these directories during the assembly of some of the flavors.
Add the mock folder , repeating the structure of services and storages packages in it :


Finally, you can start mocking our interfaces:
public class AppServiceImpl implements AppService {
  @Override
  public List searchRepositories(String query) {
      if (query.equals("error")) {
          throw new ApiException("Manual exception");
      }
      List 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 repositoryList) {}
  @Override
  public List getRepositories(String q) {
      return null;
  }
}

As you can see, the mock service gives us 10 very informative Repository models, and mock-storage does nothing at all. Initialize them in our App class:
@Override
public void onCreate() {
  super.onCreate();
  appService = new AppServiceImpl();
  repositoryStorage = new MockRepositoryStorage();
}

Now, then our application is ready to be built and launched. Now we can test and adjust the operation of the UI. Now now we ... can move on to the true implementation of our interfaces.

In the Build Variants selecting options prodDebug and similar folder mock create a folder prod with the same package and class:


We will resort to the help of retrofit2 for network requests, it will work in our implementation 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 searchRepositories(String query) {
      Call call = service.searchRepositories(query);
      try {
          Response response = call.execute();
          if (response.isSuccess()) {
              ApiRepositorySearchEntity body = response.body();
              List 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 searchRepositories(@Query("q") String query);
}

As you can see from the code, we made some more helper classes: * Entity for parsing responses and RepositoryMapper for mapping responses to the Repository model .

Please note that all classes related to the real work with the server, such as RepositoryEntity, RepositoryMapper, RetroGithubService, are in the “prod” flavor folder. This means that when building any other flavor, such as mock, these classes will not get into the resulting apk file .

An attentive reader may notice that the name of the class that implements the real work in the server and the name of its mock counterpart are the same: AppServiceImpl.java. This was done on purpose and thanks to this, nothing needs to be changed when changing the flavor in the main project code, which is located in the main folder. With the selected mock flavor, the application sees the AppServiceImpl class located in the mock folder and does not see the class located in the prod folder . Similarly, with the selected flavor prod .

An equally attentive reader may notice that we called the cache implementation class MockRepositoryStorage and may have sealed it. But no, we did it on purpose to show one of the options how you can have different names of implementation classes and even different constructors for each of them.
The trick is essentially simple, we will make the class of the same name for different flavorsRepositoryStorageBuilder , which, depending on the flavor chosen, will give us the desired implementation.

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();
  }
}


And common for both initialization in Application:
@Override
public void onCreate() {
  super.onCreate();
  ...
  repositoryStorage = new RepositoryStorageBuilder()
          .setMaxSize(5)
          .build();
}


Now the “honest” implementation of the work can be considered completed, but if we stop here, we will not use the full power of ProductFlavor. The fact is that the libraries used in the honest implementation of the search, which are declared in the dependencies section , fall into our assembly regardless of the selected flavor. Fortunately, we can indicate for each dependency separately whether we want to see it in the build by adding the desired flavor name before the word 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'
}

This will not only reduce the size of the application, but also increase the speed of its assembly, if the dependencies are really big.

What for?


Why use this approach to Dependency Injection, if there is Dagger2, Roboguice, if you can even write it manually?
Of course, the key difference of this approach is that the definition of implementations takes place at the compilation stage and only those dependencies that will actually be used get into the build, with all the consequences that follow from this. At the same time, you can continue to use your favorite DI framework to determine the dependencies in runtime.

True story


As we mentioned at the beginning, we are developing one of our projects immediately for two platforms: Android and Amazon FireOS. These operating systems are basically similar to each other (of course, we all understand who and what are they like :)), but each of them has its own implementation of push notifications and its own in-app purchase mechanism. For these and other platform differences, we, as in the demo project, left only the general interfaces in the main module: the same device registration on the push message server, the same subscription purchase process, and specific platform-dependent implementations are stored in the corresponding flavors.



We have been using this approach for a long time and are ready to share our impressions of using:
Pros
  1. An exception from the resulting assembly of all code and its dependencies that will never be used on any of the platforms.
  2. Reduced build time of the project, because only the selected (active) flavor is collected.
  3. All the benefits of using IoC, separating the interface from the implementation, and no ugly branches in the style of if isAndroid ()

Minuses
  1. Android Studio sees at the same time only the selected flavor and its directory. Because of this, automatic refactoring, searching in java classes, and searching in resources does not fully work. It does not work in the sense that it does not apply to inactive flavors. After refactoring, sometimes you have to switch between flavors and repeat refactoring separately for each of them.

As you can see, we believe that there are 3 times more pluses :) Enjoy your meal!

Also popular now: