首页
/ codegraph 跨语言桥接设计:打通混合 iOS(Swift/ObjC)与 React Native JS↔Native 的完整调用流

codegraph 跨语言桥接设计:打通混合 iOS(Swift/ObjC)与 React Native JS↔Native 的完整调用流

2026-09-06 17:34:53作者:凤尚柏Louis

本文基于 codegraph 仓库中的桥接覆盖设计文档 mixed-ios-and-react-native-bridging.md,系统讲解该项目如何为「跨语言运行时分发边界」建立确定性的命名解析规则:Swift ↔ Objective-C 的自动桥接选择器映射、React Native 旧桥(RCT_EXPORT_METHOD / @ReactMethod)、TurboModules、Expo Modules 与 Fabric 视图组件。读完后,你将理解 codegraph 如何用「resolver(解析器)」与「synthesizer(合成器)」两种机制,让 trace / callers / callees / impact / explore 流程在语言边界上端到端连通,并能对照仓库源码验证每一条设计决策的实际落地。

1. 背景与问题:静态索引在哪里断流

codegraph 是一个预索引的代码知识图谱工具:它先把源码解析为节点(函数、方法、类、属性等)与边(调用、引用),再由 Agent 通过 MCP 工具查询结构/流程问题。在纯 Objective-C 支持落地之后(#165),codegraph 对 Swift、Objective-C、JavaScript/TypeScript 各自单独索引都是正确的,但价值恰恰在跨语言流程——也就是 iOS 应用与 React Native 应用实际所在的地方。设计文档指出了三类典型断流:

  • 混合 iOS 应用MyViewController.swift 调用 imageDownloader.download(url:completion:),其实现是 ImageDownloader.m 中的 -[ImageDownloader downloadURL:completion:]。此时 Swift 调用点被 tree-sitter 解析为一个选择器「无处可去」的 call_expression,ObjC 方法存在但没有任何入边。Agent 只能把两个文件都读出来手工重建桥接。
  • React Native 应用App.jsuseEffect(() => NativeModules.Geolocation.getCurrentPosition(cb)) 应当到达 RNCGeolocation.m 中的 RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb)。断流表现为:JS 调用点没有指向 ObjC 实现的出边,ObjC 处理器没有来自 JS 的入边,impact(getCurrentPosition)(ObjC 侧)看不到任何 JS 调用者。
  • Expo Module:JS 的 await ExpoCamera.takePictureAsync(options) 应当到达 ExpoCamera.swift 中的 AsyncFunction("takePictureAsync") { ... }(Expo Modules API)。同样是断流。

这三类的共同点是:边界两侧都存在一个名字——Swift 自动桥接产生的 ObjC 选择器、RCT_EXPORT_METHOD 的字面量第一个参数、Expo Function("name") 的字面量字符串——任何命名匹配器都能关联它们。因此修复方式是一个知道每条通道桥接规则的 resolver,为调用点发出引用边(在实现中记为 framework-resolved 的 references 边,并带有 metadata.synthesizedBy 之类的溯源标记)。

这正是该文档在 动态分发覆盖手册 §6 矩阵中的位置:「Swift × Objective-C bridging」一行和新增的「React Native bridge」一行。两者都属于 resolver 模式——两侧都存在命名字面量,桥接规则是确定性的——而不是 synthesizer 模式(可以对照手册 §3a 的 Django ORM resolver 参考实现)。手册中那条「承重的警告」在这里比平时更严格:

部分覆盖比没有覆盖更糟。 只桥接了一个边界而没桥接下一个,会暴露一个跳点,Agent 转而深入 + 读文件来完成它。必须把流程端到端闭合后再重新测量——永远不要发布一个半桥接的流程。

具体到混合 iOS,意味着两个方向(Swift→ObjC 与 ObjC→Swift)和所有被桥接的种类(方法、属性、init/初始化器、协议)都必须闭合后才能测量。对 React Native 而言,JS→native 与 native→JS(RCTEventEmittersendEvent)都必须闭合,且在旧桥与 TurboModules 上都要闭合,否则混用两者的应用会半桥接。

2. 需要建模的 11 条分发通道

设计文档把跨语言机制拆成 11 行分发通道(dispatch channel)——每一行是 playbook 词汇中独立的一条通道,各自有自己的 resolver(或 synthesizer,若不存在静态 ref)、自己的验证、自己在 §6 矩阵中的一行:

