아키텍처
두 쪽, 그리고 아주 좁은 경계 하나
Rust는 Compose를 직접 호출하지 않습니다. 할 수도 없습니다. GraalVM의
@CEntryPoint는 primitive와 word 크기 값만 넘길 수 있어서
Modifier나 MutableState 같은 객체는 경계를 넘지 못합니다. 대신
Rust는 UI 트리를 값으로 기술하고, Kotlin이 고정된 스키마를 해석하는 인터프리터를
돌립니다. Redwood와 Jetpack Glance가 쓰는 것과 같은 방식입니다.
Host와 Renderer
┌──────────────── Host (Rust) ─────────────────┐
│ 사용자 컴포넌트 (rsx!, hooks, signals) │
│ dioxus-core VirtualDom │
│ dioxus-compose renderer: Mutations → bytes │
└──────────────────┬───────────────────────────┘
│ UI 스레드에서의 동기 직접 호출
│ + 호출마다 배치 버퍼 하나
┌──────────────────┴──── Renderer (Kotlin) ────┐
│ 생성된 심 (@CEntryPoint / @CName / …) │
│ 프로토콜 디코더 → 노드 테이블 (snapshot) │
│ 스키마 인터프리터: @Composable RenderNode │
│ Compose Desktop (AWT) / Compose iOS (UIKit) │
└──────────────────────────────────────────────┘
- Host
- Rust 프로세스. 사용자 컴포넌트와 도메인 로직, VirtualDom을 소유합니다.
- Renderer
- AOT 컴파일된 Kotlin/Compose 네이티브 라이브러리. 스키마 인터프리터를 포함합니다.
- Schema
- 렌더러가 이해하는 위젯 타입, 속성, Modifier, 이벤트의 닫힌 집합입니다.
- Mutation
- 트리 변경 명령 한 단위:
Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText. - Event
- 반대 방향으로 올라오는 알림.
(node_id, handler_id, payload)로 주소를 지정합니다.
C ABI 표면
경계는 의도적으로 작고, 여기에 무언가를 더하려면 명세를 고쳐야 합니다. 모델은 옛 React Native 브리지가 아니라 JSI입니다. 같은 스레드, 직접 호출, 동기 반환값.
int32_t dioxus_compose_host_init(const uint8_t* handshake, uint32_t len, MutationBatch* out);
int32_t dioxus_compose_host_dispatch_event(const uint8_t* event, uint32_t len, MutationBatch* out);
int32_t dioxus_compose_host_render_frame(uint64_t frame_time_nanos, MutationBatch* out);
void dioxus_compose_host_release_batch(MutationBatch* batch);
void dioxus_compose_host_shutdown(void);
int32_t dioxus_compose_renderer_run(void); /* 블로킹. LoopMode::Renderer 전용 */
void dioxus_compose_renderer_request_frame(void); /* 스레드 안전 */
이것이 표면 전부입니다. 여기서 두 가지가 따라옵니다.
- 넘어가는 것은 primitive와 포인터, 길이뿐입니다. 객체도 클로저도 넘지 않습니다.
- Rust 쪽도 렌더러 라이브러리도
-undefined,dynamic_lookup으로 링크합니다. 서로의 심볼은 프로세스가 로드될 때 해석됩니다.
핸드셰이크에서는 스키마 해시와 프로토콜 버전, 루프 모드를 교환합니다. 해시가 다르면 UI가
미묘하게 어긋나는 대신 초기화가 실패합니다. Rust 쪽 상태 코드는 성공이 0이고,
프로토콜 오류·미초기화·중복 초기화·붙잡은 패닉이 음수입니다. 경계를 넘어 unwind하는 것은
없습니다. 프로토콜 오류는 프로세스 종료가 아니라 ProtocolError 이벤트가 되어야
하기 때문입니다.
프로토콜
양쪽이 한 프로세스 안에 있으므로, 인코딩의 목표는 전송량이 아니라 복사 최소화입니다. 레코드는 고정 레이아웃이고 제자리에서 읽습니다. 디코드 단계도, 핫 패스의 serde류 포맷도 없습니다.
- 레코드는
tag: u16,len: u16, 그다음 고정 필드 (node_id: u32등)입니다. 리틀 엔디언에 4바이트 정렬입니다. - 문자열은 같은 arena에 두고
(offset: u32, len: u32)로 참조합니다. UTF-8이고, 렌더러는 Compose에 넘기는 순간에만 KotlinString을 만듭니다. - 배치 자체는
#[repr(C)] struct MutationBatch { ptr, len, result }이며, 프레임마다 재사용하는 Host 소유 arena를 가리킵니다.result에는 핸들러의 동기 반환값이 들어갑니다. 키 이벤트라면 소비 여부입니다.
배치는 단일 스냅샷 트랜잭션으로 적용되므로 중간 상태가 화면에 그려질 수 없습니다. 렌더러는 같은 호출 스택 안에서 배치를 적용하고 즉시 해제합니다. 쌓아 두는 배치는 없습니다.
텍스트 변경 이벤트 하나의 목표치는 경계 호출 2회(dispatch_event,
release_batch)와 렌더러 쪽 힙 할당 1회 이하, 즉 Compose
String 생성뿐입니다.
프레임 모델
- 렌더러가 입력을 받아 자기 UI 스레드에서
dispatch_event를 호출합니다. - Host가 핸들러를 실행하고, VirtualDom을 diff하고, mutation을 arena에 인코딩해 배치와 결과값을 돌려줍니다.
- 렌더러가 배치를 스냅샷 하나로 적용하고 해제한 뒤, 결과값을 Compose에 알립니다. 예를 들면
onKeyEvent의true입니다. - 상태를 바꾼 워커 스레드 때문에 프레임 요청이 발생하고, 요청들은 다음 Compose 프레임 클록
안에서
render_frame한 번으로 합쳐집니다.
VirtualDom은 자기 전용 스레드가 아니라 렌더러의 UI 스레드에서 돕니다. 그래서 락도, 큐도 필요 없습니다. 이 설계에서 스레드를 넘는 통신은 프레임 요청 wake 하나뿐입니다. 핸들러 실행 중에 요청된 프레임은 핸들러가 끝날 때까지 보류되므로, dispatch 도중에 렌더러로 다시 들어가는 일은 없습니다.
스키마 코드젠
C ABI는 바이트만 나르기 때문에 타입 안전은 그 위에 얹습니다. 위젯, 속성, Modifier, 이벤트 페이로드를 Rust에서 한 번 정의하고 Kotlin 타입과 코덱을 생성합니다. 생성물이 낡으면 저장소의 테스트가 실패하고, 핸드셰이크의 스키마 해시가 양쪽을 대조합니다. JNI나 cinterop 글루를 손으로 쓰는 사람은 없습니다.
macOS 런타임
창 소유권은 일부러 Compose Desktop의 AWT 경로에 맡겼습니다. 이를 가져오면
NSTextInputClient와 TSF, ibus/fcitx를 직접 배선해야 하는데, 그것이야말로 이
프로젝트가 위험에 빠뜨리지 않으려는 텍스트 품질입니다. ComposeWindow는 AWT의
JFrame이고, AOT 컴파일은 어떤 코드 경로가 도는지를 바꾸지 않습니다. 그래서
네이티브 이미지에서도 AWT의 IME 경로가 그대로 유지됩니다.
다만 macOS에서 정적으로 링크된 AWT도 세 가지는 여전히 파일 경로로 찾습니다. 배포물이
lib/ 디렉터리 하나인 이유입니다.
| 파일 | 있는 이유 |
|---|---|
libawt_lwawt.dylib | libawt 초기화가 경로로 로드합니다. JNI 함수는 이미 이미지 안에서 해석되므로 자리만 채웁니다. |
libjawt.dylib | Skiko가 <java.home>/lib에서 dlopen합니다. 이미지 안의 JAWT_GetAWT로 넘기는 포워더입니다. |
JNI_OnLoad_osxui | 정적으로 링크된 JNI 라이브러리가 반드시 정의해야 하는 심볼인데 NIK 아카이브에 없어서 직접 정의합니다. |
libskiko-macos-<arch>.dylib | Skiko가 경로로 로드하는 Skia입니다. |
java.desktop은 원래 Java 코드와 네이티브 코드를 JNI로 묶어 왔고, Compose를
일반 JVM에서 돌릴 때도 같은 경로를 탑니다. Host↔Renderer 경계와는 무관하며 그 경계는 순수
C ABI입니다. 이 프로젝트가 금지하는 수동 JNI는 Android 경계에 대한 조항입니다.
프로세스 구조를 결정짓는 macOS 사실이 하나 더 있습니다. AppKit은 메인 스레드를 요구합니다.
그래서 렌더러는 보조 스레드에서 돌고, 메인 스레드는 NSApplication을 직접 만들어
실행합니다. 이렇게 하면 AWT가 SWT나 JavaFX 호스트에서와 같은 임베디드 모드로 동작합니다.
AWT가 루프를 소유하게 두면 [NSApp run]에 다시 들어가 버려서, 창을 닫아도 제어가
Host로 돌아오지 않습니다. 따라서 dioxus_compose_renderer_run은 프로세스 메인
스레드에서 호출해야 하고, 아니면 RUN_NOT_MAIN_THREAD를 반환합니다.