Skip to content

Scope

Note

Importing the Scopes API is an opt-in feature through the Gradle DSL. The default value is false.

appPlatform {
  addPublicModuleDependencies true
}

Overview

Scopes define the boundary our software components operate in. A scope is a space with a well-defined lifecycle that can be created and torn down. Scopes host other objects and can bind them to their lifecycle. Sub-scopes or child scopes have the same or a shorter lifecycle as their parent scope.

A leak happens when one scope references another scope with a different lifecycle, e.g. a background thread, which is started and finishes after a certain amount of time, references an Android Activity that is being destroyed while the thread is still running. In this case the thread with the longer lifecycle leaks the Activity with the shorter lifecycle. Another example is a singleton object, which lives as long as the application process runs, keeping a strong reference to a user object, which should be released after the user session expires.

Relying purely on platform specific scopes is problematic, because these scopes are out of our control. When the platform decides to destroy one of its scopes, then we need to adjust and tear down our operations. This doesn’t always align with our use cases, e.g. we might want to finish uploading data in the background after the platform scope such as an Activity has been destroyed. Further, the platform scopes may not align with how we’d represent logical scopes for our apps, e.g. they often lack a user scope. This forces us to push objects and lifecycles into the application scope and this could cause data to leak across sessions and trigger out of memory scenarios.

We need to be in charge of our own scopes. In simple terms this means having an object that can be created and destroyed.

The App Platform provides the Scope interface to implement this concept.

Scope.kt
interface Scope {

  val name: String
  val parent: Scope?

  fun buildChild(name: String, builder: (Builder.() -> Unit)? = null): Scope
  fun children(): Set<Scope>

  fun isDestroyed(): Boolean
  fun destroy()

  fun register(scoped: Scoped)
  fun <T : Any> getService(key: String): T?
}

Creating a Scope

A Scope is created through the builder function. The Builder allows you to add services before the Scope is finalized:

val rootScope = Scope.buildRootScope {
  addService("key", service)
}

Child scopes are created using the parent:

rootScope.buildChild("user scope") {
  addService("child-service", childService)
}
Sample

The root scope is usually created when the application is launched. The sample application creates its root scope here. This Scope is never destroyed and stays alive for the entire app lifetime.

The sample application has a child scope for the logged in user. This Scope is created during login and destroyed during logout.

override fun login(userId: Long) {
  ...
  val userComponent = userComponentFactory.createUserComponent(user)

  val userScope =
    rootScopeProvider.rootScope.buildChild("user-$userId") {
      addDiComponent(userComponent)
      addCoroutineScopeScoped(userComponent.userScopeCoroutineScopeScoped)
    }

  ...

  userScope.register(userComponent.userScopedInstances)
}

override fun logout() {
  val currentUserScope = user.value?.scope
  ...
  currentUserScope?.destroy()
}

Tests usually leverage the test scope, which comes with better defaults for services such as the coroutine scope:

@Test
fun `my test`() = runTest {
  val scope = Scope.buildTestScope(this)
}

// Or
@Test
fun `my test`() = runTestWithScope { scope ->
  // `scope` is equivalent to calling `Scope.buildTestScope(this)`.
}
Sample

Classes implementing the Scoped interface usually make use of the runTestWithScope function in their tests. Notice in this sample how SessionTimeout, which implements the Scoped interface, is registered in the Scope.

@Test
fun `on timeout the user is logged out`() = runTestWithScope { scope ->
  val userManager = FakeUserManager()
  userManager.login(1L)

  val sessionTimeout = SessionTimeout(userManager, FakeAnimationHelper)
  scope.register(sessionTimeout)

  assertThat(userManager.user.value).isNotNull()

  advanceTimeBy(SessionTimeout.initialTimeout + 1.milliseconds)
  assertThat(userManager.user.value).isNull()
}

Services

A scope can host other objects like an object graph from dependency injection frameworks and a coroutine scope. The latter is especially helpful, because the coroutine scope can be canceled when our logical scope is destroyed and all pending operations are torn down. Connecting our scopes with the dependency injection components makes our dependency injection setup more flexible, because we’re in charge of instantiating components and can provide extra objects like a user ID to the object graph. When a scope is destroyed we release the dependency injection component and the memory can be reclaimed by the runtime. DI components and subcomponents form a tree, therefore subcomponents can inject all types that are provided by parent components. The strong recommendation is to align the component tree with the scope hierarchy.

While a service can be obtained through the getService() function, a more frequent pattern is to rely on extension functions for stronger types. Similarly, an extension function on the Builder allows us to add a service to a Scope.

interface MyService

private const val MY_SERVICE_KEY = "myService"

fun Scope.Builder.addMyService(service: MyService) {
  addService(MY_SERVICE_KEY, service)
}

