翻页  ·  空格 下一张  ·  f 全屏
01 / 00
EN中文
Hackathon · Slopfork 复盘 · 2026

Slopfork Voltra
React Native → Lynx

把一个直接渲染到原生系统 UI 的 React 库搬到新的运行时, 95.6% 的代码原封不动, 整个 SwiftUI / Glance 渲染层字节相同地搬过去。

32,497
原版代码量
1,440
新增代码 (Bridge + Shim)
64
Commits
~61
User Stories
5天
实际耗时
成品 · 端到端验证

同样的像素,
不同的运行时。

下面这些 iOS Live Activity、Dynamic Island 布局、桌面 Widget, 现在都由 Lynx JSX 渲染出来。因为 SwiftUI 渲染引擎字节相同, 输出和原版 React Native 版本逐像素一致

Basic Live Activity
Flight Tracker
Music Player
Workout
Compass
Liquid Glass
☀ 晴
🌧 雨
⛈ 雷暴
❄ 雪
Z-Index 叠加
背景 · 为什么这件事有意思

Voltra 是什么?

Voltra 把 React JSX 直接转成 SwiftUIJetpack Compose Glance, 让你不写一行原生代码就能做出定制的 Live Activity、 Dynamic Island 布局和 Android Widget。

原版

Voltra · React Native (Expo)

Callstack 孵化的开源库。JSX → JSON → SwiftUI / Glance。
~32,500 行代码,分成 7 个 package、一个 Expo config plugin、一组原生 Module。

@use-voltra/core /ios /android /server
expo-plugin ios-client android-client

移植目标

LynxJS

字节跳动的跨端运行时。JS 跑在后台线程,UI 走原生。 不是 React Native。Native Module ABI 不一样、 布局原语不一样、DSL 不一样,但 Reactive 心智模型一致。

@lynx-js/react LynxModule GlobalEventEmitter
flex + linear 双布局 Callback-based
硬核问题

Live Activity 没法在 JS 视图树里渲染

硬约束

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 很薄, 上下其他东西全是共享的。

洞察

五层模型告诉你哪些是免费搬过来的。

Layer 0
纯 JS 包: @use-voltra/core · ios · android · server

渲染器、Payload 压缩、JSX 组件。框架无关,只依赖 react

100% 走 npm
Layer 1
业务逻辑: live-activity/api · widgets · ongoing-notification · preload

Hooks、API 函数。只调 VoltraModule 的方法。在 Lynx 包里原样 vendoring。

~95% 原样 vendored
Layer 2
Bridge Adapter: lynx-bridge/

Promise⇄callback 包装 · GlobalEventEmitter 适配 · 平台判断。全新代码。

+ 662 LoC
Layer 3
原生 Module 注册: VoltraLynxModule.swift · .kt

Expo Module DSL → Lynx @LynxMethod 的机械改写。函数体全部委托给原有 impl。

+ 788 LoC
Layer 4
原生渲染: SwiftUI · Glance · Payload 解析 · 图片预加载

把 JSON 真正画成像素的 19,800 行原生代码,从上游 Voltra 一字节不差地复制。

100% 共享

架构原则 P3(写在 LYNX_PORT.md 里): “Adapter Pattern 吸收所有差异”。 所有上层业务代码看到的接口一致,不管底下是 Expo 还是 Lynx。

头条数字
95.6%

的代码是从 React Native 版本 直接复用 的。

只用 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,7340 (npm) 100%
L0/1 Voltra umbrella: 渲染器、JSX、Payload、样式 4,4520 (npm) 100%
L1 客户端业务逻辑: hooks / widget-api / live-activity 1,6161,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%
Layer 2 · 接缝

整个框架差异成本: 662 行

六个文件,全部在 voltra-lynx/packages/lynx/src/bridge/。 每一处 Expo→Lynx 的 ABI 差异都在这里被吸收。

module-adapter.ts
270 LoC
把 Lynx 回调风格的 NativeModules.VoltraModule 包装成基于 Promise 的 VoltraIOSModuleSpec / VoltraAndroidModuleSpec
types.ts
250 LoC
重新声明 Module Spec 接口,Adapter 和上层共用一套类型
platform.ts
53 LoC
运行时平台检测,替代 RN 的 Platform.OS
event-adapter.ts
46 LoC
把 Lynx GlobalEventEmitter 翻成 RN 兼容的 EventSubscription
index.ts
33 LoC
Barrel export
env.d.ts
10 LoC
Lynx 全局对象的 ambient 声明 (NativeModules, GlobalEventEmitter)
Before / After · TypeScript

Module 加载方式。

Before · Expo

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>
After · Lynx

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+ 个函数) 都原样不动

Before / After · Swift

方法声明。

Before · Expo Module DSL

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
    )
}
After · LynxModule 协议

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),字节相同

Before / After · Kotlin

方法声明。

Before · Expo Module DSL

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)
  }
}
After · @LynxMethod

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, runBlockingscope.launch { … ; callback.invoke(…) }。 每一个 VoltraWidgetManager, VoltraNotificationManager, Glance renderer, parser,9,509 行全部字节相同。

