Jetpack Compose Internals 책을 읽고, 몰랐던 내용을 정리하고, 책에 언급된 내용에 대한 딥다이브 및 스터디 시간에 얘기 나누면 좋을 내용들을 적어보는 글입니다.
사용자는 Dispatcher만 지정 가능
부모-자식 관계는 Compose가 관리
@Composable
inline fun rememberCoroutineScope(
// 사용자가 Dispatcher 등 추가 context 전달 가능
crossinline getContext: @DisallowComposableCalls () -> CoroutineContext = {
EmptyCoroutineContext
}
): CoroutineScope {
val composer = currentComposer
// remember로 감싸서 recomposition 시 재생성 방지
return remember { createCompositionCoroutineScope(getContext(), composer) }
}
@PublishedApi
@OptIn(InternalComposeApi::class)
internal fun createCompositionCoroutineScope(
coroutineContext: CoroutineContext,
composer: Composer
) =
// 사용자가 Job 직접 전달을 막아 부모-자식 관계 꼬임 방지
if (coroutineContext[Job] != null) {
// 에러 스코프 반환 (코루틴 실행 시 즉시 실패)
CoroutineScope(
Job().apply {
completeExceptionally(
IllegalArgumentException(
"CoroutineContext supplied to " +
"rememberCoroutineScope may not include a parent job"
)
)
}
)
} else {
// 정상 케이스
// Composition의 부모 context (Job 포함)
val applyContext = composer.applyCoroutineContext
RememberedCoroutineScope(applyContext, coroutineContext)
}
internal class RememberedCoroutineScope(
// Compose 관리 context (Job 포함)
private val parentContext: CoroutineContext,
// 사용자 전달 context (Dispatcher 등)
private val overlayContext: CoroutineContext,
) : CoroutineScope, RememberObserver {
private val lock = makeSynchronizedObject(this)
// The goal of this implementation is to make cancellation as cheap as possible if the
// coroutineContext property was never accessed, consisting only of taking a monitor lock and
// setting a volatile field.
// 최적화: scope 미사용 시 Job 생성 비용 절약
// coroutineContext 접근 안 했으면 cancel도 마커 세팅만 (저렴)
// coroutineContext 접근 했으면 cancel 시 실제 Job.cancel() 호출 (비쌈)
@Volatile private var _coroutineContext: CoroutineContext? = null
override val coroutineContext: CoroutineContext
get() {
var localCoroutineContext = _coroutineContext
if (
localCoroutineContext == null || localCoroutineContext === CancelledCoroutineContext
) {
// Yes, we're leaking our lock here by using the instance of the object
// that also gets handled by user code as a CoroutineScope as an intentional
// tradeoff for avoiding the allocation of a dedicated lock object.
// Since we only use it here for this lazy initialization and control flow
// does not escape the creation of the CoroutineContext while holding the lock,
// the splash damage should be acceptable.
synchronized(lock) {
localCoroutineContext = _coroutineContext
if (localCoroutineContext == null) {
// 첫 접근 시 lazy 생성
val parentContext = parentContext
// 부모 Job의 자식 Job 생성
val childJob = Job(parentContext[Job])
// 부모 context + 자식 Job + 사용자 context 합성
localCoroutineContext = parentContext + childJob + overlayContext
} else if (localCoroutineContext === CancelledCoroutineContext) {
// Lazily initialize the child job here, already cancelled.
// Assemble the CoroutineContext exactly as otherwise expected.
// 이미 cancel됐는데 나중에 접근한 경우
// cancel된 Job으로 context 구성
val parentContext = parentContext
val cancelledChildJob =
Job(parentContext[Job]).apply {
cancel(ForgottenCoroutineScopeException())
}
localCoroutineContext = parentContext + cancelledChildJob + overlayContext
}
_coroutineContext = localCoroutineContext
}
}
return localCoroutineContext!!
}
fun cancelIfCreated() {
// Take the lock unconditionally; this is internal API only used by internal
// RememberObserver implementations that are not leaked to user code; we can assume
// this won't be called repeatedly. If this assumption is violated we'll simply create a
// redundant exception.
synchronized(lock) {
val context = _coroutineContext
if (context == null) {
// 아직 context 생성 전 = 마커만 세팅 (lazy cancel)
_coroutineContext = CancelledCoroutineContext
} else {
// Ignore optimizing the case where we might be cancelling an already cancelled job;
// only internal callers such as RememberObservers will invoke this method.
// 이미 생성됨 = 실제 cancel
context.cancel(ForgottenCoroutineScopeException())
}
}
}
override fun onRemembered() {
// Composition에 진입 시 (아무것도 안 함)
// Do nothing
}
override fun onForgotten() {
// Composition에서 제거 시 cancel
cancelIfCreated()
}
override fun onAbandoned() {
// Composition 실패 시 cancel
cancelIfCreated()
}
companion object {
@JvmField val CancelledCoroutineContext: CoroutineContext = CancelledCoroutineContext()
}
}
Lazy 생성: coroutineContext 첫 접근 시 Job 생성
Lazy cancel: 미사용 시 마커만 세팅, 사용 시 실제 cancel
구조화된 동시성: parentContext[Job]을 부모로 하는 childJob 생성
RememberObserver: Composition lifecycle과 자동 연동
RememberObserver interface를 구현한다.
-> 해당 Composable의 생명주기에 종속된다.
내부적으로 DisposableEffect 를 사용한다!
@Composable public fun <T> LiveData<T>.observeAsState(): State<T?> = observeAsState(value)
/**
* Starts observing this [LiveData] and represents its values via [State]. Every time there would be
* new value posted into the [LiveData] the returned [State] will be updated causing recomposition
* of every [State.value] usage.
*
* The [initial] value will be used only if this LiveData is not already
* [initialized][isInitialized]. Note that if [T] is a non-null type, it is your responsibility to
* ensure that any value you set on this LiveData is also non-null.
*
* The inner observer will automatically be removed when this composable disposes or the current
* [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state.
*
* @sample androidx.compose.runtime.livedata.samples.LiveDataWithInitialSample
*/
@Composable
public fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> {
val lifecycleOwner = LocalLifecycleOwner.current
val state = remember {
@Suppress("UNCHECKED_CAST") /* Initialized values of a LiveData<T> must be a T */
mutableStateOf(if (isInitialized) value as T else initial)
}
DisposableEffect(this, lifecycleOwner) {
val observer = Observer<T> { state.value = it }
observe(lifecycleOwner, observer)
onDispose { removeObserver(observer) }
}
return state
}
@Composable
@NonRestartableComposable // recomposition 시 재실행 안 함
fun DisposableEffect(key1: Any?, effect: DisposableEffectScope.() -> DisposableEffectResult) {
// key 변경 시에만 DisposableEffectImpl 재생성
remember(key1) { DisposableEffectImpl(effect) }
}
class DisposableEffectScope {
/**
* Provide [onDisposeEffect] to the [DisposableEffect] to run when it leaves the composition or
* its key changes.
*/
/**
* cleanup 로직 정의
*/
inline fun onDispose(crossinline onDisposeEffect: () -> Unit): DisposableEffectResult =
object : DisposableEffectResult {
override fun dispose() {
onDisposeEffect()
}
}
}
interface DisposableEffectResult {
fun dispose()
}
// 싱글톤 scope 재사용 (매번 객체 생성 방지)
private val InternalDisposableEffectScope = DisposableEffectScope()
private class DisposableEffectImpl(
private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
private var onDispose: DisposableEffectResult? = null
override fun onRemembered() {
// Composition 진입 시 effect 실행 → cleanup 로직 저장
onDispose = InternalDisposableEffectScope.effect()
}
override fun onForgotten() {
// Composition 이탈 or key 변경 시 cleanup 실행
onDispose?.dispose()
onDispose = null
}
override fun onAbandoned() {
// Nothing to do as [onRemembered] was not called.
// Composition 실패 시
// onRemembered() 호출 안 됐으므로 cleanup 불필요
}
}
DisposableEffect(userId) { // key = userId
// onRemembered() 호출
val listener = setupListener()
onDispose { // DisposableEffectResult 생성 → onDispose 필드에 저장
listener.cleanup()
}
}
// userId 변경 or Composition 제거
// → onForgotten() 호출 → dispose() 실행 → listener.cleanup()
remember(key) → key 변경 시 기존 인스턴스 forgotten → 새 인스턴스 remembered
RememberObserver로 lifecycle 자동 추적
effect 람다는 onRemembered()에서 실행, 반환값(cleanup)은 나중에 dispose()에서 실행
내부적으로 produceState 를 사용한다!
@Composable
public fun <T> StateFlow<T>.collectAsState(
context: CoroutineContext = EmptyCoroutineContext
): State<T> = collectAsState(value, context)
/**
* Collects values from this [Flow] and represents its latest value via [State]. Every time there
* would be new value posted into the [Flow] the returned [State] will be updated causing
* recomposition of every [State.value] usage.
*
* @sample androidx.compose.runtime.samples.FlowWithInitialSample
* @param initial the value of the state will have until the first flow value is emitted.
* @param context [CoroutineContext] to use for collecting.
*/
@Composable
public fun <T : R, R> Flow<T>.collectAsState(
initial: R,
context: CoroutineContext = EmptyCoroutineContext,
): State<R> =
produceState(initial, this, context) {
if (context == EmptyCoroutineContext) {
collect { value = it }
} else withContext(context) { collect { value = it } }
}
@Composable
fun <T> produceState(
initialValue: T,
key1: Any?,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
// State 객체는 remember로 보존 (recomposition 시 유지)
val result = remember { mutableStateOf(initialValue) }
// key 변경 시 코루틴 재시작, producer 내부에서 result.value 업데이트 가능
LaunchedEffect(key1) { ProduceStateScopeImpl(result, coroutineContext).producer() }
return result
}
private class ProduceStateScopeImpl<T>(
state: MutableState<T>,
override val coroutineContext: CoroutineContext
) : ProduceStateScope<T>, MutableState<T> by state {
// cleanup 로직 등록 + 코루틴 무한 대기
override suspend fun awaitDispose(onDispose: () -> Unit): Nothing {
try {
// 영원히 suspend (cancel될 때까지)
suspendCancellableCoroutine<Nothing> {}
} finally {
// 코루틴 cancel 시 (key 변경 or Composition 제거) cleanup 실행
onDispose()
}
}
}
State + 코루틴 결합
awaitDispose = DisposableEffect의 코루틴 버전
produceState + repeatOnLifecycle
/**
* Collects values from this [Flow] and represents its latest value via [State] in a lifecycle-aware
* manner.
*
* Every time there would be new value posted into the [Flow] the returned [State] will be updated
* causing recomposition of every [State.value] usage whenever the [lifecycle] is at least
* [minActiveState].
*
* This [Flow] is collected every time [lifecycle] reaches the [minActiveState] Lifecycle state. The
* collection stops when [lifecycle] falls below [minActiveState].
*
* @sample androidx.lifecycle.compose.samples.FlowCollectAsStateWithLifecycle
*
* Warning: [Lifecycle.State.INITIALIZED] is not allowed in this API. Passing it as a parameter will
* throw an [IllegalArgumentException].
*
* @param initialValue The initial value given to the returned [State.value].
* @param lifecycle [Lifecycle] used to restart collecting `this` flow.
* @param minActiveState [Lifecycle.State] in which the upstream flow gets collected. The collection
* will stop if the lifecycle falls below that state, and will restart if it's in that state
* again.
* @param context [CoroutineContext] to use for collecting.
*/
@Composable
public fun <T> Flow<T>.collectAsStateWithLifecycle(
initialValue: T,
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
context: CoroutineContext = EmptyCoroutineContext,
): State<T> {
return produceState(initialValue, this, lifecycle, minActiveState, context) {
lifecycle.repeatOnLifecycle(minActiveState) {
if (context == EmptyCoroutineContext) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
} else
withContext(context) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
}
}
}
}
public suspend fun Lifecycle.repeatOnLifecycle(
state: Lifecycle.State,
block: suspend CoroutineScope.() -> Unit
) {
require(state !== Lifecycle.State.INITIALIZED) {
"repeatOnLifecycle cannot start work with the INITIALIZED lifecycle state."
}
if (currentState === Lifecycle.State.DESTROYED) {
return
}
// This scope is required to preserve context before we move to Dispatchers.Main
coroutineScope {
withContext(Dispatchers.Main.immediate) {
// Check the current state of the lifecycle as the previous check is not guaranteed
// to be done on the main thread.
if (currentState === Lifecycle.State.DESTROYED) return@withContext
// Instance of the running repeating coroutine
var launchedJob: Job? = null
// Registered observer
var observer: LifecycleEventObserver? = null
try {
// Suspend the coroutine until the lifecycle is destroyed or
// the coroutine is cancelled
suspendCancellableCoroutine<Unit> { cont ->
// Lifecycle observers that executes `block` when the lifecycle reaches certain
// state, and
// cancels when it falls below that state.
val startWorkEvent = Lifecycle.Event.upTo(state)
val cancelWorkEvent = Lifecycle.Event.downFrom(state)
val mutex = Mutex()
observer = LifecycleEventObserver { _, event ->
if (event == startWorkEvent) {
// Launch the repeating work preserving the calling context
launchedJob =
this@coroutineScope.launch {
// Mutex makes invocations run serially,
// coroutineScope ensures all child coroutines finish
mutex.withLock { coroutineScope { block() } }
}
return@LifecycleEventObserver
}
if (event == cancelWorkEvent) {
launchedJob?.cancel()
launchedJob = null
}
if (event == Lifecycle.Event.ON_DESTROY) {
cont.resume(Unit)
}
}
this@repeatOnLifecycle.addObserver(observer as LifecycleEventObserver)
}
} finally {
launchedJob?.cancel()
observer?.let { this@repeatOnLifecycle.removeObserver(it) }
}
}
}
}
https://github.com/skydoves/compose-effects

