본문 바로가기

Android/Tech

[Jetpack Compose] CompositionLocal 은 어떻게 동작하는가?

Unsplash, Janosch Diggelmann.

동기

Jetpack Compose 를 꽤 오랜 시간 사용해 왔지만, 여전히 모르는 부분이 많습니다. 그중 가장 가려웠던 부분은 CompositionLocal 인데, 언제 어떻게 사용해야 하는지에 대해서는 알고 있으나, 어떠한 과정으로 적용되는지에 대해 자세히 모르고 있어 포스팅하게 되었습니다.

 


두 개의 Composable

@Composable
@OptIn(InternalComposeApi::class)
fun CompositionLocalProvider(vararg values: ProvidedValue<*>, content: @Composable () -> Unit) {
    currentComposer.startProviders(values)
    content()
    currentComposer.endProviders()
}
@Suppress("UNCHECKED_CAST")
@Composable
@OptIn(InternalComposeApi::class)
fun CompositionLocalProvider(context: CompositionLocalContext, content: @Composable () -> Unit) {
    CompositionLocalProvider(
        *context.compositionLocals
            .map { it.key as ProvidableCompositionLocal<Any?> provides it.value.value }
            .toTypedArray(),
        content = content
    )
}

 

동일한 명칭의 Composable 입니다. 해당 포스트에서 주목할 메서드는 첫 번째 Composable 입니다. 두 번째의 경우에는 CompositionLocal 이 적용되는 CompositionLocalContext 를 파라미터로 받는 메서드로, 여러 개의 CompositionLocal 이 적용되는 CompositionLocalContext 를 전달하여 일괄 적용할 수 있도록 고안된 메서드입니다. (물론 전자도 여러 개를 일괄 적용할 수 있긴 합니다)

본론

위 언급한 첫 번째 메서드의 선언을 보도록 합니다.

 

currentComposer.startProviders(values)
content()
currentComposer.endProviders()

 

content() 는 Composable 을 작성할 수 있는 Slot API 입니다. 즉, currentComposer 에 ProvidedValue<T> 를 적용한 상태에서 여러 Composable 을 실행하고, 스코프가 닫히면 다시 원래의 상태로 돌아옵니다. 그러므로 우리는 content() 스코프 내에 적용될 CompositionLocal 을 파라미터로 넘겨주어 원하는 상태의 적용이 가능한 것입니다.

 

안드로이드 공식 문서 - Composer 에 작성된 개요를 보면, Composer 는 Compose Kotlin Compiler Plugin 의 대상이 되는 제너레이터에서 사용되는 인터페이스임을 알 수 있습니다. 즉, Compose 와 관련된 코드를 생성 및 관리하기 위한 메서드와 필드가 작성되어 있다는 것입니다. 

 

다만, Composer 가 선언된 파일의 경우 약 5,000 라인에 가까운 방대한 양의 코드가 작성되어 있으므로, 우리는 startProviders() 메서드에 집중해야 할 필요가 있습니다.

 

@InternalComposeApi
override fun startProviders(values: Array<out ProvidedValue<*>>) {
    val parentScope = currentCompositionLocalScope()
    startGroup(providerKey, provider)
    // The group is needed here because compositionLocalMapOf() might change the number or
    // kind of slots consumed depending on the content of values to remember, for example, the
    // value holders used last time.
    startGroup(providerValuesKey, providerValues)
    val currentProviders = invokeComposableForResult(this) {
        compositionLocalMapOf(values, parentScope)
    }
    endGroup()
    val providers: PersistentCompositionLocalMap
    val invalid: Boolean
    if (inserting) {
        providers = updateProviderMapGroup(parentScope, currentProviders)
        invalid = false
        writerHasAProvider = true
    } else {
        @Suppress("UNCHECKED_CAST")
        val oldScope = reader.groupGet(0) as PersistentCompositionLocalMap

        @Suppress("UNCHECKED_CAST")
        val oldValues = reader.groupGet(1) as PersistentCompositionLocalMap

        // skipping is true iff parentScope has not changed.
        if (!skipping || oldValues != currentProviders) {
            providers = updateProviderMapGroup(parentScope, currentProviders)

            // Compare against the old scope as currentProviders might have modified the scope
            // back to the previous value. This could happen, for example, if currentProviders
            // and parentScope have a key in common and the oldScope had the same value as
            // currentProviders for that key. If the scope has not changed, because these
            // providers obscure a change in the parent as described above, re-enable skipping
            // for the child region.
            invalid = providers != oldScope
        } else {
            // Nothing has changed
            skipGroup()
            providers = oldScope
            invalid = false
        }
    }

    if (invalid && !inserting) {
        providerUpdates[reader.currentGroup] = providers
    }
    providersInvalidStack.push(providersInvalid.asInt())
    providersInvalid = invalid
    providerCache = providers
    start(compositionLocalMapKey, compositionLocalMap, GroupKind.Group, providers)
}

 

 