fun Scope.myService(): MyService {
  return checkNotNull(getService<MyService>(MY_SERVICE_KEY))
}

The App Platform comes with a coroutine scope service and an integration for kotlin-inject-anvil as dependency injection framework.

val rootScope = Scope.buildRootScope {
  addDiComponent(kotlinInjectComponent)
  addCoroutineScopeScoped(coroutineScope)
}

// Obtain service.
rootScope.diComponent<AbcComponent>()
rootScope.coroutineScope()

Warning

Scopes through their service mechanism implement the service locator pattern. With the provided dependency injection framework usually it’s not needed to add custom services and it’s better to rely on dependency injection instead.

CoroutineScope

Info

By default, the IO dispatcher is used for all launched jobs for the provided CoroutineScope.

In tests when using Scope.buildTestScope() or runTestWithScope the backgroundScope is from the TestScope is used by default and added to Scope instance.

It’s strongly recommended to add a CoroutineScope to each each Scope. App Platform provides a CoroutineScope by default for the AppScope. It is important to register this CoroutineScope in the created app Scope instance in order to cancel the CoroutineScope in case the AppScope ever gets destroyed. The same applies to any child scope.

@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
interface AppComponent {
  /** The coroutine scope that runs as long as the app scope is alive. */
  @ForScope(AppScope::class) val appScopeCoroutineScopeScoped: CoroutineScopeScoped // (1)!
}

fun createAppScope(appComponent: AppComponent): Scope {
  return Scope.buildRootScope {
    addDiComponent(appComponent)
    addCoroutineScopeScoped(appComponent.appScopeCoroutineScopeScoped)
  }
}
  1. CoroutineScopeScoped wraps a CoroutineScope in a Scoped instance. In onExitScope() of this instance the CoroutineScope will be canceled.

The CoroutineScope can be injected in classes and used to launch async work. A common pattern is to use the onEnterScope() function to launch coroutine jobs:

override fun onEnterScope(scope: Scope) {
  // This job will be automatically canceled when the `scope` gets destroyed.
  scope.launch { // (1)!
    someFlow.collect {
      ...
    }
  }
}
  1. scope.launch is a convenience function for scope.coroutineScope().launch.

Since the CoroutineScope is part of the kotlin-inject-anvil object graph, the CoroutineScope can be injected in the constructor as well:

@Inject
@SingleIn(AppScope::class)
class MyClass(@ForScope(AppScope::class) coroutineScope: CoroutineScope) {
  init {
    coroutineScope.launch {
      ...
    }
  }
}

Whenever a CoroutineScope is injected, a new child CoroutineScope with its own Job is created (the parent Job points to the shared CoroutineScope Job). The prevents consumers from accidentally tearing down all running coroutines when canceling an injected CoroutineScope.

override fun onEnterScope(scope: Scope) {
  val myCoroutineScope = scope.coroutineScope()

  myCoroutineScope.launch { ... }
  myCoroutineScope.launch { ... }

  // This is safe to do and only cancels the two launched jobs and `myCoroutineScope`. It doesn't cancel the
  // shared `CoroutineScope` hosted within the `scope` object.
  myCoroutineScope.cancel()
}

Scoped

Service objects can tie themselves to the lifecycle of a scope by implementing the Scoped interface:

interface Scoped {
    fun onEnterScope(scope: Scope)
    fun onExitScope()
}

Usually, we rely on our dependency injection framework to instantiate all Scoped instances for a scope. By doing so service objects will be automatically created when their corresponding scope is created and receive a callback when their scope is destroyed. This helps with loose coupling between our service objects. Implementing the Scoped interface is a detail, which doesn’t need to be exposed to the API layer:

interface LocationProvider {
  val location: StateFlow<Location>
}

class AndroidLocationProvider(
  private val locationManager: LocationManager
) : LocationProvider, Scoped {

  private val _location = MutableStateFlow<Location>()
  override val location get() = _location

  override fun onEnterScope(scope: Scope) {
    scope.launch {
      // Observe location updates through LocationManager

      val androidLocation = ...
      _location.value = androidLocation
    }
  }
}

Note

Note in the example that the concrete implementation class implements the Scoped interface and not LocationProvider. Being lifecycle aware is an implementation detail.

How the Scoped object is instantiated depends on the dependency injection framework and which scope to use. With kotlin-inject-anvil for the app scope it would be:

@Inject // (1)!
@SingleIn(AppScope::class) // (2)!
@ContributesBinding(AppScope::class) //(3)!
class AndroidLocationProvider(
  ...
) : LocationProvider, Scoped {
  ...
}
  1. This annotation is required to support constructor injection.
  2. This annotation ensures that there is only ever a single instance of AndroidLocationProvider in the AppScope.
  3. This annotation ensures that when somebody injects LocationProvider, then they get the singleton instance of AndroidLocationProvider.
@ContributesBinding will generate and contribute bindings