# 方向 通道 映射规则 实现位置 难度
1 Swift → ObjC 直接调用,ObjC 类经 -Bridging-Header.h 导入 Swift 调用 obj.x(y:z:) ↔ ObjC 选择器 -x:z:(字面映射,见 §3) frameworks/swift-objc.ts 中的 resolver
2 ObjC → Swift @objc 暴露 Swift @objc func foo(bar:) ↔ ObjC -fooWithBar:(自动命名);@objc(custom:) 可覆盖 frameworks/swift-objc.ts
3 Swift ↔ ObjC 属性/getter/setter 桥接 Swift var name: String ↔ ObjC -name / -setName: frameworks/swift-objc.ts
4 Swift ↔ ObjC 初始化器桥接 Swift init(name:age:) ↔ ObjC -initWithName:age: frameworks/swift-objc.ts
5 Swift ↔ ObjC 协议桥接(@objc protocol 跨语言的 conformance 边 frameworks/swift-objc.ts
6 JS → ObjC(RN 旧桥) NativeModules.<Mod>.<fn>RCT_EXPORT_METHOD(<fn>:...)RCT_REMAP_METHOD(<jsName>, <selector>:...) 以 ObjC 侧 RCT_EXPORT_MODULE() 字面量为键做名字匹配 frameworks/react-native.ts
7 JS → Java/Kotlin(RN 旧桥,Android) NativeModules.<Mod>.<fn>ReactContextBaseJavaModule 子类上带 @ReactMethod 注解、且 getName() 返回 <Mod> 的方法 同 #6 的形状,JVM 侧
8 JS ↔ native(RN TurboModules / Codegen) TurboModuleRegistry.get('Mod') ↔ 生成的 spec 接口(NativeMod TS 类型)↔ 匹配 spec 的 ObjC++/Kotlin 实现 以 spec 文件为 ground truth 的 resolver
9 Native → JS(事件) ObjC [self sendEventWithName:@"x" body:b](继承 RCTEventEmitter)↔ JS new NativeEventEmitter(NativeModules.Mod).addListener('x', cb) EventEmitter 风格 synthesizer(与既有语言内 callback-synthesizer.ts 同族)
10 JS → native(Expo modules) JS ExpoX.fn(args) ↔ Swift Function("fn") { ... }AsyncFunction("fn") { ... }(位于带 Name("ExpoX")Module 子类内) frameworks/expo-modules.ts 中的 resolver
11 JS → native(Fabric 视图组件) JS <MyView prop={v}/> ↔ ObjC/Swift RCT_EXPORT_VIEW_PROPERTY(prop, ...) 或 Codegen 视图 spec resolver + JSX 跳点(与既有 JSX synthesizer 组合) 难(延期)

「难度」列驱动了分阶段实施——见 §12。

2a. 为什么这些是 resolver 而不是 synthesizer

每一行中,桥接规则都可以从一个名字确定性地推导出来

  • Swift 的 @objc 暴露是有文档的自动映射;@objc(custom:) 是显式覆盖;两者都是可静态提取的。
  • RCT_EXPORT_METHOD 接受字面量选择器;RCT_EXPORT_MODULE() 接受可选的字面量模块名(默认:类名去掉 RCT 前缀);NativeModules.Mod.fn 是对已知全局的字面量属性访问。
  • Expo Modules 的 Function("name") { ... }Module { Name("ExpoX"); ... }Module 定义内的字面量字符串。
  • TurboModules 的 spec 接口是带 TurboModuleRegistry.get<...>('<Name>') 的字面量 Native<Name> 导出。

因此工作是:提取桥接侧的名字 → 让 resolver 去匹配它们,形状与 djangoResolver 解析 _iterable_classModelIterable 相同——不需要全图关联遍历。

唯一例外是 #9 native→JS 事件:其注册站点看起来与既有 callback synthesizer 已处理的语言内 EventEmitter 模式几乎一样,扩展该 synthesizer 加入跨语言通道是最自然的落法(§8 有实测)。

2b. 源码印证:四个桥接组件均已注册进框架解析器

仓库中上述通道对应的实现都已存在,并统一注册在框架解析器索引 src/resolution/frameworks/index.ts 中(swiftObjcBridgeResolverreactNativeBridgeResolverexpoModulesResolverfabricViewResolver 依次注册),各自配有测试:tests/swift-objc-bridge.test.tstests/react-native-bridge.test.tstests/expo-modules.test.tstests/fabric-view.test.tstests/rn-event-channel.test.ts

3. Phase 1:Swift ↔ ObjC 的命名换算规则

3a. Swift → ObjC 选择器自动映射(参考表)

Swift 用一套标准规则从 Swift 方法推导 ObjC 选择器:

Swift 声明 ObjC 选择器
func greet() greet
func say(_ msg: String) say:
func set(name: String) setWithName:
func setName(_ name: String) setName:
func move(to point: CGPoint) moveTo:
func move(from a: CGPoint, to b: CGPoint) moveFrom:to:
init(name: String) initWithName:
init(name: String, age: Int) initWithName:age:
var name: String(getter) name
var name: String(setter) setName:
@objc(customSel:) func f(...) customSel:(显式覆盖)

完整的规则集来自 Apple 官方文档「Importing Swift into Objective-C」中的 "method name translation" 与 "initializer name translation" 两节。设计决策是:resolver 在提取阶段单向实现这套映射(Swift 声明产出桥接后的 ObjC 名,作为别名挂在 Swift 方法节点上),ObjC 侧的名字解析通过常规 name-matching 就能找到 Swift 方法。

仓库中这套「纯名字换算」被单独抽到一个无图/无 DB 依赖的纯函数模块 src/resolution/swift-objc-bridge.ts,这正是设计文档 §11 开放问题 3 的落地——把映射规则放在一处,供 Swift 提取器(提取时计算别名)与测试共同导入:

  • objcSelectorForSwiftMethod(baseName, externalLabels, explicitObjcName):实现上表的方法规则。核心逻辑是:无参数返回基名;单参数 _ 标签产出 baseName:;单参数显式标签 L 产出 baseNameWithL:;多参数则首关键词按同样规则成形后,每个后续标签各自成为关键词。@objc(custom:) 的字面量直接短路规则。
  • objcSelectorForSwiftInit(externalLabels, internalNames, ...):实现初始化器规则,注意 Apple 的特殊约定——即使外部标签是 _,也总是 initWith,且此时使用参数的内部名作为关键词(init(_ name: String)initWithName:)。
  • objcAccessorsForSwiftProperty(swiftName, ...):属性 getter/setter 换算,var name: String → getter name / setter setName:
  • swiftBaseNamesForObjcSelector(selector)反向换算,从 ObjC 选择器产出候选 Swift 基名。例如 playWithSong:['playWithSong', 'play'](因为 func play(song:) 和字面叫 playWithSong 的方法都可能是它);initWithName:age:['init']setName:['name', 'setName'](可能是属性 setter 也可能是普通函数)。它还会剥离 With/For/By/In/On/At/From/To/Of/As 等前置词(objectForKey:object)以兼容 Cocoa 导入侧的命名习惯。返回多个候选是因为裸基名本质上是二义的,resolver 会逐一尝试。
  • detectExplicitObjcName(sourceSlice) / isObjcExposed(sourceSlice):在声明前的小窗口源码上检测 @objc(custom:) 覆盖与 @objc 暴露(@nonobjc 优先判否,覆盖 @objcMembers 类级默认)。

3b. 两个方向的解析实现(src/resolution/frameworks/swift-objc.ts)

框架 resolver src/resolution/frameworks/swift-objc.ts 把上述纯函数接进解析流水线,两个方向实现不同:

方向 1:Swift 调用 → ObjC 方法。 Swift 调用点 obj.foo(bar:) 到 resolver 时只有裸名 foo(tree-sitter-swift 把参数标签留在参数列表里,不带进 callee 名)。解析策略是维护一个反向桥接映射:遍历图中所有 ObjC 方法节点,用 swiftBaseNamesForObjcSelector 算出每个方法自动桥接到的 Swift 基名,把节点登记在每个候选名下(buildObjcMap,按 ResolutionContext 身份用 WeakMap 惰性记忆化,索引重建时自然失效)。resolver 收到未解析的 Swift 裸名后直接查表。由于该 resolver 在精确匹配之后运行,能到达这里的候选就是合法的跨语言命中。

方向 2:ObjC 调用 → Swift 方法。 ObjC 调用点 [swiftThing fooWithBar:42] 携带选择器 fooWithBar:——Swift 节点名字里没有冒号,常规匹配必然失败。resolver 从选择器派生候选 Swift 基名,查同名 Swift 方法,再读声明前 3 行源码窗口(SOURCE_PROBE_LINES)用 isObjcExposed 验证该声明确实 @objc 暴露,滤掉「恰好同名但并未桥接」的 Swift 函数。

两条方向都返回 resolvedBy: 'framework'、置信度 0.6 的解析结果(文档表述为「不精确但由桥接规则确定性地推出」,与 Django ORM 动态分发先例一致)。另外 claimsReference(name) 对所有含 : 的选择器形名字放行:没有 Swift 节点名带冒号,若不做这个 opt-in,这些 ref 会在 name-exists 预过滤阶段就被丢弃。

精度护栏:通用名黑名单。 源码中的 GENERIC_NAMES 集合(initdescriptionhashisEqualcopycountvaluedatastringobjectaddremoveloadsavereloadcancelstartstopcloseopenshowhidedeallocreleaseretainautorelease 等)正是 §8d 记录的设计决策:每个 NSObject 子类都实现这些方法,把它们桥接到项目内任意同名 ObjC 方法只会产生噪音;这些名字的 ref 几乎总能被常规 name-matcher 解析(每个项目里都有大量 init 节点),跳过它们只是让桥接不抢答。

detect() 的启用条件也很克制:只有当项目中同时存在 .swift.m/.mm 文件时该 resolver 才生效——单边项目不需要桥接,空反向映射也只是空转。

4. Phase 2:React Native 旧桥——RCT_EXPORT_METHOD@ReactMethod

4a. 命名规则(文档 §3b)

// Native side (ObjC)
@implementation RCTGeolocation
RCT_EXPORT_MODULE();                                    // module name: "Geolocation" (RCT prefix stripped)
RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb) { ... }
@end
// JS side
import { NativeModules } from 'react-native';
NativeModules.Geolocation.getCurrentPosition(cb);       // resolves to the ObjC method above

