把一个直接渲染到原生系统 UI 的 React 库搬到新的运行时, 95.6% 的代码原封不动, 整个 SwiftUI / Glance 渲染层字节相同地搬过去。
下面这些 iOS Live Activity、Dynamic Island 布局、桌面 Widget, 现在都由 Lynx JSX 渲染出来。因为 SwiftUI 渲染引擎字节相同, 输出和原版 React Native 版本逐像素一致。









Voltra 把 React JSX 直接转成 SwiftUI 和 Jetpack Compose Glance, 让你不写一行原生代码就能做出定制的 Live Activity、 Dynamic Island 布局和 Android Widget。
Callstack 孵化的开源库。JSX → JSON → SwiftUI / Glance。
~32,500 行代码,分成 7 个 package、一个 Expo config plugin、一组原生 Module。
字节跳动的跨端运行时。JS 跑在后台线程,UI 走原生。 不是 React Native。Native Module ABI 不一样、 布局原语不一样、DSL 不一样,但 Reactive 心智模型一致。
Live Activity、Dynamic Island、Widget、Glance,全部跑在 独立进程的系统 Extension 里。
它们只接收 SwiftUI / Compose Glance。 没有 WKWebView、没有 RN Bridge、没有 Lynx runtime。
真正的 UI 渲染必须在原生侧发生。 JS 框架的唯一职责是生产一个 JSON Payload, 原生代码读这个 Payload,再构造出 SwiftUI / Glance 视图。
// JS 端 <Voltra.VStack> <Voltra.Text>Order #42</Voltra.Text> </Voltra.VStack> ↓ renderLiveActivityToString() ↓ '{"type":"VStack","children":[…]}' ↓ // 跨过 bridge // 原生侧 (Swift / Kotlin) VoltraParser.parse(json) → SwiftUI.View
这是关键洞察。Bridge 很薄, 上下其他东西全是共享的。
@use-voltra/core · ios · android · server渲染器、Payload 压缩、JSX 组件。框架无关,只依赖 react。
live-activity/api · widgets · ongoing-notification · preloadHooks、API 函数。只调 VoltraModule 的方法。在 Lynx 包里原样 vendoring。
lynx-bridge/Promise⇄callback 包装 · GlobalEventEmitter 适配 · 平台判断。全新代码。
VoltraLynxModule.swift · .ktExpo Module DSL → Lynx @LynxMethod 的机械改写。函数体全部委托给原有 impl。
把 JSON 真正画成像素的 19,800 行原生代码,从上游 Voltra 一字节不差地复制。
架构原则 P3(写在 LYNX_PORT.md 里):
“Adapter Pattern 吸收所有差异”。
所有上层业务代码看到的接口一致,不管底下是 Expo 还是 Lynx。
只用 1,440 行新代码 (662 行 JS Bridge Adapter, 788 行 Swift + Kotlin 原生 Module Shim) 就把 32,500 行的原生 UI 库搬到了一个新运行时。
| Layer | 是什么 | 原版 (RN) | Lynx 端 | 复用 |
|---|---|---|---|---|
| L0 | 纯 JS: core / ios / android / server / server-impls | 5,734 | 0 (npm) | 100% |
| L0/1 | Voltra umbrella: 渲染器、JSX、Payload、样式 | 4,452 | 0 (npm) | 100% |
| L1 | 客户端业务逻辑: hooks / widget-api / live-activity | 1,616 | 1,241 (vendored) | ~95% |
| L2 | Bridge Adapter (全新) | 0 | 662 | NEW |
| L3 | 原生 Module 注册: Swift + Kotlin | 919 | 788 | REWRITE |
| L4 | SwiftUI + Glance 渲染引擎 | 19,776 | 19,783 (δ<0.1%) | 100% |
| 合计 | 32,497 | 1,440 NEW + 21,024 reused | 95.6% | |
六个文件,全部在 voltra-lynx/packages/lynx/src/bridge/。
每一处 Expo→Lynx 的 ABI 差异都在这里被吸收。
NativeModules.VoltraModule 包装成基于 Promise 的 VoltraIOSModuleSpec / VoltraAndroidModuleSpecPlatform.OSGlobalEventEmitter 翻成 RN 兼容的 EventSubscriptionNativeModules, GlobalEventEmitter)packages/ios-client/src/VoltraModule.ts (57 LoC)
import { requireNativeModule } from 'expo' const VoltraModule = requireNativeModule<VoltraIOSModuleSpec>('VoltraModule') // Promise 返回的方法由 Expo 自动生成。 // module.startLiveActivity(json, opts) → Promise<string>
voltra-lynx/.../lynx/src/ios-client/VoltraModule.ts (17 LoC)
import { createIOSModuleAdapter } from '../bridge/index.js' declare const NativeModules: { VoltraModule: Record<string, (...a: any[]) => any> } const VoltraModule: VoltraIOSModuleSpec = createIOSModuleAdapter(NativeModules.VoltraModule)
这个 Adapter 是一个 270 行的文件,把每个方法都包成
new Promise((resolve, reject) => raw.method(args, (r) => …))。
在它之上,每一行业务逻辑 (useLiveActivity, startLiveActivity,
updateWidget,全部 28+ 个函数) 都原样不动。
packages/voltra/ios/app/VoltraModule.swift
AsyncFunction("startLiveActivity") { (jsonString: String, options: StartVoltraOptions?) async throws -> String in return try await self.impl .startLiveActivity( jsonString: jsonString, options: options ) }
voltra-lynx/host/ios/.../VoltraLynxModule.swift
@objc func startLiveActivity( _ jsonString: NSString, options: NSDictionary?, callback: LynxCallbackBlock? ) { let opts = StartVoltraOptions(from: options) Task { do { let id = try await impl.startLiveActivity( jsonString: jsonString as String, options: opts ) callback?(id as NSString) } catch { callback?("ERROR:\(error.localizedDescription)" as NSString) } } }
纯粹的机械改写。
AsyncFunction("name") { … } 变成 @objc func name(_:callback:);
async throws → T 变成 Task { … callback?(…) } 包装。
而那个 330 行的 VoltraModuleImpl.swift,
干所有真正业务活的代码 (ActivityKit 调用、图片预加载、Widget Timeline),字节相同。
packages/voltra/android/.../VoltraModule.kt
AsyncFunction("updateAndroidWidget") { widgetId: String, jsonString: String, options: Map<String, Any?> -> widgetManager.writeWidgetData( widgetId, jsonString, options["deepLinkUrl"] as? String ) runBlocking { widgetManager.updateWidget(widgetId) } }
voltra-lynx/host/android/.../VoltraLynxModule.kt
@LynxMethod fun updateAndroidWidget( widgetId: String, jsonString: String, options: Map<String, Any?>?, callback: Callback ) { widgetManager.writeWidgetData( widgetId, jsonString, options?.get("deepLinkUrl") as? String ) scope.launch { widgetManager.updateWidget(widgetId) callback.invoke(null as Any?) } }
Android 端形状一样: AsyncFunction → @LynxMethod fun … callback: Callback,
runBlocking → scope.launch { … ; callback.invoke(…) }。
每一个 VoltraWidgetManager, VoltraNotificationManager,
Glance renderer, parser,9,509 行全部字节相同。
pluginReactLynx 在 build 时把 react → @lynx-js/react 做了 alias。
意思是 Voltra 的 JSX 组件 (VStack, Text, Symbol,
Image) 在 Lynx 里直接能用。
Voltra 的组件只用 createElement 和 hooks,
从来不用 ReactDOM、也不调任何平台特定 API。
renderLiveActivityToString() 走一遍 React element tree
序列化成 JSON。这棵 tree 的形状不在乎到底是谁的 createElement 生产的。
Layer 0 走 npm 100% 复用。 不 fork、不 wrap、不 shim。
// 在 Lynx .tsx 文件里: import { Voltra } from '@use-voltra/ios' <Voltra.VStack style={{ padding: '16px' }}> <Voltra.Text>Order #42</Voltra.Text> </Voltra.VStack> // → 遍历、序列化、原生解析 → SwiftUI ✨
任何只用 createElement 和 hooks (不碰 DOM) 的 React 库,
都能靠这个 alias 在 Lynx 里跑。
同样的把戏让 @tanstack/react-query 也能在 Lynx 上用。
Lynx 同时支持完整 CSS Flexbox 和 Android-LinearLayout 风格的
display: linear。<view> 默认是 linear,
所以子 <scroll-view> 上的 flex: 1 会塌成 0 高度,
除非你在父节点上设 display: 'flex'。
一次显式声明,剩下 RN 那套 style 就能 1:1 搬过来。
| 场景 | RN / Web | Lynx (推荐) | Linear 兜底 |
|---|---|---|---|
| 填满剩余空间 | flex: 1 |
flex: 1 (父节点 display:'flex') |
linearWeight: 1 |
| 横向排布 | display: 'flex', flexDirection: 'row' |
同 | display: 'linear', linearDirection: 'row' |
| 滚动方向 | scroll-y |
scroll-orientation="vertical" |
同 |
| 圆角 | borderRadius: 12 |
borderRadius: '12px' |
数字会被静默忽略 |
| 行高 | lineHeight: 18 |
lineHeight: '18px' 或删掉 |
数字会被当成 18× font-size |
| padding 缩写 | paddingHorizontal: 16 |
paddingLeft: 16, paddingRight: 16 |
缩写不生效 |
| 点击事件 | onPress / onClick |
bindtap |
handler 不触发 |
这些都写进了 LYNX_PORT.md §Lynx CSS Gotchas。
下一个 agent 写布局前会先读一遍。
最初 (US-050 / US-051) 被标成受阻的 stretch goal,
因为 Widget 的 App 内预览看起来需要等价于 Lynx 的
requireNativeView 才能做。结果发现更简单的
Custom Element 机制就够用。
// Lynx 侧 <voltra-preview width="170" height="170" payload={renderWidgetToString(jsx)} />
LynxUIRegister.register( "voltra-preview", VoltraPreviewHostView.self )
把 Voltra SwiftUI 渲染到 Lynx app 内部, 不在桌面、不在锁屏。让 Testing Grounds 的页面直接看到 SwiftUI 输出, 而不是 JSON dump。
iOS 上包了一个 UIHostingController、Android 上是 ComposeView,
订阅 Lynx 传来的 width 变化,Payload 变了就重新渲染 SwiftUI。
9 个 testing-ground 页面被改造 App 内看到真实 SwiftUI
US-001 到 US-061。pnpm monorepo · rspeedy 壳 · Layer 0 引入验证
module-adapter · event-adapter · platform · types,共 662 LoC
iOS / Android client 包,业务逻辑原样复制
Swift + Kotlin 机械改写,方法体委托到现有 impl
xcodegen · CocoaPods · Widget Extension · 端到端 Live Activity 跑通
10 个 Live Activity · 5 个 Widget · 14 个 testing screen
flex 和 linear 共存 · scroll-orientation · borderRadius px · lineHeight
第二轮 · 跟 RN Expo 样例做到逐像素对齐
<voltra-preview> · <voltra-widget-preview> · 改造 9 个页面
统一收成单个 @use-voltra/lynx · Pods 移出 git
27,395 行代码、64 次 commit、~61 个 story。
一个开发者 + Claude Code + 一套结构化 agent + 几个起约束作用的 markdown 文件。
Harness 本身就是产品。
整个项目拆成了 4 份 PRD。每份 PRD 列出
US-001 … US-NN 一组 user story,每个都有明确的验收标准。
每个 story 对应正好一个 commit,commit message 里带上它的编号。
一个 user story 是 对 agent 友好的工作单元: 能塞进一个 context window,有明确的验收标准让 agent 自检。
commit 后缀里的 (US-XXX) 变成永久审计线索:
git log --oneline | grep US-053 就能找到
Positioning Screen 的改造。
context drift 成本降到零,每个 story 都从 PRD 这条 ground truth 重新出发。
$ git log --oneline | grep 'US-0' | wc -l
61
架构原则写成 P1..P5 放在 LYNX_PORT.md 里。
每个 agent 动代码前先读。
机械翻译表让 agent 不用再从头推导。
| Expo 写法 | Lynx 对应 |
| AsyncFunction(...)
→ @LynxMethod fun(... callback: Callback)
| requireNativeModule<T>
→ createModuleAdapter<T>(NativeModules…)
| sendEvent(name)
→ lynxContext.sendGlobalEvent("voltra:" + name)
| Platform.OS → __PLATFORM__ build constant
一个新 agent 拿到一个新 story 时,它不需要再去发现
bridge 是怎么工作的。它读三样东西: CLAUDE.md、
LYNX_PORT.md、对应的 PRD 段落,然后产出符合架构的代码。
个人 CLAUDE.md 还另外记录了过去踩过的坑:
说不清解决什么问题的改动,就不该做。 “如果你说不出一个具体能复现的 bug,就别动。 ‘更 robust’ 不是理由。”
非平凡改动会先经过一份成文 spec 和一份成文实现计划, 两份都是各自独立的 commit。然后才是实现的 commit。
| Commit | 主题 | 类型 |
|---|---|---|
14e732d | docs: spec for voltra-lynx API surface & project cleanup | SPEC |
4141225 | docs: implementation plan for API surface cleanup | PLAN |
9798e71 | refactor: create @use-voltra/lynx package with bridge subpath | EXEC |
791b239 | refactor: move ios-client into @use-voltra/lynx package | EXEC |
c9b04d4 | refactor: move android-client into @use-voltra/lynx package | EXEC |
cce1ecf | refactor: remove old packages, dead files, and Ralph artifacts | EXEC |
brainstorming → design-doc → plan 这条 pipeline (来自
superpowers:brainstorming 和 writing-plans 两个 skill)
强迫 agent 在动手前先想清楚。等真正开始 implement 的时候,
决策都已经拍板了,agent 的工作变成机械执行。
项目后半段的 Android port 完全交给了 Ralph Loop: 一个自主 agent,它读 PRD、 挑下一个没做的 story、做掉、跑验证、commit,然后接着下一个。
.claude/ralph-loop.local.md
--- active: true iteration: 6 max_iterations: 0 # 不设上限 started_at: "2026-04-26T13:19:56Z" --- 按照 tasks/prd-voltra-lynx-android.md 实现 Voltra Lynx 的 Android port。JS bridge 层已经完成。 用 Sparkling 搭 Android host app,实现 VoltraLynxModule.kt,验证全部 5 个 Android demo 端到端能跑。严格遵守 LYNX_PORT.md 里的 架构原则。
架构上的接缝在 iOS 端已经验证过。Android 是同一个问题, 只是换了一套词汇,正好是 可预测、可并行 的工作,这是 loop 风格 agent 最擅长的。
模式: 平台 A 用手做一遍把 harness 立起来,让 loop 帮你搬到平台 B。
互相独立的调研工作丢给后台 sub-agent。 主线程保持 cache 热,sub-agent 用各自的新 context 去对窄问题做穿透。
Anthropic 的 prompt cache TTL 是 5 分钟。 主线程睡超过 300 秒就意味着下一次要冷读全部 context,又慢又贵。
Sub-agent 不共享这套 cache,所以分发它们等于免费, 而且主线程在它们工作的时候也能继续走。
经验法则: 2 个及以上互相独立的任务 (没共享状态、没顺序依赖) 就该并行跑。
// 一个 message · 3 个 tool call <Agent description="loc audit" /> <Agent description="narrative" /> <Bash command="find ..." />
领域知识被捕获成 agent 能读的引用,跨 session 不丢失。 每次开新对话不用再解释一遍项目。
Agent 在相关触发条件下加载 SKILL.md,需要时再深入读对应的子文档。
tribal knowledge → 写到文件里 → 可复用。
按项目的 memory 目录,条目有类型:
user、feedback、project、reference。
当一个 session 学到了某个持久结论 (比如 “iOS host 用的是 CocoaPods Lynx 3.7.0,不是内部模板”) 它就写一条 memory。下个 session 启动时读到。
结果是: 第四周的 session 直接继承了第一周的突破, 不用重新付一次代价。
memory/ ├── MEMORY.md # 索引 ├── lynx-host-setup.md # project 类型 ├── css-gotchas.md # reference 类型 └── react-alias-pattern.md # reference 类型
每一次走错路都变成一条成文原则。个人 CLAUDE.md 按 bug 类别
长出一节又一节,下一个 agent 就不会再踩。
如果你说不出一个具体能复现的 bug,就别动。 “更 robust”、“更标准”都不是理由。
来源: vue-lynx 迁移里一次“更干净”的 path-resolve 重构, 引入了一个新的 pnpm symlink bug。
问自己: 这个模式依赖什么前提?我的环境满足那个前提吗?
来源: lynx-stack vs vue-lynx, 同一个模式但架构不一样,照搬过来不能用。
先 git stash。如果错误在父 commit 上能复现,那是已有的。
否则: 是你引入的。
来源: 一个构建错误,被怪在上游头上, 其实就是当前正在验证的改动自己引入的。
pnpm build 绿 + pnpm test 绿 ≠ 可发布。 要把 example 跑通、npm pack 出来、把 app 真的打开。
来源: 一次迁移单测都过了, 但 example app 在 runtime 挂掉。
下次再做“把一个库搬到新运行时”这种事时,可以复用的模板。
源码里哪些是框架相关的、哪些是框架无关的?把分层模型画出来。框架绑定的那块越小,移植就越便宜。
别 fork 上游代码。Vendor 进来,写一个 bridge module,把所有 ABI 差异塞到一个地方处理: Promise⇄callback、EventEmitter、平台判断,全在一个文件。
写出 P1..PN 原则。加翻译表 (Expo→Lynx、RN→Lynx CSS 等等)。每个 agent 在写代码前都读一遍。复利效应。
每个 story 有明确的验收标准、能塞进一个 context window、对应正好一个 commit。(US-NNN) 后缀成为永久审计线索。
非平凡改动会先有一个 docs: spec for X commit,再一个 docs: implementation plan for X commit,然后才有 refactor 的 commit。强迫先思考再敲键盘。
Snapshot test 抓的是类型系统抓不到的东西。npm pack 出来,打开 example,把按钮挨个点一遍。编译过 ≠ 能发。
iOS 用手做、Android 交给 Ralph Loop。Agent 一旦摸到模式,并行平台就变成排队消化的事。
React alias、flex vs linear 在 scroll-view 上的规则、LynxModule 的 Swift 协议细节,全都写进 LYNX_PORT.md。下个 agent: 白捡。
库还是那个库。运行时只是又一个 adapter。
Harness 是放大器。
voltra · voltra-lynx · LYNX_PORT.md · CLAUDE.md · ralph-loop · skills/voltra · superpowers