The native bridge¶
PythonNative renders through a native rendering core: Python owns
the component tree, reconciliation, and layout, while Swift
(PythonNativeKit) and Kotlin (the pythonnative Gradle module) own
every native view, gesture recognizer, animation, and device API. The
two sides talk through a small, versioned protocol described on this
page. If you're porting a component manager or a native module, this is
the contract to implement.
The design mirrors React Native's Fabric and TurboModules: one serialized transaction per commit, synchronous measurement, a tag-addressed event channel back into Python, and a module registry that lets a PyPI package ship Swift and Kotlin alongside its Python.
Why a bridge¶
Before the bridge, PythonNative drove UIView and android.view.View
objects directly from Python through rubicon-objc and Chaquopy. Every
prop write, every frame assignment, and every measurement was its own
foreign-function call, and iOS relied on raw libobjc calls that were
prone to crashing on arm64. The bridge replaces that with:
- One crossing per commit. The reconciler serializes its mutation batch to JSON and hands the whole transaction to native in a single call. Native applies it on the UI thread.
- Native component managers. Each element type (
Text,Image,ScrollView, ...) has a SwiftPNComponentManagerand a KotlinComponentManagerthat create views, apply props, position children, and report intrinsic sizes. - Native modules. Device APIs (
Camera,Location,Storage, ...) are Swift and Kotlin classes registered by name. Python facades call them through onecall(module, method, args)entry point and receive results synchronously or through promises. - No Python-side ObjC or JNI.
rubicon-objcis gone as a dependency; Chaquopy is used only to reach the singlePNBridgeclass.
Transport¶
The wire format is JSON on both platforms. Payloads are small (a commit is typically a few kilobytes) and JSON decoders on both platforms are fast and well-tested, which matters more than raw throughput at this size.
iOS¶
PythonNativeKit exports C symbols with @_cdecl. Python loads them
through ctypes.PyDLL(None) (the running process), which keeps the GIL
held across the call so the main thread is never parked mid-commit.
void pn_bridge_apply(const char *transaction_json);
void pn_bridge_measure(int64_t tag, double max_w, double max_h,
double *out_w, double *out_h);
char *pn_bridge_command(int64_t tag, const char *name, const char *args_json);
char *pn_bridge_animate(int64_t tag, const char *request_json);
char *pn_bridge_call(const char *module, const char *method, const char *args_json);
void pn_bridge_free(char *ptr);
void pn_bridge_set_callback(pn_callback_fn fn);
int pn_bridge_protocol_version(void);
Strings returned by native are strdup'd; Python copies them and calls
pn_bridge_free. pn_callback_fn is the Python entry point native
uses for the reverse direction:
typedef const char *(*pn_callback_fn)(const char *kind, int64_t tag,
const char *name, const char *payload_json);
The returned string (when non-null) is owned by Python and valid until the next callback returns; native copies it immediately.
Android¶
The Kotlin module exposes com.pythonnative.runtime.PNBridge, a class
with static methods mirroring the C entry points. Python reaches it
through Chaquopy's jclass; this is the only Java class Python touches.
object PNBridge {
@JvmStatic fun apply(transactionJson: String)
@JvmStatic fun measure(tag: Long, maxWidth: Double, maxHeight: Double): String // "w,h"
@JvmStatic fun command(tag: Long, name: String, argsJson: String): String?
@JvmStatic fun animate(tag: Long, requestJson: String): String?
@JvmStatic fun call(module: String, method: String, argsJson: String): String?
@JvmStatic fun protocolVersion(): Int
@JvmStatic fun setHost(host: PythonHost)
}
interface PythonHost {
fun callback(kind: String, tag: Long, name: String, payloadJson: String): String?
}
The app template installs a PythonHost that forwards to
pythonnative.bridge.native_callback through Chaquopy. The library
module itself has no Chaquopy dependency, so it can be unit-tested with
plain JUnit.
Threading¶
Every call in both directions happens on the platform main thread. Python's asyncio loop already lives there (see the runtime docs), so commits, measurements, commands, and callbacks never marshal across threads. Native modules that complete on a background queue must hop to the main thread before resolving a promise; the module base classes do this for you.
Versioning¶
pn_bridge_protocol_version() / PNBridge.protocolVersion() return
the protocol version compiled into the native library. Python refuses
to start when it doesn't match pythonnative.bridge.PROTOCOL_VERSION,
with a message pointing at pn build to re-stage the template.
Transactions¶
A transaction is a JSON array of ops, applied strictly in order. Each op is an array whose first element is a one-letter opcode:
| Opcode | Shape | Meaning |
|---|---|---|
c |
["c", tag, "Type", {props}] |
Create a view of Type with initial props |
u |
["u", tag, {changed}] |
Apply changed props (null means removed) |
i |
["i", parent, child, index] |
Ensure child is at index under parent (move-aware) |
d |
["d", tag] |
Destroy the view and release its resources |
f |
["f", tag, x, y, w, h] |
Set the frame, in points, relative to the parent's content origin |
Props are plain JSON. The Python side normalizes values before
encoding: frozenset and tuple become arrays, math.inf is emitted
as the string "inf", and values that can't be encoded (Python
callables such as render_row) are held in a Python-side sidecar and
never sent. The prop _pn_events is an array of event names wired on
the element; managers use it to attach listeners lazily (a scroll
delegate only when on_scroll is present, for example).
Failures are isolated per op. Native logs the failing op and continues with the rest of the transaction so a bad prop can't desync the tree. Unknown element types create a placeholder view and log once.
The first native root's frame is owned by the screen host (it is placed
below the safe area on iOS and matched to the fragment container on
Android); the layout pass never emits an f op for it.
Measurement¶
The Python layout engine calls measure(tag, max_w, max_h) for
content-sized leaves. Native returns the view's natural size given the
constraints; either constraint may be 1e6 meaning unconstrained. The
call is synchronous and happens after the create/update ops of the
current commit have been applied, so the view already carries its
current props.
Commands¶
command(tag, name, args_json) runs an imperative action on one view
(scroll_to_offset, focus, blur, get_scroll_offset, ...) and
returns an optional JSON result. Managers ignore unknown commands.
Events¶
Native emits events through the callback with kind = "event":
callback("event", tag, "on_press", "[]")
callback("event", tag, "on_change", "[\"new text\"]")
callback("event", tag, "gesture:0", "[{\"kind\":\"pan\",\"state\":\"changed\",...}]")
The payload is a JSON array of positional arguments, matching each
prop's documented signature. Python routes the event through
pythonnative.events.dispatch_event. A handler may return a value for
synchronous request-style events; the bridge encodes it as JSON and
returns it to native. The list row protocol below relies on this.
Gesture payloads¶
Gesture recognizers post to gesture:<index> with a single dict
argument carrying kind, state, x, y, translation_x,
translation_y, velocity_x, velocity_y, scale, rotation,
pointer_count, and direction. The gestures prop is the list of
specs produced by pythonnative.gestures.serialize_gestures; each spec
has kind, per-kind configuration, simultaneous (indices allowed to
recognize together), and wait_for (indices that must fail first).
Recognition itself is native on every platform.
Virtualized lists¶
VirtualList is backed by UITableView and RecyclerView. Rows host
full PythonNative subtrees driven by nested reconcilers, so the
platform asks Python for rows on demand:
- Native needs a row for a recycled container. It emits
on_bind_rowwith[{"container": key, "index": i, "width": w, "height": h}]and blocks on the result. - Python mounts or rebinds a
RowSubtreefor(list tag, key). The subtree's create ops flow throughapplyre-entrantly inside the callback. - Python returns
{"root": root_tag}. Native attaches the view with that tag into the container. - When a container is recycled or the list is destroyed, native emits
on_unbind_rowwith[{"container": key}](or the list'sdop arrives) and Python unmounts the subtree.
Scroll reports arrive as on_scroll with {"x", "y", "extent", "range"}.
Animations¶
animate(tag, request_json) drives a view's animatable properties
(opacity, background_color, translate_x, translate_y, scale,
scale_x, scale_y, rotate):
| Request | Result |
|---|---|
{"op": "set", "prop": p, "value": v} |
null (one Python-driven frame) |
{"op": "start", "id": n, "prop": p, "spec": {...}} |
{"ok": true} or {"ok": false} |
{"op": "cancel", "id": n} |
{"value": presentation_value} or null |
Specs are the dicts produced by pn.Animated.timing, spring, and
decay. Native reports completion through the callback with
kind = "animation" and payload {"id": n, "finished": bool}. Native
drives timing and spring on both platforms and decay on both
platforms, so the Python ticker is only used for callable easings,
per-frame listeners, and derived nodes.
Native modules¶
A native module is a named object with methods. Python calls
call(module, method, args_json) where args_json is
{"call_id": n, "args": {...}}. The result is one of:
{"ok": true, "value": ...}
{"ok": false, "error": "message", "code": "optional_code"}
{"pending": true}
pending means the method completes later; native then emits
callback("module", 0, module, "{\"call_id\": n, \"ok\": true, \"value\": ...}").
Modules push unsolicited events with
callback("module", 0, module, "{\"event\": \"change\", \"payload\": {...}}").
On the native side a module implements one method:
public final class ClipboardModule: PNNativeModule {
public static let name = "Clipboard"
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
switch method {
case "set_string": UIPasteboard.general.string = args["text"] as? String ?? ""; promise.resolve(nil)
case "get_string": promise.resolve(UIPasteboard.general.string ?? "")
default: promise.reject("unknown method \(method)")
}
}
}
If promise is resolved before call returns, the bridge answers
inline. If not, the bridge returns pending and delivers the result
when the promise settles. Kotlin modules implement the same shape
through NativeModule.call(method, args, promise).
Built-in modules¶
Host, Device, Alert, Storage, SecureStore, Clipboard,
Share, Linking, Haptics, Battery, NetInfo, AppState,
Permissions, Notifications, Camera, Location, Biometrics.
Host is special: it carries screen lifecycle and navigation
(attach_root, detach_root, push, pop, pop_to_root, replace,
reset, set_options, viewport). Native emits screen lifecycle to
Python through callback("host", screen_id, event, payload_json);
on_layout and on_resume payloads carry {"width", "height",
"insets": {"top", "left", "bottom", "right"}, "color_scheme"} so
Python never has to query for them. on_back_pressed returns
"true" when a use_back_handler consumed the event.
Python facades¶
Facades live in pythonnative.native_modules and are thin:
from .registry import native_module
_clipboard = native_module("Clipboard")
class Clipboard:
@staticmethod
def set_string(text: str) -> None:
_clipboard.call("set_string", text=text)
@staticmethod
def get_string() -> str:
return str(_clipboard.call("get_string") or "")
native_module returns a BridgeModule on device and a registered
Python implementation on desktop and in tests. Desktop implementations
are plain classes with the same method names; built-ins register theirs
in pythonnative.native_modules.desktop, and packages register theirs
through the pythonnative.modules entry point group.
Plugins¶
A PyPI package can ship native code alongside its Python facade. It
declares an entry point in the pythonnative.plugins group pointing at
a package directory that contains a pn_plugin.json manifest plus
ios/ and android/ source folders:
pn build copies ios/*.swift into PythonNativeKit/Sources/Plugins
and android/**/*.kt into the pythonnative module, then generates a
registration file that calls each entry's register(_:). An entry
registers component managers and native modules:
public enum MyBlurPlugin: PNPlugin {
public static func register(into registry: PNRegistry) {
registry.registerComponent("BlurView") { BlurViewManager() }
registry.registerModule(BlurSettingsModule.self)
}
}
Python-side element factories and typed props come from
pythonnative.sdk, exactly as for built-ins; the desktop preview and
tests use the ViewHandler protocol for their fallback rendering.
Protocol summary¶
Python -> native apply(transaction) one call per commit
measure(tag, w, h) synchronous intrinsic size
command(tag, name, args) imperative view action
animate(tag, request) animation set/start/cancel
call(module, method, args) native module method
native -> Python callback("event", tag, name, args_array)
callback("module", 0, module, {call_id|event,...})
callback("host", scr, event, payload)
callback("animation", 0, "", {id, finished})
callback("pump", 0, "", "")