The @ContributesBinding annotation will generate a component interface with bindings for LocationProvider and Scoped. The generated interface will be added automatically to the AppScope. No further manual step is needed.

@Provides
public fun provideAndroidLocationProvider(androidLocationProvider: AndroidLocationProvider): LocationProvider = androidLocationProvider

@Provides
@IntoSet
@ForScope(AppScope::class)
fun provideAndroidLocationProviderScoped(androidLocationProvider: AndroidLocationProvider): Scoped = androidLocationProvider
Sample

Another example in the sample app is SessionTimeout. This class is part of the UserScope and implements the Scoped interface. onEnterScope() will be called when the user logs in and onExitScope() when the user logs out.

@Inject
@SingleIn(UserScope::class)
@ContributesBinding(UserScope::class)
class SessionTimeout(...) : Scoped {

  override fun onEnterScope(scope: Scope) {
    // This job will be automatically canceled when the user logs out and the user scope is
    // destroyed.
    scope.launch {
      while (userManager.user.value != null) {
        ...
      }
    }

    scope.launch {
      ...
    }
  }
}

Registering Scoped

The dependency injection framework like kotlin-inject-anvil is only responsible for creating Scoped instances, but it doesn’t automatically register them in the Scope. This has to be done whenever the Scope is created:

@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
interface AppComponent {
  /** All [Scoped] instances part of the app scope. */
  @ForScope(AppScope::class) val appScopedInstances: Set<Scoped>
}

fun createAppScope(appComponent: AppComponent): Scope {
  val rootScope =
    Scope.buildRootScope {
      addDiComponent(appComponent)

      addCoroutineScopeScoped(appComponent.appScopeCoroutineScopeScoped)
    }

  rootScope.register(appComponent.appScopedInstances)

  return rootScope
}

By calling appComponent.appScopedInstances the DI framework instantiates all Scoped instances part of the AppScope. The rootScope.register(...) call will register all of the Scoped instances and invoke onEnterScope(scope). When calling rootScope.destroy() later at some point, then onExitScope() will be called for all Scoped instances.

Sample

The sample application implements this mechanism for the AppScope and the UserScope.

onExit

The convenience function onExit is handy when you want to create objects lazily within onEnterScope() and not create a property in the class itself. This callback notifies you when the Scope is destroyed similar to onExitScope().

@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class MyClass(private val application: Application) : Scoped {

  override fun onEnterScope(scope: Scope) {
    val receiver = object : BroadcastReceiver()

    application.registerReceiver(receiver, Intent())

    scope.onExit {
      // This function is invoked when the scope gets destroyed.
      application.unregisterReceiver(receiver)
    }
  }
}

Threading

Which thread is used for calling onEnterScope() and onExitScope() is an implementation detail of the scope owner when calling scope.register(Scoped). Usually, the app scope is created as soon as possible when the application launches and therefore the main thread is used. Child scopes may use the main thread or a background thread.

To safely launch long running work or blocking tasks it’s recommended to use the coroutine scope provided by the Scope:

override fun onEnterScope(scope: Scope) {
  scope.launch { ... }
}

Clean up routines in onExitScope() must be blocking, otherwise these tasks live longer than the Scope and therefore may cause a leak (thread and memory) and potential race conditions. It’s strongly recommended not to launch any asynchronous work within onExitScope(). By the time onExitScope() is called, the coroutine scope provided by the Scope has been canceled already.

Hosting Scopes

Scopes need to be remembered and must be accessible in order to get access to their services. Where to host scopes depends on what scopes are required and when they need to be created. Most apps have some form of an application scope, which is a singleton scope for the entire lifetime of the application. A natural place to host this scope for Android apps is within the Application class, for iOS apps within App struct or the main function for desktop applications.

A user scope has a shorter lifecycle than the application scope, but usually lives longer than UI components. It is commonly hosted by a service object managing the login state. This scope is destroyed after the user session expires.

App Platform by default only provides the AppScope, which has to be manually created by each application as highlighted above.

Sample

The sample application has a common class DemoApplication that is responsible for creating the app scope. The Android app instantiates DemoApplication in the Application class. The iOS sample creates the DemoApplication in the UIApplicationDelegate. On Desktop DemoApplication is created part of the main() function.

RootScopeProvider

RootScopeProvider, as the name suggests, gives access to the root Scope (“AppScope”). Usually, this interface is implemented by the application object of the individual platform to get access to the root Scope from a platform context, e.g. on Android this is handy in an Activity:

class MainActivity : Activity() {

  private val rootScopeProvider
    get() = application as RootScopeProvider

  ...
}
Sample

The sample application implements RootScopeProvider in the Android Application class and the iOS UIApplicationDelegate. On Desktop there is no concept of a singleton application object by default, but in the sample app we created an equivalent with DesktopApp.