Jetpack Compose CompositionLocal 구현 분석
CompositionLocal 은 어떻게 구현됐을까?
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 의 생성 방식에는 compositionLocalOf
와 staticCompositionLocalOf
가 있습니다. 먼저, 전자의 구현을 보겠습니다.
compositionLocalOf
는 DynamicProvidableCompositionLocal
을 제공하고 있습니다.
DynamicProvidableCompositionLocal
은 ProvidableCompositionLocal
을 상속하며, provided
를 remember { MutableState<T> }
방식으로 구현합니다. 이를 통해 compositionLocalOf
로 생성된 CompositionLocal 의 장점과 단점인 “값이 변경됐을 경우 해당 값을 사용중인 컴포저블만 리컴포지션을 진행한다.” 의 원리를 파악할 수 있습니다.
다음으로, 후자인 staticCompositionLocalOf
의 구현을 보겠습니다.
staticCompositionLocalOf
는 StaticProvidableCompositionLocal
을 제공하고 있습니다.
역시 StaticProvidableCompositionLocal
도 ProvidableCompositionLocal
을 상속하며, provided
를 State<T>
방식으로 구현합니다. DynamicProvidableCompositionLocal
에 비해 remember
가 사용되지 않았습니다. 즉, 이를 통해 staticCompositionLocalOf
로 생성된 CompositionLocal 의 장점과 단점인 “값이 변경됐을 경우 모든 컴포저블에 리컴포지션을 진행한다.” 의 원리를 파악할 수 있습니다.
이렇게 만들어지는 CompositionLocal 은 CompositionLocalProvider
에 의해 제공됩니다. CompositionLocalProvider
의 구현을 보겠습니다.
CompositionLocalProvider 구현
CompositionLocalProvider
는 composer
에 ProvidedValue
를 제공하는 간단한 코드로 구성돼 있습니다.
여기부터 컴포즈 내부 개념이 사용됩니다! 성빈랜드의 컴포즈 내부 시리즈를 보시지 않았다면, 이해가 불가능할 수 있습니다.
Composer#endProviders
는 단순히 그룹을 닫는 역할만 진행하므로, 핵심인 Composer#startProviders
를 보겠습니다.
코드가 복잡해 보이지만 천천히 보면 그리 복잡하진 않습니다.
Composer#currentCompositionLocalScope
를 통해 현재 CompositionLocal 상태를 조회함 ->parentScope
변수. 이 시점은 제공된ProvidedValue
들이 적용되기 전 상태를 나타냅니다.compositionLocalMapOf
를 통해 새로 적용될CompositionLocal
목록 계산 ->currentProviders
변수. 이 시점은 제공된ProvidedValue
들이 적용된 상태를 나타냅니다.inserting
상태라면 fast-path 로 최신 상태의CompositionLocal
적용 (Composer#updateProviderMapGroup
)inserting
상태가 아니고,skipping
상태가 아니거나currentProviders
에 변경이 있다면 최신 상태의CompositionLocal
적용 (Composer#updateProviderMapGroup
)
차례대로 내부 구현을 보겠습니다.
Composer#currentCompositionLocalScope
currentCompositionLocalScope
는 그룹을 순회하며 가장 마지막에 할당된 CompositionLocalMap
을 찾는 함수입니다.
위 코드를 이해하기 위해선 그룹 개념을 이해해야 하는데, 제가 예전에 성빈랜드에 언급했던 그룹 개념에 비해 그룹은 훨씬 더 어려운 개념이었습니다.
이 글에서 그룹의 자세한 개념을 설명하기엔 이 글의 주제에서 벗어나고, 내용 또한 간단하지 않으므로 그룹을 다루는 게시글이 별도로 작성될 예정입니다. 현재 시점에서는 그룹을 이전에 성빈랜드에서 다뤘던 개념 그대로 생각해 주시고, 위 함수는 간단하게 “가장 마지막에 할당된 CompositionLocalMap
을 찾는 함수” 로 이해하고 넘어가도록 합시다.
currentCompositionLocalScope
이 반환하는 CompositionLocalMap
은 CompositionLocal
과 이에 매핑된 State<T>
를 Map
으로 나타내는 typealias 입니다.
internal typealias CompositionLocalMap = PersistentMap<CompositionLocal<Any?>, State<Any?>>
CompositionLocal 은 Key-Value 형식으로 관리됨을 확인할 수 있습니다.
compositionLocalMapOf
compositionLocalMapOf
은 한 가지 작업을 수행합니다.
ProvidedValue#canOverride
가 true 거나parentScope
에 주어진CompositionLocal
이 존재하지 않는다면 현재ProvidedValue
를 새로운CompositionLocalMap
인result
에 추가한다.
이 함수를 통해 업데이트가 필요한 CompositionLocal 만 담은 CompositionLocalMap
을 얻을 수 있습니다.
Composer#updateProviderMapGroup
마지막으로, updateProviderMapGroup
은 parentScope
와 currentProviders
를 합쳐 현재 시점에서 최종적인 CompositionLocalMap
을 계산합니다.
parentScope
에 currentProviders
를 덮어씌우면 업데이트가 필요한 CompositionLocal
만 효율적으로 업데이트를 진행할 수 있습니다.
이후, 이렇게 구해진 최신 상태의 CompositionLocalMap
을 Composer
에 업데이트하면서 CompositionLocal 값 제공의 핵심 과정이 끝납니다.
// providers = Composer#updateProviderMapGroup 의 값
// ...
if (invalid && !inserting) {
providerUpdates[reader.currentGroup] = providers
}
// ...
providerCache = providers
CompositionLocal#current
CompositionLocal 값 제공의 구현을 보았습니다. 이어서, CompositionLocal 값을 조회하는 CompositionLocal#current
의 구현도 보겠습니다.
CompositionLocal#current
는 Composer#resolveCompositionLocal
을 위임하고 있습니다.
resolveCompositionLocal
의 동작은 2가지로 나뉩니다.
currentCompositionLocalScope
에 주어진CompositionLocal
이 존재한다면 해당 값을 반환함- 1번이 아니라면 주어진
CompositionLocal
의 기본값을 반환함
2번에 사용되는 CompositionLocal#defaultValueHolder
는 CompositionLocal
을 생성할 때 사용되는 defaultFactory
를 lazy 하게 초기화하는 필드입니다.
CompositionLocal 값이 provides 되지 않았을 때의 기본값은 이런 방식으로 구현됩니다.
마무리
지금까지 CompositionLocal 의 핵심 구현들을 모두 살펴보았습니다. 추가로, 현재 컴포저블에 속한 CompositionLocalMap
은 currentCompositionLocalContext
를 통해 가져올 수 있습니다.
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” 가 될 예정입니다.
안드로이드 개발자분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.