Skip to main content

Migrating from Hilt to Koject

· 9 min read
Mori Atsushi

Koject is a new dependency injection (DI) container library for Kotlin. In this article, I'll discuss how to migrate from Hilt to Koject using the Android sample app "Now in Android" as an example.

日本語で読む →

Hilt or Koject

Koject is compatible with multiple platforms, and if you want to share code between Android and other platforms such as iOS, you can use Koject to also share your DI container. Hilt and Dagger do not work with Kotlin/Native or Kotlin/JS.

If you're developing for Android only, Hilt is still a valid option. Hilt is optimized for Android and offers more support for Android components. Additionally, custom scopes are not provided by Koject.

On the other hand, Koject is simpler and easier to understand as it has fewer features. It also offers standard DI container functionality that is more than enough for most use cases.

Furthermore, Koject works on KSP, which tends to reduce compilation time compared to Hilt, which works on kapt. While Dagger is also planning to migrate to KSP, it will take some time.

When comparing Koject and Hilt, consider the pros and cons listed in the table below and choose the one that suits you best.

LibraryKojectDagger
Multiplatform
Android Support
Custom Scopes
Code Generation○(KSP)△(kapt)
Multi-module

Getting Started with Koject

Let's try using Koject in Now in Android. Please also check out my GitHub repository.

First, add the Koject dependency to the build.gradle.kts file in the application module as follows:

app/build.gradle.kts
plugins {
id("nowinandroid.android.application")

/* ... */

id("com.google.devtools.ksp") version "1.8.0-1.0.9"
}

dependencies {
/* ... */

implementation("com.moriatsushi.koject:koject-android-core:1.3.0")
implementation("com.moriatsushi.koject:koject-android-activity:1.3.0")
ksp("com.moriatsushi.koject:koject-processor-app:1.3.0")
}

/* ... */

In the library module, use koject-processor-lib instead of koject-processor-app. Also, don't forget to set the moduleName.

{lib}/build.gradle.kts
plugins {
id("nowinandroid.android.library")

/* ... */

id("com.google.devtools.ksp") version "1.8.0-1.0.9"
}

dependencies {
/* ... */

implementation("com.moriatsushi.koject:koject-core:1.3.0")
ksp("com.moriatsushi.koject:koject-processor-lib:1.3.0")
}

ksp {
arg("moduleName", project.name)
}

/* ... */

Now in Android has shared setup code in the build-logic directory. We can also group the Koject setup with plugins as follows:

build-logic/conversation/src/main/kotlin/AndroidLibraryKojectConventionPlugin.kt
import com.google.devtools.ksp.gradle.KspExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType

class AndroidLibraryKojectConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.google.devtools.ksp")
apply("com.android.library")
}

val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
"implementation"(libs.findLibrary("koject.core").get())
"ksp"(libs.findLibrary("koject.processor.lib").get())
}

extensions.configure<KspExtension> {
arg("moduleName", name)
allowSourcesFromOtherPlugins = true
}
}
}
}

Next, modify the application class. Remove the @HiltAndroidApp annotation and call Koject.start() in onCreate.

@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
/* ... */

override fun onCreate() {
super.onCreate()
/* ... */
}

/* ... */
}

Provide ImageLoader

Let's change the dependency distribution from Hilt to Koject. Here, I will use ImageLoader as an example.

In Now in Android, ImageLoader of Coil was provided from NetworkModule. Creating ImageLoader requires Call.Factory of OkHttp, which was also provided by Hilt. Both are singletons to use the same instances each time.

Now, let's migrate to Koject. We will remove @Module and @InstallIn. There is no need to specify a component in Koject. Although the name is the same, we need to change dagger.Provides to com.moriatsushi.koject.Provides, and javax.inject.Singleton to com.moriatsushi.koject.Singleton. Also, since Koject uses ApplicationContext by default, we will remove @ApplicationContext.

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
/* ... */

@Provides
@Singleton
fun okHttpCallFactory(): Call.Factory = OkHttpClient.Builder()
.addInterceptor(/* ... */)
.build()

