mini-render
GitHub

Rust · self-rendered · aligned with Skyline

Mini-program source in,
pixels out.

A mini-program rendering engine written from scratch in Rust. Hand it a WeChat mini-program source tree (WXML / WXSS / JS / app.json) and a writable pixel buffer, and it draws the UI and runs the interactions. In between there is no browser engine, no platform widgets, no third-party 2D graphics library — the CPU draws every pixel, and an optional GPU backend only composites the layers, byte-identical to the CPU. All 503 official wx.* APIs are there, and Skyline's own components — grid-view, gesture handlers, draggable-sheet, worklet animations — run on the same pipeline.

  • No WebView
  • No mapping to native widgets
  • No Skia
Runs on
  • Android
  • iOS
  • Flutter
  • macOS
  • Windows
  • Linux
  • HarmonyOS (C ABI)
  • Headless CI
HTTP feed page of the Flutter demo, screenshot from a OnePlus phone
Real phone · feed
E-commerce home rendered by the engine, with a carousel, a flash-sale countdown and a recommendation feed
Desktop · e-commerce home
Components tab of the Flutter demo on a OnePlus phone
Real phone · components
Shop home, full frame
6.1ms

750×1334 physical pixels, pure CPU

Repeated frames, 90 Hz phone
1.5%

list scrolling with setData, down from 11.2%

GPU vs CPU
34/34

scenes byte-identical, fallback included

Difference vs Chrome
4.57%

15 pages of the same source, per pixel

Automated tests
1055

plus 65 byte-reproducible baselines

Official wx.* APIs
503/503

every name in the index, plus 57 component tags

Demo

Twelve mini-programs,
one engine, one minute

Chat, kanban, train tickets, stocks, recipes and more: complex sample mini-programs running on the Android SDK, captured from the device frame by frame. Every screen is drawn by this engine, not by a WebView.

60 s · 1280×720 · screenshots from the Android emulator, music synthesized by script

New · Mini-games

Not just mini-programs.
Full 3D mini-games, same engine.

Point it at a game.json + game.js package and the engine switches to game mode: one full-screen canvas presented straight to the display, a vsync-locked requestAnimationFrame, multi-touch, low-latency audio, WebGL 1 / 2 and WXWebAssembly. three.js, PixiJS and Phaser run unmodified. Every screen below was drawn by this engine.

Pet Island: a 3D island builder with farms, workshops and villagers
Pet Island · 3D builder
3D endless runner on a city street
3D Runner
3D bowling lane with pins falling
3D Bowling
Pet Island: hatching a pet egg
Pet Island · hatching
3D stacking-blocks tower
3D Stack
Flappy bird between green pipes
Flappy Bird
Pet Island: in-game shop with gem packs
Pet Island · shop
2048 number-merging board
2048
  • three.js r160
  • PixiJS 7.4
  • Phaser 3.80
  • WebGL 1 · 2
  • WXWebAssembly
  • QuickJS

Landscape works too: Fist Soul

A 1-vs-1 fighting game in landscape: virtual stick and four buttons, motion inputs for specials, a super meter, hit-stop and screen shake, two stages and an AI opponent. The engine waits for the screen to turn before it creates the game, so the first frame already has landscape proportions.

Fist Soul: two fighters trading blows on a rainy city stage at night
Fist Soul: a super move with a darkened screen and the move name
Fist Soul: the other fighter's super move knocking the opponent down
WebGL conformance
96.6%

Khronos CTS subset, 169 of 175 applicable pages

three.js vs Chrome
2px

different pixels out of 250 125, same scene

On a real phone
60fps

three.js, PixiJS, Phaser: 0 repeated frames

30-minute soak
+0.2MB

RSS growth on the phone, no crash, no slowdown

Touch to game
15.4ms

median, unbuffered dispatch, multi-touch

Whole WebGL stack
0.28MB

arm64, on top of the system GLES driver

Straight to the display

The main WebGL canvas swaps buffers directly into the Android SurfaceView from the engine thread. Toasts and modals are composited on the GPU on top of it, so a spinning cube still runs at 57 fps while a toast is up.

Your engine, unmodified

three.js, PixiJS and Phaser (WebGL and Canvas renderers) load through their usual adapters. WebGL 2 brings VAOs, instancing, uniform blocks and 3D textures; a lost context is reported and restored like in WeChat.

Fingers and ears

Every pointer with its full history, dispatched before the next requestAnimationFrame. Audio runs in low-latency game mode at 28–32 ms output latency, with focus loss and phone calls handled.

WebAssembly everywhere

WXWebAssembly runs on wasmi, a pure-Rust interpreter, so it also works on iOS where apps cannot JIT. Memory is zero-copy, i64 is BigInt, traps become RuntimeError; it adds about 1.8 MB.

A real game, end to end

Pet Island is a full 3D builder: buildings, upgrades, a pet gacha with published odds, daily quests, sign-in, offline earnings, in-app purchases and rewarded ads through host APIs, saves with migration and checksums.

A few lines to host it

Drop a MiniGameView into an Android activity or a Flutter page. It locks orientation from game.json, pauses in the background, and tells you when the first frame is on screen so the loading cover leaves without a black flash.

// Android
val v = MiniGameView(activity, gameDir, dataDir, """{"appId":"wx…"}""")
v.onFirstFrame = { loadingCover.hide() }       // no black flash on launch
setContentView(v)

// Flutter
MiniGameView(gameDir: dir, options: {'appId': id}, onFirstFrame: hideCover)

One engine, every platform

Phones, desktops, CI and the web —
the same pixels everywhere

The engine only needs a writable pixel buffer (or a GPU surface) and a clock. Every platform below runs the same Rust code, the same layout and the same wx.* runtime; only the thin host layer differs. The CPU render is the byte-exact reference on all of them.

Skyline demo page rendered by the engine

iOS · iPadOSSDK ready

Swift Package / CocoaPods · MiniProgramView (UIKit)

CAMetalLayer → Metal · CPU fallback

Components tab on a OnePlus phone

AndroidTested on device · 90 Hz

AAR · Kotlin MiniProgramView (SurfaceView)

Vulkan / GLES via wgpu · CPU fallback

// XComponent + OH_NativeWindow
XComponent({ type: 'surface',
  libraryname: 'mini_render' })
// native: mr_app_create → mr_app_attach
// → mr_app_pump(vsync) each frame

HarmonyOSC ABI path · SDK in progress

C ABI include/mini_render.h + NAPI

XComponent native window · CPU blit

mini-app-window
E-commerce home in the desktop window

macOSDesktop window

Rust crate · winit window · or the C ABI

Metal via wgpu · CPU fallback

mini-app-window.exe
Health dashboard in the desktop window

WindowsSame code · no CI yet

Rust crate · winit window · or the C ABI (DLL)

DX12 / Vulkan via wgpu · CPU fallback

bash
$ cargo run --release --bin mini-app-window \
    sample-app --snapshot out/
✓ 15 pages → out/*.png
$ python3 tools/pixdiff.py a.png b.png
0.00 % · bbox none

LinuxDesktop + headless · no CI yet

Rust crate · winit window · headless PNG

Vulkan via wgpu · CPU in CI

HTTP feed in the Flutter demo app

FlutterPlugin · Android + iOS

mini_render plugin · Texture / PlatformView

Same engine library underneath

localhost/dist-html
News feed compiled to H5

Web / H5Compiled preview

Compile the mini-program to static H5

Pixel-compared against the native render

Status is what we have verified ourselves: Android on a OnePlus 90 Hz phone and an emulator; the iOS SDK builds for device and simulator and its Metal path is covered by tests; the desktop window and headless snapshots run daily on macOS. Windows and Linux build from the same winit / wgpu code but have no CI job yet. HarmonyOS has a documented C ABI path (XComponent + OH_NativeWindow, see the integration guide); a packaged ArkTS SDK is still being built.

Why self-render

The pain with WebView is not whether it can render,
it is that you do not control it

The engine version follows the OS, the same CSS lays out differently across Android builds, first paint waits for the engine to boot, scroll and gesture feel are decided by the engine, and every native capability needs a bridge. Self-rendering turns all of that into your own code: one copy of layout and paint for every platform, with frame scheduling, gesture arbitration and memory ceilings in your hands.

Comparison with common cross-platform rendering approaches
ApproachHow it worksThis engine
Mini-program WebView render layer WXML → DOM, handed to a browser engine for layout and paint No DOM, no browser engine
React Native / Weex JS description → mapped onto platform widgets No mapping to platform widgets, not at the mercy of their differences
Flutter Dart + Skia, self-rendered Closest but this is Rust + an in-house rasterizer, no Skia — and it ships as a Flutter plugin too
Electron / Tauri Ship or reuse a browser No bundled browser; about 40 MB on arm64, GPU compositor included
WeChat Skyline Native rendering with self-drawn components Alignment target semantics, feel, event model, API surface and component set all follow it

The price is real too: CSS coverage has to be filled in one property at a time, and performance is spent CPU cycle by CPU cycle. The only remaining cross-platform difference comes from system fonts — to remove even that, bundle a font with the app (assets/SourceHanSansSC-Regular.otf is there for exactly that). With glyphs expanded on first use it costs about 2MB, close to the system font's 1.4MB.

Skyline alignment

Every official API name,
the Skyline component set

Checked against WeChat's own API index and component catalog, not a hand-picked list. All 503 wx.* names exist at runtime; what only a host can do (Bluetooth, payment, login…) fails at once with a reason until the host claims it, and canIUse says so. Skyline-only components — grid-view, gesture handlers, draggable-sheet, worklet animations — go through the same layout, paint and partial-repaint pipeline as everything else.

Official wx.* names
503/503

516 keys at runtime; host-only ones fail until the host claims them

Component tags
57

incl. 17 built-in components and 8 native-component placeholders

Map vs page while scrolling
0px

same-layer rendering on Android, measured on a phone

Containers & layout12

  • view
  • block
  • scroll-view
  • swiper
  • swiper-item
  • grid-view
  • movable-area
  • movable-view
  • cover-view
  • root-portal
  • match-media
  • span

Text & media8

  • text
  • rich-text
  • icon
  • image
  • cover-image
  • video
  • canvas
  • audio

Forms16

  • button
  • input
  • textarea
  • switch
  • slider
  • progress
  • checkbox
  • checkbox-group
  • radio
  • radio-group
  • picker
  • picker-view
  • picker-view-column
  • form
  • label
  • editor

Page & navigation5

  • navigator
  • page-container
  • page-meta
  • navigation-bar
  • draggable-sheet

Skyline gestures8

<…-gesture-handler>

  • tap
  • double-tap
  • long-press
  • pan
  • horizontal-drag
  • vertical-drag
  • scale
  • force-press

Native components8

  • map
  • camera
  • live-player
  • live-pusher
  • voip-room
  • web-view
  • channel-live
  • channel-video
  • added in October 2026
  • documented degradation
  • same-layer rendering (Android)
  • placeholder until the host takes it
Demo page rendered by the engine: grid-view masonry, movable-view, gesture handlers, draggable-sheet, this.animate and worklet
Skyline components
Demo page rendered by the engine: page-container, root-portal, match-media, audio, editor, native-component placeholders under a cover-view, span
Built-in components
Demo page rendered by the engine: EventChannel, createWorker, UDP and TCP sockets, batch storage, WebAudio, MediaQueryObserver, legacy canvas clip and dashes, host defaults
New APIs

Done inside the engine

  • Routing checked against app.json, success after the new page's onShow, two-way EventChannel, route events, enableAlertBeforeUnload
  • createTCPSocket / createUDPSocket, getLocalIPAddress, createWorker in an isolated module scope
  • A WebAudio node graph with native decodeAudioData, createMediaQueryObserver, createOffscreenCanvas, createBufferURL
  • Batch storage and getStorageInfo, OS-backed getRandomValues, getVideoInfo parsed from the MP4, legacy canvas clip / setLineDash / measureText

Handed to the host, through one channel

  • Name an API in hostApis (Flutter, Android, iOS, C ABI) and the call is yours; leave it out and it fails immediately with an errMsg saying the host can take over
  • chooseImage / chooseMedia / chooseVideo / chooseMessageFile and saving to the album ship with the Android SDK and the Flutter plugin; the desktop window opens the system file dialog
  • Files returned as file:// are imported into wxfile://tmp/; MapContext and the other context objects forward each method with their id; host events reach wx.onXxx through mr_app_dispatch_event

Animation and touch, Skyline style

  • this.animate / clearAnimation, including scroll-driven timelines
  • wx.worklet: shared and derived values, timing / spring / decay / sequence / repeat / delay, Easing, and applyAnimatedStyle writing node styles
  • Gesture handlers report state, delta and velocity through on-gesture-event; while a page-container is open, going back closes it first
  • touchmove / touchend / touchcancel stay locked to the node that got touchstart, as in WeChat and the DOM, even when the finger leaves it

Degradations are written down, not hidden: worklets run on the logic thread; gesture handlers are single-finger (scale stays 1, force-press never fires); movable-view has no pinch zoom; editor is plain text; Workers share the page's QuickJS; sticky-header / sticky-section do not stick and list-view / nested-scroll-* are plain containers; WebAudio plays its sources but effect nodes do not change the sound; custom wx.router transitions are not applied; snapshot.takeSnapshot fails. Each is listed in the engine test guide (Chinese).

Render pipeline

How one frame gets drawn

Eight stages, each with its own diagnostic switch. Between the logic layer and the render layer there is exactly one channel — the data snapshot (setData → Arc<Value>). No DOM, no virtual-node diffing, no GC pressure.

  1. 01

    Parse

    WXML → node tree, WXSS → stylesheet. Hand-written parsers; the CSS selector engine is compiled at parse time and covers tag / class / #id / * / attribute / descendant / child with specificity ordering.

  2. 02

    Logic layer

    Runs App / Page / Component; setData produces a data snapshot. QuickJS runtime: CommonJS modules, Promise / async, timers, microtasks pumped every frame.

  3. 03

    Template eval

    {{ }} expressions plus wx:if / wx:for expanded into a render tree. class / style bound to arrays or objects are joined per CSS semantics.

  4. 04

    Style resolution

    Matched CSS plus inline style composed into a computed style, with inheritance semantics. Selector matching allocates nothing and the cascade borrows declarations instead of copying them — on a phone that is what took a full rebuild after setData from about 7 ms to about 3.5 ms.

  5. 05

    Layout

    taffy solves the flexbox box model. Text nodes carry a custom measure function, so wrapping and min/max-content come from real glyph widths; line counts are memoized per font, so text that did not change is never measured twice.

  6. 06

    Paint

    In-house 2D rasterizer: scanline + even-odd fill, 4× supersampled anti-aliasing, rounded corners via cubic Bézier, multi-font fallback, Apple sbix color emoji, two-tier image filtering, gradients, masked box-shadow, clip stack.

  7. 06′

    Animation

    @keyframes / transition / wx.createAnimation are evaluated at paint time against the vsync clock. transform / opacity / colors affect painting only and never trigger reflow → an animated frame costs about the same as a static one.

  8. 07

    Composition

    Normal flow → tabBar → position:fixed overlay, stacked like the browser. After setData only the changed region repaints: a list that grows at the bottom repaints from its first change downward, a fixed button appearing repaints only the overlay.

  9. 08

    Present

    Blit to a window, hand to a host view, or save a PNG. The desktop window and Android composite on the GPU once the self-test passes (wgpu takes the layers as textures) and fall back to the CPU otherwise; the native iOS view hands its CAMetalLayer to the same compositor (Metal); headless and the Flutter iOS texture composite on the CPU — one codebase, five platforms plus Flutter.

The artifact is an ordinary Rust library (cdylib / staticlib / rlib) and it asks for one thing: a writable pixel buffer — so Android, iOS, Windows, macOS and Linux run the same code, and a CI box with no screen can emit PNGs directly.

On a real phone

Smooth on a 90 Hz phone,
measured frame by frame

The same engine runs inside a Flutter plugin and native Android / iOS views. The numbers below come from a OnePlus PLY110 (90 Hz, Adreno 810, Vulkan) running release builds of the Flutter example and the native Android example (the same demo mini-program), judged by the timestamps SurfaceFlinger puts on every frame it shows — not by app logs.

One second at 90 Hz, scrolling a list that calls setData on every scroll

Before 11.2%
Now 1.5%

Each cell is one vsync; a red cell is a vsync where the previous image stayed on screen. The red count follows the measured rate (fast flick, then slow drag); where the cells fall is illustrative.

11.2% → 1.5%

List scrolling with setData

The page calls setData from onPageScroll and an IntersectionObserver. Slow drag alone: 6.6% → 0.4%. Frames that repainted the whole page: zero.

29 / 300ms

Page transitions

Every 300 ms push or pop shows 29 frames with none repeated; the first step is 0.89–1.00× and the largest 1.08× the median step.

550 → 50ms

Back from the home screen

New surface to first frame. The compositor is parked instead of torn down, and the window waits for the engine's first frame before it shows — no black flash.

870 → 480ms

Cold open

Engine creation to GPU ready: fonts and the Vulkan compositor warm up in parallel before the engine even exists.

8–13 → 0

Frames over 8 ms, heavy page

Slow drag on a gradient-and-transform page. The engine thread sits on the big cores only while you interact or frames keep coming, and is released after 800 ms.

7.3 → 0.9–1.3ms

Layout rebuild after setData

Typical value among the slowest scroll frames. Incremental layout: unchanged subtrees keep their taffy nodes and cached layout, only what changed is rebuilt, including lists that grow or shrink. Checked against a full rebuild node by node across the whole test suite: 0 mismatches.

19.9 → 2.5–3.3%

Native Android view

Same engine library, same drag: the view stopped copying every frame into a Bitmap for onDraw and now presents into its own SurfaceView from the engine thread. Home page slow drag: 7.1% → 0.4%.

2.8 → 0px

Map vs page while dragging

A <map> laid over the page drifts 2.8 px on average (15 px at worst) even with per-frame prediction. Same-layer rendering paints the host's frame into the page: 24 of 24 screenshots at 0 px.

0.5%

Dragging across the map

Repeated frames while slowly dragging the page over the same-layer map, in both examples. The host redraws the map only when its content changes, never per scroll frame.

300 → 1.4MB

Memory per font face

A CJK system font used to expand all 29 352 glyph outlines on load. Now glyphs expand on first use and the file is memory-mapped; rasterization is bit-identical to upstream fontdue.

44 → 18MB

Engine buffers in the background

Measured on the phone after pressing Home. Only the painted strip of the page stays; the compose buffer is dropped and swipe-back screenshots are packed losslessly. Native Heap 74.5 → 57.7MB.

Dual backend

Two backends, one picture

The CPU rasterizer draws every pixel and stays the single reference, so a CI box with no GPU produces the same image as a phone. The GPU backend (wgpu: Metal / Vulkan / DX12 / GLES) only composites the page, tabBar and fixed layers as textures, with integer shaders that replicate the CPU's blending arithmetic.

  • 34 / 34 scenes byte-identical in tools/gpu-parity.sh, including a device loss injected mid-run
  • A start-up self-test must match the CPU byte for byte before the GPU is used; any failure falls back to the CPU on the same frame, with a log line saying why
  • Automatic on the desktop window, Android and the native iOS view; headless and the Flutter iOS texture use the CPU. Force either with --backend / MINI_BACKEND
  • Overlays (Toast, Modal, action sheet, picker) upload only the rows they cover; scrolling just presents another slice of the page texture

Measured numbers

Before → now

Desktop: 375×667 @2x (750×1334 physical pixels), written pixel by pixel on the CPU; the phone numbers are in the section above. These numbers are a regression criterion, not a marketing line.

Shop home, full framecarousel + per-second countdown + skeleton animation
49.6 ms6.1 ms
Rebuild after one setDataold and new render trees walked in parallel
32 ms4 ms
List append after onReachBottom90 Hz phone; only the rows from the first change down repaint
38 ms≤ 11.5 ms
First frame, build tree + styleapp with three Chinese font stacks
3093 ms955 ms
Single drag frametea-app home, 60-frame average
7.17 ms3.74 ms
Remote image, second opendisk cache hit
6132 ms11 ms

Steady frames hold

On a 144 Hz screen (6.94 ms budget) the shop home's frame interval went from 6.9–25 ms jitter down to 6.7–7.2 ms; the slowest frame from 17.9 ms to 11.4 ms.

Only the changed region repaints

The home page's flash-sale countdown invalidates 45×33 pixels, not the screen. A growing list repaints from its first change down; a fixed button appearing repaints only the overlay. When in doubt it falls back to a full frame — stale pixels are far worse than one extra paint.

Scrolling does not repaint

The page canvas is full-page height in content coordinates, so scrolling just presents a different slice, 2–3 ms per frame. Only the strip you scroll into gets painted, a little before it shows.

Cross-renderer consistency

Same source: native rendering on one side, compiled to HTML and screenshotted in Chrome on the other, compared per pixel. What remains is concentrated in bold glyph shapes and subpixel text positioning.

sample-app shop15 pages4.57% changed pixels
news-app news6 pages6.36% changed pixels

Real projects that run

Not hand-made demo scaffolding.

tea-app36 pagesOutput of uni-app compiled to mp-weixin: a 259KB Vue 3 runtime + 60 CommonJS modules
real-sample8 pagesWeChat's official demo
sample-app15 pagesFull shop flow: cart → checkout → orders / shipping, cross-page state in storage
news-app6 pagesFull news flow: channel swiping, live font size, comments, persisted bookmarks
Demo catalog30 pagesShared by the Flutter example and the native Android example: components incl. the Skyline set, new APIs, a mock HTTP feed, same-layer video and map

Key trade-offs

Why it is written this way

Every one of these has a mistake behind it; the counter-examples are all written up in the gotchas doc.

Text measurement drives layout; widths are never estimated

The measure context is attached to taffy leaves so real glyphs participate in layout, letting "wrap at the container width" and "a shrinking container pushed out by its content" hold at the same time.

A +4px "safety margin" per text box once made every content-sized badge a ring wider than the browser's.

Animation repaints, never reflows

Changing width / height in @keyframes has no effect; transform / opacity / colors do. What you buy is an animated frame being as cheap as a static one.

Scroll feel follows iOS / WeChat

Rubber-band damping 1 - 1/(x·0.55/d + 1); bounce-back is a critically damped spring with initial velocity, not a fixed-duration easing; inertia hitting a boundary does not stop dead, it hands its momentum to the spring.

Gesture ownership is not decided on touch-down

It waits for the first meaningful movement (4px), locks to the dominant axis, then hands off to the outer scroller on "still pushing at the boundary". Swiping vertically inside a horizontal card list scrolls the page; swiping horizontally inside a vertical list moves nothing.

The host layer lives in the lib, shared by every host

Page stack, route transitions, overlays, touch state machine, gesture arbitration, picker sheet and pixel composition all live in src/host/; only frame scheduling is per-platform. A per-pixel script plus input-behaviour tests guard it from both sides.

The CPU draws, the GPU only composites

The CPU rasterizer is the single pixel reference, so CI and embedded boxes with no GPU produce the same image. The GPU backend (desktop window, Android and the native iOS view) only composites the page / tabBar / fixed layers as textures, byte-identical to the CPU for the same frame, and falls back to the CPU on any failure. No vello / Skia: their own anti-aliasing would make that unverifiable.

All eight, and every "what went wrong" story behind them, are in the gotchas log and architecture & implementation (Chinese).

Getting started

Run it in 30 seconds

Desktop needs only the Rust toolchain. In an app it is one View — or one Flutter widget.

# needs Rust (https://rustup.rs)
cargo build --release

# pick a sample mini-program interactively
./target/release/mini-launcher

# or name one directly
./target/release/mini-app-window sample-app
./target/release/mini-app-window sample/tea-app --route pages/index/index

Already handled inside the View

  • A dedicated render thread (the engine is not thread-safe)
  • Size, dpr and safe-area conversion, system bars on Android
  • All three touch phases with timestamps, including historical points / coalescedTouches, nothing dropped
  • On-demand frames; idle costs no battery
  • Sandbox directory, lifecycles and memory warnings
  • GPU surface attach / detach, warm-up and CPU fallback

Only two things are yours to decide

  1. Where the package comes from — download / verify / unpack into the sandbox. The engine reads a directory; it does not parse .wxapkg and does no signature checking.
  2. Who hosts native components — <map>, <camera>, <web-view> live in WeChat's native component layer. Hand the engine a frame (sameLayerViews, Android) and it paints it into the page; or let the engine report position and parameters and place WKWebView / android.webkit.WebView / ArkUI Web() yourself. Tags nobody claims draw a labelled placeholder.

Integration contract

What the SDK already does, and what is left to you

Rendering, input, lifecycle and the system plumbing live in the SDK. What remains is what only your app can do (accounts, payment, location, hardware), and each of those is one named entry in hostApis.

Built into the SDK: no host code

AndroidFlutteriOS
Pick images, videos and files, take photos, save to the album, open documents (the 7 APIs marked ⧉ below)✓ builtinHostApis✓ builtinHostApis—
System clipboard for wx.setClipboardData and copy / cut in inputs✓✓✓
Haptics for wx.vibrateShort / wx.vibrateLong✓ hapticFeedback✓ hapticFeedback—
Foreground, background and memory warnings: App and current-page onShow / onHide, frames stop in the background; a state reported twice is dispatched once✓ autoLifecycle✓✓
Drawing under the status bar, safe area, env(safe-area-inset-*)✓ autoEdgeToEdge✓✓
Navigation bar, capsule button, route transitions, edge swipe back✓ navigationBar✓ navigationBar✓ navigationBar
Soft keyboard and text input for input / textarea✓✓—
<video> on the system player, painted into the page; fullscreen hides the system bars✓ sameLayerVideo✓ sameLayerVideo—
Pushing events into wx.onXxx listeners✓ dispatchEvent✓ dispatchEvent—

Each switch in a cell can be turned off before open if your app wants to handle that part itself; the event still reaches onEvent / events.

What you write

  1. APIs only your app can serve: register each name in hostApis. The call arrives on the main thread with its arguments; answer with reply.success(…) / reply.fail(errMsg) (Flutter: return a map or throw MiniHostApiError). A user who cancels gets an errMsg ending in :fail cancel, as in WeChat.
  2. Names you leave out still exist on wx and fail on the next tick with an errMsg that names the hostApis option; wx.canIUse reports false. Nothing hangs and nothing throws.
  3. Events only your app sees: sensors, location updates, Bluetooth discovery and the other wx.onXxx listeners fire when you call dispatchEvent(name, payload). It returns the listener count, so a sensor nobody listens to can stay off.
  4. The back key: if (!mini.goBack()) close the mini-program. With minSdk 24 a View cannot take the Activity's back press for you.
  5. Start from the examples: DemoHostApis.kt (Android) and host_apis.dart (Flutter) answer the 23 APIs marked ✦ with demo data and confirm dialogs. Replace the bodies with your own account, payment and map SDKs.

APIs that go to the host

Names exactly as you register them. Object methods use the dotted name, e.g. MapContext.getCenterLocation, and the call carries contextId (the component id). The number after a class is its method count.

Account & open data42

  • login
  • pluginLogin
  • checkSession
  • getUserProfile
  • getUserInfo
  • authorize
  • authorizeForMiniProgram
  • openSetting
  • getShareInfo
  • getGroupEnterInfo
  • getWeRunData
  • shareToWeRun
  • chooseAddress
  • chooseInvoice
  • chooseInvoiceTitle
  • chooseLicensePlate
  • openCard
  • addCard
  • requestSubscribeMessage
  • requestSubscribeDeviceMessage
  • requestSubscribeEmployeeMessage
  • checkEmployeeRelation
  • bindEmployeeRelation
  • checkIsAddedToMyMiniProgram
  • showRedPackage
  • addVideoToFavorites
  • addFileToFavorites
  • openCustomerServiceChat
  • startSoterAuthentication
  • checkIsSupportSoterAuthentication
  • checkIsSoterEnrolledInDevice
  • requestFacialVerify
  • checkIsSupportFacialRecognition
  • openStoreOrderDetail
  • openStoreCouponDetail
  • requestDeviceVoIP
  • getDeviceVoIPList
  • openStickerSetView
  • openStickerIPView
  • openSingleStickerView
  • openDesignerProfile
  • getStoreTraceId

Payment11

  • requestPayment
  • requestOrderPayment
  • requestPluginPayment
  • requestVirtualPayment
  • requestCommonPayment
  • requestMerchantTransfer
  • requestAppleSubscribeSign
  • requestAutomaticRepayment
  • verifyHKPaymentPassword
  • openHKOfflinePayView
  • jumpToOfflinePay

Location8

  • getLocation
  • getFuzzyLocation
  • chooseLocation
  • choosePoi
  • openLocation
  • startLocationUpdate
  • startLocationUpdateBackground
  • stopLocationUpdate

Media20

  • chooseImage
  • chooseMedia
  • chooseVideo
  • chooseMessageFile
  • saveImageToPhotosAlbum
  • saveVideoToPhotosAlbum
  • openDocument
  • saveMediaToPhotosAlbum
  • saveFileToDisk
  • editImage
  • cropImage
  • compressVideo
  • openVideoEditor
  • checkDeviceSupportHevc
  • joinVoIPChat
  • join1v1Chat
  • exitVoIPChat
  • updateVoIPChatMuteConfig
  • subscribeVoIPVideoMembers
  • setEnable1v1Chat

Navigation & sharing26

  • navigateToMiniProgram
  • navigateBackMiniProgram
  • openEmbeddedMiniProgram
  • openOfficialAccountProfile
  • openOfficialAccountChat
  • openOfficialAccountArticle
  • openInquiriesTopic
  • openPhotoTopicView
  • copyRealname
  • postMessageToReferrerPage
  • postMessageToReferrerMiniProgram
  • updateWeChatApp
  • shareFileMessage
  • shareVideoMessage
  • shareToOfficialAccount
  • authPrivateMessage
  • shareAppMessageToGroup
  • shareImageToGroup
  • shareVideoToGroup
  • shareFileToGroup
  • shareEmojiToGroup
  • selectGroupMembers
  • openChatTool
  • notifyGroupMembers
  • getChatToolInfo
  • enterChatToolMode

Channels9

  • reserveChannelsLive
  • openChannelsUserProfile
  • openChannelsLiveNoticeInfo
  • openChannelsLive
  • openChannelsEvent
  • openChannelsActivity
  • getChannelsShareKey
  • getChannelsLiveNoticeInfo
  • getChannelsLiveInfo

Device & system12

  • scanCode
  • makePhoneCall
  • sendSms
  • chooseContact
  • addPhoneContact
  • addPhoneCalendar
  • addPhoneRepeatCalendar
  • setVisualEffectOnCapture
  • openSystemBluetoothSetting
  • openAppAuthorizeSetting
  • startLocalServiceDiscovery
  • stopLocalServiceDiscovery

Sensors8

  • startAccelerometer
  • stopAccelerometer
  • startCompass
  • stopCompass
  • startGyroscope
  • stopGyroscope
  • startDeviceMotionListening
  • stopDeviceMotionListening

Bluetooth & BLE20

  • openBluetoothAdapter
  • closeBluetoothAdapter
  • getBluetoothAdapterState
  • startBluetoothDevicesDiscovery
  • stopBluetoothDevicesDiscovery
  • getBluetoothDevices
  • getConnectedBluetoothDevices
  • makeBluetoothPair
  • isBluetoothDevicePaired
  • createBLEConnection
  • closeBLEConnection
  • getBLEDeviceServices
  • getBLEDeviceCharacteristics
  • getBLEDeviceRSSI
  • getBLEMTU
  • setBLEMTU
  • readBLECharacteristicValue
  • writeBLECharacteristicValue
  • notifyBLECharacteristicValueChange
  • createBLEPeripheralServer

Wi-Fi, beacons & HCE13

  • startWifi
  • stopWifi
  • getWifiList
  • setWifiList
  • connectWifi
  • getConnectedWifi
  • startBeaconDiscovery
  • stopBeaconDiscovery
  • getBeacons
  • startHCE
  • stopHCE
  • sendHCEMessage
  • getHCEState

Wallet & transit cards12

  • getSecureElementPasses
  • canAddSecureElementPass
  • removeSecureElementPass
  • addPaymentPassGetCertificateData
  • addPaymentPassFinish
  • checkTransitCardSupport
  • getTransitCardList
  • getTransitCardInfo
  • getTransitCardCPLC
  • issueTransitCard
  • rechargeTransitCard
  • deleteTransitCard

AI & privacy5

  • initFaceDetect
  • faceDetect
  • stopFaceDetect
  • getCommonConfig
  • openPrivacyContract

Object methods, registered by dotted name20

  • MapContext.* 29
  • CameraContext.* 4
  • CameraFrameListener.* 2
  • LivePlayerContext.* 15
  • LivePusherContext.* 29
  • NFCAdapter.* 2
  • IsoDep.* 7
  • MifareClassic.* 6
  • MifareUltralight.* 6
  • NfcA.* 8
  • NfcB.* 6
  • NfcF.* 6
  • NfcV.* 6
  • Ndef.* 5
  • MediaRecorder.* 6
  • VideoDecoder.* 4
  • MediaContainer.* 5
  • GlobalPayment.* 3
  • RecorderManager.* 4
  • UserCryptoManager.* 1

Events you push with dispatchEvent49

  • accelerometerChange
  • compassChange
  • gyroscopeChange
  • deviceMotionChange
  • locationChange
  • locationChangeError
  • bluetoothDeviceFound
  • bluetoothAdapterStateChange
  • BLEConnectionStateChange
  • BLECharacteristicValueChange
  • BLEMTUChange
  • BLEPeripheralConnectionStateChanged
  • beaconUpdate
  • beaconServiceChange
  • wifiConnected
  • wifiConnectedWithPartialInfo
  • getWifiList
  • HCEMessage
  • batteryInfoChange
  • screenRecordingStateChanged
  • generatePoster
  • copyUrl
  • localServiceFound
  • localServiceLost
  • localServiceResolveFail
  • localServiceDiscoveryStop
  • voIPChatInterrupted
  • voIPChatMembersChanged
  • voIPChatSpeakersChanged
  • voIPChatStateChanged
  • voIPVideoMembersChanged
  • audioInterruptionBegin
  • audioInterruptionEnd
  • lazyLoadError
  • apiCategoryChange
  • userTriggerTranslation
  • userOffTranslation
  • menuButtonBoundingClientRectWeightChange
  • windowStateChange
  • parallelStateChange
  • embeddedMiniProgramHeightChange
  • keyDown
  • keyUp
  • backgroundFetchData
  • needPrivacyAuthorization
  • networkStatusChange
  • userCaptureScreen
  • contextEvent
  • nfcDiscovered
  • working demo in the example apps
  • built into the Android and Flutter SDKs

Scope

What it does, and what it deliberately will not do

"Deliberately not" is a decision, not a backlog item.

Implemented

  • 57 component tags: 26 drawn by the renderer (incl. swiper, scroll-view, picker-view, grid-view aligned and masonry); navigator / label / form with official behavior; 17 built-in WXML components injected only when a page uses them (movable-view, page-container, draggable-sheet, 8 gesture handlers…); 8 native components drawn as placeholders until the host takes them
  • Events: six binding prefixes with the full capture-bubble chain; touch sequences follow WeChat semantics (slop cancels tap, 350ms longpress, a touchcancel is synthesized when scrolling takes over)
  • Templates: wx:if / wx:for / wx:key / block, the expression engine, template / import / include, wxs modules, custom components (one instance per use site, style isolation, named, default and dynamic <slot>, virtualHost, bubbling triggerEvent, wx://form-field behaviors)
  • Styles: selectors with specificity cascade, inheritance, @import, CSS variables, rpx, calc(), gradients, shadows, transform / transition / @keyframes, @media (size, orientation, dark mode), @font-face
  • All 503 official wx.* APIs: TCP / UDP, Worker, WebAudio, EventChannel, route events, request, connectSocket, downloadFile / uploadFile and FileSystemManager, InnerAudioContext, storage persisted across launches, Toast / Modal / ActionSheet, previewImage, routing, createSelectorQuery, createIntersectionObserver, createVideoContext, the tabBar APIs, clipboard, canIUse, network status, loadFontFace; hardware, payment, login and the rest go to the host through hostApis and fail immediately until it claims them
  • Lifecycles: 28 hooks across three levels (App 7 / Page 13 / Component 5 + pageLifetimes 3)
  • Interaction: inertial scrolling with critically damped bounce, gesture arbitration, 300 ms route transitions, left-edge swipe back, engine-drawn navigation bar and capsule button, pull-to-refresh, picker sheet, action sheet, image preview, partial repaint
  • Skyline animation and gestures: this.animate / clearAnimation with scroll timelines, wx.worklet with applyAnimatedStyle, eight gesture handlers, movable-view with inertia and edge bounce, draggable-sheet with snap points; touch sequences stay on the touchstart node
  • Media: a Canvas 2D context plus <canvas type="2d"> with gradients, patterns, shadows, composite modes and pixel readback; <video> (MP4 demux + H.264 software decode + audio, playback events, VideoContext, fullscreen, poster, danmu), or hosted by the app's own player
  • Dual backend: CPU rasterization + optional GPU compositing (wgpu). Enabled only after a start-up self-test is byte-identical to the CPU, forceable with --backend / MINI_BACKEND, falls back to the CPU on any failure. Wired into the desktop window, headless, the Android SDK and the native iOS view (CAMetalLayer); the Flutter iOS texture composites on the CPU
  • Component behavior: scroll-view controlled position, events, custom pull-to-refresh, reverse and padding; swiper drag, circular mode, margins and Skyline's direction / indicator-type / layout-type; form submit / reset; picker column linkage and header-text; the inline picker-view wheel
  • Hosts: desktop window and launcher, headless snapshots for CI, a C ABI, native Android and iOS views (with a native Android example app), and a Flutter plugin (MiniProgramScaffold, hostApis, native components through nativeViewBuilder, same-layer sameLayerVideo / sameLayerViews)

Deliberately not

Drawing content on the GPU (vello / Skia style)The GPU backend that exists only composites layers the CPU already drew. Content is always drawn by the CPU, so it stays the single pixel reference and places with no GPU (CI, embedded) produce the same image. Drawing on the GPU would mean a second rasterizer next to the CPU one; not done in this round
Parsing .wxapkg / signature checks.wxapkg is WeChat's private compiled format and the signature is WeChat's key system; subpackages, staged rollout and integrity belong to the host, and the engine reads a source directory
Compiling <web-view> / <map> into the engineSkyline itself does not render web-view (its component table says "not planned"; such a page falls back to the WebView renderer), and map is a same-layer native component in Skyline: the host draws the map, the engine paints that frame into the page (or reports the position so the host can lay its widget over it)
Accommodating code that deviates from WeChat semanticsSkyline is the only alignment target. This is a principle, not a feature that can be implemented

Still open

Each with its impact written down in the test guide: wx:key in incremental invalidation and WXS event handlers; custom components in the H5 export; scrollbars, Skyline's sticky sections and nested scrolling; dragging video progress and picture-in-picture; multi-finger gestures (pinch on movable-view and the scale handler); worklets off the logic thread and swiper worklet callbacks; type="webgl" canvases; bare text placed directly in a <view> does not wrap; inherited color overriding progress / slider / icon defaults; per-element CSS variables and grid-template; textarea auto-height and IME composition; same-layer components on iOS.

Good fit

  • You want to embed a mini-program container in your own app — native or Flutter — without inheriting WebView version drift and first-paint cost
  • You need rendering that is controllable and reproducible: with the same fonts, every platform outputs pixel-identical results
  • Batch image generation with no screen: marketing long-images, server-rendered cards, UI regression baselines
  • You want one mini-program source tree to also ship as H5 (built-in compiler)

Poor fit

  • You need the full web platform (arbitrary CSS, third-party H5 SDKs, <iframe>) — use a WebView
  • Heavy 3D or lots of filters — content is rasterized on the CPU (the GPU only composites), which will not hold up
  • You expect any production mini-program to run with zero adaptation — there are still known gaps
  • Very tight memory budgets — fonts are no longer the problem, but decoded images (capped at 64MB) and a CPU page canvas about one screen tall still count

Stack

Few dependencies, all on current stable

src/ is 410 .rs files / ~88k lines (49 of them test modules), plus 33 integration-test files; any file over 500 lines gets split. Outside the FFI boundary, unsafe appears in 12 audited spots — pixel slices viewed as bytes, surfaces created from a native window pointer, two Android system calls (logcat, CPU affinity) and one zeroed canvas allocation — each with a SAFETY note; there is no static mut.

  • taffy0.12Flexbox / block layout solving
  • rquickjs0.12QuickJS bindings, the logic-layer runtime
  • fontdue0.9Glyph rasterization (vendored: glyphs expand on first use)
  • image0.25PNG / JPEG / GIF frames / WebP decoding
  • ureq3.3wx.request and remote images
  • tungstenite=0.30.0wx.connectSocket: the WebSocket protocol (TLS from the same rustls)
  • rustls + webpki-roots=0.23.42 / =1.0.9TLS and root certificates for wss://, shared with ureq
  • base64 / flate2 / getrandom=0.22.1 / =1.1.9 / =0.2.17Binary data across the bridge / deflate for unzip / OS randomness for wx.getRandomValues
  • symphonia0.6Audio demux and decode
  • openh2640.9H.264 software decode (optional)
  • winit + softbuffer0.30 / 0.4Desktop window and software framebuffer, also the fallback when the GPU is unavailable (optional)
  • wgpu + pollster=30.0.1 / =1.0.1GPU compositing backend: Metal / Vulkan / DX12 / GLES (optional, pinned exactly)
  • rodio0.22Desktop audio playback (optional)
  • arboard + rfd3 / =0.17.2Desktop clipboard for wx.setClipboardData and system file dialogs for wx.chooseImage on macOS / Windows (optional)
  • jni0.21Android JNI entry

Artifact sizemeasured, arm64

Android .so, stripped, GPU included41.4 MB
The same without the GPU backend36.3 MB

Regression suiteall green on every change

Rust tests1055
Scene gallery65 images, byte-reproducible
Partial-repaint check5 desktop scenes + 12 SDK cases, frame by frame
SDK vs desktopsample 15/15, news 6/6 pixel-identical
GPU vs CPU34/34 byte-identical
Flutter plugin tests36
Full runbash tools/regress-async.sh, about 5 min, logged into timestamped batches

Three biggest TODOs

  1. Same-layer components on iOS — <map> and <video> are still laid over the page there
  2. The last incremental-layout gaps — subtrees with a transform or animation are rebuilt whole, and content that gets shorter still repaints a full frame
  3. Custom components in the H5 export — the cross-check against Chrome only covers pages without them

Documentation

The part written in more detail than the code

The documents below are in Chinese; the README is available in English.

Give it a writable pixel buffer,
and the engine takes it from there.