Bridging Kotlin/JS and Native Skia for Fine‑Grained Dynamic UI in Compose

This article presents a KMP‑centric solution that uses Kotlin/JS to compute Compose UI on the JS side and Skia PictureRecorder on the native side, enabling block‑level, script‑driven dynamic UI embedding in native Compose pages without WebView or additional rendering stacks.

Alipay Experience Technology
Alipay Experience Technology
Alipay Experience Technology
Bridging Kotlin/JS and Native Skia for Fine‑Grained Dynamic UI in Compose

Background and Goals

With KMP + Compose becoming the mainstream native UI stack, businesses need a way to deliver fine‑grained dynamic UI without breaking the existing rendering pipeline, adding a new DSL, or hurting performance. The goal is to let developers write UI with the familiar Compose DSL, embed a dynamic region driven by a JS script, and keep rendering on the native Skia stack.

Existing Solutions Analysis

Kuikly Dynamic

Kuikly retains the Kotlin UI tree, reuses Compose DSL, and pushes layout and property changes to native views. Adding dynamic capability would simply involve parsing the DSL, measuring on the native side, and updating native views.

KJS for Web

Kotlin/JS compiles Kotlin code to JS together with the Kotlin runtime and Compose runtime, then runs in a browser using WebCanvas and a Wasm‑based Skia engine (skiko.wasm). This approach depends heavily on the web environment and WebView, which is unsuitable for performance‑critical native pages.

RemoteCompose

RemoteCompose serialises the Compose drawing process into a binary document and plays it back on Android devices. It currently only supports Android, requires a backend service for state sync, and introduces latency for interactive scenarios.

KJS Native Architecture

The proposed “KJS Native” solution keeps the existing native Skia rendering pipeline and adds a lightweight JS side that runs only the Compose runtime (layout, measurement, recomposition). The three key points are:

KJS only hosts the Compose logic layer – no skiko.wasm, no WebCanvas. It produces a list of drawing commands (CanvasDrawScope) instead of real pixels.

JS Binding maps drawing commands to native – the JS target of compose‑ui/ui‑graphics/ui‑text is modified to emit cross‑language drawing instructions (drawRect, drawPath, drawText, …) which are sent to native via JSI/NativeBinding.

Native uses Skia PictureRecorder for recording and replay – the native side receives the commands, records them with Skia PictureRecorder, and later draws the resulting Picture in the Compose container.

Step 1: Declare Dynamic Container

@Composable
fun HomePage() {
    Column {
        Header() // static area
        RemoteView(
            scriptId = "home_feed_dynamic_v1",
            modifier = Modifier.fillMaxWidth().height(200.dp)
        )
        Footer() // static area
    }
}

RemoteView fetches the JS bundle from scriptUrl, creates a KJSEngine and a JSContext, then loads and executes the compiled Compose JS code.

Step 2: JS Runtime Composition

Inside the JS context the Kotlin/JS runtime starts, runs the @Composable function, performs the first recomposition, layout, and measurement, and generates a series of drawing commands inside CanvasDrawScope (e.g., drawRect, drawText, drawImage).

Step 3: Native Picture Recording

Native receives a “beginRecording” signal and creates a Skia PictureRecorder.

It iterates over the received drawing commands and calls the corresponding Skia APIs (e.g., canvas.drawRect(...), canvas.drawPath(...), paragraph.paint(canvas) for text).

After all commands are processed, endRecording() produces a Picture object.

The Picture is sent back to the Compose layer via a State<Picture?>.

Step 4: Picture Playback

The Compose runtime observes the Picture state change and calls drawPicture(picture) inside the container’s DrawScope, rendering the dynamic region on the native canvas.

Step 5: Subsequent Updates

When the JS side state changes (data load, click, animation), the Compose runtime recomposes, emits a new set of drawing commands, native records a new Picture, and the updated Picture is replayed, achieving localised re‑rendering without affecting the rest of the page.

State and Interaction

JS Side State

@Composable
fun DynamicButton() {
    var clicked by remember { mutableStateOf(false) }
    Button(onClick = { clicked = !clicked }) {
        Text(if (clicked) "Clicked" else "Click Me")
    }
}

State changes trigger recomposition and new drawing commands automatically.

Native Side Event Forwarding

Native Compose captures click/scroll events.

Event data (coordinates, type) is sent to the JS side via Native Binding.

JS maps the event to Compose’s input system, updates state, and the cycle repeats.

Text Rendering Pipeline

On the native side, text layout is performed by Skia’s SKParagraph. The flow is:

Receive TextLayoutInput (text, style, constraints).

Construct MultiParagraphIntrinsics to compute intrinsic sizes.

Determine layout width based on constraints.

Adjust maxLines and overflow.

Create MultiParagraph which builds individual Paragraph objects.

Wrap the result in TextLayoutResult and expose it to Compose.

During recording, paragraph.paint(canvas) is called and captured in the Picture.

Comparison with Other Approaches

KJS for Web : Requires WebView, DOM, Canvas, and Wasm Skia – unsuitable for native core pages.

RemoteCompose : Server‑driven document playback, Android‑only, adds a separate protocol layer.

Our KJS Native : No WebView, no Wasm, reuses existing native Skia, and integrates directly with the Compose runtime, giving fine‑grained control and low engineering overhead.

Performance Baseline

Single‑product bundle after DCE and obfuscation: ~1.52 MiB (includes Kotlin runtime and essential Compose libraries).

First‑frame rendering time for the demo view: 256 ms on KJS Native vs 180 ms on pure native KMP.

Conclusion and Outlook

The POC demonstrates that a KJS + Native Skia pipeline can deliver block‑level dynamic UI with modest code changes, keeping the existing KMP architecture intact while enabling rapid script‑driven updates, A/B testing, and even future LLM‑generated UI scenarios. Remaining work includes component library adaptation, build‑pipeline standardisation, and defining clear boundaries for what can be dynamically updated.

支付宝技术
支付宝技术
KotlinDynamic UISkiaComposeKMP
Alipay Experience Technology
Written by

Alipay Experience Technology

Exploring ultimate user experience and best engineering practices

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.