아키텍처
두 반쪽, 좁은 경계 하나
Rust는 Compose API를 부르지 않습니다. UI 트리를 값, 곧 고정 레이아웃 레코드로 기술하고, Kotlin 쪽의 범용 인터프리터가 그것을 실제 Compose 트리로 만듭니다. Cash App의 Redwood와 Jetpack Glance가 같은 패턴을 씁니다.
Host와 Renderer
┌──────────────────────── Host (Rust) ────────────────────────┐
│ your code │
│ the layer that builds the tree (Runtime) │
│ compose-rust: schema, fixed-layout records, one batch │
└──────────────────────────────┬──────────────────────────────┘
│
synchronous, same-thread direct calls
primitives, pointers and lengths only
│
┌──────────────────────────────┴──────────────────────────────┐
│ generated shims (C exports, JNI, wasm) │
│ protocol decoder ──► node table │
│ schema interpreter ──► Compose │
└──────────────────── Renderer (Kotlin, AOT) ─────────────────┘
- Host
- Rust 쪽입니다. 사용자의 코드, 도메인 로직, 트리를 만드는 런타임을 갖습니다.
- Runtime
- 트리를 만들고 바꾸는 것으로,
Runtime트레이트 뒤에 있습니다. 1.0.0에서는 compose-rust 자신의 recomposition 런타임입니다. - Renderer
- 미리 컴파일된 Kotlin과 Compose 라이브러리이며, 스키마 인터프리터를 포함합니다.
- 스키마
- 렌더러가 이해하는 위젯, 속성, Modifier, 역할, 이벤트의 닫힌 집합입니다.
- Mutation
- 트리를 바꾸는 레코드 하나입니다.
Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText같은 것입니다. - 이벤트
- 반대 방향으로 가는 레코드입니다. 클릭, 키, 범위 요청, 크기 클래스 변화 같은 것입니다.
C ABI 표면
경계는 일부러 아주 작습니다. 본보기는 옛 React Native 브리지가 아니라 JSI입니다. 같은 스레드, 직접 호출, 동기 반환값입니다.
int32_t compose_rust_host_init(const uint8_t* handshake, uint32_t len, MutationBatch* out);
int32_t compose_rust_host_dispatch_event(const uint8_t* event, uint32_t len, MutationBatch* out);
int32_t compose_rust_host_render_frame(uint64_t frame_time_nanos, MutationBatch* out);
void compose_rust_host_release_batch(MutationBatch* batch);
void compose_rust_host_shutdown(void);
int32_t compose_rust_renderer_run(void); /* blocking; the renderer owns the loop */
void compose_rust_renderer_request_frame(void); /* callable from any thread */
여기서 규칙 두 가지가 나옵니다.
- 건너가는 것은 primitive, 포인터, 길이뿐입니다. 객체도 클로저도 건너가지 않습니다. Kotlin 타입이 Rust의 단일 정의에서 생성되므로, 그 선 위에서 타입 안전이 돌아옵니다.
- 애플리케이션 코드는 이 함수들을 부르지 않습니다. Host가 부르고, 워커 스레드는
request_frame_from_worker로 프레임을 요청합니다.
핸드셰이크는 스키마 해시, 프로토콜 버전, 루프 모드를 주고받습니다. 해시가 다르면 미묘하게 틀린
UI를 만드는 대신 초기화가 실패합니다. 상태 코드는 성공이 0 이고, 프로토콜 오류,
초기화되지 않은 Host, 이중 초기화, 잡은 패닉은 음수입니다. 경계를 넘어 되감기가 일어나지
않습니다. 프로토콜 오류는 프로세스 중단이 아니라 ProtocolError 이벤트가 되어야
하기 때문입니다.
루프 모드는 두 가지입니다. 데스크톱과 iOS에서는 Rust의 main 이
compose_rust_renderer_run 을 부르고, 그 뒤로는 렌더러가 루프를 갖습니다.
Android와 Web에서는 플랫폼이 루프를 갖고, 렌더러가 먼저 compose_rust_host_init 을
부릅니다. 애플리케이션은 두 경우에 같습니다.
프로토콜
두 반쪽이 한 프로세스에 있으므로, 인코딩은 선로 위의 바이트 수가 아니라 복사를 줄이는 데 맞춥니다. 레코드는 고정 레이아웃이고 그 자리에서 읽힙니다. 디코딩 단계도, 핫 패스의 serde 같은 포맷도 없습니다.
- 레코드는
u16태그,u16길이, 그리고 고정 필드입니다. 리틀 엔디언이고 4바이트로 정렬합니다. - 문자열은 같은 아레나에
(offset, len)쌍으로, UTF-8로 놓입니다. 렌더러는 텍스트를 Compose에 넘기는 순간에만 KotlinString을 만듭니다. - 배치는
MutationBatch { ptr, len, result }이고, 프레임마다 재사용하는 Host 소유 아레나를 가리킵니다.result는 핸들러의 동기 반환값을 싣습니다. 키 이벤트라면 소비되었는지입니다.
배치 하나는 스냅샷 하나 안에서 적용되므로 중간 트리가 그려질 수 없습니다. 렌더러는 같은 호출 스택에서 배치를 적용하고 곧바로 놓습니다. 큐에 쌓이는 배치는 없습니다.
프레임 모델
- 렌더러가 입력을 받아 자기 UI 스레드에서
dispatch_event를 부릅니다. - Host가 핸들러를 실행하고, 런타임이 바뀐 것을 아레나에 쓰고, Host가 배치와 결과값을 돌려줍니다.
- 렌더러는 배치를 스냅샷 하나로 적용하고 놓은 뒤, 결과를 Compose에 알립니다. 예를 들면 처리된 키 이벤트로 알립니다.
- 상태를 바꾼 워커 스레드는 프레임 요청을 일으킵니다. 요청들은 Compose 프레임 시계의 다음
틱에서
render_frame하나로 합쳐집니다.
런타임은 자기 스레드가 아니라 렌더러의 UI 스레드에서 돕니다. 그래서 잠금도 큐도 필요 없습니다. 설계 전체에서 스레드를 건너는 신호는 프레임 요청 하나뿐입니다. 핸들러가 도는 동안 요청한 프레임은 핸들러가 돌아갈 때까지 미뤄지므로, 디스패치 도중에 렌더러에 다시 들어가는 일이 없습니다.
측정 호출 계획
자기 레이아웃을 계산하는 층은 텍스트를 놓기 전에 크기를 알아야 합니다. 측정 호출은 Host가 레이아웃을 계산하는 도중에, 텍스트 한 덩이나 이미 보낸 위젯이 주어진 제약 안에서 차지할 크기를 렌더러에 묻게 합니다. #106 이 추적합니다.
int32_t compose_rust_renderer_measure(const uint8_t* requests, uint32_t len,
uint32_t count, MeasureResult* results);
- 답이 그린 것과 같습니다. 렌더러는 그릴 때와 같은 글꼴 해석, 같은 밀도와 글꼴 배율, 같은 타입 스케일로 잽니다. 그 같음이 이 호출이 있는 이유입니다.
- 호출 하나에 요청 여럿. 레이아웃 한 번에 텍스트 잎 수백 개를 물을 수 있으므로, 호출 하나가 그것을 모두 싣습니다. 배치처럼 버퍼 안의 고정 레이아웃 레코드입니다.
- 같은 스레드, 같은 스택. 렌더러가 이미 Host를 부른 호출 안에서 Host가 이 호출을 합니다. UI 스레드 위의 재진입 호출이고, 큐도 스레드 홉도 없습니다. 다른 스레드에서 부르면 아무것도 재지 않고 오류를 돌려줍니다.
- 적용된 노드만. 지금 만들고 있는 배치에서 보낸 위젯은 아직 잴 수 없습니다. 그 배치는 Host가 돌아간 뒤에야 적용되기 때문입니다.
애플리케이션 코드는 이것을 부르지 않습니다. 트리를 만드는 층이, Host의 프레임 작업 안에서만 동작하는 안전한 Rust API로 부릅니다.
확대 배율 계획
compose-rust는 VS Code처럼 확대합니다. 창 전체가 커지고, 시작값은 운영체제의 글자 크기입니다.
- 배율은 OS 글자 크기 배율과 앱 확대를 곱한 값입니다. 앱 확대는 -8부터 8까지의
정수 수준에 대해
1.2의 거듭제곱이고, VS Code와 같은 단계입니다. macOS에는 공개된 글자 크기 설정이 없으므로 OS 배율이 1입니다. - 키는 macOS에서 ⌘+, ⌘-, ⌘0, Windows와 Linux에서는 같은 키에 Ctrl 입니다. 앱이 먼저 키를 소비하지 않았다면 렌더러가 처리합니다. 보기 메뉴의 확대 항목을 위해 Host가 수준을 정할 수도 있습니다.
- 수준은 UI 로컬 상태이므로 렌더러가 앱마다 플랫폼의 설정 저장소에 저장하고, 다음 실행에서 그대로 시작합니다.
- Host에는 시작할 때 한 번, 그 뒤 배율이 바뀔 때마다 배율, OS 몫, 수준이 전해집니다. 창을 밀도가 다른 모니터로 옮기는 것은 배율을 바꾸지 않으므로 아무것도 오지 않습니다.
- 측정도 배율을 따릅니다. 측정 요청마다 배율이 실리므로, 어느 확대 수준에서나 잰 값과 그린 크기가 같습니다.
지금 데스크톱 렌더러는 글꼴 배율을 1로 고정하고 OS 글자 크기를 무시합니다 (#68).
스키마 코드 생성
C ABI는 바이트만 싣기 때문에 타입 안전은 그 위에 얹습니다. 위젯, 속성, Modifier, 역할, 이벤트 페이로드는 Rust에서 한 번 정의합니다. Kotlin 타입, 코덱, Android의 JNI 심, Web의 wasm 심은 그 정의에서 생성하고, 생성 결과가 낡으면 체크인된 테스트가 실패합니다. JNI나 cinterop 접착 코드를 손으로 쓰는 사람은 없습니다.
어디서 무엇이 도나
렌더러는 두 방식으로 빌드합니다. Compose를 그 플랫폼용으로 직접 컴파일할 수 있으면 결과에 가상 머신이 없습니다. 아직 그럴 수 없으면 렌더러는 Java 런타임이 컴파일되어 들어간 GraalVM 네이티브 이미지입니다. 어느 쪽도 사용자의 기계에 Java가 설치되어 있을 필요가 없습니다.
| 플랫폼 | 0.0.1이 배포하는 것 | 다음 |
|---|---|---|
| macOS | 네이티브 이미지 | 실행 파일 안에 링크되는 Kotlin/Native. CI에서 돎(#22) |
| Linux | 네이티브 이미지 | 실행 파일 안에 링크되는 Kotlin/Native. CI에서 시작 테스트 통과(#22) |
| Windows | 네이티브 이미지 | Kotlin/Native(#24) |
| iOS | Kotlin/Native 정적 아카이브 | 그대로 |
| Android | Android 라이브러리 | 그대로 |
| Web | Kotlin/Wasm | 그대로 |
무게 차이는 대부분 여기서 납니다. Kotlin/Native 렌더러를 쓴 macOS의 notepad 샘플은 옆에 아무것도 없는 28.76 MB 파일 하나입니다. 같은 샘플을 네이티브 이미지로 빌드하면 파일 넷, 93.1 MB였습니다. 늘어난 몫은 Compose도 compose-rust 자신의 코드도 아니고, Java 런타임과 닫힌 세계가 요구하는 메타데이터입니다.
Linux에도 Kotlin/Native용으로 게시된 Compose UI는 없지만 빌드를 막는 것도 없으므로, 고정된 Compose 포크가 Linux 타깃을 더합니다. Windows는 더 어렵습니다. Kotlin/Native의 Windows 타깃은 MinGW이고 Skia는 MSVC로 빌드되므로, 둘을 그냥 링크할 수 없습니다.
경계는 어느 렌더러에서나 같은 C ABI이고, 레코드는 같은 바이트이고, 인터프리터는 같은 소스입니다. 사용자가 쓰는 것은 아무것도 바뀌지 않습니다.
macOS의 Kotlin/Native 렌더러
창은 Compose가 이 플랫폼에서 여는 창이 아니라 렌더러 자신의 NSWindow 입니다.
Compose의 창도 바르게 그리지만, 그 안의 것에 이름이 없습니다. 스크린 리더는 제목과 제목 막대의
버튼 셋만 보고, 앱이 그린 것은 보지 못합니다. 창을 직접 가지면 내용을 읽을 수 있게 됩니다.
뷰의 바탕 레이어는 CAMetalLayer 이고, Skia가 Metal로 거기에 그립니다. 프레임은
창 크기를 바꾸는 바로 그 트랜잭션 안에서 표시되므로, 창을 끄는 동안 내용이 뒤처지지 않고 창
가장자리를 따라갑니다.
텍스트는 플랫폼을 돌아가지 않고 통과합니다. 뷰가 NSTextInputClient 를 구현하므로,
조합 중인 텍스트, 후보 창, 한국어, 일본어, 중국어 조합은 입력기 자신의 일입니다. 선택 영역이
띄우는 메뉴는 진짜 NSMenu 입니다.
AppKit은 메인 스레드를 요구합니다. 렌더러는 자신이 불린 스레드에서 NSApplication
을 돌리고, 그 호출은 창이 닫힐 때 돌아옵니다. 그래서 compose_rust_renderer_run 은
프로세스의 메인 스레드에서 불러야 합니다.