@Provides
@Singleton
fun imageLoader(
okHttpCallFactory: Call.Factory,
@ApplicationContext application: Context,
): ImageLoader = ImageLoader.Builder(application)
.apply { /* ... */ }
.build()
}

ImageLoader was used in the application class. In Koject, we can use inject() to get ImageLoader, so the change will look like this:

@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var imageLoader: Provider<ImageLoader>

override fun onCreate() {
super.onCreate()
/* ... */
}

override fun newImageLoader(): ImageLoader = imageLoader.get()
}

Hilt and Koject have many similarities, so there won't be many changes in the code that need to be made.

Provides Repositories

Let's continue to migrate other dependencies.

The repositories, which are responsible for persisting data, consist of separate interfaces and implementation classes. When using Hilt, the implementation class was provided with @Inject and it was associated with the interface using @Binds in DataModule.

In Koject, we provide the classes using the @Provides annotation. Since the primary constructor is automatically used, we can remove the constructor keyword. DataModule is changed from an interface to an object and described using @Provides.

All types used in the constructor must be provided by Koject.

class OfflineFirstNewsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
) : NewsRepository {
/* ... */
}
@Module
@InstallIn(SingletonComponent::class)
interface DataModule {
@Binds
fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository,
): NewsRepository

/* ... */
}

In Koject, you can easily provide it as a supertype by using the @Binds annotation as follows, without the need for a Module class.

@Provides
@Binds
class OfflineFirstNewsRepository(
private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
) : NewsRepository {
/* ... */
}

Provids ViewModel

In this section, I will introduce how to migrate ViewModel distribution method. In Koject, it is easy to use AndroidX ViewModel.

To migrate ViewModel, you need to remove @Inject and @HiltViewModel annotations, and add @Provides and @ViewModelComponent annotations.

@HiltViewModel
class TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
getSaveableNewsResources: GetUserNewsResourcesUseCase,
) : ViewModel() {
/* ... */
}

If you want to use ViewModel from a Composable function, replace hiltViewModel() with injectViewModel().

@Composable
internal fun TopicRoute(
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TopicViewModel = hiltViewModel(),
) {
/* ... */
}

If you want to use ViewModel from an Activity, use ComponentActivity.lazyViewModels(). If Hilt is not needed in the Activity, you can also remove @AndroidEntryPoint.

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
/* ... */

val viewModel: MainActivityViewModel by viewModels()

/* ... */
}

Provides JankStats

Finally, let me introduce a slightly more complex method for providing dependencies.

JankStats is an AndroidX library for measuring app performance. Creating an instance requires access to the window of an Activity, which was achieved in Hilt by using the ActivityComponent. By using this component, you can access the Activity instance.

Koject also provides @ActivityComponent, but unlike Hilt, you need to annotate each individual function rather than using a module.

@Module
@InstallIn(ActivityComponent::class)
object JankStatsModule {
@Provides
fun providesOnFrameListener(): JankStats.OnFrameListener {
return JankStats.OnFrameListener { frameData ->
/* ... */
}
}

@Provides
fun providesWindow(activity: Activity): Window {
return activity.window
}

@Provides
fun providesJankStats(
window: Window,
frameListener: JankStats.OnFrameListener,
): JankStats {
return JankStats.createAndTrack(window, frameListener)
}
}

To use it in an Activity, use ComponentActivity.lazyInject().

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var lazyStats: dagger.Lazy<JankStats>

/* ... */
}

Review all changes

Koject has many similarities to Hilt, so its usage should be easy to understand. However, migrating all dependencies to Koject can be a bit of a hassle. Koject, like Hilt, can check for missing dependencies at compile time, but I recommend thoroughly testing the migration.

You can see all the changes made in Now in Android in Migrate from hilt to koject #1. You can also learn more about settings and usage in testing. If you feel like trying Koject, feel free to use it as a reference.

If you encounter any issues while migrating from Hilt, please send me feedback via Issue. I will support you as much as possible.