Background
It’s been a while since the Kotlin compiler added an experimental / pre-alpha implementation of Context Receivers. As we learned from the 2023 KotlinConf keynote, there is a lot of excitement about this new feature. Engineers in the broad Kotlin community are already exploring it in exciting ways, as Arrow’s team showed us with typed errors and companion DSLs.
One of the prominent use cases around Kotlin Context Receivers revolves around Inversion of Control and Dependency Injection. Due to its nature, Context Receivers allow a function or class instance to grab properties or call instance methods belonging to an outer context with access consistency guaranteed by the Kotlin Compiler.
In practice, Context Receivers replace the traditional constructor-based injection for Kotlin Classes instances while delivering a new capability for plain-old Kotlin Functions. However, in Android applications, taking full advantage of Context Receivers requires instantiating some of the Android Framework building blocks (like Activities or Services) with a mechanism that allows us to drive constructor-based injection, as we’ll see. Here is where AppComponentFactory comes into play.
AppComponentFactory is one of those remarkable examples of an unknown but powerful and underrated utility within the Android framework. It would be way more popular nowadays if it lived older than it is; although it is also available under Jetpack libraries, it requires minSDK 28 (Android 9.0 or newer).
According to Alex Styl’s excellent Android Distribution charts, setting Pie as the minimum supported version of Android means targeting approximately 80% of existing users on average, which is already great, but eventually has not been feasible for large consolidated products by the time this article has been written.
AppComponentFactory allows us to have an Activity instance with a custom constructor. Therefore, they provide the perfect entry point to set the Context Object for a single screen or an entire user flow designed according to the Single Activity pattern (for example, with Composable screens).
An example of Kotlin Context Receivers enabled by AppComponentFactory
All the code snippets that will follow exist on my live playground, Norris, which is available on Github. As mentioned in my previous article, I’m happy to use this project to provide functional examples for my posts.
Let’s consider a particular feature from the sample project as an example. It implements one of the most common tasks in Mobile products: getting a list of items from an HTTP/REST service and displaying the content to the user. We use a simple unidirectional MVVM approach to model the inner layers of such a functionality, and the dependency chain looks like this:
Our Activity does not have a custom constructor in this approach, and the viewModels extension function handles the field-like injection based on a custom ViewModelProvider.Factory:
@Suppress("UNCHECKED_CAST")
class FactsViewModelFactory(
private val localStorage: LocalStorage,
private val restClient: RestClient
) : ViewModelProvider.Factory {
override fun <VM : ViewModel> create(modelClass: Class<VM>): VM {
val factsDataSource = FactsDataSource(restClient, localStorage)
return FactsViewModel(factsDataSource) as VM
}
}
As we can see, all the dependency wiring happens inside this particular Factory, and all the inner abstractions follow a constructor-based DI approach.
To keep the point of this article, I leave a detailed analysis of the project’s modules and its dependency graph as an exercise for the reader. The critical insight sticks to how we want to model our Context boundary and how such modeling impacts existing abstractions. Hence, one can observe that the Facts module pulls two dependencies from downstream platform modules, namely RestClient and LocalStorage, and these two instances define the Context Object we will expose using Kotlin Context Receivers:
data class FactsContext(
val restClient: RestClient,
val localStorage: LocalStorage
) {
companion object {
context (ApiUrlFactory)
fun standard() = FactsContext(
restClient = RestClientFactory.create(),
localStorage = LocalStorageFactory.create()
)
}
}
Note that such abstraction also depends on an instance of ApiUrlFactory, which controls which Mock Server responds to the application when running Instrumentation and Functional tests. I plan to write about how I use Mock Servers in an upcoming article, so stay tuned 🔥
Now we can get started! Firstly, we define a Factory for our target Activity, which will create an instance for the Context object and instantiate our Activity based on it:
object FactsActivityFactory {
context (ApiUrlFactory)
fun create(): FactsActivity =
with(FactsContext.standard()) {
FactsActivity()
}
}
This Factory is just a simple utility we’ll hook into our AppComponentFactory. More on that ahead! For now, let’s evaluate changes in our middle layers. In the case of our DataSource, we have a simple replacement for its constructor, since the Context Object provides the required dependencies:
context (FactsContext)
class FactsDataSource {
// restClient and localStorage come from our Context
suspend fun search(term: String): List<ChuckNorrisFact> {
if (term.isEmpty()) throw FactsRetrievalError.EmptyTerm
return restClient.search(term).asChuckNorrisFacts()
}
suspend fun actualQuery(): String =
with(localStorage.lastSearches()) {
if (isEmpty()) FALLBACK else last()
}
}
However, in the case of our ViewModel, there is one catch: rather than passing our DataSource through the Context, we allow the ViewModel to instantiate this dependency by itself, similarly to what we’d have with a field injection DI-style. Of course, such an approach requires our ViewModel to be aware of our Context Object:
class FactsViewModel : ViewModel() {
private val dataSource = FactsDataSource()
// Use this dataSource in the way you want
}
In addition, we have to tweak our ViewModeProvider.Factory to be aware of our Context as well. The new version of this Factory becomes trivial, but this design change forces us also to put our Activity under the same Context; otherwise, we won’t be able to create an instance for this Factory:
context(FactsContext)
class FactsViewModelFactory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <VM : ViewModel> create(modelClass: Class<VM>): VM =
FactsViewModel() as VM
}
context (FactsContext)
class FactsActivity : AppCompatActivity() {
private val viewModel by viewModels<FactsViewModel> { FactsViewModelFactory() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
}
}
Last but not least, we hook our ActivityFactory utility inside our custom AppComponentFactory, providing the proper outward Context at that level:
class NorrisAppComponentFactory : AppComponentFactory() {
override fun instantiateActivityCompat(
loader: ClassLoader,
className: String,
intent: Intent?
) : Activity =
with(NorrisApiUrlFactory) {
when (loader.loadClass(className)) {
FactsActivity::class.java -> FactsActivityFactory.create()
// ....
AnotherActivity::class.java -> AnotherActivityFactory.create()
else -> super.instantiateActivityCompat(loader, className, intent)
}
}
}
And voilá, everything works! Note that, based on our changes, the dependency chain now looks like this:
In order words, by designing a Context Object that wraps all the external dependencies for our feature, we can eliminate all constructors in all classes inside our MVVM modeling, and everything we need in terms of Dependency Injection is:
- Tag the target Function or Class with the proper Context type
- In the case of Classes, allow them to instantiate any dependencies they need fearlessly
Testability and other considerations
After tweaking our production code with Kotlin Context Receivers, it’s time to change a few bits on our test code. The first essential use case is the unit tests; we’ll consider the ones for our ViewModel as an example. It turns out that adjusting the setup is quite simple since we can use a fake Context Object at the testing time, replacing external dependencies with instances we control:
@RunWith(AndroidJUnit4::class)
class FactsViewModelTests {
private val restHelper = RestTestHelper()
private val storageHelper = StorageTestHelper()
private val testContext = FactsContext(restHelper.restClient, storageHelper.storage)
private val viewModel = with(testContext) {
FactsViewModelFactory().create()
}
// Tests follow here
}
That’s it. No mocks are required. Simple and easy.
Another critical use case for us is the Android/Instrumentation tests. In this case, we can define another AppComponentFactory, which the Instrumentation process will pick at runtime and use to instantiate our Activity under test as long we declare it in our companion androidTest/AndroidManifest file:
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name="io.dotanuki.platform.android.testing.app.NorrisTestApplication"
android:appComponentFactory="io.dotanuki.features.facts.util.FactsComponentFactory"
tools:replace="android:appComponentFactory"
tools:ignore="GoogleAppIndexingWarning">
</application>
</manifest>
class FactsComponentFactory : AppComponentFactory() {
override fun instantiateActivityCompat(
loader: ClassLoader,
className: String,
intent: Intent?
) =
// External context is faked
with(MockServerUrlFactory) {
when (loader.loadClass(className)) {
FactsActivity::class.java -> FactsActivityFactory.create()
// No need to reference Activities from other modules here
else -> super.instantiateActivityCompat(loader, className, intent)
}
}
}
Note that we pass another implementation of ApiUrlContext as the outer Context for our ActivityFactory, which points out to a Mock Server I control.
In addition to tweaking our test setup, there is another topic of vital importance to examine: controlling the lifecycle of objects at runtime. Dagger Scopes provide such a feature, for example. Still, in our case, we have only two scopes in place: creating an instance once and letting it follow the lifecycle of our application (sometimes called Singleton) and creating an instance on demand whenever it is needed (sometimes called Factory). There is no such thing as a UserSessionScope in our simple case.
While having unscoped instances is trivial - just call the Factory function again! - implementing a Singleton scope is also simple, thanks to built-in Kotlin Objects:
object LocalStorageFactory {
private const val prefsName = "last-searches"
private val memoized by lazy {
val appContext = PersistanceContextRegistry.targetContext()
val prefs = appContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
LocalStorage(prefs)
}
fun create(): LocalStorage = memoized
}
Note that in this case, we take advantage of a utility called PersistanceContextRegistry. Why do we need such a utility?
If you check how we defined our ActivityFactory, we’ll see we cannot grab the global Android Application instance before having an Activity instance using a method like Activity#getApplication(). Hence, we have a chicken-and-egg problem here.
To tackle this issue, we hook our PersistanceContextRegistry at the AppComponentFactory level, storing the application instance in this centralized place and memoizing its value:
// The complete implementation for our AppComponentFactory
class NorrisAppComponentFactory : AppComponentFactory() {
override fun instantiateApplicationCompat(
cl: ClassLoader,
className: String
): Application =
super.instantiateApplicationCompat(cl, className).also {
PersistanceContextRegistry.register(it)
}
override fun instantiateActivityCompat(
loader: ClassLoader,
className: String,
intent: Intent?
) : Activity =
with(NorrisApiUrlFactory) {
when (loader.loadClass(className)) {
FactsActivity::class.java -> FactsActivityFactory.create()
// ....
else -> super.instantiateActivityCompat(loader, className, intent)
}
}
}
object PersistanceContextRegistry {
private var app: Application? = null
fun register(target: Application) {
app = target
}
fun targetContext() = app ?: error("No Context available in this registry")
}
Another situation happens with RestClientFactory, where we want to ensure only one instance of RestClient during the entire application lifecycle, not necessarily depending on anything related to Android but still dependent on an outer Context Object. In this case, we also memoize the instance at that level, working around some limitations of the current implementation of Context Receivers. Yet, still simple, and simplicity is always welcome!
Final remarks
I conclude this article with a mix of sadness and relief. The sadness comes from the fact that I had a chance to work on a green-field project which became a large and successful product a few years ago, an achievement made without using Dagger, Anvil, Hilt or anything based on JSR330. Since then, I’ve been convinced that such libraries generally represent a complex solution to a simple problem. It is disappointing that Android Engineers stick to them (and all the cons they bring) without criticism.
I even engaged in DI wars a few times in the past. That happened in Twitter threads and discussions inside the workplace, and honestly, I no longer spend my time on them. Nevertheless, it is a relief to know that, in 2023, we are close to having a simple, Kotlin built-in, reflection-less, compiler-validated solution for Dependency Injection in Android projects. Better late than never 🙂
As we saw, when enabling DI with Kotlin Context Receivers, we do need a couple of tweaks to deal with the lifecycle of instances, and most of them are simple to handle. In addition, we can leverage AppComponentFactory to fully unleash the potential of Context Receivers, a utility built-in in the Android Framework that allows us to model Context Objects and drive Context propagation from outside of Android’s framework building blocks downwards our feature module.
Of course, the approach proposed in this article has the rigid requirement of minSDK 28+. Still, this stands feasible for new products and is something to consider at some point for already established products. Last but not least, Kotlin Context Receivers look super promising for teams willing to invest in KMM and Jetpack Compose in the long run, and I recommend following the updates on this feature as soon as Kotlin reaches 2.0.
To conclude, if you made it this far, thank you for reading 🧡
I also share a couple of links that might be of your interest:
- KEEP document on Context Receivers by Roman Elizarov and Anastasia Shadrina
- A preview of Kotlin Context Receivers by PSPDFKit team
- Kotlin Context Receivers are coming by Sebastian Aigner (Youtube)
- Exploring Kotlin Context Receivers by Simon Wirtz
- Typed Error Handling in Kotlin by Mitchel Yowono
- A Dagger to Remember by Artur Dryomov
See you in the next post!