Design Decisions
This page collects the architectural choices that are easiest to misunderstand when reading individual files.
These decisions define where Kanvas is allowed to take ownership, what it treats as compatibility, and which failures must stay visible. Changing one usually affects more than one module.
Keep Compose; replace the rendering path
Kanvas does not ask applications to rewrite UI code into a custom scene framework.
Reason: the project's purpose is renderer substitution/integration for ordinary Kotlin desktop apps, not replacement of Compose as an application UI model.
The integration therefore sits around:
- Gradle lifecycle
- JVM launch
- Skiko/Skia capture points
- native presentation
rather than around application state and composable APIs.
Instrument instead of forking the target application
The Java agent transforms known renderer boundaries at runtime.
Reason: a hard source fork of every supported Compose/Skiko application would destroy the "apply a Gradle plugin" integration model.
Trade-off: Kanvas must actively track private renderer ABI changes.
This is not accidental coupling. Renderer takeover requires a boundary below public Compose APIs. The cost is active ABI tracking, runtime inspection, and tests that fail when those private boundaries move.
Source: KanvasAgent.java
Capture the final picture replay
Modern Skiko records a picture and then replays it.
Reason: the final replay is the first boundary that reliably represents the complete visual payload of the frame.
Earlier callbacks may represent recording state rather than the frame that is actually about to be presented. Kanvas prefers semantic completeness over simply hooking the earliest convenient method.
Source: SkikoFrameTransformer.java
Separate capture from presentation ownership
capture and takeover are distinct runtime switches.
Reason: being able to observe/capture a renderer without suppressing its presentation is useful for compatibility investigation and differential debugging.
Source: KanvasExtension.kt
Batch JNI frame data
Kanvas sends flattened primitive command data across JNI instead of invoking native code for every high-level object.
Reason: fewer boundary crossings, simpler memory validation, and a backend-neutral command batch.
Source: jni_display_bridge.c
Keep backend fallback GPU-only during active rendering
The Windows automatic path can move among implemented GPU backends. GDI remains a reference/software path rather than silently becoming the "GPU fallback."
Reason: silently dropping to software would make an application appear healthy while violating the core renderer-ownership/performance expectation.
A renderer whose goal is GPU presentation should not quietly turn a GPU failure into a software success. GDI stays useful as a reference path, but not as a disguised equivalent to GPU takeover.
Source: jni_display_bridge.c
Pin released runtime source to the release tag
A released plugin with no explicit sourceRef resolves its matching vX.Y.Z tag rather than main.
Reason: Gradle plugin code, agent instrumentation, JNI protocol, and native runtime are one compatibility unit.
Source: KanvasPlugin.kt
Prefer explicit failure over fake compatibility
Examples include:
- Doctor
ERRORresults for blocking prerequisites - frame-transform failures when required bytecode boundaries do not exist
- packaging failure when the expected application image cannot be identified
Reason: a renderer plugin that silently disengages is harder to debug than one that tells you exactly which ownership boundary failed.
For a system that crosses Gradle, bytecode instrumentation, JNI, native windows, and GPU APIs, precise failure boundaries are developer-facing behavior. They are not just logging polish.