Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avoid retaining outdated LocalKoinApplication/LocalKoinScope #1586

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

jjkester
Copy link

@jjkester jjkester commented May 17, 2023

The CompositionLocals that Koin uses only work when the (GlobalContext) Koin application is not changed at runtime, see #1557

Option 1: The KoinApplication composable

The Koin application is looked up in the Context tree and the Koin CompositionLocals are provided with/from this value. This (still) requires this function to be called around the first time Koin is used from Compose. A practical place for this is the setContent function. This means that (boilerplate) code needs to be added to every Activity or Fragment using Compose, and every test case where a Composable is tested in isolation.

Benefits:

  • Works in Kotlin Compose Multiplatform
  • Follows principles of already existing APIs

Drawbacks:

  • Use is required to keep Compose and Context based APIs in sync, easy to forget to add

Option 2: Throwing UnknownKoinContext in the default value factory

By throwing an exception as default factory for the CompositionLocals we signal that there is no explicit value set. This will trigger the default lookup behavior that used to be the result of the factory function, as long as the appropriate functions getKoin() and getKoinScope() are used. By using the internal Compose API we are able to catch the exception to run the lookup code. We remember the result of the try/catch block to ensure we only incur the overhead of the exception once per getKoin() call per composition.

The default value of a CompositionLocal is singleton for the JVM process; when remembering a value as done in this approach the specific value is tied to the composition where it is used.

Benefits:

  • Developers do not have to add the KoinApplication composable as compared to option 1

Drawbacks:

  • Exceptions means a performance hit
  • Uses an internal Compose API that we are not supposed to call

Other options

  • Always retrieve the Koin application from the Android Context on Android
    • This means a specific Android implementation while the MPP code stays the same
  • Reuse the initially referenced objects in Koin
    • This means keeping the same global Koin and Scope objects, even when a different Koin application is started using the test rule
    • Unsure about viability
  • Ask Compose devs nicely to create a CompositionLocal variant that evaluates the default value expression every time instead of using lazy (and wait a long time for them to implement it)

Open tasks

  • Investigate alternatives
  • Investigate integrations with test frameworks for testing standalone composables
  • Analyze performance of exception strategy
  • Add test cases

Open questions to maintainer(s)

  • Where to place Robolectric and instrumented tests?
  • What is the preferred solution?

The Koin application is looked up in the Context tree and the Koin CompositionLocals are provided with/from this value.
This (still) requires this function to be called around the first time Koin is used from Compose.
A practical place for this is the setContent function.
This means that (boilerplate) code needs to be added to every Activity or Fragment using Compose, and every test case where a Composable is tested in isolation.

Untested - will need verification with these test frameworks!

Fixes InsertKoinIOgh-1557
By throwing an exception as default factory for the CompositionLocals we signal that there is no explicit value set. This will trigger the default lookup behavior that used to be the result of the factory function, as long as the appropriate functions getKoin() and getKoinScope() are used. By using the internal Compose API we are able to catch the exception to run the lookup code. We remember the result of the try/catch block to ensure we only incur the overhead of the exception once per `getKoin()` call per composition.

Performance analysis and testing necessary!

Fixes InsertKoinIOgh-1557
@jjkester jjkester changed the title Inject current Koin application from Android context Avoid retaining outdated LocalKoinApplication/LocalKoinScope May 17, 2023
* @author Jan-Jelle Kester
*/
@Composable
fun KoinApplication(content: @Composable () -> Unit) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The koin-compose module has already a KoinApplication function. Do we need a "special" one here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this specific use-case, yes.

The KoinApplication function that already exists creates a new application and starts it. For this one, the assumption is that externally (likely in the Android Application class) an application was already created. This is an Android specific implementation because it uses the Android Context to find the relevant Koin application/scope. This is all not implemented for multiplatform Compose (as it works differently), so this code cannot live in koin-compose.

In short, this is needed to work around the issue that the composition local defaults are never changing between test runs, ensuring that we have a way of setting them to the correct value.

Personally I don't like this workaround, since we are introducing production code purely for test purposes, but with the way composition locals in Compose are built and the way Koin uses them (for composition-based scopes) I could not think of a good alternative.

@arnaudgiuliani
Copy link
Member

Hey @jjkester

I've ported your work on 61a88bb

Lets see how it goes with KoinContext and KoinAndroidContext

@jjkester
Copy link
Author

@arnaudgiuliani Thanks for the heads-up, do to a busy time at work and the summer holidays I completely forgot about this.

Your commit looks good at a first glance, I'll definitely replace the workaround in our project with this approach once released.

I'd still would like to write a regression test for this, earlier I could not find a place in the codebase where to put it. Do you have any thoughts on this?

I'd like to make the tests as "real" as possible by using the Koin test utilities on a dummy project, of course ensuring that the tests fail without applying KoinScope/KoinAndroidScope.

@arnaudgiuliani
Copy link
Member

arnaudgiuliani commented Nov 16, 2023

@jjkester there is an issue on that: #1668

I believe I could make access to scope like this:

fun currentKoinScope(): Scope = currentComposer.run {
    try {
        consume(LocalKoinScope)
    } catch (_: UnknownKoinContext) {
        val ctx = getKoinContext()
        ctx.warnNoContext()
        getKoinContext().scopeRegistry.rootScope
    }
}


fun rememberCurrentKoinScope(): Scope = currentComposer.run {
    remember {
        try {
            consume(LocalKoinScope)
        } catch (_: UnknownKoinContext) {
            val ctx = getKoinContext()
            ctx.warnNoContext()
            getKoinContext().scopeRegistry.rootScope
        }
    }
}

With or without remember for LocalKoinScope, to help reevaluate scope if needed
PR I'm opening: #1706

@jjkester
Copy link
Author

jjkester commented Nov 16, 2023

All the more reason to create some extensive test cases for this kind of thing (it is just fairly complicated!)

Have not had time yet to delve into the mentioned issue, but from the surface it seems to make sense. Because the composition local is accessed from within remember it won't invalidate the computed result (since it is not passed as a parameter).

Besides plenty of regression tests I'd also advise to check the performance without remember (not sure how though). Maybe there's a more low-level API that can be leveraged to claw back some of the performance.

From a performance standpoint you'd want to avoid the UnknownKoinContext exception being thrown very often on your main thread.

In my project we did not run into this issue since we do not use koinInject.

@arnaudgiuliani
Copy link
Member

All the more reason to create some extensive test cases for this kind of thing (it is just fairly complicated!)

Have not had time yet to delve into the mentioned issue, but from the surface it seems to make sense. Because the composition local is accessed from within remember it won't invalidate the computed result (since it is not passed as a parameter).

Besides plenty of regression tests I'd also advise to check the performance without remember (not sure how though). Maybe there's a more low-level API that can be leveraged to claw back some of the performance.

From a performance standpoint you'd want to avoid the UnknownKoinContext exception being thrown very often on your main thread.

In my project we did not run into this issue since we do not use koinInject.

I went with this PR #1723

I've tried manual benchmarking around the internals, with local measure calls. Seems to be faster without remember.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants