dioxus-compose 가이드

아키텍처

두 쪽, 그리고 아주 좁은 경계 하나

Rust는 Compose를 직접 호출하지 않습니다. 할 수도 없습니다. GraalVM의 @CEntryPoint는 primitive와 word 크기 값만 넘길 수 있어서 ModifierMutableState 같은 객체는 경계를 넘지 못합니다. 대신 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입니다. 같은 스레드, 직접 호출, 동기 반환값.

c: Renderer → Host (Rust가 export)
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);
c: Host → Renderer (Kotlin이 export)
int32_t dioxus_compose_renderer_run(void);            /* 블로킹. LoopMode::Renderer 전용 */
void    dioxus_compose_renderer_request_frame(void);  /* 스레드 안전 */

이것이 표면 전부입니다. 여기서 두 가지가 따라옵니다.

핸드셰이크에서는 스키마 해시와 프로토콜 버전, 루프 모드를 교환합니다. 해시가 다르면 UI가 미묘하게 어긋나는 대신 초기화가 실패합니다. Rust 쪽 상태 코드는 성공이 0이고, 프로토콜 오류·미초기화·중복 초기화·붙잡은 패닉이 음수입니다. 경계를 넘어 unwind하는 것은 없습니다. 프로토콜 오류는 프로세스 종료가 아니라 ProtocolError 이벤트가 되어야 하기 때문입니다.

프로토콜

양쪽이 한 프로세스 안에 있으므로, 인코딩의 목표는 전송량이 아니라 복사 최소화입니다. 레코드는 고정 레이아웃이고 제자리에서 읽습니다. 디코드 단계도, 핫 패스의 serde류 포맷도 없습니다.

배치는 단일 스냅샷 트랜잭션으로 적용되므로 중간 상태가 화면에 그려질 수 없습니다. 렌더러는 같은 호출 스택 안에서 배치를 적용하고 즉시 해제합니다. 쌓아 두는 배치는 없습니다.

텍스트 변경 이벤트 하나의 목표치는 경계 호출 2회(dispatch_event, release_batch)와 렌더러 쪽 힙 할당 1회 이하, 즉 Compose String 생성뿐입니다.

프레임 모델

  1. 렌더러가 입력을 받아 자기 UI 스레드에서 dispatch_event를 호출합니다.
  2. Host가 핸들러를 실행하고, VirtualDom을 diff하고, mutation을 arena에 인코딩해 배치와 결과값을 돌려줍니다.
  3. 렌더러가 배치를 스냅샷 하나로 적용하고 해제한 뒤, 결과값을 Compose에 알립니다. 예를 들면 onKeyEventtrue입니다.
  4. 상태를 바꾼 워커 스레드 때문에 프레임 요청이 발생하고, 요청들은 다음 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.dyliblibawt 초기화가 경로로 로드합니다. JNI 함수는 이미 이미지 안에서 해석되므로 자리만 채웁니다.
libjawt.dylibSkiko가 <java.home>/lib에서 dlopen합니다. 이미지 안의 JAWT_GetAWT로 넘기는 포워더입니다.
JNI_OnLoad_osxui정적으로 링크된 JNI 라이브러리가 반드시 정의해야 하는 심볼인데 NIK 아카이브에 없어서 직접 정의합니다.
libskiko-macos-<arch>.dylibSkiko가 경로로 로드하는 Skia입니다.
여기 나오는 JNI는 JDK 내부 이야기입니다

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를 반환합니다.