Koin

Biblioteki

  |   8 min czytania
(Koin 2.0)

Wstęp

Wstrzykiwanie zależności (Dependency Injection) niezależnie od sposobu realizacji jest nieodłącznym elementem procesu budowania większości aplikacji. Wykorzystanie dostawcy zależności realizuje paradygmat Odwrócenia sterowania (Inversion of Control), który przenosi odpowiedzialność za inicjalizację niektórych obiektów na rzecz kontenera. Pozwala zmniejszyć ilość często powtarzającego się nadmiarowego kodu (boilerplate) dzięki czemu tworzony kod staje się krótszy, czytelniejszy, łatwiejszy w utrzymaniu i testowaniu. Koin jest biblioteką wstrzykiwania zależności o lekkiej strukturze przeznaczoną przede wszystkim dla programistów Kotlin. W przeciwieństwie do innych popularnych rozwiązań (np. Dagger) nie generuje dodatkowego kodu oraz nie wykorzystuje proxy czy refleksji, a pragmatyczne API sprawia, że jego użycie i konfiguracja jest łatwa i zwięzła.

Moduł

Konfiguracja i inicjalizacja obiektów dla kontenera zależności odbywa się w instancji modułu (module) dzięki czemu nie jest wymagane tworzenie osobnej klasy dla każdego modułu. Aby utworzyć moduł należy przypisać do zmiennej rezultat funkcji module w której zwracane są instancje danych typów - funkcje single dla pojedynczej instancji w obrębie zakresu i funkcja factory dla zwyczajnej instancji.

//create main module for whole app
class App : Application() {

    //create Koin module
    val appModule = module {

        //single instance of something
        single { PreferenceManager.getDefaultSharedPreferences(get()) }

        //provide names to recognize instances of the same type
        single(name = "red") { SimpleDependency("red", "oval") }
        single(name = "blue") { SimpleDependency("blue", "triangle") }

        //get provided NetworkManager into ComplexDependency factory
        factory { ComplexDependency(get()) }
        single { NetworkManager("androidcode.pl", 80) }

        //single instance of ParentDependency provided by ChildDependency
        single<ParentDependency> { ChildDependency("text", 100) }
        //for providing single instance matching multiple types use code below:
        //single { ChildDependency("text", 100) } bind ParentDependency::class
    }
}

Rejestracja

Tworzenie i rejestracja komponentu kontenera KoinApplication zachodzi przy wywołaniu funkcji startKoin w której dokonuje się konfiguracji i przekazania referencji do modułów. Tworzenie kontenera może odbywać się w dowolnej klasie Android jednakże warto zarejestrować go w głównej klasie aplikacji dzięki czemu stanie się dostępny globalnie.

class App : Application() {

    //create Koin module
    val appModule = module {
      //definition
    }

    override fun onCreate() {
        super.onCreate()

        //create and register KoinApplication instance
        startKoin {
            androidLogger()
            androidContext(this@App)
            modules(appModule)
        }
    }
}

Wstrzykiwanie

Wstrzykiwanie zależności może zostać zrealizowane przez leniwą inicjalizację członków klasy przy pomocy funkcji inject lub poprzez bezpośrednie wstrzyknięcie do instancji funkcją get.

class MainActivity : AppCompatActivity() {

    //lazy inject
    val simpleDependencyRed by inject<SimpleDependency>("red")
    val simpleDependencyBlue: SimpleDependency by inject("blue")
    val complexDependency: ComplexDependency by inject()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //inject by directly getting instance
        val pref: SharedPreferences = get()
    }
}

Zakres

Zakres pozwala na wykorzystanie wybranych zależności tylko w obrębie danej klasy, a jego tworzenie realizowane jest przy pomocy funkcji scope i oznaczania zależności definicją scoped. Wstrzyknięcie zależności odbywa się z poziomu obiektu ScopeInstance otrzymywanego z funkcji getActivityScope (getFragmentScope dla Fragment), którego należy powiązać z cyklem życia komponentu za pomocą bindScope.

class App : Application() {

    val appModule = module {
      //definitions
    }

    val scopeModule = module {
        scope<ScopeActivity> {
            scoped("scoped") { SimpleDependency("black", "square") }
        }
    }

    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger()
            androidContext(this@App)
            modules(appModule, scopeModule)
        }
    }
}

class ScopeActivity : AppCompatActivity() {

    //inject instance from current scope
    val simpleDependencyBlack: SimpleDependency by getActivityScope().inject()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_scope)

        //bind scope session to lifecycle
        bindScope(getActivityScope())
    }
}

ViewModel

Koin oferuje także integracje z klasą ViewModel co znacząco ułatwia pracę w przypadku realizacji aplikacji z wykorzystaniem mechanizmu data binding dla widoków. Aby dostarczyć obiekt typu ViewModel należy go oznaczyć w module jako viewModel oraz pobrać w miejscu docelowym za pomocą funkcji viewModel dla leniwej inicjalizacji lub bezpośrednio zainicjalizować funkcją getViewModel.

class App : Application() {

    val appModule = module {
      //definitions
    }

    val viewModelModule = module {
        single { SimpleDependency("white", "rectangle") }
        viewModel {
            SomeViewModel(get())
        }
    }

    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger()
            androidContext(this@App)
            modules(appModule, viewModelModule)
        }
    }
}

class ViewModelActivity : AppCompatActivity() {

    //lazy inject ViewModel
    val viewModel: SomeViewModel by viewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_viewmodel)

        //inject by directly getting ViewModel instance
        val viewModel: SomeViewModel = getViewModel()
    }
}

Testowanie

Wstrzykiwanie zależności może zostać także wykorzystywane w procesie testowania z JUnit dokładnie w taki sam sposób jak w klasach kodu źródłowego. Klasa testowa powinna rozszerzać KoinTest. Dodatkowo Koin umożliwia również tworzenie atrap (mock) obiektów dzięki czemu możliwa jest współpraca z bibliotekami naiwnych implementacji (np. Mockito).

class SimpleDependencyTest: KoinTest {

    //define some test module dependencies
    val testModule = module {
        single { SimpleDependency("red", "oval") }
    }

    //inject instance into member or get inside method test
    val dependency: SimpleDependency by inject()

    //init test dependencies before each test
    @Before
    fun before() {
        startKoin {
            modules(testModule)
        }
    }

    //stop test dependencies after each test
    @After
    fun after() {
        stopKoin()
    }

    //test dependency
    @Test
    fun testChangeColorWithInjection() {
        assertEquals("SimpleDependency is red oval", dependency.getInfo())
        dependency.changeColor("orange")
        assertEquals("SimpleDependency is orange oval", dependency.getInfo())
    }

    //test mock
    @Test
    fun testCheckMock() {
        //declare and get mock
        declareMock<SimpleDependency>(name = "mock")
        val mock: SimpleDependency = get(name = "mock")

        //asserts, rules, verify
        assertNotNull(mock)
        Mockito.`when`(mock.getInfo()).thenReturn("SimpleDependency is green oval")
        assertEquals("SimpleDependency is green oval", mock.getInfo())
        Mockito.verify(mock, times(1)).getInfo()
    }
}