9.0 KiB
Koin Migration Implementation Plan (Annotations & K2 Compiler Plugin)
This document outlines the meticulous, step-by-step strategy for migrating Meshtastic-Android from Hilt (Dagger) to Koin with Annotations. This approach leverages the new native Koin Compiler Plugin (K2) to automatically generate Koin DSL at compile time, providing a developer experience nearly identical to Hilt/Dagger but with pure, boilerplate-free KMP compatibility. We are targeting Koin 4.2.0-RC1+ and the Koin Compiler Plugin for maximum Compose Multiplatform support and optimal build performance.
1. Goal & Objectives
- Remove Hilt/Dagger completely from the project.
- Adopt Koin Annotations for declarative, compile-time verified DI using the native K2 Compiler Plugin.
- Eliminate Android*ViewModel Wrappers by injecting KMP ViewModels (
@KoinViewModel) directly. - Improve Build Times by replacing Dagger KAPT/KSP with the lightweight, native Koin Compiler Plugin.
- Maintain Incremental Progress using the Strangler Fig Pattern.
2. Phase 1: Infrastructure Setup
Objective: Add Koin Annotations and Koin Compiler Plugin to the build system.
- Add Dependencies in
gradle/libs.versions.toml:- Ensure versions are at least Koin
4.2.0-RC1(or stable when available) and Koin Compiler Plugin. - Dependencies:
koin-core,koin-android,koin-annotations,koin-compose-viewmodel. - Plugins:
io.insert-koin.compiler.plugin.
- Ensure versions are at least Koin
- Configure Root Compiler Plugin in
build.gradle.kts(root or build-logic):- Ensure the plugin is available and applied in KMP modules (
alias(libs.plugins.koin.compiler)).
- Ensure the plugin is available and applied in KMP modules (
- Setup Koin Application in
MeshUtilApplication.kt:- Initialize Koin with
startKoin { androidContext(this@MeshUtilApplication); modules(AppModule().module) }. - Note:
.moduleis an extension property automatically generated by the compiler plugin for classes annotated with@Module. - Note: In Koin 4.1+, standard native Context handling is unified, making explicit
androidContextpassing into KMP modules significantly simpler than in Koin 3.x.
- Initialize Koin with
3. Phase 2: Core Modules Migration (core:*)
Objective: Replace Hilt modules with Koin Annotated modules.
- Annotate Classes:
- Replace
@Singleton+@Inject constructorwith just@Single. - Koin automatically binds implementations to their interfaces if it's the only interface implemented.
- Standard constructor injection requires no explicit
@Injectannotations—the compiler auto-detects constructors from the class-level scope annotation (@Single,@Factory, etc.).
- Replace
- Define Koin Modules (
expect/actualPattern):- KMP Best Practice: In
commonMain, declare anexpect val platformModule: Module. - In each platform source set (e.g.,
androidMain,iosMain), implement this withactual val platformModule: Module = module { includes(AndroidModule().module) }. - Use
@Moduleand@ComponentScan("org.meshtastic.core.module")on these platform-specific classes so the plugin builds the platform dependency graphs correctly.
- KMP Best Practice: In
- Bridge Hilt/Koin (Incremental Step):
- If a Hilt class needs a Koin dependency, provide a temporary Hilt
@Providesthat fetches fromGlobalContext.get().get().
- If a Hilt class needs a Koin dependency, provide a temporary Hilt
expect/actualClass Injection:- When you have an
expect classthat you want to inject, do not annotate theexpectdeclaration. - Instead, annotate each platform's
actual classwith@Singleor@Factory. The compiler plugin will automatically compile-time link the injected interface to the correct platform implementation.
- When you have an
4. Phase 3: Feature & ViewModel Migration [COMPLETED]
Objective: Migrate ViewModels and eliminate Android-specific wrappers using latest mapping features.
- Migrate ViewModels:
- Replace
@HiltViewModelwith@KoinViewModel. - Move ViewModels to
commonMainwhere applicable to share logic across targets.
- Replace
- Update Compose Navigation:
- Replace
hiltViewModel()withkoinViewModel()inapp/navigation/. - Nitty-Gritty: If using nested Jetpack Navigation graphs, leverage Koin 4.1's
koinNavViewModel()to replicate Hilt's graph-scoped ViewModels securely.
- Replace
- Compose Previews Integration (Experimental):
- Replace dummy Hilt setups in
@Previewwith Koin'sKoinApplicationPreviewto inject dummy modules specifically for rendering Compose previews.
- Replace dummy Hilt setups in
- Purge Wrappers:
- Delete
AndroidMetricsViewModel,AndroidRadioConfigViewModel, etc.
- Delete
5. Phase 4: Advanced Edge Cases (@AssistedInject & WorkManager)
Objective: Address Dagger-specific advanced injection patterns.
- WorkManager &
@HiltWorker:- Add
io.insert-koin:koin-androidx-workmanagerto dependencies. - Replace
@HiltWorkerand@AssistedInjecton Workers with@KoinWorker. - Initialize WorkManager factory in
MeshUtilApplicationviaWorkManagerFactory().
- Add
@AssistedInject(Non-Worker classes):- Meshtastic heavily uses AssistedInject for Radio Interfaces (
NordicBleInterface,MockInterface, etc.). - Replace
@AssistedInjectwith Koin's@Factoryon the class. - Replace
@Assistedparameters in the constructor with@InjectedParam. - In Koin Annotations, when injecting this factory, you pass parameters dynamically:
val radio: RadioInterface = get { parametersOf(address) }.
- Meshtastic heavily uses AssistedInject for Radio Interfaces (
- Dagger Custom
@Qualifiers:- Project uses many custom qualifiers (e.g.,
@UiDataStore,@MapDataStore) for DataStore instances. - Replace these custom annotations with Koin's
@Named("UiDataStore"). - Apply
@Namedto both the provided dependency (e.g., inside the@Modulefunction) and the constructor parameter where it is injected.
- Project uses many custom qualifiers (e.g.,
- Compiler Plugin Multiplatform Benefit:
- By using the new
io.insert-koin.compiler.plugin, we completely bypass the old KSP boilerplate. There is no need forkspCommonMainMetadataor complex KSP target wiring in KMP modules.
- By using the new
6. Phase 5: Testing & Final Cleanup
Objective: Complete Hilt eradication and verify tests.
- Update Tests:
- Replace
@HiltAndroidTestwith Koin testing utilities. - Use
KoinTestinterface andKoinTestRulein your Android instrumented tests and Robolectric unit tests to supply mock modules.
- Replace
- Remove Hilt Annotations:
- Delete
@HiltAndroidApp,@AndroidEntryPoint,@InstallIn, etc.
- Delete
- Clean Build Scripts:
- Remove Hilt plugins and dependencies from all
build.gradle.ktsandlibs.versions.toml.
- Remove Hilt plugins and dependencies from all
- Final Verification:
- Run
./gradlew clean assembleDebug testto ensure successful compilation and structural integrity.
- Run
6. Migration Key mappings (Cheat Sheet)
| Hilt/Dagger | Koin Annotations |
|---|---|
@Singleton class X @Inject constructor(...) |
@Single class X(...) |
@Module + @InstallIn |
@Module + @ComponentScan |
@Provides |
@Single or @Factory on a module function |
@Binds |
Automatic (or @Single on implementation) |
@HiltViewModel |
@KoinViewModel |
hiltViewModel() |
koinViewModel() or koinNavViewModel() |
Lazy<T> |
Lazy<T> (Native Kotlin) |
Dummy @Preview ViewModels |
KoinApplicationPreview { ... } |
7. Troubleshooting & Lessons Learned (March 2026)
Koin K2 Compiler Plugin Signature Collision
During Phase 3, we discovered a bug in the Koin K2 Compiler Plugin (v0.3.0) where multiple @Single provider functions in the same module with identical JVM signatures (e.g., several DataStore providers taking (Context, CoroutineScope)) were incorrectly mapped to the same internal lambda. This caused ClassCastException at runtime (e.g., LocalStats being cast to Preferences).
Solution: Split providers with identical signatures into separate @Module classes. This forces the compiler plugin to generate unique mapping classes, preventing the collision.
Circular Dependencies in Koin 4.2.0
True circular dependencies (e.g., Service -> InterfaceFactory -> Spec -> Factory -> Service) can cause StackOverflowError during graph resolution even with Lazy<T> injection if the Lazy is accessed too early (e.g., in a coroutine launched from init).
Solution: Break cycles by passing dependencies as function parameters instead of constructor parameters where possible (e.g., passing service to InterfaceSpec.createInterface(...)).
Robolectric Tests & KoinApplicationAlreadyStartedException
When running Robolectric tests, MeshUtilApplication is recreated for each test. If startKoin is called in onCreate but not stopped, subsequent tests will fail with org.koin.core.error.KoinApplicationAlreadyStartedException.
Solution: Explicitly call org.koin.core.context.stopKoin() in the application's onTerminate method, which is invoked by Robolectric during teardown.
Status: Fully Completed & Stable.
- Hilt completely removed.
- Koin Annotations and K2 Compiler Plugin fully integrated.
- All DataStore and Circular Dependency issues resolved.
- App verified stable on device via Logcat audit.