规则四条:

  1. 原生侧对每个含 RCT_EXPORT_MODULE() 的类提取一个合成 module 节点。名字 = 显式字符串参数(若有),否则类名去掉 RCT 前缀。
  2. 每个 RCT_EXPORT_METHOD(<sel>)RCT_REMAP_METHOD(<jsName>, <sel>) 成为挂在模块节点上的方法节点,JS 可见名分别为 <sel> 的首关键词与 <jsName>
  3. JS 侧解析器把字面量属性链 NativeModules.<Mod>.<fn> 与原生侧 (module, jsName) 对匹配。
  4. 从 JS 调用点向原生方法发出 references 边(provenance: 'heuristic'synthesizedBy: 'rn-bridge')。

4b. 源码实现(src/resolution/frameworks/react-native.ts)

src/resolution/frameworks/react-native.ts 完整实现了该规则,且有一个文档未展开的工程细节:RCT_EXPORT_METHOD 宏在 tree-sitter ObjC 语法中解析为宏表达式(ERROR 节点)而不是 method_definition,ObjC 提取器根本不会为它建节点——iOS 侧的 native module 对图是不可见的。因此 resolver 通过 extract() 直接对 .m/.mm 文件做正则扫描:

  • parseObjcRNExports:匹配 RCT_EXPORT_MODULE(可选显式名)、RCT_EXPORT_METHOD(首个标识符即 JS 名)、RCT_REMAP_METHOD(第一个参数为 JS 名);findObjcClassName@implementation 提取类名作为无参模块名的回退(defaultObjcModuleName 实现去 RCT 前缀:RCTGeolocationGeolocation)。
  • parseJvmRNExports:Android 侧同形状——从 getName() 的字面量返回值取模块名(回退为类名去 Module 后缀),从 @ReactMethod 注解后的 fun <name>((Kotlin)/ void <name>((Java)取 JS 可见名。
  • 所有导出汇入一张 (jsName) → 原生方法节点 的懒建映射(buildRNMaps,按 context 记忆化)。resolve() 只重定向 JS 系语言(js/ts/tsx/jsx)的调用点:剥掉一个点前缀得到 JS 方法名后查表,且在 iOS 与 Android 目标并存时优先 ObjC(RN 库文档的惯用一等平台),返回 resolvedBy: 'framework'、置信度 0.6 的单目标解析。

detect() 用任一信号即启用:根 package.json 依赖 react-native,或前 200 个文件里出现 RCT_EXPORT_MODULE / TurboModuleRegistry.get*< 标记——因为不少库把 JS 包与原生代码分目录,不要求两侧同时出现。

精度护栏:emitter 内建方法黑名单。 RN_EMITTER_BUILTINSaddListenerremoveListenersremoveinvalidatestartObservingstopObserving)是每个 RCTEventEmitter 子类都经 RCT_EXPORT_METHOD 声明的继承内建。JS 代码并不直接调用它们——它们经由 JS 抽象 NativeEventEmitter 走事件通道。若留在桥接映射里,每个 JS addListener / remove 调用(Firestore 订阅、RxJS 管道、甚至数组的 remove)都会被错误桥接到恰好声明了它们的 emitter。这条黑名单正是 §8b 中 react-native-firebase 从 78 条边(含 60 条假阳性)收敛到 18 条全部精确边的关键措施。

5. Phase 5:TurboModules——以 TS spec 文件为 ground truth

5a. 命名规则(文档 §3c)

// Spec (TS) — codegen ground truth
export interface Spec extends TurboModule {
  getCurrentPosition(cb: (loc: Location) => void): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('Geolocation');
// ObjC++ impl
@implementation RCTGeolocation
- (void)getCurrentPosition:(RCTResponseSenderBlock)cb { ... }
@end
import Geolocation from './NativeGeolocation';
Geolocation.getCurrentPosition(cb);  // resolves to the ObjC method via the spec

规则:spec 文件是唯一事实源——解析 TurboModuleRegistry.get*<Spec>('<Name>') 得到模块名,读取 Spec 接口的方法列表;每个 spec 方法与同名原生实现匹配(ObjC 按选择器首关键词;类名由命名约定或 JSI_EXPORT_MODULE 宏确定);JS 对 spec 文件的 import 经由 spec 完成命名解析;发出的边与旧桥相同,仅 synthesizedBy 标记为 'rn-turbomodule'

5b. 源码实现

parseTurboModuleSpecreact-native.ts)用两条正则完成:TurboModuleRegistry.(getEnforcing|get)\s*<...>\s*\(\s*'Name' 提取模块名,export interface Spec ... { ... } 体内以「标识符后跟 (」的形状挑出方法名(属性无括号,自动跳过)。匹配时不要求 ObjC 侧模块名一致——spec 的模块名并不决定原生文件路径(Codegen 靠命名约定接线),所以在全部同名原生方法(ObjC 首关键词 + JVM 裸名)中建立映射。

一个实际观察值得注意:真实 JS 代码中最常见的形态不是 NativeModules.<Mod>.fn(),而是 import Geo from './NativeGeolocation'; Geo.getPosition()——接收者是 default export 而非字面的 NativeModules。因此映射实际以 JS 方法名为键(byJsName),模块限定符只是附带信息,这也解释了为什么 react-native-svg 的 9 条 TurboModule 解析全部精确命中 isPointInStrokegetTotalLengthgetPointAtLength 等方法名。

6. Phase 4:Expo Modules——DSL 字面量变图谱节点

6a. 命名规则(文档 §3d)

// Native (Swift, expo-modules-core API)
public class ExpoCameraModule: Module {
  public func definition() -> ModuleDefinition {
    Name("ExpoCamera")
    AsyncFunction("takePictureAsync") { (options: CameraOptions) in /* ... */ }
    View(ExpoCameraView.self) {
      Prop("type") { (view: ExpoCameraView, type: String) in /* ... */ }
    }
  }
}
import { requireNativeModule } from 'expo-modules-core';
const ExpoCamera = requireNativeModule('ExpoCamera');
await ExpoCamera.takePictureAsync({ quality: 1 });

规则:继承 Moduledefinition()(或新 API 的 init { /* DSL */ })含 Name("X") 的类定义模块;每个 Function("y") / AsyncFunction("y") 字面量定义一个方法,尾随闭包是方法体——提取为挂在模块 X 下、名为 y 的方法节点。JS 侧 requireNativeModule('X') 产生绑定,对其属性访问解析到命名方法。Prop("name")(视图模块)行为类似 RN 的 RCT_EXPORT_VIEW_PROPERTY,与视图组件前线一起延期。

6b. 源码实现(src/resolution/frameworks/expo-modules.ts)

expo-modules.ts 的巧妙之处在于不需要自定义 resolve()extract() 对每个 .swift/.kt 文件扫描 Expo Module 声明(EXPO_DECL_RE 匹配 Function / AsyncFunction / Property / Constants 后的字面量名,Name("X") 字面量优先、类名回退作为模块名),为每个字面量发出一个 method 节点。之后 JS 调用点 Foo.takePictureAsync(...)标准 name-matcher 经由既有的 obj.method → 方法名路径解析到这些合成节点,resolve() 直接返回 null 就是正确行为。

两个实现细节:其一,EXPO_DECL_RE 中可选的 <…> 泛型段是事后修复——Kotlin 的 AsyncFunction<Float>("getBatteryLevelAsync") 形式若不被匹配,所有 Android 侧 Expo Module 方法都会被静默丢弃,JS 调用点只能解析到 iOS Swift 实现;其二,detect() 要求 class X: Module 继承与至少一条声明字面量同时出现,单独任一信号假阳性太多(isExpoModuleSource)。

实测(文档 §8f):expo-haptics 14 个文件提取出 6 个方法节点;expo-camera 72 个文件提取出 41 个(takePictureAsyncrecordresumePreview 及视图侧 width/height 属性等)。一个有意思的精度案例:包内 JS 侧调用点被 TS wrapper 遮蔽(CameraView.tsx 上定义了 pausePreview()),name-matcher 正确地优先了本地 TS 方法——而外部消费应用调用 Camera.takePictureAsync() 时则直接落到原生方法。五个测试覆盖提取器与端到端 fixture(JS callsite of literal AsyncFunction("uniqueExpoHapticCall") resolves to the native impl node)。

7. Phase 3:Native → JS 跨语言事件通道

这是 11 条通道中唯一的 synthesizer 形态,扩展了既有 callback synthesizer。实现是 src/resolution/callback-synthesizer.ts 中的 rnEventEdges(约 L1393 起),形状与语言内的 eventEmitterEdges 相同但跨语言:

Native (ObjC, on RCTEventEmitter subclass):
  [self sendEventWithName:@"locationUpdate" body:@{...}];
Native (Java/Kotlin):
  emitter.emit("locationUpdate", body);
JS (subscriber):
  new NativeEventEmitter(NativeModules.Geo).addListener("locationUpdate", handler);

字面量事件名把 native 分发站点关联到 JS handler。扫描模式覆盖各平台变体:ObjC 的 sendEventWithName:@"X"、Swift 的 sendEvent(withName: "X", body: ...)RNFusedLocation.swift 这类 Swift 侧 RCTEventEmitter 子类同样被捕获)、JVM 的 .emit("X", …) 与常见的手写包装器 sendEvent(ctx, "X", body)(字面量在包装器调用里而非定义里)。JS 订阅侧限定在 JS 系文件——避免把 ObjC 方法 addListener: 误认为 JS 订阅。

两条精度策略与文档一致:

  • 扇出上限EVENT_FANOUT_CAP = 6callback-synthesizer.ts),分发器或 handler 超过该数量的通用事件名(无接收者类型信息时无法精确匹配)直接跳过而不是过度连边。
  • subscribe-wrapper 模式:RN 库里常见 messaging().onMessage(listener)listener 是向上流入用户代码的参数)。当 handler 参数不是命名符号时,把订阅归属到包裹它的 JS 函数(抽象层),得到可达性正确的跳点;对对象字面量 API 形状(const Foo = { watchX(...) { ... } })再回退到最小包裹 constant/variable 节点。