LaunchedEffect는 내부 블럭에 작업에 상관없이 새로운 코루틴스코프를 만들기에, 컴포지션 재실행, 키 변경시 매번 새로운 코루틴 스코프가 만들어지는 문제가 있어서 이를 해결하고자 RememberedEffect를 개발하셨다고 하신다.
LaunchedEffect를 사용하지만, 내부 블럭에서 suspend 함수 호출과 같은 코루틴 관련 작업을 하지 않을 경우에 RememberedEffect를 사용하여 무분별한 코루틴 스코프 생성을 줄일 수 있다.
var count by remember { mutableIntStateOf(0) }
// LaunchedEffect will launch a new coroutine scope regardless the task is related to the coroutines.
// You can avoid this by using RememberedEffect for executing non-coroutine tasks.
- LaunchedEffect(key1 = count) {
+ RememberedEffect(key1 = count) {
Log.d(tag, "$count")
}
Button(onClick = { count++ }) {
Text("Count: $count")
}
/**
* When [LaunchedEffect] enters the composition it will launch [block] into the composition's
* [CoroutineContext]. The coroutine will be [cancelled][Job.cancel] and **re-launched** when
* [LaunchedEffect] is recomposed with a different [key1]. The coroutine will be
* [cancelled][Job.cancel] when the [LaunchedEffect] leaves the composition.
*
* This function should **not** be used to (re-)launch ongoing tasks in response to callback events
* by way of storing callback data in [MutableState] passed to [key1]. Instead, see
* [rememberCoroutineScope] to obtain a [CoroutineScope] that may be used to launch ongoing jobs
* scoped to the composition in response to event callbacks.
*/
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(key1: Any?, block: suspend CoroutineScope.() -> Unit) {
// Composition의 부모 context
val applyContext = currentComposer.applyCoroutineContext
// key 변경 시에만 LaunchedEffectImpl 재생성 (기존 forgotten → 새로 remembered)
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
internal class LaunchedEffectImpl(
// Composition의 Job 포함된 context
parentCoroutineContext: CoroutineContext,
private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
// 부모 context 기반 scope 생성 (구조화된 동시성)
private val scope = CoroutineScope(parentCoroutineContext)
private var job: Job? = null
override fun onRemembered() {
// This should never happen but is left here for safety
// Composition 진입 or key 변경 시 onRemembered 호출
// 방어 코드: 기존 job 있으면 취소 (정상적으로는 발생 안 함)
job?.cancel("Old job was still running!")
// 새로운 코루틴 시작
job = scope.launch(block = task)
}
override fun onForgotten() {
// Composition 이탈 or key 변경 시 코루틴 취소
job?.cancel(LeftCompositionCancellationException())
job = null
}
override fun onAbandoned() {
// Composition 실패 시 코루틴 취소
job?.cancel(LeftCompositionCancellationException())
job = null
}
}
package com.skydoves.compose.effects
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.RememberObserver
import androidx.compose.runtime.remember
private const val RememberedEffectNoParamError =
"RememberedEffect must provide one or more 'key' parameters."
/**
* It is an error to call [RememberedEffect] without at least one `key` parameter.
*/
// This deprecated-error function shadows the varargs overload so that the varargs version
// is not used without key parameters.
@Deprecated(RememberedEffectNoParamError, level = DeprecationLevel.ERROR)
@Suppress("DeprecatedCallableAddReplaceWith", "UNUSED_PARAMETER")
@Composable
public fun RememberedEffect(effect: () -> Unit): Unit = error(RememberedEffectNoParamError)
/**
* `RememberEffect` is a side-effect API that executes the provided [effect] lambda when it enters
* the composition and re-executes it whenever [key1] changes.
*
* Unlike [LaunchedEffect], `RememberEffect` does not create or launch a new coroutine scope
* on each key change, making it a more efficient option for remembering the execution of side-effects,
* if you don't to launch a coroutine task.
*/
@Composable
@NonRestartableComposable
public fun RememberedEffect(
key1: Any?,
effect: () -> Unit,
) {
remember(key1) { RememberedEffectImpl(effect = effect) }
}
/**
* `RememberEffect` is a side-effect API that executes the provided [effect] lambda when it enters
* the composition and re-executes it whenever any of [key1] and [key2] changes.
*
* Unlike [LaunchedEffect], `RememberEffect` does not create or launch a new coroutine scope
* on each key change, making it a more efficient option for remembering the execution of side-effects,
* if you don't to launch a coroutine task.
*/
@Composable
@NonRestartableComposable
public fun RememberedEffect(
key1: Any?,
key2: Any?,
effect: () -> Unit,
) {
remember(key1, key2) { RememberedEffectImpl(effect = effect) }
}
/**
* `RememberEffect` is a side-effect API that executes the provided [effect] lambda when it enters
* the composition and re-executes it whenever any of [key1], [key2], and [key3] changes.
*
* Unlike [LaunchedEffect], `RememberEffect` does not create or launch a new coroutine scope
* on each key change, making it a more efficient option for remembering the execution of side-effects,
* if you don't to launch a coroutine task.
*/
@Composable
@NonRestartableComposable
public fun RememberedEffect(
key1: Any?,
key2: Any?,
key3: Any?,
effect: () -> Unit,
) {
remember(key1, key2, key3) { RememberedEffectImpl(effect = effect) }
}
/**
* `RememberEffect` is a side-effect API that executes the provided [effect] lambda when it enters
* the composition and re-executes it whenever any of [keys] changes.
*
* Unlike [LaunchedEffect], `RememberEffect` does not create or launch a new coroutine scope
* on each key change, making it a more efficient option for remembering the execution of side-effects,
* if you don't to launch a coroutine task.
*/
@Composable
@NonRestartableComposable
public fun RememberedEffect(
vararg keys: Any?,
effect: () -> Unit,
) {
remember(*keys) { RememberedEffectImpl(effect = effect) }
}
/**
* Launches the provided [effect] lambda when it enters the composition.
*/
internal class RememberedEffectImpl(
private val effect: () -> Unit,
) : RememberObserver {
override fun onRemembered() {
effect.invoke()
}
override fun onAbandoned() {
// no-op
}
override fun onForgotten() {
// no-op
}
}
Key가 있는 SideEffect라고 생각(React의 UseEffect느낌)
snapshotFlow, derivedStateOf 같은 아직 알아보지 않았던, effect handler들의 내부 구조도 확인해봐야 함
관련해서 읽어보면 좋은 글
https://haeti.palms.blog/compose-snapshot-system
reference)
https://developer.android.com/develop/ui/compose/side-effects
https://github.com/skydoves/compose-effects