parentScope 변수는 currentCompositionLocalScope() 메서드의 결과값이 할당되는데, 캐싱된 PersistentCompositionLocalMap 객체가 있으면 바로 반환 받고, 그렇지 않은 경우에는 현재 적용되어 있는 PersistentCompositionLocalMap 을 반환 받습니다. 이 캐싱은 후술하겠지만, 최적화를 위한 로직입니다.

 

PersistentCompositionLocalMap 은 CompositionLocal 이 매핑된 Map<K, T> 객체이며, 키로는 CompositionLocal 을 받습니다. 즉, 이 경우에는 파라미터로 넘겨 준 값들이 적용되지 않은, 현재 적용된 CompositionLocal 이 모두 저장된 Map<K, T>  객체를 반환받는 것이지요.

 

이후 startGroup() 메서드가 두 번 실행됩니다. 주어진 key 들이 적용되어 Group 이 시작되는데요. Group 이란 개념은 조금 생소한데, 하나의 스코프 내에서 실행되는 Composable 의 묶음을 Group 이라고 보시면 좋을 것 같습니다. 각 Group 은 함께 Composition 되고요.

 

첫 번째 시작되는 Group 과 두 번째 시작되는 Group 모두 트리의 노드가 아닌 Group 이며, 그러므로 특정 데이터를 포함할 수 있게 됩니다. 여기서 데이터는 CompositionLocalMap 을 의미합니다.

 

when {
    isNode -> writer.startNode(key, Composer.Empty)
    data != null -> writer.startData(key, objectKey ?: Composer.Empty, data)
    else -> writer.startGroup(key, objectKey ?: Composer.Empty)
}

 

하지만, 두 번의 startGroup() 메서드 모두 Data 를 포함하지 않습니다. 그러므로, else 문에 분기 처리 되어 objectKey 를 파라미터로 넘겨주며 Group 을 시작하게 됩니다. 넘겨진 objectKey 는 Slot Table 내의 값들 중 사용 중인 인덱스를 마킹하기 위해 사용됩니다. (Slot Table 은 Jetpack Compose 에서 Composition 에 대한 정보를 담아 두는 공간입니다.) 즉, CompositionLocal Slot Table 에 추가될 수 있으므로 Slot Table 에 미리 어떠한 정보가 추가될 것이라는 것에 대해 알려주는 것입니다.

 

Group 을 두 번이나 시작하는 이유는, 적용되어 있는 Slot Table 이 새로운 compositionLocalOf() 메서드에 의해 변경될 수 있으므로, 이를 한 번 감싸 원래의 CompositionLocalMap 가 적용된 Group 을 하나 의도적으로 생성하고, 그 내부에 새롭게 생성한 CompositionLocalMap 이 적용된 Group 을 생성하여 개발자가 의도하는 작업을 수행하고도 기존의 CompositionLocalMap 을 이용하는 Group Slot Table 이 유지되도록 하기 위함입니다.

 

currentProviders 라는 명칭의 변수에는 invokeComposableForResult() 메서드의 리턴 값을 할당하고 있는데, 내부 구현을 찾아보면 타입 파라미터 T 를 리턴하고 있으므로, currentProviders == compositionLocalMapOf(values, parentScope) 가 성립합니다.

 

