Jetpack Compose CompositionLocal 구현 분석

CompositionLocal 은 어떻게 구현됐을까?

Ji Sungbin
성빈랜드
16 min readDec 17, 2022

--

Photo by Zdeněk Macháček on Unsplash

CompositionLocal 은 Jetpack Compose 의 대표적인 장점 중 하나이자, 우리가 필수적으로 사용하는 요소 중 하나입니다. 이번 글에선 CompositionLocal 의 내부 구현을 소개합니다.

CompositionLocal 의 기본 구성

모든 CompositionLocal 은 CompositionLocal 클래스에 의해 관리됩니다.

하지만 값을 제공하는 provided 함수가 internal 로 설정돼 있습니다. CompositionLocal 는 컴포즈 내부에서 사용할 목적으로 설계된 클래스이고, 우리는 ProvidedValue 클래스로 CompositionLocal 에 접근하게 됩니다.

ProvidedValue 는 아래와 같이 provides[Default] 중위 함수로 생성되며, 이 값을 CompositionLocalProvider 로 제공하여 사용할 수 있습니다.

// `LocalContext provides this` or `LocalContext providesDefault this` => ProvidedValue 타입
CompositionLocalProvider(LocalContext provides this) {}

provides[Default] 는 CompositionLocal 의 중위 함수이지만, 위 CompositionLocal 코드에는 중위 함수가 정의돼 있지 않습니다. provides[Default]CompositionLocal 의 래퍼 클래스인 ProvidableCompositionLocal 클래스에서 구현됩니다.

이제 한 가지 의문점만이 남았습니다. CompositionLocal 의 추상 함수인 fun provided(value: T): State<T> 는 어디서 구현될까요? 이 함수의 구현 방식에 따라 CompositionLocal 의 종류가 결정됩니다.

provided 구현

CompositionLocal 의 생성 방식에는 compositionLocalOfstaticCompositionLocalOf 가 있습니다. 먼저, 전자의 구현을 보겠습니다.

compositionLocalOfDynamicProvidableCompositionLocal 을 제공하고 있습니다.

DynamicProvidableCompositionLocalProvidableCompositionLocal 을 상속하며, providedremember { MutableState<T> } 방식으로 구현합니다. 이를 통해 compositionLocalOf 로 생성된 CompositionLocal 의 장점과 단점인 “값이 변경됐을 경우 해당 값을 사용중인 컴포저블만 리컴포지션을 진행한다.” 의 원리를 파악할 수 있습니다.

다음으로, 후자인 staticCompositionLocalOf 의 구현을 보겠습니다.

staticCompositionLocalOfStaticProvidableCompositionLocal 을 제공하고 있습니다.

역시 StaticProvidableCompositionLocalProvidableCompositionLocal 을 상속하며, providedState<T> 방식으로 구현합니다. DynamicProvidableCompositionLocal 에 비해 remember 가 사용되지 않았습니다. 즉, 이를 통해 staticCompositionLocalOf 로 생성된 CompositionLocal 의 장점과 단점인 “값이 변경됐을 경우 모든 컴포저블에 리컴포지션을 진행한다.” 의 원리를 파악할 수 있습니다.

이렇게 만들어지는 CompositionLocal 은 CompositionLocalProvider 에 의해 제공됩니다. CompositionLocalProvider 의 구현을 보겠습니다.

CompositionLocalProvider 구현

CompositionLocalProvidercomposerProvidedValue 를 제공하는 간단한 코드로 구성돼 있습니다.

여기부터 컴포즈 내부 개념이 사용됩니다! 성빈랜드의 컴포즈 내부 시리즈를 보시지 않았다면, 이해가 불가능할 수 있습니다.

Composer#endProviders 는 단순히 그룹을 닫는 역할만 진행하므로, 핵심인 Composer#startProviders 를 보겠습니다.

