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

Help with understanding of Context <> Scope relationship (expanded docs)? #464

Open
Nek-12 opened this issue Jan 4, 2025 · 1 comment
Open

Comments

@Nek-12
Copy link

Nek-12 commented Jan 4, 2025

I'm trying to implement a simple behavior, where there is a Decompose component. This component has a lifecycle that is similar to Android lifecycle, but is retained across configuration changes. It is also scoped to the navigation destination. I need two things from kodein in this case:

  1. The coroutineScope that is provided by the component to start and stop the coroutine-based logic (exactly like a viewModelScope). That logic is named Container.
  2. A way for consumers to get access to the lifecycle inside the Compose UI. This lifecycle controls when they subscribe and unsubscribe to data streams provided by the Container.

The way I want this to work is:

  1. For each instance of a component, it provides the lifecycle along with it to the compose UI. Each time a composable is rendered and DI is used inside, it should get the same lifecycle and store as the parent Component. So there must be a DI scope that:
    a) survives configuration changes
    b) is bound to the Component lifecycle. When the parent component is destroyed, the scope must be cleared and no longer available for use
    c) exposes the lifecycle for injection into the DI scope (to create child components etc.)

When a particular composable is rendered, it wants to grab a business logic Container from the current destination scope, and a lifecycle to subscribe to that Container. The Container must be started using the coroutineScope.

So far I attempted multiple approaches after scouring the documentation for hours:

  1. Using context variables. These are good because the root Component is created, and then creates its navigation children, which each override that context to the correct value. Each one persists.
    However, I was unable to implement dependencies that are strictly scoped to that particular context. There is no way to permanently "close" the context.
  2. Using scopes. I attempted to implement the scope in the root component of the main Activity, but then when I declare different scoped dependencies in my DI.Modules, I cannot reference it. Not only it is in another module, but it is also not exactly a singleton, so using an object won't do. The root component itself can be destroyed sometimes along with the activity's viewModelStore, in which case it's scope, too, should be closed permanently. Besides, the Scope requires a context, but in that case, the scope itself (Component) is already the context. Using Scope<ComponentScope> results in an infinite recursion.
  3. Using SubScopes. This attempt failed as well because of #2 but also it isn't really clear how to close the scope permanently.

Here's the code I have so far:

interface ComponentScope {

    val coroutineScope: CoroutineScope
    val scope: Scope<Any> // unclear what to use as a context
    val lifecycle: StoreLifecycle
}

That above is what I need from each destination. The coroutineScope is used to start the Container injected from scope, the Container instance is scoped to the scope, which is itself scoped to the StoreLifecycle strictly. Each invocation of scope.instance<MyContainer>() must return the same value until the ComponentScope is closed, after which it should simply throw.

In my DI.Modules, I want to declare how the Container instances are going to be created, and nothing more. I created utility functions:

inline fun <reified T : Container<*, *, *>, reified P : Any> DI.Builder.container(
    crossinline definition: BindingDI<ComponentScope>.(P) -> T
) = bind<T>() with contexted<ComponentScope>().factory { params: P ->
    definition(params).apply { store.start(context.coroutineScope) }
}

inline fun <reified T : Container<*, *, *>> DI.Builder.container(
    crossinline definition: NoArgBindingDI<ComponentScope>.() -> T
) = bind<T>() with contexted<ComponentScope>().provider {
    definition().apply { store.start(context.coroutineScope) }
}

The functions above are not correct however, because they are not properly reusing the same instance. Instead, they should use singleton scoped to the ComponentScope's lifecycle. If I replace the contexted with scoped, it wants a DI scope instance, which I do not have. The only way I can get it is from the ComponentScope variable. However, when I do that by using both contexted and scoped, I can no longer declare the dependency properly, as the contexted has no scoped extension function.

Despite reading the documentation many times, I still struggle to understand how to implement my desired outcome. Can you provide some help?