@Composable
internal fun compositionLocalMapOf(
    values: Array<out ProvidedValue<*>>,
    parentScope: PersistentCompositionLocalMap
): PersistentCompositionLocalMap {
    val result: PersistentCompositionLocalMap = persistentCompositionLocalHashMapOf()
    return result.mutate {
        for (provided in values) {
            if (provided.canOverride || !parentScope.contains(provided.compositionLocal)) {
                @Suppress("UNCHECKED_CAST")
                it[provided.compositionLocal as CompositionLocal<Any?>] =
                    provided.compositionLocal.provided(provided.value)
            }
        }
    }
}

 

ProvidedValue<T> 의 canOverride 프로퍼티가 true 이거나, parentScope 에 ProvidedValue<T> 가 적용되지 않은 경우에는 현재 ProvidedValue<T> 를 추가합니다. 이렇게 생성된 CompositionLocalMap 은 이후 providers 변수에 기존의 CompositionLocalMap 과 비교 및 업데이트하는 데에 사용되며, 이것은 곧 개발자가 추가한 LocalComposition 이 실질적으로 적용되는 것을 의미합니다.

 

이후 inserting 변수의 값에 따라 분기 처리 되는데요. inserting 상태라면 CompositionLocalMap 을 최신 상태(파라미터로 전달한 값들을 적용한)로 업데이트하고, inserting 상태가 아니라면 또 다른 분기를 수행합니다. 

여기서 inserting 은 Recomposition 등에 의한 Composable 재호출이 아닌, 최초 호출 또는 상태 변경에 의해 처음 호출되는 Composable 인 경우입니다.

 

메서드의 마지막에 start() 메서드와 함께, 새롭게 할당된 CompositionLocalMap 을 providers 로 파라미터에 넣어 보냅니다.

 

본문의 마지막이 Group 을 시작하는 것이고, 직후에 content() 메서드를 바로 실행하므로 새로운 CompositionLocalMap 이 적용된 상태에서 Composable 들이 추가됩니다.

 

startProviders() 메서드를 간단하게 정리하자면, 기존에 존재하던 CompositionLocalMap 을 획득하여 업데이트가 필요한 부분만 업데이트하여 적용하는 역할을 수행합니다. 끝에는 Group 이 시작되기 때문에, 작성되는 코드들은 같은 Group 에 노드로 배치되어 같은 CompositionLocalMap 의 영향권 내에 들어가게 되는 것입니다.

 


조감도(?)

 

슈도 코드를 작성하듯 그려보았습니다. startProviders() 메서드를 통해 시작된 첫 번째 Group 에서 두 번의 startGroup() 을 통해 첫 번째 Group Slot Table 의 변경 가능성을 제거하고, 내부의 Group 에서 새로운 CompositionLocalMap 을 생성합니다. 이후 Group 을 닫아 주고, 생성된 CompositionLocalMap 과 기존의 CompositionLocalMap 을 비교, 필요한 부분만 업데이트하여 startProviders() 메서드 마지막에 Group 생성을 다시 시작합니다.

 

해당 부분에 개발자가 작성한 Composable 들이 배치되어 기존과 비교, 업데이트 된 CompositionLocalMap 의 영향을 받게 되고, 이후 endProviders() 에서 호출하는 endGroup() 에 의해 하위 Group 이 닫히고, 이후 한 번 더 호출되는 endGroup() 에 의해 상위 Group() 이 닫히는 형태입니다.

 

LocalComposition 은 이러한 과정을 바탕으로 개발자가 유연한 방식으로 코드를 작성할 수 있게 하며, 그와 동시에 안정적인 최적화까지 지원하고 있습니다.

 


CompositionLocal 은 생각보다 자주 사용하게 되는 Composable 입니다. 다양한 요구 사항에 유연히 대응할 수 있도록 API 가 잘 구조화되어 있다는 생각이 듭니다.