Skip to content

Latest commit

 

History

History
123 lines (94 loc) · 14.7 KB

File metadata and controls

123 lines (94 loc) · 14.7 KB

JS 桥接 UI 框架 · 设计文档

基于 QuickJS(经 quickjs-kmp 接入)与 Compose Multiplatform 的 UI 框架:业务页面用 JS 写声明式组件,Android / iOS 双端由 Compose 渲染。热下发见 ADR-006。本目录是各项关键技术选型的决策记录(ADR),一项决策一个文件;本文只放各决策共享的背景,不重复写进单项 ADR。

框架定位

  • 逻辑和 UI 描述都在 JS:JS 侧维护组件树与状态,产出节点树 / patch;Kotlin 侧只负责把 patch 映射到 Compose 原生组件(Text / Column / LazyColumn …)
  • 业务感知是声明式组件:写法接近 React / Solid 的组件函数 + JSX,语言目标 ES2025,构建期只做 TS 擦除与 JSX → h(),预编译成 ES 模块字节码内置在 App 里
  • 渲染不自己画:直接映射到 Compose 组件,双端一致性由 CMP 保证

引擎约束(所有选型的前提)

引擎为 QuickJS(bellard/quickjs,版本 2026-06-04,ES2025),经 quickjs-kmp 接入。2026-09-15 由 MicroQuickJS 切换而来(ADR-005);ADR-001~004 中提到的 ES5 / 无 Proxy / 固定堆等约束均为切换前的历史背景,已在各处标注。

约束 对设计的直接影响
ES2025,无 JIT,纯解释 业务 TS 零降级;性能量级见 ADR-005 §3.1(单次更新 + flush 约 3 µs,1000 行挂载约 15 ms)
有微任务队列,但没有事件循环;JS 的每次执行都是宿主调进来的 宿主调用返回前排空微任务再 flush,"一次 K 入口 = 一个事务"的边界不变
malloc 分配 + 引用计数 + 环检测 GC;JS_SetMemoryLimit 限额;一个 Runtime 基线约 4~8 MB(含 1000 行页面) 每页一 Runtime,返回栈深页要有回收策略
原生 ESM,loader 回调 运行时与页面都是模块字节码,引擎侧只解析预注册的裸说明符
没有 Intl、DOM、fetch 格式化、网络、存储走 tinyui-native
值跨界只走原始类型 / 字符串 / JSON,Kotlin 永不持有 JSValue(句柄表) JS 侧节点就是整数 id;树 / patch 以 JSON 字符串过桥

总体运行模型

JS                                   桥                          Kotlin
组件函数只跑一次,建出节点表          __host.apply(json)   →       NodeTree.apply:patch 写入
状态变化 → 只重算受影响的绑定                                     SnapshotStateMap / List
→ 攒 patch,本次宿主调用返回前 flush   ←  __dispatch(id, event)     Compose 重组做最小刷新
  • JS 不做整树 diff,Kotlin 不做任何比对:两边都只处理"真正变了的那几个属性"(详见 ADR-001)
  • 每次 K 入口结束前先排空微任务(Promise 回调在同一次宿主调用内跑完),再 flush
  • 结构变化只发生在 For(keyed reconcile,限于单个父节点的子列表)和 Show 两个原语里
  • 文本输入、滚动位置等高频交互状态默认留在 Kotlin 侧自治,JS 只收 onChange 通知,需要改时发命令(x op)而非回写 prop——避免受控组件跨桥往返造成的输入卡顿(详见 ADR-004)

决策索引

编号 主题 状态 结论
ADR-001 状态管理与更新模型 已定(2026-09-14,所有权 / 清理 2026-09-15 补定) 运行时 Signal(细粒度绑定),不做 vdom diff,不走编译期静态依赖;作用域只开在结构边界,同步渲染期外创建 effect 抛错
ADR-002 JS 与 Kotlin 通信机制(含序列化) 已定(2026-09-15) 双向入口 5 + 5;JS 专用线程、K 入口不等待;一次 K 入口一个事务;每页一个引擎;错误七类一个 sink;JSON 文本载荷
ADR-003 Kotlin 侧节点表与组件注册 已定(2026-09-15) 节点即重组单元;JS 线程直接写快照状态、主线程只重组;App 级注册表 + 清单下发;prop / event schema 写入时转换
ADR-004 事件与输入状态归属 已定(2026-09-15) 事件三分(离散 / 流式输入 / 连续),60 fps 状态留 Kotlin;新增 x 命令 op(ref + cmd);文本框 initial prop + 命令 + 事件,不受控回写
ADR-005 JS 引擎选型 已定(2026-09-15) MicroQuickJS → QuickJS(ES2025,经 quickjs-kmp);原生 ESM 必选;不降级 ES5;热下发不在本期(2026-09-18 起见 ADR-006)
ADR-006 热下发 已定(2026-09-18,2026-09-19 补服务端与签名、改多包模型,2026-09-24 运行时随宿主) App 由 N≥1 个包组成,包名进模块名与 URL,各包独立发布回退;整包原子、下次启动生效;运行时随宿主、包只含页面,包的 tinyui 与宿主同 major 且不高于宿主即可跑;宿主 hostVersion(宿主对页面承诺的版本,不是 tinyui 版本)为兼容键;内置包是地板,下发页失败即回退并拉黑;tinyui-updates 独立 artifact 不做网络 / 调度 / UI;投递两次 GET、发布 PUT;manifest 签名 ECDSA P-256,密钥按包、公钥随包进 manifest;服务端 tinyui-updates-server 独立开源仓、CF 为主可私有化,托管 updates.tinyui.app;契约见 updates.md,实施计划见 updates-plan.md
ADR-007 框架职责边界 已定(2026-09-25) 判据"改它要不要宿主发版":能随包变的都进包;框架自己实现网络、存储、包内 i18n、toast / 对话框、打开链接、Icon / Loading、埋点门面与会话;宿主只提供标准接口的实现(网络通道、语言、会话、埋点出口、链接打开器),host.call 与宿主组件兜底;页面按请求选网络通道;删 HostServices;core 库放开零 I/O