코드가 복잡해 보이지만 천천히 보면 그리 복잡하진 않습니다.

  1. Composer#currentCompositionLocalScope 를 통해 현재 CompositionLocal 상태를 조회함 -> parentScope 변수. 이 시점은 제공된 ProvidedValue 들이 적용되기 전 상태를 나타냅니다.
  2. compositionLocalMapOf 를 통해 새로 적용될 CompositionLocal 목록 계산 -> currentProviders 변수. 이 시점은 제공된 ProvidedValue 들이 적용된 상태를 나타냅니다.
  3. inserting 상태라면 fast-path 로 최신 상태의 CompositionLocal 적용 (Composer#updateProviderMapGroup)
  4. inserting 상태가 아니고, skipping 상태가 아니거나 currentProviders 에 변경이 있다면 최신 상태의 CompositionLocal 적용 (Composer#updateProviderMapGroup)

차례대로 내부 구현을 보겠습니다.

Composer#currentCompositionLocalScope

currentCompositionLocalScope 는 그룹을 순회하며 가장 마지막에 할당된 CompositionLocalMap 을 찾는 함수입니다.

위 코드를 이해하기 위해선 그룹 개념을 이해해야 하는데, 제가 예전에 성빈랜드에 언급했던 그룹 개념에 비해 그룹은 훨씬 더 어려운 개념이었습니다.

이 글에서 그룹의 자세한 개념을 설명하기엔 이 글의 주제에서 벗어나고, 내용 또한 간단하지 않으므로 그룹을 다루는 게시글이 별도로 작성될 예정입니다. 현재 시점에서는 그룹을 이전에 성빈랜드에서 다뤘던 개념 그대로 생각해 주시고, 위 함수는 간단하게 “가장 마지막에 할당된 CompositionLocalMap 을 찾는 함수” 로 이해하고 넘어가도록 합시다.

currentCompositionLocalScope 이 반환하는 CompositionLocalMapCompositionLocal 과 이에 매핑된 State<T>Map 으로 나타내는 typealias 입니다.

internal typealias CompositionLocalMap = PersistentMap<CompositionLocal<Any?>, State<Any?>>

CompositionLocal 은 Key-Value 형식으로 관리됨을 확인할 수 있습니다.

compositionLocalMapOf

compositionLocalMapOf 은 한 가지 작업을 수행합니다.

  • ProvidedValue#canOverride 가 true 거나 parentScope 에 주어진 CompositionLocal 이 존재하지 않는다면 현재 ProvidedValue 를 새로운 CompositionLocalMapresult 에 추가한다.

이 함수를 통해 업데이트가 필요한 CompositionLocal 만 담은 CompositionLocalMap 을 얻을 수 있습니다.

Composer#updateProviderMapGroup

마지막으로, updateProviderMapGroupparentScopecurrentProviders 를 합쳐 현재 시점에서 최종적인 CompositionLocalMap 을 계산합니다.

parentScopecurrentProviders 를 덮어씌우면 업데이트가 필요한 CompositionLocal 만 효율적으로 업데이트를 진행할 수 있습니다.

이후, 이렇게 구해진 최신 상태의 CompositionLocalMapComposer 에 업데이트하면서 CompositionLocal 값 제공의 핵심 과정이 끝납니다.

// providers = Composer#updateProviderMapGroup 의 값

// ...

if (invalid && !inserting) {
providerUpdates[reader.currentGroup] = providers
}

// ...

providerCache = providers

CompositionLocal#current

CompositionLocal 값 제공의 구현을 보았습니다. 이어서, CompositionLocal 값을 조회하는 CompositionLocal#current 의 구현도 보겠습니다.

CompositionLocal#currentComposer#resolveCompositionLocal 을 위임하고 있습니다.

resolveCompositionLocal 의 동작은 2가지로 나뉩니다.

  1. currentCompositionLocalScope 에 주어진 CompositionLocal 이 존재한다면 해당 값을 반환함
  2. 1번이 아니라면 주어진 CompositionLocal 의 기본값을 반환함

2번에 사용되는 CompositionLocal#defaultValueHolderCompositionLocal 을 생성할 때 사용되는 defaultFactory 를 lazy 하게 초기화하는 필드입니다.

CompositionLocal 값이 provides 되지 않았을 때의 기본값은 이런 방식으로 구현됩니다.

마무리

지금까지 CompositionLocal 의 핵심 구현들을 모두 살펴보았습니다. 추가로, 현재 컴포저블에 속한 CompositionLocalMapcurrentCompositionLocalContext 를 통해 가져올 수 있습니다.

val currentCompositionLocalContext: CompositionLocalContext
@Composable get() = CompositionLocalContext(
currentComposer.buildContext().getCompositionLocalScope()
)

class CompositionLocalContext internal constructor(
internal val compositionLocals: CompositionLocalMap
)

하지만 반환되는 CompositionLocalContext 의 필드가 internal 인지라 접근하기 위해선 리플렉션이 필요합니다.

val compositionLocals =
currentCompositionLocalContext.javaClass.getDeclaredField("compositionLocals").run {
isAccessible = true
get(currentCompositionLocalContext)
} as Map<CompositionLocal<Any?>, State<Any?>>

compositionLocals.forEach { (local, state) ->
println("local: ${local.toString().substringAfterLast(".")}, state: ${state.value}")
}

위 코드의 결과는 다음과 같습니다.

local: StaticProvidableCompositionLocal@9827ac0, state: Ltr
local: StaticProvidableCompositionLocal@9554dc1, state: androidx.compose.ui.platform.AndroidClipboardManager@6ea4384
local: StaticProvidableCompositionLocal@6b66842, state: land.sungbin.androidplayground.view.PlaygroundActivity@e69276d
local: StaticProvidableCompositionLocal@8605443, state: androidx.compose.ui.input.InputModeManagerImpl@8172fa2
local: StaticProvidableCompositionLocal@b491966, state: DensityImpl(density=2.75, fontScale=1.0)
local: StaticProvidableCompositionLocal@ec114a7, state: androidx.compose.ui.focus.FocusManagerImpl@baed433
local: StaticProvidableCompositionLocal@7f87ea8, state: androidx.compose.ui.autofill.AutofillTree@8a13f0
local: StaticProvidableCompositionLocal@2c65689, state: androidx.compose.ui.platform.DisposableSaveableStateRegistry@87dcd69
local: StaticProvidableCompositionLocal@6bfc14a, state: androidx.compose.ui.platform.AndroidComposeView$pointerIconService$1@a5ca3ee
local: StaticProvidableCompositionLocal@52dd6cb, state: null
local: StaticProvidableCompositionLocal@37482ec, state: androidx.compose.ui.platform.AndroidViewConfiguration@b78808f
local: DynamicProvidableCompositionLocal@7d55e8d, state: {1.0 310mcc260mnc en_US ldltr sw392dp w392dp h778dp 440dpi nrml long port finger -keyb/v/h dpad/v s.6}
local: StaticProvidableCompositionLocal@3c6c68e, state: androidx.compose.ui.platform.AndroidComposeView{f4e29de VFED..... .F....I. 0,0-0,0}
local: StaticProvidableCompositionLocal@bf5feaf, state: androidx.compose.ui.res.ImageVectorCache@7147f1c
local: StaticProvidableCompositionLocal@d288d90, state: land.sungbin.androidplayground.view.PlaygroundActivity@e69276d
local: StaticProvidableCompositionLocal@63d65f2, state: androidx.compose.ui.hapticfeedback.PlatformHapticFeedback@e1cd725
local: StaticProvidableCompositionLocal@47ab053, state: land.sungbin.androidplayground.view.PlaygroundActivity@e69276d
local: StaticProvidableCompositionLocal@caadd54, state: androidx.compose.ui.platform.AndroidFontResourceLoader@77310fa
local: StaticProvidableCompositionLocal@e74f3f9, state: androidx.compose.ui.text.input.TextInputService@68696ab
local: StaticProvidableCompositionLocal@e6cbd9a, state: androidx.compose.ui.platform.AndroidAccessibilityManager@c787108
local: StaticProvidableCompositionLocal@72804fd, state: androidx.compose.ui.text.font.FontFamilyResolverImpl@5e8c0a1
local: StaticProvidableCompositionLocal@5b7ef3e, state: androidx.compose.ui.platform.AndroidTextToolbar@c5142c6
local: StaticProvidableCompositionLocal@229f19f, state: androidx.compose.ui.platform.AndroidUriHandler@c15f287
local: StaticProvidableCompositionLocal@16cc9d5, state: null
local: StaticProvidableCompositionLocal@707d6b5, state: androidx.compose.ui.platform.WindowInfoImpl@a795b4

다양한 CompositionLocal 들이 기본적으로 제공됨을 확인할 수 있습니다. 기본적으로 제공되는 CompositionLocal 들은 아래 게시글을 참고해 주세요.

끝!

사실 이 글은 제가 글쓰기 공부를 한 이후 처음으로 작성되는 글입니다. 이전 글들에 비해 가독성이 향상됐기를 소망하며 이만 글을 마치도록 하겠습니다.

이번에도 읽어주셔서 감사합니다. 다음 컴포즈 내부 시리즈는 (드디어) “Jetpack Compose 런타임에서 일어나는 마법 완전히 파헤치기 — Canvas” 가 될 예정입니다.

[목차로 돌아가기]

안드로이드 개발자분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.