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
14 changes: 14 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ jobs:
distribution: temurin
java-version: 25
- uses: gradle/actions/setup-gradle@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: quickjs-kmp host tools (qjsc-kmp for bytecode, host JNI for Android host tests)
run: |
version=$(sed -n 's/^quickjsKmp = "\(.*\)"/\1/p' gradle/libs.versions.toml)
git clone --depth 1 --branch "$version" https://github.com/HarlonWang/quickjs-kmp.git "$RUNNER_TEMP/quickjs-kmp"
(cd "$RUNNER_TEMP/quickjs-kmp" && ./gradlew :library:buildHostTools :library:buildNativeHostJni --no-daemon)
echo "TINYUI_QJSC=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-tools/bin/qjsc-kmp" >> "$GITHUB_ENV"
echo "TINYUI_QUICKJS_HOST_JNI=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-jni/lib" >> "$GITHUB_ENV"
- run: ./gradlew :compose:testAndroidHostTest :compose:apiCheck :sample:androidApp:assembleDebug --no-daemon

pnpm:
Expand All @@ -29,4 +42,5 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm typecheck
- run: pnpm test
13 changes: 13 additions & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,17 @@ jobs:
distribution: temurin
java-version: 25
- uses: gradle/actions/setup-gradle@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: quickjs-kmp host tools
run: |
version=$(sed -n 's/^quickjsKmp = "\(.*\)"/\1/p' gradle/libs.versions.toml)
git clone --depth 1 --branch "$version" https://github.com/HarlonWang/quickjs-kmp.git "$RUNNER_TEMP/quickjs-kmp"
(cd "$RUNNER_TEMP/quickjs-kmp" && ./gradlew :library:buildHostTools :library:buildNativeHostJni --no-daemon)
echo "TINYUI_QJSC=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-tools/bin/qjsc-kmp" >> "$GITHUB_ENV"
echo "TINYUI_QUICKJS_HOST_JNI=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-jni/lib" >> "$GITHUB_ENV"
- run: ./gradlew build --no-daemon
14 changes: 9 additions & 5 deletions 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 双端。

> 设计阶段。关键选型已定,代码尚未开始;进度见 [docs/roadmap.md](./docs/roadmap.md)。
> 早期阶段。关键选型已定,构建链已打通(TSX → 模块字节码 → 双端加载);进度见 [docs/roadmap.md](./docs/roadmap.md)。

