본문 바로가기

Compose

[ComposeInternals] 컴포즈 런타임 (ComposeRuntime) - (3) Recomposer

Jetpack Compose Internals 참고하여 작성하였습니다

 

https://kancho.tistory.com/61 ComposeRuntime - (2)에서는 Composition에 대한 내용을 작성

 

글에서는 Recomposer 대해 알아본다

 

 

Recomposer

Recomposer는 ControlledComposition을 제어하고 변경 사항들을 적용하기 위해 필요할 때 Recomposition을 트리거한다

또한 Composition을 시작하거나 Recomposition할 스레드 및 변경 사항을 적용할 때 필요한 스레드를 결정한다

 

 

Recomposer를 생성하고 invalidation을 대기 시키는 방법

 

 

Recomposer 생성 (Spawning the Recomposer)

클라이언트에서 Compose 진입은 Composition을 생성하고 setContent를 호출하는 것이다

생성 시 해당 컴포지션에 대한 부모를 함께 제공하고 Root 컴포지션의 경우 부모가 Recomposer가 된다

 

안드로이드에서는

ViewGroup.setContent를 호출해 Compose를 사용

내부적으로 Recomposer 팩토리에게 상위 컨텍스트 생성 작업을 위임한다

 

WindowRecomposerFactory 인터페이스

현재 연결중인 Window에 대한 Recomposer 생성

@InternalComposeUiApi
fun interface WindowRecomposerFactory {

    // rootView에 대한 참조 전달
    fun createRecomposer(windowRootView: View): Recomposer

    // 라이프사이클을 인식하는 Recomposer를 생성
    companion object {
        @OptIn(ExperimentalComposeUiApi::class)
        val LifecycleAware: WindowRecomposerFactory = WindowRecomposerFactory { rootView ->
            rootView.createLifecycleAwareWindowRecomposer()
        }
    }
}

 

Compose UI에서 UI를 통해 발생하는 모든 일은 AndroidUiDispatcher를 이용해 조정되고 전달된다

이로 인해 AndroidUiDispatcher는 Choreographer 인스턴스 및 메인 Looper의 핸들러와 연결된다

 

위 팩토리 함수는 먼저

PausableMonotonicFrameClock을 생성한다 (AndroidUiDispatcher의 모노리틱 클럭 위에 구축된 래퍼)

기본적으로 CoroutineContext.Element를 상속하고 있어서 다른 CoroutineContext와 결합될 수 있다

@ExperimentalComposeUiApi
fun View.createLifecycleAwareWindowRecomposer(
    coroutineContext: CoroutineContext = EmptyCoroutineContext,
    lifecycle: Lifecycle? = null
): Recomposer {

    // BaseContext 생서
    // CoroutineContext에 필요한 요소가 없으면 AndroidUiDispatcher.CurrentThread를 기반으로 설정
    val baseContext = if (coroutineContext[ContinuationInterceptor] == null ||
        coroutineContext[MonotonicFrameClock] == null
    ) {
        AndroidUiDispatcher.CurrentThread + coroutineContext
    } else coroutineContext
    
    val pausableClock = baseContext[MonotonicFrameClock]?.let {
        PausableMonotonicFrameClock(it).apply { pause() }
    }

    // 결합된 context
    // Recomposer가 내부 Job을 생성하여 Recomposer를 종료할 때 모든 composition 또는
    // recomposition에 사용되는 이펙트들이 취소될 수 있도록 한다
    // Composition 및 Recomposition 후 변경 사항을 적용하는데 사용되는 Context
    val contextWithClockAndMotionScale =
        baseContext + (pausableClock ?: EmptyCoroutineContext) + motionDurationScale
    val recomposer = Recomposer(contextWithClockAndMotionScale).also {
        it.pauseCompositionFrameClock()
    }
    
    // 위 결합된 context를 사용해 CoroutineScope 생성
    // invalidation을 기다리고 이에 따라 recomposition을 트리거하는 작업을 시작하는데 사용
    val runRecomposeScope = CoroutineScope(contextWithClockAndMotionScale)
    
    ....
    
    // 관찰자는 viewTreeLifecycle에 연결됨
    viewTreeLifecycle.addObserver(
        object : LifecycleEventObserver {
            override fun onStateChanged(
                source: LifecycleOwner,
                event: Lifecycle.Event
            ) {
                val self = this
                when (event) {
                    Lifecycle.Event.ON_CREATE -> {
                        runRecomposeScope.launch(start = CoroutineStart.UNDISPATCHED) {
                            try {
                                // Recomposition 작업 시작
                                recomposer.runRecomposeAndApplyChanges()
                            } finally {
                                source.lifecycle.removeObserver(self)
                            }
                        }
                    }
                    Lifecycle.Event.ON_START -> {
                        pausableClock?.resume()
                        recomposer.resumeCompositionFrameClock()
                    }
                    Lifecycle.Event.ON_STOP -> {
                        recomposer.pauseCompositionFrameClock()
                    }
                    Lifecycle.Event.ON_DESTROY -> {
                        // Recomposer 종료
                        recomposer.cancel()
                    }
                    Lifecycle.Event.ON_PAUSE -> {
                        // Nothing
                    }
                    Lifecycle.Event.ON_RESUME -> {
                        // Nothing
                    }
                    Lifecycle.Event.ON_ANY -> {
                        // Nothing
                    }
                }
            }
        }
    )
    return recomposer
}

 

다시 setContent를 통해 Composition이 어떻게 생성되었는지 보면

// parent -> Recomposer or parent composition reference
internal fun AbstractComposeView.setContent(
    parent: CompositionContext,
    content: @Composable () -> Unit
): Composition {
    val composeView = ...
    return doSetContent(composeView, parent, content)
}

private fun doSetContent(
    owner: AndroidComposeView,
    parent: CompositionContext,
    content: @Composable () -> Unit
): Composition {
    ...
    val original = Composition(UiApplier(owner.root), parent)
    val wrapped = owner.view.getTag(R.id.wrapped_composition_tag)
        as? WrappedComposition
        ?: WrappedComposition(owner, original).also {
            owner.view.setTag(R.id.wrapped_composition_tag, it)
        }
    wrapped.setContent(content)
    return wrapped
}

 

 

Recomposition 프로세스 (Recomposition process)

recomposer.runRecomposeAndApplyChanges 함수가 호출되면 invalidation을 기다리고 invalidation이 발생하면 자동으로 recomposition 된다

 

runRecomposeAndApplyChanges 함수를 호출할 때

  • 스냅샷 변경 전파 프로세스에 대한 관찰자 등록
    • 해당 관찰자가 활성화되어 모든 변경사항을 스냅샷 invalidation 목록에 추가하고 알고 있는 모든 Composer에게 전파
    • 전파를 통해 어떤 부분이 recomposition 되어야 하는지 기록
    • 관찰자는 상태 변경 시 자동적으로 recomposition을 트리거
  • 초기 Invalidation
    • 관찰자 등록 후 Recomposer는 모든 Composition에 대해 초기 invalidation 수행
    • 처음에는 모든 것이 변경되었다고 가정
    • invalidation이 수행된 시간을 기점으로 변경 사항 기록 시작
    • 모든 보류 중인 invalidation이 유입될 때까지 작업 지연
  • 프레임 관련
    • parentFrameClock.withFrameNanos 함수를 호출해 다음 프레임 대기
    • 변경 사항을 프레임 단위로 통합
    • 추가적인 invalidation 발생 가능 (애니메이션 상태 변경으로 인한 Composable 교체 등)
  • 상태 변경 처리
    • 보류 중인 모든 스냅샷에 대해 invalidation 수행
    • 마지막 recomposition 이후 수정된 모든 상태 값으로 Composer의 모든 변경 사항을 보류 중인 recomposition으로 기록
  • Recomposition 실행
    • invalidate된 Composition에 대해 Recomposition 수행
    • Recomposition은 Composition 상태(슬롯 테이블)와 구체화된 트리(Applier)에 필요한 변경을 다시 계산하는 것
    • 이미 계산된 코드와 상태를 최대한 재사용
  • 잠재적인 Recomposition 처리
    • 첫 recomposition으로 인한 추가적인 변경 사항을 감지하고 추가적인 recomposition 예약 (CompositionLocal)
  • 변경 사항 적용
    • 변경 사항이 있는 모든 Composition에 대해 composition.applyChanges 함수 호출하고 실제 UI에 적용
    • Recomposer 상태 업데이트

 

 

Recomposition의 동시성 (Concurrent recomposition)

Recomposer에는 recomposition을 동시에 수행할 수 있는 기능이 있다 (runRecomposeConcurrentlyAndApplyChanges)

 

runRecomposeConcureentlyAndApplyChanges

자동적으로 recomposition 트리거하는 suspend 함수

외부에서 CoroutineContext를 받아 invalidation된 Recomposition 수행

(recomposition 작업을 여러 코루틴에 분산)

@ExperimentalComposeApi
suspend fun runRecomposeConcurrentlyAndApplyChanges(
    recomposeCoroutineContext: CoroutineContext
) = recompositionRunner { parentFrameClock ->
    ...
    val recomposeCoroutineScope = CoroutineScope(
        coroutineContext + recomposeCoroutineContext + Job(coroutineContext.job)
    )
    ...
    
    // recomposition이 필요한 동안 계속 작업 수행
    while (shouldKeepRecomposing) {
    
        // 작업이 준비될 때까지 대기
        awaitWorkAvailable()

        // 병렬 Composition 처리
        recordComposerModifications { composition ->
            synchronized(stateLock) {
                concurrentCompositionsOutstanding++
            }
            
            // 각 composition마다 별도의 코루틴을 사용
            recomposeCoroutineScope.launch(composition.recomposeCoroutineContext) {
                val changedComposition = performRecompose(composition, null)
                synchronized(stateLock) {
                    // recomposition 결과를 compositionsAwaitingApply에 추가
                    changedComposition?.let { compositionsAwaitingApply += it }
                    concurrentCompositionsOutstanding--
                    deriveStateLocked()
                }?.resume(Unit)
            }
        }
        ...
    }
    recomposeCoroutineScope.coroutineContext.job.cancelAndJoin()
    frameLoop.cancelAndJoin()
}

suspend fun runRecomposeAndApplyChanges() = recompositionRunner { parentFrameClock -> ... }

 

 

Recomposer의 상태 (Recomposer states)

enum class State {
    // Recomposer가 취소되고 정리 완료 (완전 종료)
    ShutDown,
    // Recomposer가 취소었지만 아직 정리 중
    ShuttingDown,
    // Recomposer가 composer들의 invalidation을 무시 변경 무시
    // 새로 시작하려면 runRecomposeAndApplyChanges 호출
    Inactive,
    // Recomposer 비활성 상태. 대기 중인 작업이 있지만 아직 활성화되지 않은 상태
    InactivePendingWork,
    // Recomposer가 composition과 스냅샷 invalidation을 추적 중이지만, 현재 수행할 작업이 없는 상태
    // 활성화되어 있지만 현재 처리할 변경사항이 없음
    Idle,
    // Recomposer가 수행할 보류 중인 작업이 있음을 알림 받은 상태
    // 작업을 적극적으로 수행 중이거나 수행할 적절한 기회를 기다리는 중
    PendingWork
}