Table of Contents
Nota: esto se montó con la versión 2.0.1 de koin, en versiones más recientes han cambiado algunas cosas. Referir a la documentación oficial para más información: https://insert-koin.io/
Contexto
Tenemos un proyecto legacy, empezado por un equipo de otra empresa, con otros estándares, prácticas, experiencias, etc. Este proyecto se montó inicialmente con Dagger como mecanismo de inyección de dependencias y no está modularizado. A medida que el proyecto iba creciendo lo iban haciendo también los tiempos de compilación. Cuando llegó el punto en el que compilar el proyecto podía llevar más de 10 minutos decidimos ver qué podíamos hacer para solucionarlo.
Modularizar, posible solución?
Nos planteamos en un primer momento modularizar el proyecto, de este modo solo se tendrían que recompilar los módulos modificados en vez de todo el proyecto. Esto no solucionaría el tiempo de compilación inicial pero las builds incrementales serían mucho más rápidas.
Pero dado el tiempo que se había estado desarrollando el proyecto sin seguir unas buenas guías para reducir el acoplamiento, intentar sacar módulos era tremendamente complicado.
Poder modularizar el proyecto requería de un refactor a un nivel muy profundo, desacoplando partes esenciales de la aplicación unas de otras. Y había que hacer todo esto mientras seguíamos entregando nuevas funcionalidades al cliente.
Dagger y el procesado de anotaciones
Gracias a la herramienta de análisis de builds de Android Studio pudimos ver que aproximadamente entre un 40% y un 50% del tiempo de cada build lo ocupaba el procesador de anotaciones. Y prácticamente la totalidad de ese tiempo lo ocupaba Dagger.
Nosotros ya habíamos trabajado en otros proyectos usando Koin y dado que el código del proyecto ya era en más de un 90% kotlin nos pareció una buena idea migrar de una librería a la otra a ver qué pasaba. En el peor de los casos terminaríamos con una librería de inyección de dependencias que ya conocíamos y con la que nos sentíamos cómodos.
Configuración inicial
Empezamos la migración poco a poco. El primer paso fue incluir la librería al proyecto y configurarla.
private fun initKoin() {
startKoin {
androidContext(this@MyApp)
modules(koinModules)
}
}
En un primer momento la lista de módulos koinModules
incluye las instancias de los elementos comunes sin estado más básicos:
val koinModules = listOf(
commonModule,
networkModule,
databaseModule
)
Estos módulos incluyen cosas como el ApiClient, la base de datos de Room, un label Manager o el manager de analiticas. Cosas que cualquier feature de proyecto podría necesitar en mayor o menor medida.
El siguiente paso fue añadir el test para asegurarnos de que las definiciones de los módulos sean correctas. El principal inconveniente al pasar de Dagger a Koin es que Dagger avisa en cada compilación si hemos hecho algo mal, por otro lado koin fallara solo en tiempo de ejecución, por este motivo es especialmente importante tener una forma de garantizar la correctitud de nuestros módulos. Por suerte la forma de testear esto en koin es bastante fácil y tenemos un CI que ejecuta todos los tests antes de dejarnos publicar una versión (ya sea de prueba o en producción).
class KoinModulesTest : KoinTest {
@get:Rule
@ExperimentalCoroutinesApi
val coroutineRule = CoroutineMainDispatcherRule()
@get:Rule
val rule: TestRule = InstantTaskExecutorRule()
@get:Rule
val mockProvider = MockProviderRule.create { clazz ->
mockkClass(clazz, relaxed = true)
}
@Test
fun testKoinDependencies() {
startKoin {
androidContext(mockk(relaxed = true))
modules(koinModules)
}.checkModules {
//Here we can define the parameters for the ViewModels
create<FeatureViewModel> { parametersOf(mockk<Foo>(), mockk<Bar>()) }
//We can also declare mocks
declareMock<MyService>()
}
}
}
}
Con este único test podemos testear el árbol de dependencias completo. Lo único que requiere de cierta atención son las dependencias que requieren de parámetros externos al propio árbol, por ejemplo un parámetro que pasemos de un fragment a su viewmodel. También es posible que necesitemos declarar otros mocks sobre todo si hay alguna dependencia que ejecute código en tiempo de construcción, por ejemplo:
val data = liveData {
myService.getData(request)?.let { emit(it) }
}
Si no mockeamos la dependencia el test acabará dando problemas intentando llamar al servicio real.
Las dos reglas que encabezan la clase del test son para evitar problemas con las corrutinas, como en el ejemplo anterior si el método getData
es suspendible nos puede acabar fallando el test aunque las dependencias estén bien montadas.
La tercera es para definir a koin qué framework de mocking debe usar, en nuestro caso usamos Mockk, pero se podría usar mockito o cualquier otro framework que queramos.
Migración poco a poco
Aprovechamos los nuevos desarrollos para usar Koin en las features nuevas. Es más fácil crear los módulos y dependencias en paralelo, esto puede inducir a problemas de performance cuando tenemos por ejemplo el mismo ApiService instanciado dos veces, pero excepto los ViewModels, el resto de clases son stateless así que no afecta al funcionamiento del proyecto. Y como las features nuevas requieren de ViewModel nuevos no tenemos el problema de tener el mismo ViewModel inyectado de dos formas diferentes.
Cada nueva feature tendrá un módulo nuevo y este se añade a la lista de módulos definida al principio. Por ejemplo, imaginemos que tenemos una nueva feature cuyo ViewModel recibe del fragment un Foo y un Bar, y necesita de un FeatureService. El módulo quedaría tal que así:
val featureModule = module {
single {
FeatureService(get(), get())
}
//single<FeatureService>() if we can use reflection
viewModel { (foo: Foo, bar: Bar) ->
FeatureViewModule(foo, bar, get())
}
}
Usando una feature experimental de koin podemos ahorrarnos tener que definir los parámetros del servicio. Este feature usa reflexión así que puede no ser usable en todos los casos, pero en nuestro caso el impacto en performance no fue apreciable y decidimos mantenerla.
Para las features ya existentes la migración es similar. Definimos un módulo de koin equivalente al ya definido en dagger, lo añadimos a la lista de módulos y cambiamos la inyección del fragment de:
@Inject
lateinit var viewModel: LegacyViewModel
A:
private val viewModel: LegacyViewModel by viewModel { parametersOf(Foo()) }
Otra ventaja que nos ofrece koin en este caso es no tener que declarar los elementos inyectados como lateinit
var haciéndolas más claras y seguras, usando el delegado by viewModel
el viewmodel se instanciará de forma lazy, es decir, solo en caso de que se necesite.
Una vez migrado un módulo, podemos quitar los @Inject constructor de las clases inyectadas haciendo que nuestro código de producción no necesite saber nada sobre cómo se le pasaran los parámetros. Solo las definiciones de los módulos de koin y nuestras clases Android (Fragments, Activities) saben algo sobre cómo se inyectan las dependencias.
También podemos dejar de heredar de DaggerFragment
y de DaggerAppCompatActivity
ya que koin funciona por extensiones no necesitamos modificar los padres de nuestras clases.
Resultado final
Esta migración nos llevó un tiempo, empezamos de forma incremental y aprovechamos un momento de pocas features para terminar de forma definitiva la migración. Una vez terminada la migración nos quedó un código más idiomático, sin lateinit var y @Inject por todas partes e igual de robusto, pues la CI ejecutaba el test y nos avisaba de cualquier error.
La cantidad de líneas de código en todo el proyecto disminuyó en unas 3000 líneas de código (un 5% del total aproximadamente). Con 3MB menos de código generado, ahora el único código generado es de Room, BuildConfig y Navigation Component.
Y lo más importante, el tiempo de compilación se redujo a más de la mitad: pasamos de builds de más de 10 minutos a builds de menos de 5.
No nos arrepentimos en ningún momento del esfuerzo que implicó esta migración, en el momento fue una inversión de tiempo importante pero el tiempo que hemos ahorrado día a día nos ha merecido la pena con creces.
Author
-
Software engineer with over 6 years of experience working as an Android Developer. Passionate about clean architecture, clean code, and design patterns, pragmatism is used to find the best technical solution to deliver the best mobile app with maintainable, secure and valuable code and architecture. Able to lead small android teams and helps as Android Tech Lead.
Ver todas las entradas