- 设计文档与决策记录:[docs/](./docs/README.md)
- 响应式模型与引擎 benchmark:[bench/](./bench/README.md)
Expand All @@ -15,7 +15,7 @@ docs/ 设计文档、ADR、roadmap
packages/ npm 包:@tiny-ui/core · @tiny-ui/native · @tiny-ui/cli
compose/ KMP 库 wang.harlon:tinyui(Compose Multiplatform 侧)
schema/ 内置组件 schema,两侧契约的唯一真值
sample/ 示例 App
sample/ 示例 App(sample/js 是页面源码,Gradle 构建时经 CLI 编成字节码打进资源)
bench/ 响应式模型与引擎 benchmark
build-logic/ Gradle convention plugins
```
Expand All @@ -25,11 +25,15 @@ build-logic/ Gradle convention plugins
## 构建

```sh
./gradlew build # compose 库 + 测试(Android host / iOS simulator)+ sample APK + iOS framework;需要 JDK 25、Android SDK、Xcode
pnpm install && pnpm build && pnpm test
pnpm install # Node 22+,pnpm 10
./gradlew build # compose 库 + 测试(Android host / iOS simulator)+ sample APK + iOS framework;需要 JDK 25、Android SDK、Xcode
pnpm build && pnpm test # 三个 npm 包
```

本地联调 quickjs-kmp:`local.properties` 加 `quickjs-kmp.dir=<quickjs-kmp 仓路径>`,即从源码构建(composite build)。
sample 的 Gradle 构建会调 `tinyui build` 把 `sample/js` 编成字节码,需要 quickjs-kmp 的宿主工具 `qjsc-kmp`,Android host 测试还需要宿主 JNI 库。两种来源:

- 本地联调:`local.properties` 加 `quickjs-kmp.dir=<quickjs-kmp 仓路径>`,SDK 从源码构建(composite build),两样宿主产物由该仓的任务自动编出,零配置
- 否则:`TINYUI_QJSC` 指向 `qjsc-kmp`,`TINYUI_QUICKJS_HOST_JNI` 指向含 `libquickjs_kmp` 的目录(CI 的做法见 `.github/workflows/build.yml`)

## License

Expand Down
25 changes: 25 additions & 0 deletions compose/api/compose.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,36 @@
// - Show declarations: true

// Library unique name: <tinyui:compose>
final class wang.harlon.tinyui/PageEngine : kotlin/AutoCloseable { // wang.harlon.tinyui/PageEngine|null[0]
final fun callDefault(kotlin/Array<out wang.harlon.quickjs/JsValue>...): wang.harlon.quickjs/JsValue // wang.harlon.tinyui/PageEngine.callDefault|callDefault(kotlin.Array<out|wang.harlon.quickjs.JsValue>...){}[0]
final fun close() // wang.harlon.tinyui/PageEngine.close|close(){}[0]

final object Companion { // wang.harlon.tinyui/PageEngine.Companion|null[0]
final const val MODULE_SCHEME // wang.harlon.tinyui/PageEngine.Companion.MODULE_SCHEME|{}MODULE_SCHEME[0]
final fun <get-MODULE_SCHEME>(): kotlin/String // wang.harlon.tinyui/PageEngine.Companion.MODULE_SCHEME.<get-MODULE_SCHEME>|<get-MODULE_SCHEME>(){}[0]

final fun load(wang.harlon.tinyui/RuntimeBundle, kotlin/ByteArray, wang.harlon.quickjs/JsEngineConfig = ...): wang.harlon.tinyui/PageEngine // wang.harlon.tinyui/PageEngine.Companion.load|load(wang.harlon.tinyui.RuntimeBundle;kotlin.ByteArray;wang.harlon.quickjs.JsEngineConfig){}[0]
}
}

final class wang.harlon.tinyui/RuntimeBundle { // wang.harlon.tinyui/RuntimeBundle|null[0]
constructor <init>(kotlin/ByteArray, kotlin/ByteArray) // wang.harlon.tinyui/RuntimeBundle.<init>|<init>(kotlin.ByteArray;kotlin.ByteArray){}[0]

final val core // wang.harlon.tinyui/RuntimeBundle.core|{}core[0]
final fun <get-core>(): kotlin/ByteArray // wang.harlon.tinyui/RuntimeBundle.core.<get-core>|<get-core>(){}[0]
final val native // wang.harlon.tinyui/RuntimeBundle.native|{}native[0]
final fun <get-native>(): kotlin/ByteArray // wang.harlon.tinyui/RuntimeBundle.native.<get-native>|<get-native>(){}[0]
}

final object wang.harlon.tinyui/TinyUI { // wang.harlon.tinyui/TinyUI|null[0]
final val engineVersion // wang.harlon.tinyui/TinyUI.engineVersion|{}engineVersion[0]
final fun <get-engineVersion>(): kotlin/String // wang.harlon.tinyui/TinyUI.engineVersion.<get-engineVersion>|<get-engineVersion>(){}[0]
}

final val wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop|#static{}wang_harlon_tinyui_PageEngine$stableprop[0]
final val wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop|#static{}wang_harlon_tinyui_RuntimeBundle$stableprop[0]
final val wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop|#static{}wang_harlon_tinyui_TinyUI$stableprop[0]

final fun wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop_getter|wang_harlon_tinyui_PageEngine$stableprop_getter(){}[0]
final fun wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop_getter|wang_harlon_tinyui_RuntimeBundle$stableprop_getter(){}[0]
final fun wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop_getter|wang_harlon_tinyui_TinyUI$stableprop_getter(){}[0]
10 changes: 10 additions & 0 deletions compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,13 @@ mavenPublishing {
apiValidation {
klib { enabled = true }
}

// Android host test 要加载宿主编译的 libquickjs_kmp:composite 时取 quickjs-kmp 的 buildNativeHostJni 产物,
// 否则读 TINYUI_QUICKJS_HOST_JNI(CI 从 quickjs-kmp 源码现编,见 .github/workflows/build.yml)
val quickjsKmpDir = (gradle as ExtensionAware).extra.properties["quickjs-kmp.dir"] as File?
val hostJniDir: Provider<String> = providers.environmentVariable("TINYUI_QUICKJS_HOST_JNI")
.orElse(providers.provider { quickjsKmpDir?.resolve("library/build/native/host-jni/lib")?.absolutePath })
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) }
}
54 changes: 54 additions & 0 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/PageEngine.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package wang.harlon.tinyui

import wang.harlon.quickjs.JsEngine
import wang.harlon.quickjs.JsEngineConfig
import wang.harlon.quickjs.JsRef
import wang.harlon.quickjs.JsValue
import wang.harlon.quickjs.ObjectTransport

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

/**
* One page's engine: the runtime modules registered, the page module evaluated, its namespace held
* (K0 of docs/adr-002-bridge-communication.md). Closing it unloads the page.
*/
class PageEngine private constructor(
private val engine: JsEngine,
private val namespace: JsRef,
) : AutoCloseable {
/** Calls the page module's `default` export with [args]; objects come back as JSON. */
fun callDefault(vararg args: JsValue): JsValue =
(namespace.get("default", ObjectTransport.REF) as JsRef).use { it.call(*args) }

override fun close() {
namespace.close()
engine.close()
}

companion object {
/** `import.meta.url` of a page module is `tinyui:<name>` (docs/adr-005-engine.md). */
const val MODULE_SCHEME = "tinyui"

fun load(
runtime: RuntimeBundle,
page: ByteArray,
config: JsEngineConfig = JsEngineConfig(moduleScheme = MODULE_SCHEME),
): PageEngine {
val engine = JsEngine(config)
try {
engine.registerModule(runtime.core)
engine.registerModule(runtime.native)
val namespace = engine.runBytecode(page, ObjectTransport.REF) as JsRef
if (namespace.isPromise) {
namespace.close()
error("page module is still pending after microtasks were drained; top-level await may only wait on synchronous work")
}
return PageEngine(engine, namespace)
} catch (e: Throwable) {
engine.close()
throw e
}
}
}
}
56 changes: 56 additions & 0 deletions compose/src/commonTest/kotlin/wang/harlon/tinyui/PageEngineTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package wang.harlon.tinyui

import wang.harlon.quickjs.JsBytecode
import wang.harlon.quickjs.JsException
import wang.harlon.quickjs.JsValue
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue

class PageEngineTest {
private val runtime = RuntimeBundle(
core = module("@tiny-ui/core", "export const VERSION = '0.0.0'; export function h(type, props) { return { type, props }; }"),
native = module("@tiny-ui/native", "import { VERSION } from '@tiny-ui/core'; export const NATIVE = 'native ' + VERSION;"),
)

@Test
fun loadsRuntimeModulesThenPage() {
val page = module(
"pages/home",
"""
import { VERSION, h } from "@tiny-ui/core";
import { NATIVE } from "@tiny-ui/native";
export default (who) => `${'$'}{who}: core ${'$'}{VERSION}, ${'$'}{NATIVE}, at ${'$'}{import.meta.url}`;
""",
)
PageEngine.load(runtime, page).use { engine ->
val text = engine.callDefault(JsValue.Str("hello")) as JsValue.Str
assertEquals("hello: core 0.0.0, native 0.0.0, at tinyui:pages/home", text.value)
}
}

@Test
fun topLevelAwaitOnSynchronousWorkResolvesBeforeLoadReturns() {
val page = module("pages/async", "export const ready = await Promise.resolve(42); export default () => ready;")
PageEngine.load(runtime, page).use { engine ->
assertEquals(JsValue.Num(42.0), engine.callDefault())
}
}

@Test
fun pendingPageModuleIsRejected() {
val page = module("pages/stuck", "await new Promise(() => {}); export default () => 0;")
val e = assertFailsWith<IllegalStateException> { PageEngine.load(runtime, page) }
assertTrue("pending" in e.message!!)
}

@Test
fun unknownImportFailsAtLoad() {
val page = module("pages/bad", "import { x } from 'pages/other'; export default () => x;")
assertFailsWith<JsException> { PageEngine.load(runtime, page) }
}

private fun module(name: String, source: String): ByteArray =
JsBytecode.compile(source.trimIndent(), name, module = true, strip = JsBytecode.Strip.SOURCE)
}
10 changes: 10 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ tinyui/
├── compose/ wang.harlon:tinyui KMP 库(Compose Multiplatform 侧):节点表、注册表、桥、内置组件;依赖 quickjs-kmp
├── schema/ 内置组件 schema 的唯一真值 → 生成 packages/core 的 .d.ts 与 compose/ 的注册代码
├── sample/ 示例 App,M1 Counter / M2 列表页在这里跑
│ ├── js/ 页面源码(pnpm workspace 成员),shared 的 Gradle 任务调 CLI 编成字节码打进 Compose 资源
│ ├── shared/ KMP 模块:App() 与 iOS 入口,出静态 framework(AGP 9 不允许 application 与 KMP 插件同模块)
│ ├── androidApp/ Android 壳(纯 com.android.application)
│ └── iosApp/ Xcode 壳
Expand All @@ -92,6 +93,15 @@ tinyui/

约定:顶层只放这八个目录;新 npm 包进 `packages/`,新 Kotlin 模块进 `compose/` 作为子模块,不在顶层增生。ADR 文本中的"宿主"仍指 Kotlin 侧这一运行时角色,与目录名 `compose/` 不冲突。

## 实现期文档

不需要 ADR 但实现前必须定的方案,一项一个文件:

| 文件 | 主题 | 状态 |
|---|---|---|
| [build-chain.md](./build-chain.md) | 构建链:TSX → ESM 模块字节码,esbuild 选型,字节码由 CLI 经 `qjsc-kmp` 生成 | 已完成(2026-09-16) |
| [app-model.md](./app-model.md) | 应用模型:路由、生命周期、跨页状态在 Kotlin;页面之间经 Kotlin 中转的四种通信 | 已对齐(2026-09-16),API 面归 C 组 |

## 下一阶段

四份 ADR 遗留项的汇总、分组与建议顺序见 [roadmap.md](./roadmap.md),状态在那里更新。
Expand Down
50 changes: 50 additions & 0 deletions docs/app-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 应用模型:页面是 JS 的世界,应用是 Kotlin 的世界

- 状态:已对齐(2026-09-16);API 面(`@tiny-ui/native` 的 `navigation` / `store` / `events`)归 roadmap C 组与 J2 白名单一起定
- 来源:ADR-002 "跨页共享状态走 Kotlin(K5 推送)"、"多页共享引擎被否:JS 全局正是隔离问题的来源"、`navigation.push` 为 J4;ADR-003 "组件注册表 App 级、不可变"

## 1. 前提

JS 侧不存在长生命周期的"应用对象":没有 `app.tsx`、没有 `onLaunch`、没有 `globalData`。每个页面模块是一个孤岛,出口只有 `@tiny-ui/native` 的宿主能力,入口只有 K1 生命周期、K2 事件、K3 回送、K5 推送。页面对另一个页面的存在一无所知。

## 2. 四类应用级事务的归属

| 事务 | 归属 | 机制 |
|---|---|---|
| 路由 | Kotlin 导航栈 | 页面名 = 模块名 = 路由键(`pages/detail`)。JS `navigation.push(name, params)` 经 J4 → Kotlin 建页面作用域 → K0 加载 → K1 `__mount(props)`,params 即 props。返回 = pop = `__unmount` = 关引擎。深链由宿主解析成页面名 + 参数走同一条 push |
| 应用生命周期 | 宿主事件 | 前后台、内存警告、主题、语言、登录态经 K5 `__emit(topic)` 投给每个订阅了的活页面;栈顶页额外收 K1 `__visible`。页面只有挂载、卸载、可见性三个钩子 |
| 跨页状态 | Kotlin 内存 store | 见第 3 节 |
| 应用级 JS 逻辑 | Kotlin,或无状态工具代码打进各页模块 | 常驻 JS 的出口是"应用级服务 Runtime":一个不挂 UI、随 App 生命周期的引擎,页面经 Kotlin 以 JSON 与它通信,形态同 J3 / K3。v1 不做,触发条件见 roadmap D 组 |

**TinyUI 页从宿主看只是一个 Composable**,导航栈里可以混放原生页与 TinyUI 页。v1 不内置路由器:库提供页面 Composable 与 `Navigator` 接口,宿主用自己的导航框架实现,JS 的 `navigation.*` 委托给它;sample 给最简实现。

## 3. 页面之间怎么通信

页面之间没有直接通道,全部经 Kotlin 中转,载荷只有 JSON。两个引擎本来就不能共享对象,这是设计而不是限制。

| 场景 | JS 侧 | 过桥 | 到达形态 |
|---|---|---|---|
| 向前传参 | `navigation.push("pages/detail", {id})` | J4 | 新页 K1 `__mount(props)` |
| 向后回传 | `navigation.pop(result)` | J4 | 上一页收 K5 固定 topic(如 `navigation.result`) |
| 共享状态 | `store.get(key)` / `store.set(key, v)` | J2 读,J4 写 | 真值在 Kotlin,变更经 K5 推给订阅了该 key 的页面 |
| 事件广播 | `events.emit(topic, payload)` / `events.on(topic, fn)` | J4 | 所有订阅了该 topic 的活页面各收一次 K5 |
| 直接调用另一页 | 无 | 无 | 不支持;需要它说明那个状态该进 store |

- **向前传参**:props 是 JSON,函数不过桥。"完成后回调我"只能靠回传或事件
- **向后回传**:原语是 K5 topic,结果投给"处在那个栈位置的页面"而不是某个闭包,页面被回收重建后照样收到。`push` 返回 Promise 的写法(J3 + cbId)与栈深回收冲突——被回收页的闭包已不存在;可之后作为糖加在 `@tiny-ui/native`,文档写明回收语义
- **共享状态**:真值在 Kotlin,原生页与 TinyUI 页共用同一份;内存 key-value 所以 J2 同步读合法;JS 侧包成 signal,K5 到达时更新 signal,绑定了它的 prop 自动重算,与页内响应式同一套。持久化由宿主决定
- **事件广播**:与宿主事件同一条总线,网络状态、主题是 Kotlin 往总线发,业务事件是 JS 往总线发,消费方不区分来源。订阅登记到 Kotlin,只推给感兴趣的页面(每次 K5 = 一次线程切换 + 一个事务);订阅随引擎关闭消失

## 4. 投递语义

- **状态会留下,事件会错过**:store 是状态,页面回收重建时从 store 重读;事件是瞬时的,页面不在时发生的事件不补发。业务判断"错过了要不要紧",要紧就写进 store
- **顺序只在单页内保证**:每个引擎串行,同一页收到的 K5 严格有序;跨页无顺序承诺
- **同名页面多实例**:detail 跳 detail 是两个引擎,Kotlin 以页面实例 id 寻址而不是页面名;回传目标、订阅登记都挂在实例上

## 5. 返回栈回收

ADR-002 遗留的"栈深页引擎回收策略"归 Kotlin 侧路由器:栈深超过 N 关引擎,返回时用保存的 props 重新 `__mount`。推论是页面必须能从 props + store 重建,页内临时状态丢失由业务经 store 自救。N 等 roadmap B 组真机内存数据出来后定。

## 6. 对构建链的影响

每页一份字节码正好是路由键到产物的一一映射,`tinyui build` 输出页面清单(`manifest.json`),Kotlin 侧路由表以它为来源,不两边手写。见 [build-chain.md](./build-chain.md)。
Loading
Loading