命名

框架名 TinyUI(2026-09-15 定,接续 2021 年同名项目的方向)。三种拼写各有固定位置,不随手混用:

拼写 用在哪 理由
TinyUI 品牌与行文:文档标题、README 首行、日志前缀、对外提及 大小写体现 Tiny + UI 两个词
tinyui 所有机器标识符:GitHub 仓 tiny-ui/tinyui、Maven app.tinyui:tinyui、Kotlin 包 app.tinyui、CLI 命令、环境变量前缀 TINYUI_、目录名 无连字符是唯一在每种标识符里都合法的形态;避开 npm 上他人的 tiny-ui React 库
tiny-ui 仅 GitHub 组织名 github.com/tiny-ui 2021 年同名项目建的组织沿用(2026-09-20 仓库转入),组织名不再改;仓名仍是 tinyui
app.tinyui Maven group 与 Kotlin 根包(2026-09-20 起,此前 wang.harlon) 反转域名 tinyui.app,Central 命名空间按域名验证;先例 app.cash.*
tinyui-core / tinyui-native / tinyui-cli 仅 npm 包名(也是引擎里的运行时模块名) 无 scope;tinyui- 前缀粘连、功能词用连字符接

npm 裸包 tinyui 不可用:npm 防仿冒规则判定其与已有的 tiny-ui 过于相似(2026-09-15 实测 E403);scope @tiny-ui 是他人 2017 年起的用户 scope(@tiny-ui/components),@tinyui 是他人的空 org,2026-09-18 定为无 scope 的 tinyui-*。tiny-ui 本身是活跃维护的 React 组件库(2026-03 发 1.0,GitHub 233 star),转让与争议均无望,裸名不再追。同日在 npm 建了免费 org tinyui-app(与域名对齐,仅占位防蹭,暂不发包;账号 whlong 为 owner)。域名 tinyui.app 已于 2026-09-15 在 Cloudflare Registrar(个人账号)注册,自动续费 $14.20/年,DNS 在 Cloudflare。

业界对照(2026-09-16 核对 24 个带 UI 的库,仓名 / 包名取自 GitHub API 与各包管理器,品牌拼写取官网 <title>):机器标识符粘连 13 个(headlessui、fluentui、onsenui、gioui、heroui、daisyui、tamagui、nicegui、imgui、egui、baseui、primeng、SwiftUI)、连字符 8 个(material-ui、chakra-ui、radix-ui、semantic-ui、jquery-ui、kendo-ui、element-plus、ant-design)、其余为 nuxt/ui 这类单词形。连字符一派集中在老项目,近几年新库几乎都粘连。"品牌分词 Xxx UI、标识符粘连 xxxui"与本项目完全相同的组合有 Headless UI、Fluent UI、Onsen UI、Gio UI 四例;品牌与标识符脱钩更是常态(Material UI ↔ @mui,Base Web ↔ baseweb 仓 ↔ baseui 包),所以 npm scope 单独带连字符不算异类。

仓库结构

单仓 tiny-ui/tinyui(2026-09-15 定):两侧契约(patch 协议、schema → TS 类型)在一处,一次 PR 同时改两侧,示例 App 直接联调;代价是一个仓里同时有 Gradle 与 pnpm 两套工具链。

