Skip to content

JamieCruwys/android-modularisation

Repository files navigation

Android Modularisation Example

Architecture

This project contains the app module and 4 feature modules:

  • base - this contains all shared code and test utilities
  • crypto - feature to show crypto prices
  • forex - feature to show exchange rate prices
  • metal - feature to show silver/gold prices

Modularisation Architecture

The app module must:

  • Depend on all feature modules.
  • Depend on the base module. This is required because some items injected via DI are provided in the base module.

The feature modules must:

  • Depend on the base module. This is required because some items injected via DI are provided in the base module.

Dependency management

Each module has its own dependencies. These dependencies and their versions are provided via the buildSrc folder. The buildSrc folder is automatically compiled before anything else. This means you can declare dependencies in it and have all the other modules use these standardised dependencies.

Check out Dependencies.kt to see the full list of dependencies for this project.

For this project, we have the following objects available to all modules:

  • Versions - versions for our dependencies
  • Libs - libraries that should be registered with implementation
  • AnnotationLibs - libraries that should be registered with kapt
  • UnitTestLibs - libraries that should be registered with testImplementation
  • UITestLibs - libraries that should be registered with androidTestImplementation
  • GradlePlugins - gradle plugins to be registered in the root build.gradle

Example usages:

  • implementation Libs.coroutines
  • kapt AnnotationLibs.dagger_hilt_compiler
  • testImplementation UnitTestLibs.junit
  • androidTestImplementation UITestLibs.espresso
  • classpath GradlePlugins.kotlin

Code quality

The following code quality tools are used:

How to run the code quality tools:

  • Run ./gradlew lint to run Android lint
  • Run ./gradlew detekt to run Detekt
  • Run ./gradlew spotlessCheck to run Spotless (runs ktlint and checks licence headers)
  • Run ./gradlew spotlessApply to automatically apply Spotless suggestions
  • Run ./gradlew check to run all code quality checks and unit tests

Spotless configuration

See spotless.gradle for the full configuration. In this file you can see the licence header that will be enforced in all Kotlin files.

Testing

The following libraries are used for testing:

  • JUnit - framework to write and run tests
  • MockK - mocking library for Kotlin
  • Kluent - syntactic sugar for writing assertions
  • Architecture testing - adds test rules to swap the background executor used by the Architecture Components with a different one
  • Coroutines testing - test utilities for Kotlin Coroutines

How to run tests:

  • Run ./gradlew test to run unit tests
  • Run ./gradlew connectedAndroidTest to run UI tests
  • Run ./gradlew check to run all code quality checks and unit tests

Rules

The following rules are used for testing:

  • InstantTestExecutorRule - this is part of the architecture testing library. It swaps the background executor used by the Architecture Components with one that executes tasks synchronously. This means that instead of tests executing immediately and failing while the operation is ongoing in a different thread, they will wait for the operation to complete before asserting results.

Example usage:

@get:Rule
var rule = InstantTaskExecutorRule()
  • CoroutineTestRule - this allows us to swap the main dispatcher for a test dispatcher. The test dispatcher will execute tasks immediately and will skip any delay() calls.

Example usage:

@ExperimentalCoroutinesApi
@get:Rule
var coroutinesTestRule = CoroutineTestRule()

Mocking

Mocking functionality is provided by MockK.

We can mock items by using the @MockK annotation.

By default mocks are strict and you must have set all the required behaviour using every{} blocks. If you don't provide the expected behaviour a MockKException will be thrown.

If you don't want to describe the behaviour of each method, then you should use a relaxed mock. This can be achieved in two ways:

  1. @MockK(relaxed = true)
  2. @RelaxedMockK

Personally, I think the second option is much cleaner to read so this is what this project uses.

Test data

Some tests use JSON files. For this project the test data is provided by android-modularisation-test-data in the form of a git submodule.

This shared repository may be used by other platforms e.g. iOS.

In order to use the JSON files, they must exist in androidTest/assets.

The following gradle script adds the test data files into androidTest/assets for the forex module:

android {
    // This will include the contents of testData/forex into the androidTest/assets folder,
    // allowing us to use the json files for testing
    sourceSets {
        androidTest {
            assets.srcDirs += ["$rootProject.projectDir/testData/forex"]
        }
    }
}

For more information please see the forex module's build gradle.

Navigation

This project uses the navigation component to move the user between different screens.

In a multi-module application, each module should have its own navigation graph. When the user is to move between different modules screens, they should be sent to the navigation graph for that module.

For example, the app module navigation graph will launch the forex module navigation graph when it wants to show the forex module screens.