P.S. I was able to implement the desired outcome with Koin very easily:

inline fun <reified T : Container<*, *, *>> Module.container(noinline definition: Definition<T>) {
    scope<ComponentScope> {
        scoped<T> { params ->
            definition(this, params).apply {
                store.start(get<CoroutineScope>())
            }
        }
    }
}


internal fun destinationComponent(
    destination: Destination?,
    lifecycle: Lifecycle,
): DestinationComponent = object : ComponentScope {

    override val coroutineScope = lifecycle.coroutineScope() 
    override val lifecycle = StoreLifecycle(lifecycle)
    override val scope = getKoin().getOrCreateScope(
        scopeId = destination?.getScopeId() ?: "RootComponent",
        qualifier = qualifier<DestinationScope>(),
        source = context.typed<DestinationScope>()
    )

    init {
        scope.declare(instance = coroutineScope, allowOverride = false)
        context.typed<DestinationScope>()?.let { scope.linkTo(it.scope) } // provide parent bindings too.
        lifecycle.doOnDestroy { scope.close() }
    }
}

The DestinationScope above is then easily provided as a CompositionLocal using Koin's LocalKoinScope.

@Nek-12
Copy link
Author

Nek-12 commented Jan 8, 2025

Okay here's what I was able to achieve, for anyone's future reference:

// setup
interface DestinationContext {

    val coroutineScope: CoroutineScope
    val subLifecycle: SubscriberLifecycle
    val registry: ScopeRegistry
}

// scope
@PublishedApi
internal object DestinationScope : Scope<DestinationContext> {

    override fun getRegistry(context: DestinationContext) = context.registry
}

// declare in modules

@Suppress("INVISIBLE_REFERENCE") // to resolve the ambiguity
@kotlin.internal.LowPriorityInOverloadResolution
inline fun <reified T : Container<*, *, *>, reified P : Any> DI.Builder.container(
    @BuilderInference crossinline definition: BindingDI<DestinationContext>.(P) -> T
) = bind<T>() with scoped(DestinationScope).multiton { params: P ->
    definition(params).apply { store.start(context.coroutineScope) }
}

inline fun <reified T : Container<*, *, *>> DI.Builder.container(
    @BuilderInference crossinline definition: NoArgBindingDI<DestinationContext>.() -> T
) = bind<T>() with scoped(DestinationScope).singleton {
    definition().apply { store.start(context.coroutineScope) }
}

// provide context for each child

@Composable
internal fun ProvideDestinationLocals(
    component: DestinationComponent,
    content: @Composable () -> Unit
) = CompositionLocalProvider(
    LocalSubscriberLifecycle provides component.subLifecycle,
    LocalDestinationContext provides component,
) {
    val di = localDI()
    withDI(remember(di) { di.On(component.diContext) }, content = content)
}

// implement components 

internal fun destinationComponent(
    context: ComponentContext,
): DestinationComponent = object :
    DestinationContext,
    ComponentContext by context {

    override val coroutineScope = instanceKeeper.retainedScope()
    override val subLifecycle = lifecycle.asSubscriberLifecycle
    private val _registry by fastLazy { context.retainedInstance { KeptScopeRegistry() } }
    override val diContext by fastLazy { diContext<DestinationContext>(this) }
    override val registry by _registry::delegate
}

// inject

@Composable
inline fun <reified T : Container<S, I, A>, S : MVIState, I : MVIIntent, A : MVIAction> container(): Store<S, I, A> {
    val value by rememberInstance<T>()
    return value.store
}

@Composable
inline fun <reified T : Container<S, I, A>, reified P : Any, S : MVIState, I : MVIIntent, A : MVIAction> container(
    noinline params: () -> P,
): Store<S, I, A> {
    val value by rememberInstance<P, T>(fArg = params)
    return value.store
}

I will keep this open as the docs definitely need improvement

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

No branches or pull requests

1 participant