tinyui/
├── docs/               README、ADR、roadmap
├── packages/           所有 npm 包,目录名 = 包名去掉 scope(pnpm workspace)
│   ├── core/           tinyui-core    JS 运行时(signal / effect / owner / h / For / Show / ref + cmd)+ 内置组件的 TS 类型(由 schema/ 生成,无运行时代码);作为 ES 模块字节码内置
│   ├── native/         tinyui-native  业务调用的宿主能力 API(http / storage / toast / navigation / i18n …,即 ADR-002 的 J2 / J3)
│   └── cli/            tinyui-cli     构建工具:TSX → h()(ES2025)→ 每页一个 ESM 模块字节码,`tinyui build`;`tinyui bundle` 产签名后的热下发目录,`tinyui keys generate` 产签名密钥对
├── compose/            app.tinyui:tinyui  KMP 库(Compose Multiplatform 侧):节点表、注册表、桥、内置组件;依赖 quickjs-kmp
├── updates/            app.tinyui:tinyui-updates  KMP 库:热下发的校验、落盘、选择、回退(ADR-006 §2.4),依赖 compose/
├── schema/             内置组件 schema 的唯一真值(TS DSL)→ `pnpm schema` 生成 packages/core 的类型与 compose/ 的注册 schema
├── sample/             示例 App,M1 Counter / M2 列表页在这里跑
│   ├── js/             包 `sample` 的页面源码(pnpm workspace 成员),shared 的 Gradle 任务调 CLI 编成字节码打进 Compose 资源
│   ├── js-extra/       包 `sample-extra`:第二个包,演示多包热下发(docs/updates.md §0)
│   ├── shared/         KMP 模块:App() 与 iOS 入口,出静态 framework(AGP 9 不允许 application 与 KMP 插件同模块)
│   ├── androidApp/     Android 壳(纯 com.android.application)
│   └── iosApp/         Xcode 壳
├── bench/              响应式模型与引擎 benchmark(引擎现场编译进 .engine*/,见 bench/README.md)
└── build-logic/        Gradle convention plugins

命名依据:

  • packages/:npm 生态惯例,pnpm -r / changesets 零配置;目录名与包名一一对应
  • core:主包,业务 import from "tinyui-core";内置组件类型并入而不单独成包(只有 .d.ts,不值得多一个依赖;宿主 App 自己扩展的组件类型由它自己的 schema 生成到它自己的包里,不经过本仓)
  • native:回答业务的问题"怎么调原生";与 core 互补——core 是 JS 世界里的东西,native 是伸到 JS 外面的手。否掉 host(与运行时视角的"宿主"一词混)、platform(泛)、capabilities(长)
  • compose/:直说它是 Compose Multiplatform 那一侧,将来若有第二渲染端可并列扩展;否掉 host/(运行时视角的词不适合做目录)、library/(多生态仓里"library of what"不清)、container/(国内直觉好但海外联想 Docker)、kmp/(说构建方式不说职责)
  • schema/:两侧共同真值,独立顶层目录以体现"契约在中间"
  • sample/:Gradle 工程按 Android / KMP 惯例叫;bench/、build-logic/ 沿用现有

本地联调 quickjs-kmp:local.properties 写 quickjs-kmp.dir=<仓路径> 即 composite build 从源码构建,坐标映射由该仓 gradle/composite-substitutions 声明;CI 无 local.properties,解析 Maven 版本(catalog quickjsKmp)。

约定:顶层只放这八个目录;新 npm 包进 packages/;Kotlin 侧只有独立发 artifact 的库才占顶层目录(updates/,ADR-006 §2.4),其他 Kotlin 模块进 compose/ 作为子模块,不在顶层增生。ADR 文本中的"宿主"仍指 Kotlin 侧这一运行时角色,与目录名 compose/ 不冲突。

实现期文档

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

文件 主题 状态
build-chain.md 构建链:TSX → ESM 模块字节码,esbuild 选型,字节码由 CLI 经 qjsc-kmp 生成;§7 错误上报与 source map 回映射 已完成(2026-09-16;§7 2026-09-17)
app-model.md 应用模型:路由、生命周期、跨页状态在 Kotlin;页面之间经 Kotlin 中转的四种通信 已对齐(2026-09-16),API 面归 C 组
js-runtime.html JS 层总览:七样运行时对象、四个时期、响应式闭环、一个页面的一生、规则表。先读这份(浏览器打开) 已定(2026-09-16)
runtime-api.md tinyui-core v1 的 API 定义、页面模块契约、桥入口 已定(2026-09-16)
jsx-transform.md CLI 的 JSX 变换:thunk 包裹规则、编译期报错、source map 已定(2026-09-16)
patch-protocol.md patch 协议 v1:六种 op、id、值、顺序保证、版本、E5 已定(2026-09-16)
components.md 内置组件:schema 真值与生成链、公共布局 prop 与 Modifier 顺序、首批八个组件、命令消费、Placeholder 已定(2026-09-16)
native-api.md tinyui-native 与 TinyUIHost:J2 白名单、navigation / store / events / http / storage / i18n / session / ui / linking / analytics、host.call 兜底、E3 错误码与阈值 已定(2026-09-16;2026-09-25 按 ADR-007 重写)

下一阶段

四份 ADR 遗留项的汇总、分组与建议顺序见 roadmap.md,状态在那里更新。

ADR 写法

每份固定五段:背景(要解决什么、受哪些约束,引用本文不重复)→ 候选方案(机制、代价、前提,一张对比表)→ 依据(推理 + 实测数据、复现路径)→ 决策(选什么、为什么;事先判据与实际偏离要写明)→ 后果与遗留(接受的代价、待验证项、派生出的下游决策)。