Dagger Party Tricks: Deferred OkHttp Initialization
This is part of a blog series of a talk I gave at Droidcon NYC and Londroid earlier this year. Dagger has a steep learning curve that frequently leaves developers not wanting to explore it further once they've integrated it. This series attempts to show some neat "party tricks" and other clever Dagger patterns to inspire more advanced usage.
The Problem
Consider the following code snippet.
@Provides
fun provideApi(): MyApi {
return Retrofit.Builder()
.baseUrl("https://example.com")
.build()
.create(MyApi::class.java)
}
This is roughly the same three lines of Retrofit that most apps have somewhere in their app. Expand it out a bit to be more Dagger-y:
@Module
object ApiModule {
@Provides
fun provideCache(ctx: Context): Cache {
return Cache(ctx.cacheDir, CACHE_SIZE)
}
@Provides
fun provideClient(cache: Cache): OkHttpClient {
return OkHttpClient.Builder()
.cache(cache)
.build()
}
@Provides
fun provideRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("https://example.com")
.client(client)
.build()
}
@Provides
fun provideApi(retrofit: Retrofit): MyApi {
return retrofit.create(MyApi::class.java)
}
}
Now you've got your Retrofit
, its OkHttpClient
, and its Cache
all being provided into your MyApi
provider. Again - probably most applications using Retrofit and Dagger have some form of the above code in their app. This can then be injected into some component later:
Now here's the kicker: when does this DI initialization run?
Obviously you're likely using some Retrofit call adapter that makes requests on a background thread (RxJava, Coroutines, etc), but the initialization itself in the above code will always happen on whatever thread called the Dagger injection. In the above code, that's going to be the Main thread (or whatever framework equivalent is). It's not terrible, but it's not free either. In an Android world where you're racing to keep execution within the 16ms VSYNC buffer, this suddenly becomes a source of frame drops.
- Most applications are probably doing this on startup, whether to create a singleton instance, log app-opening analytics, or if the example
MainController
is the first point of entry into the app. Now this is participating in a startup path with whatever else is on that path. - OkHttpClient initialization can take upwards of 100ms on some Android devices in the wild due to its use of
TrustManagerFactory
. Initializing theCache
is also not free, as it creates an internalExecutor
and may incur some disk IO if you want to prepare a cache directory for it first.
All of this is done even though this thing is supposed to only ever do work on a background thread!
The Solution
Dagger has an API called Lazy
. You might have heard or used this before. It's pretty simple: wrap your injected dependency in Lazy
and Dagger will magically lazily initialize it. But where can we use this?
If we try in our MainController
, it doesn't really work.
class MainController @Inject constructor(private val api: Lazy<MyApi>) {
suspend fun onLoad() {
val result = api.get().fetchStuff()
}
}
Sure our dependency is lazy, but we're just kicking the can down the road to the caller below. We could manually wrap that get()
call in something on the background, but that's going to be unwieldy to maintain if we inject this anywhere else. It could also result in us allocating a sacrificial thread for initialization at every usage. Yikes!
We want to try to move this further up the chain. Let's look at that Retrofit block again.
@Provides
fun provideRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.client(client)
...
}
Everyone knows Retrofit speaks OkHttp. Something you may not know however is that Retrofit specifically just speaks the Call.Factory
interface in its builder API. OkHttpClient
is just an implementation of this interface. The above snippet is actually just a short-hand for this:
@Provides
fun provideRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.callFactory(client)
...
}
Since Call.Factory
is just a SAM interface, we can wrap and delegate in a lambda.
@Provides
fun provideRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.callFactory { client.newCall(it) }
...
}
Boom, we can push our Lazy
into here!
@Provides
fun provideRetrofit(client: Lazy<OkHttpClient>): Retrofit {
return Retrofit.Builder()
.callFactory { client.get().newCall(it) }
...
}
The best part of this is that the Call.Factory
we've given it will be called on a background thread, meaning the entire OkHttpClient
stack above it is initialized off the main thread. This is exactly what we wanted! As an added bonus, downstream users of this dependency don't have to know anything about this behavior or try to defend against the previous main thread initialization.
If you want to be super sure, you could even add main thread checks in your OkHttpClient
and Cache
providers.
@Provides
fun provideCache(ctx: Context): Cache {
checkMainThread()
return Cache(ctx.cacheDir, CACHE_SIZE)
}
@Provides
fun provideClient(cache: Cache): OkHttpClient {
checkMainThread()
return OkHttpClient.Builder()
.cache(cache)
.build()
}
This is a clever trick to offload expensive initialization to a background thread and hurdle that bottleneck on your startup path. It's also simple enough that you could probably drop this into your code base today. What are you waiting for?
If you want some real examples in action, I use this in my CatchUp side project:
Special thanks to the folks at Square, who shared this a few years ago.