Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

JS 写声明式组件、Compose Multiplatform 渲染的 UI 框架,引擎为 QuickJS(经 quickjs-kmp 接入)。Android / iOS 双端。

> 早期阶段。关键选型已定,构建链已打通(TSX → 模块字节码 → 双端加载);进度见 [docs/roadmap.md](./docs/roadmap.md)。
> 早期阶段。关键选型已定,M1 Counter 已在双端跑通(TSX → 模块字节码 → 引擎 → patch → Compose);进度见 [docs/roadmap.md](./docs/roadmap.md)。

- 设计文档与决策记录:[docs/](./docs/README.md)
- 响应式模型与引擎 benchmark:[bench/](./bench/README.md)
Expand Down
236 changes: 227 additions & 9 deletions compose/api/compose.klib.api

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,6 @@ val hostJniDir: Provider<String> = providers.environmentVariable("TINYUI_QUICKJS
tasks.withType<Test>().matching { it.name == "testAndroidHostTest" }.configureEach {
if (quickjsKmpDir != null) dependsOn(gradle.includedBuild("quickjs-kmp").task(":library:buildNativeHostJni"))
hostJniDir.orNull?.let { systemProperty("java.library.path", it) }
// BundleSmokeTest loads the sample's bytecode when it exists: never a stale one from before a rebuild in the same run
mustRunAfter(":sample:shared:buildTinyUIPages")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package wang.harlon.tinyui

import wang.harlon.quickjs.JsEngine
import wang.harlon.quickjs.JsEngineConfig
import wang.harlon.quickjs.JsValue
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals

/** Loads what `tinyui build` produced for the sample through the real engine; skips when the sample was not built. */
class BundleSmokeTest {
private val out = File("../sample/shared/build/tinyui-cli")

@Test
fun builtBundlesLoadAndTheRuntimeComesUp() {
if (!out.isDirectory) return
JsEngine(JsEngineConfig(moduleScheme = PageHost.MODULE_SCHEME)).use { engine ->
for (fn in listOf("__host_apply", "__host_query", "__host_call", "__host_send", "__host_report")) engine.registerFunction(fn) { JsValue.Undefined }
assertEquals("@tiny-ui/core", engine.registerModule(out.resolve("runtime/core.bin").readBytes()))
assertEquals("@tiny-ui/native", engine.registerModule(out.resolve("runtime/native.bin").readBytes()))
engine.evaluateModule("import \"@tiny-ui/core\"; export const p = globalThis.__tinyui.protocol;").use {
assertEquals(JsValue.Num(PageHost.PROTOCOL), it.get("p"))
}
engine.runBytecode(out.resolve("pages/counter.bin").readBytes()).let { if (it is AutoCloseable) it.close() }
}
}
}
54 changes: 0 additions & 54 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/PageEngine.kt

This file was deleted.

232 changes: 232 additions & 0 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package wang.harlon.tinyui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import wang.harlon.quickjs.JsEngine
import wang.harlon.quickjs.JsEngineConfig
import wang.harlon.quickjs.JsException
import wang.harlon.quickjs.JsRef
import wang.harlon.quickjs.JsRuntime
import wang.harlon.quickjs.JsValue
import wang.harlon.quickjs.ObjectTransport
import wang.harlon.tinyui.node.Command
import wang.harlon.tinyui.node.NodeTree
import wang.harlon.tinyui.node.PatchProblem
import wang.harlon.tinyui.node.UiNode
import wang.harlon.tinyui.schema.ComponentRegistry
import wang.harlon.tinyui.schema.NodeScope

/** Bytecode of the runtime modules every page imports, as `tinyui build` writes them under `runtime/`. */
class RuntimeBundle(val core: ByteArray, val native: ByteArray)

/** Everything the host learns about a page's health, in one place (docs/adr-002 §3.5). */
interface PageSink {
fun patchProblem(problem: PatchProblem)
fun businessError(entry: String, message: String, stack: String?)
fun log(line: String)
}

/** The page's failure: engine gone, error page to be rendered by the host. */
class PageFailure(val kind: String, val message: String)

/**
* One page: its engine, coroutine scope and node tree (docs/adr-002 §3.4). Every K entry is
* `entry` + `flush` under one [JsRuntime.withEngine] (docs/js-runtime.html §2).
*/
class PageHost(
private val runtimeBundle: RuntimeBundle,
private val page: ByteArray,
val registry: ComponentRegistry,
private val sink: PageSink,
private val propsJson: String = "{}",
) {
val tree = NodeTree(registry, sink::patchProblem)
var failure: PageFailure? by mutableStateOf(null)
private set

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val timers = HashMap<Int, Job>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[建议] timers 是非线程安全的 HashMap,但它会在引擎回调中的 schedule/__host_send、Dispatchers.Default 的延迟协程以及 Compose 销毁时的 close() 中并发读写。尤其 close() 遍历 timers.values 时,刚到期的任务可同时执行 timers.remove,可能触发并发修改异常并中断后续清理。应将定时器表约束到单一串行上下文,或用跨平台同步机制保护所有访问,并在关闭时先取得受保护的任务快照再取消。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已改 ae5a180:timers 只在持 JsRuntime 锁时访问(schedule 与 timer.cancel 在宿主函数内、到期移除放进 resolve 入口的 before 步骤),close() 只 cancel scope 不再遍历;cancel 的 cbId 先解包。

private val runtime = JsRuntime(JsEngineConfig(moduleScheme = MODULE_SCHEME, logger = sink::log))
private var entries: Entries? = null

private class Entries(val self: JsRef, val fns: Map<String, JsRef>) {
fun call(name: String, vararg args: JsValue) = fns.getValue(name).invoke(self, args.toList())
fun close() { fns.values.forEach { it.close() }; self.close() }
}

fun start() {
scope.launch {
try {
runtime.withEngine {
registerHost(this)
step("registering @tiny-ui/core") { registerModule(runtimeBundle.core) }
step("registering @tiny-ui/native") { registerModule(runtimeBundle.native) }
// a registered module only runs on its first import; the runtime must be up before the page
evaluateModule("import \"@tiny-ui/core\"; import \"@tiny-ui/native\";").close()
val namespace = runBytecode(page, ObjectTransport.REF) as JsRef
if (namespace.isPromise) { namespace.close(); error("page module is still pending after microtasks were drained") }
val self = evaluate("__tinyui", objects = ObjectTransport.REF) as JsRef
val fns = ENTRY_NAMES.associateWith { self.get(it, ObjectTransport.REF) as JsRef }
val e = Entries(self, fns).also { entries = it }
val protocol = (self.get("protocol") as? JsValue.Num)?.value?.toInt()
check(protocol == PROTOCOL) { "protocol $protocol from the runtime module, host implements $PROTOCOL" }
namespace.use { ns ->
(ns.get("default", ObjectTransport.REF) as JsRef).use { default ->
e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest()))
}
}
e.call("flush")
}
} catch (t: Throwable) {
fail("E6", t)
}
}
}

fun dispatch(nodeId: Int, event: String, payload: String) = entry("dispatch", JsValue.Num(nodeId), JsValue.Str(event), JsValue.Str(payload))
fun visible(visible: Boolean) = entry("visible", JsValue.Bool(visible))
fun resolve(cbId: Int, resultJson: String) = entry("resolve", JsValue.Num(cbId), JsValue.Str(resultJson))
fun reject(cbId: Int, errorJson: String) = entry("reject", JsValue.Num(cbId), JsValue.Str(errorJson))
fun emit(topic: String, payloadJson: String) = entry("emit", JsValue.Str(topic), JsValue.Str(payloadJson))

private fun entry(name: String, vararg args: JsValue, before: () -> Unit = {}) {
if (failure != null) return
scope.launch {
try {
runtime.withEngine {
before()
val e = entries ?: return@withEngine
e.call(name, *args)
e.call("flush")
}
} catch (t: Throwable) {
fail("E2", t)
}
}
}

fun close() {
scope.cancel() // pending entries and timers are its children
tree.clear()
// not a child of `scope`: cancelling the page must not cancel its own teardown
CoroutineScope(Dispatchers.Default).launch {
try {
runtime.withEngine {
entries?.let { e -> runCatching { e.call("unmount") }; e.close() }
entries = null
}
} finally {
runtime.close()
}
}
}

private inline fun <T> step(what: String, block: () -> T): T =
try { block() } catch (t: Throwable) { throw IllegalStateException("$what: ${t.message}", t) }

private fun fail(kind: String, t: Throwable) {
val message = (t as? JsException)?.let { "${it.message}\n${it.jsStack ?: ""}" } ?: t.message ?: t.toString()
sink.log("page failed ($kind): $message")
failure = PageFailure(kind, message)
}

private fun registerHost(engine: JsEngine) {
engine.registerFunction("__host_apply") { args ->
tree.apply((args[0] as JsValue.Str).value)
JsValue.Undefined
}
engine.registerFunction("__host_report") { args ->
val kind = (args[0] as? JsValue.Str)?.value
val detail = (args[1] as? JsValue.Str)?.value ?: ""
val fields = runCatching { Json.parseToJsonElement(detail).jsonObject }.getOrNull()
if (kind == "E1" && fields != null) {
sink.businessError(
entry = fields["entry"]?.jsonPrimitive?.contentOrNull ?: "?",
message = fields["message"]?.jsonPrimitive?.contentOrNull ?: "",
stack = fields["stack"]?.jsonPrimitive?.contentOrNull,
)
} else {
sink.businessError(entry = "?", message = "unparseable $kind report: $detail", stack = null)
}
JsValue.Undefined
}
engine.registerFunction("__host_query") { _ -> JsValue.Str("null") }
engine.registerFunction("__host_call") { args ->
val name = (args[0] as JsValue.Str).value
val cbId = (args[1] as JsValue.Num).value.toInt()
val argsJson = (args[2] as JsValue.Str).value
when (name) {
"timer.schedule" -> schedule(cbId, argsJson)
else -> scope.launch { reject(cbId, """{"code":"E_UNSUPPORTED","message":"$name is not available"}""") }
}
JsValue.Undefined
}
engine.registerFunction("__host_send") { args ->
val name = (args[0] as JsValue.Str).value
val argsJson = (args[1] as JsValue.Str).value
if (name == "timer.cancel") {
val cbId = runCatching { Json.parseToJsonElement(argsJson).jsonObject["cbId"]?.jsonPrimitive?.intOrNull }.getOrNull()
if (cbId != null) timers.remove(cbId)?.cancel()
}
JsValue.Undefined
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* The host side of `setTimeout` (J3 `timer.schedule` → K3 `resolve`); dies with the page.
* [timers] is only touched while holding the runtime lock: here and in `__host_send` (host functions
* run inside the calling entry) and in the `before` step of the firing entry.
*/
private fun schedule(cbId: Int, argsJson: String) {
val ms = runCatching { Json.parseToJsonElement(argsJson).jsonObject["ms"]?.jsonPrimitive?.doubleOrNull }.getOrNull() ?: 0.0
timers[cbId] = scope.launch {
delay(ms.toLong().coerceAtLeast(0))
entry("resolve", JsValue.Num(cbId), JsValue.Str(""), before = { timers.remove(cbId) })
}
}

/** The scope a component renders through. */
internal inner class Scope(override val node: UiNode) : NodeScope {
@Suppress("UNCHECKED_CAST")
override fun <T> get(key: String): T? = (node.props[key] ?: registry.schema(node.type)?.props?.get(key)?.default) as T?
override fun has(event: String): Boolean = node.events[event] == true
override fun dispatch(event: String, payload: String) = this@PageHost.dispatch(node.id, event, payload)
override fun takeCommands(): List<Command> = node.commands.toList().also { node.commands.clear() }
override fun modifier(): Modifier = Modifier

@Composable
override fun Children() {
for (child in node.children) key(child.id) { Render(child) }
}
}

@Composable
internal fun Render(node: UiNode) {
registry.Render(node.type, Scope(node))
}

companion object {
/** `import.meta.url` of a page module is `tinyui:<name>` (docs/adr-005-engine.md). */
const val MODULE_SCHEME = "tinyui"
/** docs/patch-protocol.md §6 */
const val PROTOCOL = 1
private val ENTRY_NAMES = listOf("mount", "unmount", "visible", "dispatch", "resolve", "reject", "emit", "flush")
}
}
37 changes: 37 additions & 0 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package wang.harlon.tinyui

import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import wang.harlon.tinyui.schema.ComponentRegistry

/** Renders one TinyUI page; the host owns navigation and passes the page's bytecode (docs/app-model.md). */
@Composable
fun TinyUIPage(
runtime: RuntimeBundle,
page: ByteArray,
registry: ComponentRegistry,
sink: PageSink,
propsJson: String = "{}",
modifier: Modifier = Modifier,
error: @Composable (PageFailure) -> Unit = { Text("TinyUI page failed (${it.kind}): ${it.message}") },
) {
val host = remember(runtime, page, registry, sink, propsJson) { PageHost(runtime, page, registry, sink, propsJson) }
DisposableEffect(host) {
host.start()
onDispose { host.close() }
}
Box(modifier) {
val failure = host.failure
if (failure != null) {
error(failure)
} else {
for (child in host.tree.root.children) key(child.id) { host.Render(child) }
}
}
}

Loading
Loading