Shared Gradle Files

The problem:

  • Each feature module requires a gradle file for its configuration.
  • To update config for all of them we must go in and change each file individually.
  • This is prone to human error - we update one feature module but forget to apply to the changes to another.

The solution:

  • Extract the common gradle setup into a gradle file.
  • Include that gradle file in all of the feature modules.
  • Only have unique dependencies for that module in the feature module's gradle file.
  • We can now update the common config for all feature modules in one place.

For this project android-library.gradle contains all of the common gradle config and is applied by:

apply from: "$rootProject.projectDir/android-library.gradle"

See the build.gradle of the forex module for an example.

Common config

The gradle file standardises feature module config such as:

  • Compile & target SDK versions
  • Java 8 feature compatibility
  • View binding support
  • Code quality setup (detekt)
  • Kotlin setup
  • Navigation dependencies
  • Dagger Hilt dependencies
  • Logging dependencies
  • Coroutines dependencies
  • Testing dependencies

Dependency Injection

What is it?

Dependency injection is a way of making a class independent of its dependencies. Instead of creating dependencies itself, the dependencies are passed to the object.

Why do we need it?

  • It makes testing easier as components can be tested independently (and dependencies can be mocked)
  • Allows you to more closely follow the Single Responsibility Principle which is: a class should only have one reason to change. Having dependencies created inside a class adds more reasons to change - by having the dependency provided removes this reason.

Why are we using Dagger?

As your project grows, so does the graph of dependencies. Manual dependency injection (that is managing dependencies yourself) becomes more difficult the larger the project gets. Once you start having different flows in the app, you'll want dependencies to only live in the scope of that flow. In short - you'll end up writing a lot of boiler plate and have to spend time managing it yourself.

Using Dagger automates this process and generates the code you would have written anyway.

What is Dagger Hilt?

Dagger Hilt makes it easier to set up Dagger dependency injection into an Android app. It does this by:

  • Removing the need to create a component
  • Replacing the call to DaggerAppComponent.create() in the Application class with the @HiltAndroidApp annotation
  • Providing you with an ApplicationComponent so you don't need to create a custom @Component anymore
  • Adding entrypoints - you can annotate Fragments/Activities with @AndroidEntryPoint
  • Adding @InstallIn annotation

In summary - it boilerplate from Dagger set ups and adds annotations in their place.

How Dagger Hilt works in a multi-module set up

Each module should provide a Dagger @Module, which defines all of the dependencies that the module provides. For example, ForexModuleDependencies provides all of the dependencies from classes on in the forex module such as the the implementation of ForexRepository.

For more information please read docs for Dagger multi-module set up

An example with UserRepository

The base module defines UserRepository. This is provided to DI via BaseModuleDependencies.

Feature modules, such as the forex module can then use the UserRepository which is provided by DI. An example of this is the ForexViewModel which has the UserRepository injected like so:

@HiltViewModel
class ForexViewModel @Inject constructor(
    private val forexRepository: ForexRepository,
    private val userRepository: UserRepository
) : ViewModel() {

In this case the ForexViewModel has the UserRepository dependency resolved by the base modules BaseModuleDependencies.

Why does the base module need to be included in app to build?

The app module needs the base module in order to resolve the full dependency graph for DI.

Providing multiple dependencies of the same type

As the dependency graph expands, you may end up having to provide multiple dependencies of the same type.

For example, the forex module and crypto module both provide OkHttpClient and Retrofit instances.

See CryptoModuleNetworkDependencies for an example.

When Dagger encounters multiple bindings of the same type it will throw an exception as it has no idea which way to provide the dependency.

We can use qualifier annotations to tell Dagger which binding we would like it to use.

There are two ways of setting qualifier annotations:

  1. Use javax.inject's @Named annotation e.g. @Named("CryptoOkHttp")

  2. Define your own annotation like so:

@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class CryptoOkHttp

And then reference it using @CryptoOkHttp.

I personally prefer the second option as the first option has the possibility of typos, whereas the second option will be checked during compilation.

Feature modules

TODO:

  • Add a submodule with some test data that feature modules will use

base

This module provides:

crypto

forex

metals

TODO:

  • Create example data for metal module
  • Build UI for metal module using example data
  • Implement API for metal module using https://www.goldapi.io/
  • Replace metal example data with API data

Further reading

Modularisation:

Dependency Injection:

Dependency management - buildSrc:

Shared gradle files:

Testing:

Mocking:

Gradle tasks:

Navigation:

Coroutines testing:

Clean architecture with a multi-module setup:

Jacoco setup:

Example modularisation projects: