Jetpack Compose 런타임에서 일어나는 마법 완전히 파헤치기 — doCompose
#2 컴포즈 런타임 분석 — 초기 컴포지션
“Jetpack Compose 런타임에서 일어나는 마법 완전히 파헤치기 — Recomposer” 글과 이어지는 내용입니다. 1편을 읽지 않으신 분은 1편을 먼저 읽어 주세요.
이번 내용에선 RememberObserver 가 등장합니다. 아래 글을 참고해 주세요.
컴포즈 내부를 다루는 시리즈다 보니 이 내용을 이해하기 위해선 다른 개념들을 먼저 이해하고 있어야 하는 상황이 종종 발생합니다. 지금 이 글을 이해하기 위해서도 8개의 글을 먼저 읽어야 합니다. 최대한 쉽게 설명하고 싶은데 어쩔 수 없는점 양해 부탁드립니다.
저번 내용에서는 Recomposer 생성 후 끝납습니다. 이번 글에서는 이 이후 과정을 알아보겠습니다.
Recomposer 생성 후 AbstractComposeView#setContent 가 호출됩니다.
먼저 15번 라인을 보면 글로벌 스냅샷 write 이벤트 감지가 시작되고 있습니다. 이후 21번 라인에서 doSetContent 로 Composition 생성 단계가 시작됩니다.
Composition 생성
6번 라인을 보면 Composition 을 만들어주고, 이어서 WrappedComposition 으로 해당 Composition 을 래핑하고 있습니다.
중요하진 않지만 알고 있을만한 포인트는 Composition 으로 만들어지는 객체는 Composition 이 아닌 ControlledComposition 입니다.
Composition 함수가 사용하는 CompositionImpl 은 ControlledComposition 을 상속받아 구현하고 있고, ControlledComposition 은 Composition 을 상속받고 있습니다.
Composition 과 ControlledComposition 의 정의를 보면 Composition 은 “컴포지션” 정의에 맞는 딱 4개의 필드로만 구성돼 있고, ControlledComposition 은 Composition 을 자유롭게 제어하기 위해 많은 필드로 구성돼 있습니다.
다음으로 Composition 을 래핑하는 WrappedComposition 의 정의를 보겠습니다.
WrappedComposition 은 Composition 과 추가로 LifecycleEventObserver 를 상속받고 있습니다.
즉, WrappedComposition 은 Owner 를 통해 Android View System 과 통신할 수 있도록 도와주는 Composition 의 래퍼이고, ControlledComposition 은 컴포지션이 무효화되고 이후에 리컴포지션되는 방법과 시기를 자유롭게 제어하기 위한 래퍼라고 볼 수 있습니다.
Composition 자체로는 할 수 있는게 초기 컴포지션 밖에 없기 때문에 많은 컴포즈 내부 API 에서 ControlledComposition 을 주로 사용하고 있습니다.
다시 본문으로 돌아와서 doSetCompose 가 생성하는 WrappedComposition 구현에 대해 보겠습니다.
우선 disposed, addedToLifecycle, lastContent 변수를 만들어 주고, setContent 구현에서 13번과 15번 라인을 보면 disposed 가 false 일 때 lastContent 와 addedToLifecycle 을 업데이트하고 있습니다.
disposed 는 onStateChanged 구현에서 업데이트 됩니다.
만약 ON_DESTROY 를 받았다면 dispose() 를 호출하여 disposed 를 true 로 바꾸고, addedToLifecycle 에서 observer 를 지워주고 있습니다. 또한 원래 Composition 의 dispose() 도 호출하고 있습니다.
ON_CREATE 를 받았고 dispose 되지 않았다면 lastContent 로 setContent 를 호출하고 있습니다.
이렇게 다시 setContent 가 시작된다면 addedToLifecycle 의 값이 null 이 아니기 때문에 18번 라인의 original.setContent 로 초기 컴포지션 과정이 시작됩니다.
이 초기 컴포지션 과정에 대해 알아보기 전에 original.dispose() 를 먼저 보겠습니다.
Composition#dispose
이 함수는 해당 Composition 이 사용중인 모든 정보를 지우는 작업을 합니다.
동작을 해석해 보겠습니다.
- 사용중인 컴포저블을 지웁니다. (4번 라인)
- 슬릇 테이블에 저장된 데이터들을 지웁니다. (10번 라인)
- 배치된 노드를 루트부터 모두 지웁니다. (12번 라인)
- onForgotten 을 호출합니다. (13번 라인)
- 관련 정보들과 Composition 을 모두 지우고 비활성화 합니다. (17번, 19번 라인)
10번 라인에서 사용되는 SlotWriter#removeCurrentGroup 을 보겠습니다.
만약 지울 슬릇이 RememberObserver 라면 forgetting 해주고 있습니다. 이 덕분에 위 Composition#dispose 의 4번 동작이 가능한 것입니다.
다시 Composition#dispose 로 돌아와서 17번과 19번 라인해서 하는 dispose 과정을 보겠습니다. 먼저 composer.dispose()
를 보면 여기에서 사용하는 composer 는 ControlledComposition 을 만드는 과정에서 생성됩니다.
Composer 란 컴포즈 컴파일러가 컴포즈 트리를 빌드하고 상호작용 할 수 있게 도와주는 역할을 합니다. 이러한 Composer 를 생성함과 동시에 Recomposer 에 registerComposer 를 해주고 있습니다. 메서드 이름에서 부터 알 수 있듯이 Recomposer 가 관리할 Composer 를 등록해 줍니다.
composer.dispose() 와 parent.unregisterComposition(this) 는 Composition 이 사용하는 정보를 지우고 비활성화 하는것으로 구현됩니다.
이제 초기 컴포지션 과정을 보겠습니다.
Recomposer#composeInitial
original.setContent 이 실행되면 결국 Recomposer#composeInitial 로 델리게이트됩니다.
Recomposer#composeInitial 는 4가지 작업을 하고 있습니다.
- composing snapshot 활성화
- Snapshot 시스템 초기화
- Recomposer 가 관리하는 Composition 목록 업데이트
- 초기 컴포지션 과정 동안 생성된 Change 적용
27번 라인을 보면 우리에게 익숙한 composing snapshot 이 보이고, 29번 라인에서 ControlledComposition#composeContent 를 호출하고 있습니다. 이 함수는 추후 알아보겠습니다.
32번 라인에서는 컴포지션이 이미 진행중이 아니라면 StateObject 가 초기화 됐다고 가정하고 스냅샷 시스템에게 알립니다. 이렇게 알려진 StateObject 들에 대해서만 observer 가 활성화 됩니다.
36번 라인에서는 Recomposer 가 관리하는 Composition 목록에 composition 인스턴스를 추가하고 있고, 이어서 39~40번 라인에서 이 과정까지 오면서 생긴 Change 를 적용하고 있습니다.
Composition.apply[Late]Change 모두 Composition#applyChangesInLocked 를 호출합니다.
Composition#applyChangesInLocked 를 보면 19~21번 라인을 통해 Change 가 반영되고 27~28번 라인을 통해 RememberObserver 를 호출하고 있습니다.
이제 ControlledComposition#composeContent 를 보겠습니다.
ControlledComposition#composeContent
컴포즈 런타임 전체에서 가장 중요한 부분이자, 그래프가 끝나는 지점 입니다.
ControlledComposition#composeContent 도 결국은 Composer#doCompose 로 델리게이트 하고 있으며, 인자로 takeInvalidations() 를 주고 있습니다.
takeInvalidations() 는 현재 Composition 에 있는 무효화 목록을 반환하고 이어서 초기화 하는 함수 입니다. 이는 Composition 에서 관리하던 RecomposeScope(리컴포지션이 진행될 컴포지션 영역을 의미합니다) 를 Composer 로 이전하기 위해 사용됩니다.
doCompose 가 하는 첫 번째 일은 인자로 받은 Composition 의 무효화 목록을 Invalidation 으로 래핑해서 Recomposer 가 관리하는 무효화 목록인 invalidations 에 추가하고 있습니다. 이어서 무효화마다 RecomposeScope 를 기준으로 정렬을 해주고 있습니다.
그 후 startRoot 로 슬릇 테이블에서 Composition 에 대한 루트 그룹을 시작하고 다른 필수 필드 및 구조를 초기화 하여 Composition 이 작동하기 위한 조건을 만들어 줍니다.
다음으로 startGroup 으로 컴포저블이 들어갈 그룹을 열어주고 invokeComposble 로 컴포저블 함수를 실행함으로써 컴포지션을 진행합니다.
마지막으로 endGroup 과 endRoot 로 해당 컴포저블의 그룹을 닫고 있습니다.
우리가 사용하는 content 는 람다식(content: @Composable () -> Unit
)으로 content 를 배치하기 위해선 content() 로 content 람다를 실행하는 부분이 필요합니다. 이 역할을 invokeComposable 에서 해줍니다.
invokeComposable 로 인해 컴포저블 함수들이 실행되면서 UI 과정이 시작됩니다. invokeComposable 은 content 람다에 인자 2개를 추가로 넣은 Function2 로 캐스팅하여 실행하고 있습니다. 람다에 인자를 강제로 추가하는데 이게 어떻게 예외 없이 작동하는 걸까요?
정답은 컴파일 과정에서의 IR Transform 에 있습니다.
컴파일의 람다 최적화 단계에서 컴포저블 람다는 값을 캡처하는 경우에는 composableLambda 로 래핑되고, 값을 캡처하지 않는 경우에는 composableLambdaInstance 로 래핑됩니다.
두 개의 함수 모두 ComposableLambdaImpl 를 이용해 구현되고 있습니다.
ComposableLambdaImpl 의 핵심인 invoke 부분을 보면 인자로 composer 와 changed 를 받고 있습니다. 이 덕분에 content 를 Function2 로 캐스팅해도 잘 됐던 것입니다.
또한 함수의 시작과 끝에 startRestartGroup, endRestartGroup 으로 RestartGroup 을 생성하고 있다는 것도 중요합니다. 이 과정으로 각각 content 마다 RecomposeScope 가 만들어 집니다. 이 원리로 도넛홀 건너뛰기를 비롯한 smart-recompose 이 가능한 것입니다.
이렇게 해서 ControlledComposition#setContent 가 끝납니다.
이제 우리에게 익숙하기만 했지 내부는 어떻게 진행되는지는 다뤄본 적 없는 Recomposer#composing 을 보겠습니다.
Recomposer#composing
readObserverOf 와 writeObserverOf 를 통해 각각 이벤트에 맞는 람다를 만들어 주고 있습니다. readObserverOf 는 Composition#readObserverOf, writeObserverOf 는 Composition#recordWirteOf 를 사용하여 구현됩니다.
Composition#recordReadOf 는 읽힌 값을 observations 에 RecomposeScope 와 함께 더해주고 있고, Composition#recordWriteOf 는 observations 을 순회하며 value 가 쓰인 RecomposeScope 를 찾아 invalidate 를 호출하고 있습니다.
recordWriteOf 로 인해 마지막에 호출되는 Recomposer#invalidate 를 보면 deriveStateLocked()?.resume(Unit) 을 해주고 있고 덕분에 무효화가 진행됩니다.
마무리
이렇게 Composition#setContent 가 구현되고, 컴포즈 런타임의 핵심 흐름이 끝납니다.
끝!
총 2편에 걸쳐 이 그래프의 모든 영역을 살펴 보았습니다. 이번 글 역시 많은 코드가 생략됐습니다. 직접 컴포즈 코드를 봐보시는걸 추천합니다.
런타임을 다룬 2개의 글 모두 무효화에 대해선 많은 내용이 생략됐습니다. 무효화가 워낙 과정이 많아서 런타임 파헤치기의 3번째 글로 작성될 예정입니다. 간단하게 무효화가 진행되는 원리를 말씀해 드리면 해당 RecomposeScope 가 사용하는 슬릇 테이블의 위치로 들어가서 invokeComposable 을 호출합니다. 이렇게 하면 해당 컴포저블이 사용중인 정보를 효율적으로 덮어 쓸 수 있습니다.
이 글을 쓰기 위해 컴포즈 내부 흐름을 이해하기 까지 약 6개월이 걸렸으며, 각각 개념들을 정리하는 게시글이 5월 말부터 총 53개가 작성됐습니다.
끝까지 봐주셔서 감사합니다. 다음 아티클은 “Jetpack Compose 최고의 성능을 위한 Best Practice” 가 될 예정입니다.
안드로이드 개발자 분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.