每条边为 provenance: 'heuristic'metadata.synthesizedBy: 'rn-event-channel'。RNFirebase 实测(§8e):messaging_message_received 通道 2 条边(application:didReceiveRemoteNotification:... → TS onMessage),messaging_notification_opened 1 条边。

8. Phase 6:Fabric / Codegen 视图组件

这是难度最高、最后落地的一条通道,采用提取器 + 合成器两段式设计(文档 §8g):

第一段:框架提取器 src/resolution/frameworks/fabric.ts

  • Codegen spec 侧(.ts/.tsx):extractFabricNodes 解析 codegenNativeComponent<Props>('Name', ...) 声明,每处声明发出一个 component 节点(以 JS 可见组件名命名,恰好与 JSX synthesizer 的 name+kind 过滤门吻合);再解析 NativeProps 接口体,每个 prop 字段发出一个 property 节点——让 onTapnativeContainerBackgroundColor 这类 JSX 可调用 prop 成为可发现的图节点。
  • 实现侧的约定匹配由合成器 fabricNativeImplEdgescallback-synthesizer.ts 中)完成:遍历每个 Fabric component 节点,查找名字匹配且带 RN 约定后缀(空 / View / ViewManager / ComponentView / Manager)的原生类,发出 calls 边(synthesizedBy: 'fabric-native-impl')。这个约定在规范 RN 库里足够精确、不会撞名。
  • 提取器还额外覆盖了文档未规划的旧 Paper ViewManagerRCT_EXPORT_VIEW_PROPERTY / RCT_CUSTOM_VIEW_PROPERTY / RCT_REMAP_VIEW_PROPERTY 宏,类名去 Manager/ViewManager 后缀推导组件名)与 JVM 侧 @ReactProp("name") 注解,使旧桥视图库与新架构走同一 component → native-class 连线。