💡
关键突破 · 01 / 03

The React Alias

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 上用。

📐
关键突破 · 02 / 03

Lynx 布局: flex 能用,但默认是 linear。

Lynx 同时支持完整 CSS Flexbox 和 Android-LinearLayout 风格的 display: linear<view> 默认是 linear, 所以子 <scroll-view> 上的 flex: 1 会塌成 0 高度, 除非你在父节点上设 display: 'flex'。 一次显式声明,剩下 RN 那套 style 就能 1:1 搬过来。

场景RN / WebLynx (推荐)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 写布局前会先读一遍。

🧩
关键突破 · 03 / 03

Custom Element > Native View。

最初 (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

整个过程 · 64 个 commit 分成 10 个阶段

US-001US-061

Phase 01
项目脚手架

pnpm monorepo · rspeedy 壳 · Layer 0 引入验证

Phase 02
Bridge Adapter

module-adapter · event-adapter · platform · types,共 662 LoC

Phase 03
Client Vendoring

iOS / Android client 包,业务逻辑原样复制

Phase 04
Native Module

Swift + Kotlin 机械改写,方法体委托到现有 impl

Phase 05
iOS Host App

xcodegen · CocoaPods · Widget Extension · 端到端 Live Activity 跑通

Phase 06
Demo 移植

10 个 Live Activity · 5 个 Widget · 14 个 testing screen

Phase 07
CSS 现实检查

flex 和 linear 共存 · scroll-orientation · borderRadius px · lineHeight

Phase 08
高保真重写

第二轮 · 跟 RN Expo 样例做到逐像素对齐

Phase 09
Custom Element 预览

<voltra-preview> · <voltra-widget-preview> · 改造 9 个页面

Phase 10
API 表面清理

统一收成单个 @use-voltra/lynx · Pods 移出 git

243 文件变更 +27,395 / −200 ~61 个 user story 每个阶段 1 份 PRD ≈ 1 story / 1 commit
Part Two

Harness Engineering
怎么真把它做出来的

27,395 行代码、64 次 commit、~61 个 story。 一个开发者 + Claude Code + 一套结构化 agent + 几个起约束作用的 markdown 文件。

Harness 本身就是产品。

技巧 · 01

PRD → User Story → 一个 commit 一个 story。

整个项目拆成了 4 份 PRD。每份 PRD 列出 US-001 … US-NN 一组 user story,每个都有明确的验收标准。 每个 story 对应正好一个 commit,commit message 里带上它的编号。

4 份 PRD

prd-voltra-lynx-port.md
51 stories
原始移植: bridge + demo + Testing Grounds
prd-high-fidelity-rewrite.md
16 stories
CSS gotcha 暴露之后的对齐轮次
prd-lynx-voltra-preview-screens.md
12 stories
<voltra-preview> Custom Element 改造
prd-voltra-lynx-android.md
28+ stories
Android host + Kotlin module + Glance widget

为什么这么干

一个 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
技巧 · 02

CLAUDE.md 当作架构护栏。

架构原则写成 P1..P5 放在 LYNX_PORT.md 里。 每个 agent 动代码前先读。 机械翻译表让 agent 不用再从头推导。

这 5 条原则

P1  不要改 Layer 0 (纯 JS 包)
P2  Vendor 业务逻辑,只重写 bridge 文件
P3  Adapter Pattern 吸收所有差异
P4  原生代码做机械翻译
P5  往下走之前先端到端验证

+ 翻译表

| 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.mdLYNX_PORT.md、对应的 PRD 段落,然后产出符合架构的代码。

个人 CLAUDE.md 还另外记录了过去踩过的坑:

原则 1 · 从过去的坑里来

说不清解决什么问题的改动,就不该做。 “如果你说不出一个具体能复现的 bug,就别动。 ‘更 robust’ 不是理由。”

技巧 · 03

Spec → Plan → Implement。

非平凡改动会先经过一份成文 spec 和一份成文实现计划, 两份都是各自独立的 commit。然后是实现的 commit。

Commit主题类型
14e732ddocs: spec for voltra-lynx API surface & project cleanupSPEC
4141225docs: implementation plan for API surface cleanupPLAN
9798e71refactor: create @use-voltra/lynx package with bridge subpathEXEC
791b239refactor: move ios-client into @use-voltra/lynx packageEXEC
c9b04d4refactor: move android-client into @use-voltra/lynx packageEXEC
cce1ecfrefactor: remove old packages, dead files, and Ralph artifactsEXEC

brainstorming → design-doc → plan 这条 pipeline (来自 superpowers:brainstormingwriting-plans 两个 skill) 强迫 agent 在动手前先想清楚。等真正开始 implement 的时候, 决策都已经拍板了,agent 的工作变成机械执行。

技巧 · 04

Ralph Loop · 自主 story-by-story 执行。

项目后半段的 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 里的
架构原则。

Ralph 完成了什么

+ Android host app 脚手架
+ VoltraLynxModule.kt, 558 LoC, 28 个方法
+ 5 个 Android demo 端到端
+ Sparkling 生成的 gradle 配置

为什么这里能用

架构上的接缝在 iOS 端已经验证过。Android 是同一个问题, 只是换了一套词汇,正好是 可预测、可并行 的工作,这是 loop 风格 agent 最擅长的。

模式: 平台 A 用手做一遍把 harness 立起来,让 loop 帮你搬到平台 B。

技巧 · 05

并行 sub-agent 分发。

互相独立的调研工作丢给后台 sub-agent。 主线程保持 cache 热,sub-agent 用各自的新 context 去对窄问题做穿透。

这份 deck 自己用过的 agent

Agent #1
研究
算每层的 LoC 和复用率 (并行)
Agent #2
叙事
从 git log + JSONL session 抽阶段叙事 (并行)
Agent #3
复现
试着启动 iOS sim 给 LynxVoltra 截图 (后台)

经济性

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 ..." />
技巧 · 06

项目内的 skills 和跨 session 的 memory

领域知识被捕获成 agent 能读的引用,跨 session 不丢失。 每次开新对话不用再解释一遍项目。

skills/voltra/ · 17 份参考文档

SKILL.md component-mapping ios-widgets ios-live-activities android-widgets widget-families charts variant-shapes plugin-schema runtime-api-checklist push-flow ios-server-updates server-driven-widgets images setup app-config source-of-truth

Agent 在相关触发条件下加载 SKILL.md,需要时再深入读对应的子文档。 tribal knowledge → 写到文件里 → 可复用。

~/.claude/projects/.../memory/

按项目的 memory 目录,条目有类型: userfeedbackprojectreference

当一个 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 类型
技巧 · 07

把踩过的坑写下来。

每一次走错路都变成一条成文原则。个人 CLAUDE.md 按 bug 类别 长出一节又一节,下一个 agent 就不会再踩。

原则 1

说不清解决什么问题的改动,就不该做

如果你说不出一个具体能复现的 bug,就别动。 “更 robust”、“更标准”都不是理由。

来源: vue-lynx 迁移里一次“更干净”的 path-resolve 重构, 引入了一个新的 pnpm symlink bug。

原则 2

不要机械照搬上游的模式

问自己: 这个模式依赖什么前提?我的环境满足那个前提吗?

来源: lynx-stack vs vue-lynx, 同一个模式但架构不一样,照搬过来不能用。

原则 3

不要凭感觉判断“这是已有问题”

git stash。如果错误在父 commit 上能复现,那是已有的。 否则: 是你引入的。

来源: 一个构建错误,被怪在上游头上, 其实就是当前正在验证的改动自己引入的。

原则 4

编译过 ≠ 能用。要走一遍用户路径。

pnpm build 绿 + pnpm test 绿 ≠ 可发布。 要把 example 跑通、npm pack 出来、把 app 真的打开。

来源: 一次迁移单测都过了, 但 example app 在 runtime 挂掉。

给下一次 Slopfork

Slopfork 配方

下次再做“把一个库搬到新运行时”这种事时,可以复用的模板。

01
找到架构上的接缝。

源码里哪些是框架相关的、哪些是框架无关的?把分层模型画出来。框架绑定的那块越小,移植就越便宜。

02
把目标框架当成又一个 adapter。

别 fork 上游代码。Vendor 进来,写一个 bridge module,把所有 ABI 差异塞到一个地方处理: Promise⇄callback、EventEmitter、平台判断,全在一个文件。

03
把架构写进 CLAUDE.md。

写出 P1..PN 原则。加翻译表 (Expo→Lynx、RN→Lynx CSS 等等)。每个 agent 在写代码前都读一遍。复利效应。

04
拆成 PRD → user story。

每个 story 有明确的验收标准、能塞进一个 context window、对应正好一个 commit。(US-NNN) 后缀成为永久审计线索。

05
先 spec,再 plan,最后写代码。

非平凡改动会先有一个 docs: spec for X commit,再一个 docs: implementation plan for X commit,然后才有 refactor 的 commit。强迫先思考再敲键盘。

06
验逐像素一致,而不是只验编译过。

Snapshot test 抓的是类型系统抓不到的东西。npm pack 出来,打开 example,把按钮挨个点一遍。编译过 ≠ 能发。

07
接缝跑通了,就把循环交给 loop。

iOS 用手做、Android 交给 Ralph Loop。Agent 一旦摸到模式,并行平台就变成排队消化的事。

08
把每一次突破升级成成文原则。

React alias、flex vs linear 在 scroll-view 上的规则、LynxModule 的 Swift 协议细节,全都写进 LYNX_PORT.md。下个 agent: 白捡。

Fin · 感谢观看

Voltra 跑在 Lynx 上
因为几乎什么都不用改。

库还是那个库。运行时只是又一个 adapter。
Harness 是放大器。

95.6%
复用
662
Bridge LoC
788
原生 Shim LoC
7
Harness 技巧

voltra · voltra-lynx · LYNX_PORT.md · CLAUDE.md · ralph-loop · skills/voltra · superpowers