Skip to main content

Design Decisions

This page collects the architectural choices that are easiest to misunderstand when reading individual files.

These are contracts, not trivia

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.

Deliberate dependency on internals

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.

Why the later boundary wins

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.

Software fallback would hide the failure

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 ERROR results 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.

Failure messages are part of the API

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.