组合既有 reactJsxChildEdges JSX synthesizer 后,完整流程闭合:消费应用的 JSX <MyView prop=v/> → Fabric component 节点 MyView → 原生类 MyViewView(或 MyViewManager / MyViewComponentView 等)。

react-native-screens 复验数据(Phase 2 时因全 Fabric 而 0 桥接的语料库):54 个 codegenNativeComponent 声明、27 个 component 节点(*.web.ts 变体被 spec 有效性过滤)、272 个 prop 节点、68 条 fabric-native-impl 桥接边。四个测试覆盖提取器与完整端到端 fixture(App (TSX) → MyView (fabric-component) → MyViewView (ObjC class)),断言 JSX→component 边与 component→native-class 边都存在。

9. 边模型:每条通道需要哪些边

对每条通道,闭合后的流程由三段边构成:

  • JS 调用点 → 被桥接方法节点references,heuristic,synthesizedBy: '<channel>'
  • 被桥接方法节点 → 原生实现方法(已提取;对 #6/#7 被桥接方法就是原生实现,对 #10 闭包体就是实现)
  • 原生实现方法 → 它自己的被调(语言内已提取)

对 Swift ↔ ObjC,最干净的模型是声明节点上的别名:扩展 Swift 方法提取,计算自动桥接的 ObjC 名并作为备选名存储,让 resolver 考虑。Swift 与 ObjC 方法节点之间不需要新边——提取后两侧对被桥接选择器达成一致,常规名字解析足够。

MCP 读工具已经内联展示 heuristic 边(metadata.synthesizedBy 的管线),这些新边走同一条展示路径,无需额外布线。

10. 验证语料:小/中/大三档方法论

文档遵循仓库 CLAUDE.md 的验证方法论:small / medium / large 各仓库 ≥3 个流程 prompt,确定性探针 + Agent A/B,每臂 ≥2 次运行。候选语料与标准流程如下(仓库名此处按纯文本给出,避免外部链接)。

10a. 混合 iOS(Swift+ObjC)——选 3

档位 仓库 理由 标准流程
Small Charts(约 150 文件 Swift+ObjC) Swift 优先库带 ObjC 兼容层,知名 「给 ChartView 设置 data 如何到达渲染器?」
Small (备选) Lottie-ios(约 300 文件,曾混合;当前可能纯 Swift——需验证) 动画引擎,知名混合体 AnimationView.play() 如何到达层合成器?」
Medium Realm-Cocoa(约 500 文件) 重度 Swift-on-ObjC:Swift API 包 ObjC 核,ObjC 核包 C++ Realm Core Realm.write { realm.add(obj) } 如何到达 ObjC 持久层?」
Large Wikipedia-iOS(约 2500 Swift+ObjC 文件) 真实应用,深度混合,活跃开发 「点击搜索结果如何到达文章抓取的网络调用?」
Large (备选) WordPress-iOS 更重的 ObjC 遗留 + Swift 增量 「新草稿保存如何到达 Core Data 持久化?」

每仓库标准:(1) 纯语言探针仍通过(Swift 内 trace、ObjC 内 trace)——相对 #165 纯 ObjC 基线无回归;(2) 跨语言探针通过:标准流程用 trace 端到端可追,语言边界无断裂;(3) Agent A/B(有/无 codegraph,每臂 ≥2 次):explore 预算内 Read = 0;比无 codegraph 快;纯 Swift 或纯 ObjC 对照仓库无回归;(4) 节点数不爆炸:桥接前后 select count(*) from nodes 对比。

10b. React Native——选 3

档位 仓库 理由 标准流程
Small react-native-svg(约 100 文件 JS+ObjC+Java) 小而范围清晰的 native 模块集 「设置 <Path d=.../> 如何到达 iOS Core Graphics 调用?」
Medium react-native-screens(约 300 文件 JS+native) 真实导航原语,旧桥与 Fabric 都有 「导航到新屏幕如何到达 UINavigationController?」
Medium (备选) react-native-firebase(跨包约 1000 文件) 大量 native 模块、双平台——压测模块发现 firestore().collection('x').get() 如何到达 iOS Firebase SDK 调用?」
Large facebook/react-native 的 RNTester 子集(约 3000 文件) 框架本体 + 示例应用;标准桥接用法 「按下 RNTester GeolocationExample 的按钮如何到达 iOS Core Location 调用?」

每仓库标准:(1) 纯 JS 探针不变(useState → re-render 流程仍可解析——既有 react synthesizer 无回归);(2) JS → ObjC 桥探针通过:每仓库 ≥1 个已知 RCT_EXPORT_METHOD;(3) JS → TurboModule 探针通过(在用了 TurboModules 的仓库上;react-native 主线两者都有,各选一);(4) Native → JS 事件探针通过(≥1 个 emitter,NativeEventEmitter 模式);(5) Agent A/B:一个跨越桥的问题(如「按下按钮 X 如何到达网络调用」)在 ≥1 次有 codegraph 的运行中把 Read 降到 0;(6) 纯 JS 对照仓库无回归(既有 react-realworld / excalidraw 测量值不变)。

10c. Expo——选 2(范围更小,API 面更窄)

Small/Medium 档取 expo/expo 的单模块(如 expo-camera 或 expo-location)——最干净的 Expo Modules API 实例;Large 档取完整 expo/expo monorepo,压测跨多包的模块名解析。标准流程:「await Camera.takePictureAsync()(JS)如何到达原生相机 API 调用(Swift AVCaptureSession 或 Kotlin CameraDevice)?」

11. 实测数据与两条关键教训

11a. Phase 1 测量——Swift ↔ ObjC

仓库 源文件 桥接边(framework-resolved) 示例边
Charts(小) 269(205 Swift + 59 ObjC/.h) 28 objc→swift,1 swift→objc handleOption:forChartView:animatesetupPieChartView:setExtraOffsetssetDataCount:range:setColor
realm-swift(中) 369(151 Swift + 218 ObjC 系) 36 objc→swift,1185 swift→objc valueForUndefinedKey:getsetValue:forUndefinedKey:setpromote:on:initialize
wikipedia-ios(大) 1734(1234 Swift + 500 ObjC/.h) 52 objc→swift,983 swift→objc 真实 iOS 应用跨多 feature 模块的桥接

三者均:语言内基线不变、节点数不爆炸、trace 跨边界连通标准流程(在 Charts 上验证:trace(handleOption:forChartView:, animate) 直接浮现桥接边)。注意 swift→objc 方向边数远多于反向——与源码中「Swift 裸名查反向映射」的不对称实现一致。

11b. Phase 2 + 5(部分)测量——React Native 桥

仓库 源文件 桥接边 备注
react-native-svg(小/中) 约 700(93 .mm + 115 .java + 6 .kt + 49 js + 92 ts + 154 tsx) 9 条 tsx→java(经 TurboModule spec) RNSvg 的 iOS 用 TurboModule 自动生成(无 RCT_EXPORT_METHOD),解析落在 Java 侧;9 条全精确
AsyncStorage(小,纯旧桥) 约 60(28 kt + 2 mm + 16 ts + 14 tsx …) 8/8 精确 标准旧桥测试——Kotlin @ReactMethod + ObjC RCT_EXPORT_METHOD;JS setItem → Kotlin legacy_multiSet
react-native-firebase(大) 约 1100 RCTEventEmitter 黑名单后 18 条(此前 78 条) 初始 78 条含 60 条指向 addListener: / remove: 的假阳性;黑名单砍到 18 条且全部精确
react-native-screens(中) 1211 0——空 TurboModule spec、无 RCT_EXPORT_METHOD、全 Fabric/Codegen 视图侧 RNScreens 完全落在 Phase 6(Fabric)。桥接在此拒绝过度匹配正是正确行为

11c. 验证中发现的架构性修复(§8c)

resolver 的 initialize() 原本在 CodeGraph 构造时运行——早于任何文件被索引——因此凡 detect() 需要查询已索引文件列表的框架 resolver(UIKit/SwiftUI 的 import 扫描、swift-objc-bridge 的双语言文件探测、react-native-bridge 的 RN 标记探测)在首轮全部返回 false 并静默自我剔除。这影响了代码库中每一个读 context.getAllFiles() / context.readFile() 而非直接扫文件系统的框架 resolver——是一个既有潜伏 bug,并非桥接特有。修复:indexAll() 现在在提取完成后调用 resolver.initialize(),让 detect() 对着已填充的索引运行。

11d. 桥接精度黑名单(经验教训汇总,§8d)

屏蔽名 原因
swift-objc initdescriptionhashisEqualcopycountvaluedatastringobjectaddremoveupdateloadsavereloadcancelstartstoppauseresumecloseopenshowhidedeallocreleaseretainautorelease 每个 NSObject 子类都实现这些;桥接到任意项目内同名 ObjC 方法只会产出噪音,常规 name-matcher 自己能处理
react-native addListenerremoveListenersremoveinvalidatestartObservingstopObserving 每个 RCTEventEmitter 子类都经 RCT_EXPORT_METHOD 声明它们;JS 的 .addListener(...) / .remove(...) 调用走 JS 抽象 NativeEventEmitter,不直接走原生桥

12. 反目标(明确不做什么)

  • Android Kotlin/Java 提取质量——不在范围内。使用既有 Kotlin/Java 提取器的产出;若它们漏掉 @ReactMethod 注解的字面量名,可以加一个微小的提取器修补,但不重新设计 JVM 提取。
  • 动态/计算桥接键——NativeModules[someVar]name 为参数的 requireNativeModule(name) 等。只解析字面量键访问(与 playbook 中「仅命名字符」反目标一致——仅匿名模式延期)。
  • 桥接头文件内容解析——.h 文件会被索引(#165 的内容嗅探已做),但不会把桥接头里的 #import 列表当作「Swift 可见了什么」的特殊清单;它就是普通 ObjC 头。
  • performSelector: 运行时分发——不在范围;匹配同样的「仅命名字符」反目标。
  • JSI(裸的、非 TurboModule)——不在范围。用裸 JSI 的应用通过自定义 Host* 接口调原生,没有文档化的声明式 spec;等它们迁移到 TurboModules。
  • Swift-only 泛型 over ObjC 协议 / Swift extensions on ObjC 类——扩展方法若 @objc 在 ObjC 中仍可调,走同一 Phase 1 路径;泛型不行,会静默漏掉。可接受,与 Java/Kotlin 泛型前线一致。

13. 分阶段路线、开放问题与完成标准

13a. 六个阶段,顺序由「最小仓库最先闭合端到端流程」固定

按 playbook 的难度梯度与半桥接规则,顺序不可交换:

  1. Phase 1 — Swift ↔ ObjC 桥接(行 1–5):范围最小、命名映射确定、无 JS 参与。在 Charts/Realm/Wikipedia 语料上验证后再进入 Phase 2。
  2. Phase 2 — React Native 旧桥(行 6–7,ObjC + Java/Kotlin):iOS 与 Android 两侧必须在同一 PR 闭合——只桥一个平台会在另一侧暴露半覆盖跳点,Agent 会去读文件。
  3. Phase 3 — Native → JS 事件(行 9):扩展既有 callback synthesizer 的跨语言通道。
  4. Phase 4 — Expo Modules(行 10):叠加在 Phase 1 的 Swift 提取之上,语料更小。
  5. Phase 5 — RN TurboModules / Codegen(行 8):需要把 spec 文件作为跨语言 ground truth 读取。
  6. Phase 6 — Fabric 视图组件(行 11):延期——与既有 JSX synthesizer 及 TurboModules 视图侧组合;当 §5b 语料中某仓库桥接其余部分已闭合、但 Fabric 流程仍断时再处理。

13b. Phase 1 期间的四个开放问题

  1. 声明别名 vs 新桥接边:把自动桥接的 ObjC 选择器存为 Swift 方法节点的备选名更便宜、且与既有名字解析一致;替代方案(合成跨语言 references 边)在 trace 输出中更显式,但每个 @objc 符号多 N 条边。默认:别名
  2. trace 如何显示跨语言跳点:跨语言跳点应在渲染输出中显式标注(「Swift func foo(bar:) → 桥接为 ObjC 选择器 -fooWithBar: → ObjC -[ImageDownloader fooWithBar:]」)。
  3. 桥接规则放哪里src/resolution/frameworks/swift-objc.ts(纯函数)供 Swift 提取器与测试共用——已按此落地为 swift-objc-bridge.ts + frameworks/swift-objc.ts 两层。
  4. @objcMembers 类级导出:对类的所有成员生效(除非 @nonobjc)。通过在 Swift 提取器中检查类修饰符、默认各成员的 @objc 性来处理——isObjcExposed 已实现 @nonobjc 判否优先的规则。

13c. Done-bar(何时算完成)

Phase 1 完成的条件:§5a 三个语料全部通过——纯语言探针不变、跨语言标准流程探针端到端找到路径、Agent A/B 显示有 codegraph 时 ≥1 次运行 Read = 0 且更快;playbook §6 覆盖矩阵行以数字填充;存在用户视角书写的 CHANGELOG [Unreleased] 条目。

后续每个 Phase 同形——自己的语料、自己的矩阵行、自己的 CHANGELOG 条目——且前一阶段不通过就不发布。半桥接在这里不是「可以避开的坑」而是硬约束:它会让 codegraph 在这些代码库上比完全没有桥接更差。


延伸阅读:本设计的上位方法论见 动态分发覆盖手册(resolver vs synthesizer 的判别标准与 Django ORM 参考实现),回调边的合成原理见 callback-edge-synthesis.md;框架解析器的注册入口在 src/resolution/frameworks/index.ts,跨语言事件合成器在 src/resolution/callback-synthesizer.ts

登录后查看全文
热门项目推荐
相关项目推荐