This project contains the app
module and 4 feature modules:
base
- this contains all shared code and test utilitiescrypto
- feature to show crypto pricesforex
- feature to show exchange rate pricesmetal
- feature to show silver/gold prices
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.
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 dependenciesLibs
- libraries that should be registered withimplementation
AnnotationLibs
- libraries that should be registered withkapt
UnitTestLibs
- libraries that should be registered withtestImplementation
UITestLibs
- libraries that should be registered withandroidTestImplementation
GradlePlugins
- gradle plugins to be registered in the rootbuild.gradle
Example usages:
implementation Libs.coroutines
kapt AnnotationLibs.dagger_hilt_compiler
testImplementation UnitTestLibs.junit
androidTestImplementation UITestLibs.espresso
classpath GradlePlugins.kotlin
The following code quality tools are used:
- Android lint - scans Android code for bugs
- Spotless - Runs ktlint to check Kotlin code & enforces licence headers
- Detekt - Static code analysis for Kotlin code
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
See spotless.gradle for the full configuration. In this file you can see the licence header that will be enforced in all Kotlin files.
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
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 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:
@MockK(relaxed = true)
@RelaxedMockK
Personally, I think the second option is much cleaner to read so this is what this project uses.
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.
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.
- 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.
- 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.
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 is a way of making a class independent of its dependencies. Instead of creating dependencies itself, the dependencies are passed to the object.
- 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.
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.
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 theApplication
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.
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
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.
The app
module needs the base
module in order to resolve the full dependency graph for DI.
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:
-
Use
javax.inject
's @Named annotation e.g.@Named("CryptoOkHttp")
-
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.
TODO:
- Add a submodule with some test data that feature modules will use
This module provides:
- UserRepository that other modules use to get retrieve User information
- Dependency injection of UserRepository via BaseModuleDependencies
- CoroutineTestRule that other modules use to test code involving Coroutines
- Shows crypto prices
- Data loaded from https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc
- Shows foreign exchange rates
- Data loaded from https://exchangeratesapi.io/
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
Modularisation:
Dependency Injection:
- https://developer.android.com/training/dependency-injection
- https://developer.android.com/training/dependency-injection/dagger-multi-module
- https://www.raywenderlich.com/14212867-migrating-from-dagger-to-hilt
- https://proandroiddev.com/exploring-dagger-hilt-and-whats-main-differences-with-dagger-android-c8c54cd92f18
Dependency management - buildSrc:
- https://medium.com/better-programming/gradle-dependency-management-with-buildsrc-and-kotlin-dsl-1de958eab166
- https://proandroiddev.com/stop-using-gradle-buildsrc-use-composite-builds-instead-3c38ac7a2ab3
Shared gradle files:
Testing:
Mocking:
- https://blog.kotlin-academy.com/mocking-is-not-rocket-science-mockk-features-e5d55d735a98
- https://www.baeldung.com/kotlin/mockk
- https://mockk.io/#relaxed-mock
Gradle tasks:
Navigation:
- https://itnext.io/android-multimodule-navigation-with-the-navigation-component-99f265de24
- https://developer.android.com/guide/navigation/navigation-getting-started
Coroutines testing:
- https://www.youtube.com/watch?v=KMb0Fs8rCRs
- https://www.valueof.io/blog/injecting-coroutines-dispatchers-with-dagger
- https://craigrussell.io/2019/11/unit-testing-coroutine-suspend-functions-using-testcoroutinedispatcher/
Clean architecture with a multi-module setup:
Jacoco setup:
Example modularisation projects: