从 0 到 1:react-native-transformer-text-input 鸿蒙化适配实录
一个「Fabric 装饰视图 + Worklets UI Runtime」型 RN 三方库,在 RNOH 0.84 上从「看起来没法移植」到「纯 JS 平台变体跑通全流程」的完整记录。
配套技能:
rnoh-lib-adaptation(https://atomgit.com/CPF-RN/skills | https://atomgit.com/oh-react-native/rnoh-skills)适配后仓库:https://atomgit.com/oh-react-native/react-native-transformer-text-input (含 README.OpenHarmony.md 双语文档与本次 commit)
目录
- 背景:一个怎样的库,为什么值得适配
- 原库架构深度拆解(JS 侧 + 原生侧 + C++ 公共层)
- 对外接口与通信契约分析
- 鸿蒙适配前的平台能力调研:三条路都被堵死的"死局"
- 架构决策:为什么最终选择「JS 平台变体回退」
- 核心实现:
TransformerTextInput.harmony.tsx逐段拆解 - 最小改动:
Transformer.ts对 harmony 放宽 worklet 校验 - 静态验证:tsc 与 jest 全绿
- 宿主接入实录:RNOH084Demo 的两次"移植事故"
- 构建与真机验证:从 assembleHap 到 UI 断言
- 踩坑清单(按痛苦程度排序)
- 适配文档与技能沉淀
- 总结与展望
- 附录:RNOH 学习资源与相关组织
1. 背景:一个怎样的库,为什么值得适配
react-native-transformer-text-input(下文简称 rntti)是 AppAndFlow 出品的一个 TextInput 组件库,核心卖点一句话就能说清:
在用户打字的同时,在 UI 线程同步地对输入内容做变换(手机号、信用卡、用户名、日期掩码......),没有 JS Bridge 往返,没有光标闪烁。
它与主流方案的差异:
| 方案 | 机制 | 问题 |
|---|---|---|
| 声明式掩码(如 advanced-input-mask) | 用 pattern 描述格式 | 表达不了条件逻辑、变长格式、依赖上下文的变换 |
| 受控输入(value + onChangeText + state) | 原生 → JS → 重渲染 → 原生 | 每个键一次往返,延迟 + 光标跳动 |
| rntti | 变换逻辑以 worklet 跑在 UI 线程 ,在原生文本变更回调里同步执行 | 兼具 JS 灵活性与原生手感 |
实现上它深度依赖两样东西:
- react-native-worklets 的 UI WorkletRuntime(把 JS 函数搬到 UI 线程同步执行的能力);
- 一个能"包住" RN 核心 TextInput 并拦截其原生变更事件的 Fabric 装饰视图(iOS 靠换绑 delegate,Android 靠 TextWatcher)。
这两样,恰好是鸿蒙侧(RNOH 0.84)最不确定的部分。整篇博客的核心剧情,就是围绕"这两样在 RNOH 上到底有没有"展开的调研与决策,以及最终落地的纯 JS 替代方案。
版本基线:
- 库:react-native-transformer-text-input v0.4.1(New Architecture only,codegen 名空间
rntti,Fabric + TurboModule + react-native-worklets) - 宿主:RNOH084Demo(RN 0.84.1 + @react-native-oh/react-native-harmony 0.84.3 + @rnoh/react-native-openharmony 0.84.3)
- 工具:DevEco Studio(hvigor 6.x,Compile SDK API 17+)、真机 HarmonyOS

2. 原库架构深度拆解
适配任何库之前,必须先把它的"血型"搞清楚。rntti 不是普通的 TurboModule 库,它是 JS 逻辑 + 原生视图 + C++ 运行时 + worklet 运行时 四层咬合的库。逐层拆:
2.1 数据流总览
用户敲键
│
▼
原生 TextInput(iOS UITextView / Android EditText)
│ 文本变更回调(同步、UI 线程)
▼
TransformerTextInputDecoratorView(Fabric 装饰视图,包住 TextInput)
│ ① 读当前 value + selection
│ ② rntti::LookupTransformer(transformerId) ← 从 UI runtime 全局 registry 取 wrapper
│ ③ rntti::RunTransformer(...) ← uiRuntime->runSync(...) 同步执行 worklet
│ ④ 回写 value + selection
▼
__rntti_registerTransformerRegistry(UI runtime 上的 Map<id, wrapper>)
│
▼
用户写的 transformer worklet(如小写化 + @ 前缀、日期掩码)
要点:变换发生在原生文本变更回调内部 ,且通过 runSync 阻塞式同步执行 JS 函数------这就是"无闪烁"的来源:格式化结果在用户看到中间态之前就已经写回。
2.2 JS 侧(src/)
Transformer.ts ------ 薄封装:
ts
export class Transformer {
constructor(worklet: TransformerWorklet) {
// 非 web 平台强制要求 worklet 已被 babel 插件编译(带 __workletHash)
if (Platform.OS !== 'web') {
const workletHash = (worklet as { __workletHash?: number }).__workletHash;
if (workletHash == null) {
throw new Error('[rntti] Transformer must be a worklet. ...');
}
}
this._worklet = worklet;
}
get worklet() { return this._worklet; }
}
注意:web 是唯一的豁免平台------web 没有 UI runtime,它的实现直接同步调用 worklet 函数。这个"豁免"先例,后来成了鸿蒙方案的模板。
registry.ts ------ UI runtime 上的注册中心(全库最关键的一层):
ts
// 在 UI runtime 上同步建立 registry(保证 native 在 install() 之后一定能访问到)
executeOnUIRuntimeSync(() => {
'worklet';
const transformersMap = new Map<number, TransformerWrapper>();
globalThis.__rntti_registerTransformerRegistry = {
register(id, transformer) { transformersMap.set(id, transformer); },
unregister(transformerId) { transformersMap.delete(transformerId); },
get(transformerId) { return transformersMap.get(transformerId); },
};
})();
NativeTransformerTextInputModule.install(); // 告诉原生侧 UI runtime 在哪
export function registerTransformer(transformer: Transformer): number {
const id = currentId++; // id 从 1 自增(0 是默认值)
runOnUI(() => {
'worklet';
let previousValue = null;
let previousSelection = null;
const wrapper = (value, selectionStart, selectionEnd, transform) => {
const result = transform
? worklet({ value, previousValue: previousValue ?? value,
selection: {start: selectionStart, end: selectionEnd},
previousSelection: previousSelection ?? {start: selectionStart, end: selectionEnd} })
: null;
const newValue = result?.value ?? value;
// ...selection 优先用 worklet 返回值,否则 computeUncontrolledSelection 兜底...
previousValue = newValue;
previousSelection = newSelection;
return { value: newValue, selection: newSelection };
};
globalThis.__rntti_registerTransformerRegistry?.register(id, wrapper);
})();
return id;
}
这是跨平台通信契约的核心 :原生 C++ 侧不直接持有用户 worklet,而是持有一个 wrapper 的弱引用,每次文本变更都通过它拿 {value, selection}。previousValue / previousSelection 的"记忆"就存在 wrapper 闭包里。
TransformerTextInput.tsx ------ React 组件:
tsx
export const TransformerTextInput = forwardRef(({ transformer, onChangeText, defaultValue, ...others }, forwardedRef) => {
const transformerId = useMemo(() => registerTransformer(transformer), [transformer]);
const transformedDefaultValue = useMemo(() => {
// 初始 defaultValue 先在 JS 线程预变换,让 Yoga 从第一帧就量到正确文本
if (defaultValue == null) return undefined;
const result = transformer.worklet({ value: defaultValue, ... });
return result?.value ?? defaultValue;
}, [defaultValue, transformer]);
return (
<TransformerTextInputDecoratorViewNativeComponent ref={decoratorRef} style={styles.decorator} transformerId={transformerId}>
<TextInput ref={inputRef} onChangeText={handleChangeText} defaultValue={transformedDefaultValue} {...others} />
</TransformerTextInputDecoratorViewNativeComponent>
);
});
注意结构:装饰视图是父亲,RN 核心 TextInput 是唯一孩子 。装饰视图在原生侧"偷看"孩子的变更事件。ref 上的 getValue/update/clear 是挂在 TextInput 宿主实例上的(Object.assign),update 走 Commands.update(...) 下发到原生装饰视图。
Fabric 组件定义 (TransformerTextInputDecoratorViewNativeComponent.ts):codegenNativeComponent + 一个命令 update(viewRef, transform, value, selectionStart, selectionEnd),prop 只有 transformerId: Int32。
2.3 原生侧(iOS / Android)
iOS (TransformerTextInputDecoratorView.mm):继承 RCTViewComponentView,但真正的魔法在挂载后:
addTextInputObservers:拿到第一个子视图(RCTTextInputComponentView),通过 KVC 掏它的私有_backedTextInputView(UITextView/UITextField),把它的 textInputDelegate 换成自己 ,原 delegate 存为_baseDelegate;- 之后所有
textInputDidChange先经过装饰视图:读当前 value + selection →rntti::RunTransformer同步执行 → 写回 attributedText + 选区 → 再转发给_baseDelegate; - 卸载时把 delegate 还给 TextInput,清理
_transformer。
Android (TransformerTextInputDecoratorView.kt):继承 ReactViewGroup,自己实现 TextWatcher;onAttachedToWindow 时拿到第一个孩子 ReactEditText 并 addTextChangedListener(this);afterTextChanged 里做同样的"变换并回写"。还处理了一个 iOS 不存在的问题:同帧重复派发相同文本的事件要过滤(lastEventValue),避免污染 previous 语义。
布局 :display: 'contents' 的装饰视图默认会被 Yoga 跳过(ForceFlattenView),所以自定义了 ShadowNode------initialize() 里 unset ForceFlattenView 强制建宿主视图,layout() 里把孩子(TextInput)的度量复制到自己身上、把孩子 origin 归零。这样装饰视图和输入框同尺寸贴合,Android 的 accessibility/事件冒泡才正常。
2.4 C++ 公共层(cpp/,rntti 命名空间)
TransformerTextInputRuntime.h/.cpp 是 iOS/Android 共享的核心:
cpp
void SetUIWorkletRuntime(const std::shared_ptr<worklets::WorkletRuntime> &runtime);
// 在 UI runtime 上调度的闭包里存 weak_ptr<WorkletRuntime>(无锁,靠调度时机保证)
std::optional<jsi::WeakObject> LookupTransformer(int transformerId);
// 从 runtime.global().__rntti_registerTransformerRegistry.get(id) 拿 wrapper,存弱引用
std::optional<TransformResult> RunTransformer(
const std::optional<jsi::WeakObject> &transformer,
const std::string &value, SelectionRange selection, bool transform);
// transformer->lock() 后 uiRuntime->runSync(fn, ...),异常捕获并 console.error
install() 则分别由 iOS 的 TransformerTextInputModule.mm / Android 的 TransformerTextInputModule.kt + JNI 承担:从 WorkletsModule 掏出 getUIWorkletRuntime(),交给 rntti::SetUIWorkletRuntime。
到这里,原库的"血型"已经很清楚了:它不是一个能靠"实现同名 TurboModule 方法"就适配的库,它需要 (a) worklets UI runtime,(b) 一个能拦截 RN 核心 TextInput 原生事件的自定义 Fabric 视图,© 与 UI runtime 同线程的同步执行能力。
3. 对外接口与通信契约分析
动手调研 RNOH 之前,先按 rnoh-lib-interface-analyzer 的方法把对外契约完整盘一遍。这一步的产出不只是文档,更是后面判断"哪些能保留、哪些必须妥协"的依据。
3.1 JS 对外 API(必须 100% 保留)
| 类别 | 内容 |
|---|---|
| 类 | Transformer(构造校验 + .worklet getter) |
| 组件 | TransformerTextInput(props = 除 value 外的全部 TextInputProps + transformer) |
| ref 方法 | getValue() / update({value?, selection?, transform?}) / clear() |
| 类型 | Selection、TransformerWorklet、TransformerTextInputInstance(Methods/Props) |
| 子路径 | ./formatters/pattern(PatternTransformer)、./formatters/phone-number(PhoneNumberTransformer)、./formatters/currency(CurrencyTransformer) |
3.2 worklet 契约(鸿蒙侧必须等价实现的语义)
- 输入 :
{ value, previousValue, selection, previousSelection }previousValue/previousSelection:上次变换后的提交值,首调用回退为当前值;
- 返回 :
null/undefined= 不变换;对象里value?/selection?字段也可为 null = 该字段不动; - selection 决定规则 :worklet 返回了 selection →
validateSelection校验(非负、end>=start、不越界);没返回 →computeUncontrolledSelection(oldValue, newValue, start, end)推算:- 光标在末尾 → 保持末尾;
- 光标在中间 → 随字符差 delta 位移;
- 结果非法 → 回退到末尾。
- previous 语义:只在"变换运行后"更新(纯光标移动不算)。
3.3 原生 ↔ JS 通信面(鸿蒙评估的"硬骨头")
| 通信面 | 形态 | 鸿蒙侧能否等价 |
|---|---|---|
| TurboModule | TransformerTextInputModule.install(): boolean,取 WorkletsModule 的 UI runtime |
依赖 worklets UI runtime,0.84 没有 |
| Fabric 组件 | TransformerTextInputDecoratorView:prop transformerId + command update,包一个 TextInput 孩子 |
需要能拦截核心 TextInput 事件 |
| UI runtime 全局 | __rntti_registerTransformerRegistry(register/unregister/get) |
依赖 UI runtime |
| C++ | rntti::SetUIWorkletRuntime/LookupTransformer/RunTransformer(jsi::WeakObject + runSync) |
依赖 worklets C++ 头与 runSync 语义 |
3.4 关键洞察(决定后续方向)
把 3.1/3.2 与 3.3 分开看很重要:JS 对用户的承诺(API + 变换语义)并不强依赖某一种原生实现 。web 平台已经证明了这一点------TransformerTextInput.web.tsx 没有 UI runtime、没有原生视图,只是把 worklet 当普通函数在每次 change 里同步跑、直接写 DOM。也就是说:"变换逻辑是普通 JS,放哪跑都能跑"是库作者自己认可的降级路径。这为鸿蒙方案留了后门。
4. 鸿蒙适配前的平台能力调研:三条路都被堵死的"死局"
这是整个适配里最关键的调研环节。结论先行:RNOH 0.84 上,原生的 1:1 移植路径全部不可行。以下是逐条证据。
4.1 事实一:RNOH 0.84 核心没有 worklets
对宿主工程里的 @rnoh/react-native-openharmony(0.84.3 全量源码,约 1 万+ 文件)做了全量 grep:
bash
grep -rn "Worklet|worklet" oh_modules/@rnoh/react-native-openharmony
# → No matches(0 命中)
RNOH 核心没有 WorkletRuntime、没有 worklet 注册/调度机制。而 rntti 的 C++ 层直接 include <worklets/WorkletRuntime/WorkletRuntime.h>、调用 uiRuntime->runSync(...)------这套头文件与语义在 RNOH 0.84 里不存在。
4.2 事实二:OHOS 的 worklets 移植够不着 0.84
社区存在 @react-native-ohos/react-native-worklets(基于 Software Mansion 的 react-native-worklets 0.7.x 移植)。但查证其配套版本后发现:该移植服务于 reanimated 4 的鸿蒙适配线,只支持到 RN 0.82(0.84 版本线仍在适配中/未开放)。RNOH 0.84 的宿主里装不了它能用的 worklets。
4.3 事实三:RNOH 核心 TextInput 没有"装饰/拦截"扩展口
rntti 在 iOS/Android 的拦截手法都是扒 RN 核心 TextInput 的内裤:
- iOS:KVC 掏
_backedTextInputView私有字段,换绑它的 delegate; - Android:
getChildAt(0)拿到ReactEditText,挂TextWatcher。
RNOH 的核心 TextInput 是 C++ TextInputComponentInstance(基于 ArkUI TextInput 节点),其文本变更事件走自己的组件实例管线,对外没有任何"第三方视图可换绑 delegate / 挂 watcher"的公开扩展点。自定义 Fabric 组件在 RNOH 上是受支持的,但"作为父亲去拦截核心组件内部事件"这种模式没有对应 API。
4.4 事实四:装饰视图的 ShadowNode 魔法也带不走
display: contents + 自定义 layout() 拷贝孩子度量 + unset ForceFlattenView,这套是为 iOS/Android 的 Yoga 渲染管线写的。RNOH 的布局走 ArkUI,装饰型布局没有对应物。
4.5 结论:可选项矩阵
| 方案 | 需要 | 可行性 |
|---|---|---|
| A. 全量原生移植(装饰视图 + C++ runSync + worklets registry) | RNOH 0.84 worklets UI runtime + 核心 TextInput 拦截口 | ❌ 两项都不存在 |
| B. 等 RNOH 0.82 worklets 升级到 0.84 | 版本线进度 | ⏳ 不可控,先不赌 |
| C. 塞一个自绘原生 TextInput 替代核心 TextInput | 重新实现全部 TextInput props/行为 | ❌ 表面过大,违背库的"包装核心 TextInput"设计 |
| D. JS 平台变体回退(对齐 web 先例) | 无原生依赖;Metro 平台文件机制 | ✅ 立即可行 |
D 方案本质上是把 web 已经验证过的降级路径平移到 harmony:在 harmony 平台文件里直接调用 worklet 函数、走受控 value/selection 回写。剩下的工程问题就变成:怎么把"受控回写"做得尽量贴近原生手感、怎么保住 selection 语义、怎么保证打包时不会误走原生路径。
5. 架构决策:为什么选「JS 平台变体回退」
5.1 平台文件机制:与 .web.tsx 同款
Metro 支持平台后缀解析:TransformerTextInput.web.tsx 在 web 平台自动命中。RNOH 的打包平台叫 harmony(宿主工程里到处是 BackHandler.harmony.ts、Platform.harmony.ts 这种文件),所以新增一个 TransformerTextInput.harmony.tsx ,Metro 在为 harmony 平台打包时会自动优先选中它,iOS/Android 的 TransformerTextInput.tsx 完全不受影响。src/index.tsx 的导出语句一行都不用改。
text
import './TransformerTextInput' → 按平台解析:
ios/android → TransformerTextInput.tsx (原生装饰视图路径)
web → TransformerTextInput.web.tsx (DOM 直写路径)
harmony → TransformerTextInput.harmony.tsx(新增:受控回写路径)
5.2 为什么不用 registry / TurboModule / C++ 层
既然在 JS 线程直接执行 worklet,那:
- 不需要
registerTransformer注册到 UI runtime(没有 UI runtime); - 不需要
NativeTransformerTextInputModule.install()(没有 worklets 模块可拿); - 不需要 codegen Fabric 组件(不拦截原生事件)。
所以 harmony 变体不能 import registry.ts / NativeTransformerTextInputModule / 装饰视图组件------一旦 import,打包时就会拉进 worklets 与 TurboModule 的代码,运行时报错。这正是后续"打包验证"环节要盯死的红线(见第 10 章)。
5.3 语义差异必须文档化(这是适配的"职业底线")
选择 D 不等于"无损"。与 iOS/Android 相比:
- 执行线程:JS 线程(一个 onChangeText 事件往返内完成校正),非 UI 线程原生回调内同步------最坏多一次事件延迟,无闪烁"保证"弱于原生;
- 原始光标 :RN 的 onChangeText 不携带光标,原生侧是读真光标,JS 侧只能估算(详见 6.3);
- worklet 编译 :harmony 直接调用函数体,不要求
'worklet'指令编译产物(对用户透明:带指令的代码原样可用,纯 JS 函数也能用)。
这三条必须写进 README.OpenHarmony 的能力差异章节,让接入方自己判断是否可接受------适配的价值在于诚实交付可用的子集 + 说清边界,而不是假装无损。
6. 核心实现:TransformerTextInput.harmony.tsx 逐段拆解
新文件约 280 行,逻辑上由四块拼成:组件骨架(受控 state + refs)→ 变换执行器(runTransform)→ 事件处理(文本/光标)→ ref 实例方法。下面按文件顺序拆。
6.1 组件骨架:受控 value/selection + 双 ref 语义
tsx
type TextState = { value: string; selection: Selection };
export const TransformerTextInput = forwardRef(
({ transformer, onChangeText, onSelectionChange, defaultValue, ...others }, forwardedRef) => {
// ① defaultValue 预变换:与原生侧一致的初始行为(首帧即显示格式化文本)
const transformedDefaultValue = useMemo(() => {
if (defaultValue == null) return '';
const result = transformer.worklet({ value: defaultValue, previousValue: defaultValue,
selection: {start: defaultValue.length, end: defaultValue.length},
previousSelection: {start: 0, end: 0} });
return result?.value ?? defaultValue;
}, [defaultValue, transformer]);
const [state, setState] = useState<TextState>(() => ({
value: transformedDefaultValue,
selection: { start: transformedDefaultValue.length, end: transformedDefaultValue.length },
}));
组件把 {value, selection} 作为单一受控状态管理,一次提交两者(避免"文本已变但光标还是旧的"的分裂帧):
tsx
return (
<TextInput
ref={inputRef}
{...others}
value={state.value} // 受控:格式化后的文本
selection={state.selection} // 受控:光标/选区一并写回
onChangeText={handleChangeText}
onSelectionChange={handleSelectionChange}
/>
);
三个 ref 各司其职:
tsx
const textRef = useRef(state.value); // 当前已提交(格式化后)的文本
const previousRef = useRef<{value, selection} | null>(null); // worklet 的 previous 记忆
const selectionRef = useRef<Selection>(state.selection); // 最近一次"已知光标"
const transformerRef = useRef(transformer); // 跟随 props 更新,避免闭包过期
transformerRef.current = transformer;
commit(next) 是唯一写入口,同时维护"提交前状态 → previousRef"的滚动:
tsx
const commit = useCallback((next: TextState) => {
// 快照被替换的旧状态作为 previous(registry 语义:previous 只在一次变换后更新)
previousRef.current = { value: textRef.current, selection: selectionRef.current };
textRef.current = next.value;
selectionRef.current = next.selection;
setState(next);
}, []);
注意 previousRef 的快照时机:在覆盖之前取旧值,等价于原生 wrapper 里"previousValue = 上次 newValue"的语义;首调用时 previousRef 为 null,runTransform 里回退到当前值。
6.2 runTransform:把 registry wrapper 的语义搬回 JS 线程
原生侧变换逻辑在 registry.ts 的 wrapper 里;harmony 变体不能 import registry(会拉进 worklets),所以把 wrapper 的核心逻辑内联等价重写:
tsx
const runTransform = useCallback((raw: TextState, transform: boolean): TextState => {
const previous = previousRef.current;
const worklet = transformerRef.current.worklet;
let result = null;
try {
result = transform
? worklet({
value: raw.value,
previousValue: previous?.value ?? raw.value,
selection: raw.selection,
previousSelection: previous?.selection ?? raw.selection,
})
: null;
} catch (err) {
// worklet 抛错不破坏输入:记日志、保留原始文本(对齐原生 RunTransformer 的兜底)
console.error('[rntti] Transformer threw an error:', err);
return raw;
}
const newValue = result?.value ?? raw.value;
let newSelection: Selection;
if (result?.selection != null) {
newSelection = result.selection;
try {
validateSelection(newSelection, newValue.length);
} catch (err) {
// 越界 selection:回退到 computeUncontrolledSelection(原生侧同样在 JS 抛错被 C++ 捕获)
console.error('[rntti] Invalid selection returned by transformer:', err);
newSelection = computeUncontrolledSelection(raw.value, newValue, raw.selection.start, raw.selection.end);
}
} else {
newSelection = computeUncontrolledSelection(raw.value, newValue, raw.selection.start, raw.selection.end);
}
return { value: newValue, selection: newSelection };
}, []);
这里复用的是库自己的共享纯函数 (validateSelection / computeUncontrolledSelection 都在 src/selection.ts,本身只依赖 'worklet' 字符串、不依赖 worklets 运行时)------所以 selection 的推算规则与 iOS/Android 逐字符一致,而不是新发明一套。
6.3 estimateRawSelection:没有真光标时的"最优估计"
原生侧读的是 TextInput 的真实光标;RN 的 onChangeText(text) 只给文本、不给光标,这是整个 harmony 实现里最需要谨慎的地方。解法:用"上次提交文本 + 最近一次已知光标"推算本次编辑后的原始光标。
tsx
const estimateRawSelection = (oldValue, newValue, oldSelection): Selection => {
const delta = newValue.length - oldValue.length;
let caret;
if (oldSelection.start === oldSelection.end) { // 无选区(最常见:敲键/退格/粘贴在光标处)
if (oldSelection.end >= oldValue.length) {
caret = newValue.length; // 光标原在末尾 → 保持末尾
} else {
caret = oldSelection.start + delta; // 光标在中间 → 随插入/删除位移
}
} else {
caret = oldSelection.start + Math.max(delta, 0); // 覆盖一段选区 → 落在替换内容起点后
}
if (caret < 0 || caret > newValue.length) caret = newValue.length; // 钳制,绝不越界
return { start: caret, end: caret };
};
语义推导:假设"编辑发生在最近一次光标处"是键盘输入的主路径假设(与原生侧隐含假设一致)。末尾追加 → delta>0 → 光标保持末尾;中间插入/粘贴 → 光标前进 delta;退格 → delta<0 → 光标后退;前向删除等非常规编辑 → 结果被钳制到合法值,仍能保证 selection 不越界、后续变换不崩。边界场景(长按选择菜单、输入法组合输入、前向删除)在文档中如实声明。
6.4 事件处理:change 去抖 + selection 追踪
tsx
const handleChangeText = useCallback((text: string) => {
// 防"回声":受控回写后 RN 可能回报相同文本,再变换一次会污染 previous 语义
if (text === textRef.current) return;
const rawSelection = estimateRawSelection(textRef.current, text, selectionRef.current);
const next = runTransform({ value: text, selection: rawSelection }, true);
commit(next);
onChangeText?.(next.value); // 上报的是变换后值(与原生路径的 onChangeText 语义一致)
}, [runTransform, commit, onChangeText]);
const handleSelectionChange = useCallback((event) => {
selectionRef.current = { start: event.nativeEvent.selection.start, end: event.nativeEvent.selection.end };
onSelectionChange?.(event); // 透传给用户
}, [onSelectionChange]);
onSelectionChange 顺带维护 selectionRef,用户纯移动光标(不编辑)时,下一次编辑的估算就能从正确位置起算。
6.5 ref 实例方法:update / clear 走同一提交管线
tsx
const setInputRef = useCallback((instance) => {
if (instance != null) {
Object.assign(instance, {
getValue() { return textRef.current; },
update({ value, selection, transform }) {
const base = value ?? textRef.current;
const newSelection = selection ?? { start: base.length, end: base.length };
commit(runTransform({ value: base, selection: newSelection }, transform ?? true));
},
clear() { commit(runTransform({ value: '', selection: { start: 0, end: 0 } }, false)); },
} satisfies TransformerTextInputInstanceMethods);
}
}, [runTransform, commit]);
6.6 类型处理的两个工程细节
- Platform.OS 并集没有 'harmony' :RN 0.83 的 TS 类型里
Platform.OS是有限字面量并集,!== 'harmony'直接比较会报 TS2367。解法是转 string 再比(见第 7 章); - 事件类型用
ComponentProps<typeof TextInput>['onSelectionChange']推导 :RN 的 .d.ts 存在多入口类型副本(types_generated 与 Libraries 下可能"同名不同类型"),手写事件结构体容易撞上"两个不相关的同名类型"。从实际渲染的TextInput组件推导,保证处理器签名与 prop 始终一致。
7. 最小改动:Transformer.ts 对 harmony 放宽 worklet 校验
原生路径要求 worklet 必须被 babel 插件编译(有 __workletHash),因为要 runOnUI 送去别的 runtime。harmony 变体是直接调用函数体 ,编译与否无所谓,所以 Transformer 构造器要把 harmony 加进豁免名单(与 web 并列):
ts
constructor(worklet: TransformerWorklet) {
// 注释里写明:web 无 UI runtime;harmony(RNOH 0.84)同样无 worklets UI runtime,
// 平台变体直接调用函数 ------ 因此两者都不强制 __workletHash
const os = Platform.OS as string; // RN 类型并集暂无 'harmony',先转 string
if (os !== 'web' && os !== 'harmony') {
const workletHash = (worklet as { __workletHash?: number }).__workletHash;
if (workletHash == null) {
throw new Error('[rntti] Transformer must be a worklet. Did you forget to add the "worklet" directive?');
}
}
this._worklet = worklet;
}
效果:用户在鸿蒙上既可以直接写带 'worklet' 指令的函数(与 iOS/Android 代码完全通用),也可以写纯 JS 函数。对存量业务零成本迁移。
8. 静态验证:tsc 与 jest 全绿
纯 JS 改动的好处是可以用库自带的工具链先做一轮全量静态验证,不用等真机。
8.1 tsc 三连修
第一次 tsc --noEmit 报了几个类型错,逐个解决的过程本身就是"平台文件 + 既有类型系统"磨合的缩影:
| 报错 | 根因 | 修复 |
|---|---|---|
TS2367: 'harmony' 与并集无重叠 |
RN 0.83 的 Platform.OS 类型并集没有 'harmony' |
const os = Platform.OS as string 后再比较 |
no exported member 'TextInputSelectionChangeEventData' |
版本间事件类型名不同(0.83 导出的是 TextInputSelectionChangeEvent) |
用 ComponentProps<typeof TextInput>['onSelectionChange'] 条件推导事件参数类型 |
ref 类型不匹配(TextInput 的 ref 与携带实例方法的宿主实例) |
harmony 变体把方法 Object.assign 到宿主实例(与原生 .tsx 同样套路) |
// @ts-expect-error + 注释说明(与原生文件一致) |
最终 tsc --noEmit 通过,新增的 .harmony.tsx 也被纳入类型检查(tsconfig include 全 src)。
8.2 jest:123 用例全绿
bash
yarn test # 7 个套件,123 个用例全部通过
既有单测覆盖的是 Transformer/formatters/selection 等纯 JS 逻辑与原生组件的行为契约------harmony 变体复用了同一套共享函数,因此这些测试同时为"harmony 路径的 selection 语义与原生一致"提供了静态背书(不依赖任何 mock 原生环境)。
8.3 别忘了重新构建 lib/
库的 npm 入口指向 lib/(bob 构建产物)。新增的 .harmony.tsx 需要被编译进 lib/module/ 才能被宿主 Metro 解析:
bash
yarn prepare # bob build:src → lib/module,平台后缀文件原样保留
ls lib/module/TransformerTextInput.harmony.js # ✅ 存在
9. 宿主接入实录:RNOH084Demo 的两次"移植事故"
宿主工程用的是 RNOH 官方示例线 RNOH084Demo(RNOH 0.84),库以本地 file: 依赖接入。这一步踩了两个坑,都跟 Metro 如何解析 file: 依赖的真实目录有关。
9.1 事故一:Unable to resolve module react-native(symlink 方案)
首次接入按社区惯例用 npm file: 依赖------npm 会把它变成 node_modules/react-native-transformer-text-input -> ../../react-native-transformer-text-input 的符号链接 ,同时 metro.config.js 把库真实目录加进 watchFolders:
js
watchFolders: [
path.resolve(__dirname, '../react-native-screenshot-aware'),
path.resolve(__dirname, '../react-native-transformer-text-input'),
],
但一跑 react-native bundle-harmony --dev 就报:
Unable to resolve module react-native from
.../react-native-transformer-text-input/lib/module/Transformer.js:
react-native could not be found within the project or in these directories:
../react-native-transformer-text-input/node_modules
根因 :Metro 解析到 symlink 的真实目录后,从真实路径向上找 react-native,先撞见库自己 node_modules 里的 react-native@0.83 (我在库内跑过 yarn install 装 devDeps 做单测)。而宿主是 RN 0.84,两个 react-native 并存 → 解析失败/错版本。extraNodeModules 在 watchFolder 外部文件的场景下没能兜住。
修复 :既然本地联调不追求"改库即热更",直接把库拷贝 进宿主 node_modules(去掉嵌套的 node_modules),让 Metro 从宿主自身的依赖树里解析:
bash
rm -f node_modules/react-native-transformer-text-input # 移除 symlink
cp -R ../react-native-transformer-text-input node_modules/... # 拷贝(先清掉其内部 node_modules)
rm -rf node_modules/react-native-transformer-text-input/node_modules
教训:RNOH 宿主接 file: 库时,库目录里不要残留自己的 node_modules(尤其 react-native),否则 Metro 层级解析会串版本。这个坑值得写进技能库(已沉淀,见第 12 章)。
9.2 事故二:Metro 文件树缓存报"目录与文件冲突"
第一次拷贝前没清掉旧 symlink 的 metro 缓存,bundle-harmony 报文件树冲突。加 --reset-cache 重建后通过:
bash
npm run dev -- --reset-cache # dev = react-native bundle-harmony --dev
9.3 示例页:验证"变换真的发生"
宿主 App.tsx 里放三个输入框做对照实验:
tsx
const usernameTransformer = new Transformer(({ value }) => {
'worklet';
const cleaned = value.replace(/[^0-9a-zA-Z]/g, '').toLowerCase();
return { value: cleaned ? '@' + cleaned : '' };
});
const dateTransformer = new Transformer(({ value }) => {
'worklet';
const digits = value.replace(/[^0-9]/g, '').slice(0, 8);
const parts = [digits.slice(0,2), digits.slice(2,4), digits.slice(4,8)].filter(Boolean).join('/');
return { value: parts };
});
// ① username TransformerTextInput ② date TransformerTextInput ③ 普通 TextInput(对照)
// 每个框下方实时展示 onChangeText 回读值
对照组的意义:如果 ③ 正常而 ①② 异常,问题在变换管线;如果 ③ 也异常,问题在宿主环境。
10. 构建与真机验证:从 assembleHap 到 UI 断言
10.1 先验"包":防止误走原生路径
bundle 产物必须满足两个断言(这是纯 JS 回退方案的红线):
bash
# ✅ 必须包含 harmony 变体特征代码
grep -c "estimateRawSelection" bundle.harmony.js # ≥1
# ❌ 绝不能包含原生路径的 worklet registry 代码
grep -c "__rntti_registerTransformerRegistry" bundle.harmony.js # =0
grep -c "executeOnUIRuntimeSync" bundle.harmony.js # =0
如果第二条不为 0,说明 Metro 解析到了 TransformerTextInput.tsx(原生文件),运行时会因找不到 TurboModule / UI runtime 直接崩。实测:estimateRawSelection 命中、registry 代码 0 命中------平台变体解析正确。
10.2 assembleHap + 安装启动
bash
cd harmony
hvigorw --mode module -p product=default assembleHap --no-daemon # BUILD SUCCESSFUL
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.rnoh084.demo
hdc shell ps -A | grep rnoh084 # 进程存活
10.3 用 uitest dumpLayout 做"免键入"的功能断言
真机验证输入变换不一定非要自动化敲键盘------鸿蒙的 uitest 可以转储当前界面的控件树与文本,配合示例页"onChangeText 回读展示",直接断言变换结果:
bash
hdc shell uitest dumpLayout -b com.rnoh084.demo -m true -p /data/local/tmp/layout.json
hdc shell cat /data/local/tmp/layout.json
实测转储出的关键节点(数据为真实设备输出):
text
TextInput | '@163com' | onChangeText: @163com ← 用户输入 "163com" 被变换为小写+@ 前缀
TextInput | '11/11/1111' | onChangeText: 11/11/1111 ← 用户输入 "11111111" 被日期掩码格式化
TextInput | '哈哈哈哈' | (普通 TextInput 对照,原样保留)
三项断言全部通过:
- 变换执行 :原始输入 → 格式化输出(username 的 @ 前缀 + 小写清洗、date 的
##/##/####掩码); - onChangeText 语义 :回读的是变换后的值,与 iOS/Android 原生路径行为一致(原生路径也是回写后才派发 change);
- 对照组不受影响:普通 TextInput 原样输入原样显示。
进程存活、日志无 rntti / registry / TurboModule 相关报错。真机功能验证通过。
10.4 小插曲:设备掉线的处理
验证中途设备从 USB 掉线(hdc list targets 变 Empty/Offline),assembleHap 已经成功但装不上。多轮 hdc kill/start 无法恢复------这种物理层掉线只能等设备重连(重新插拔/授权)。恢复后补装补验完成。教训:hdc 服务别乱 kill,掉线等待比重试更有效。
11. 踩坑清单(按痛苦程度排序)
| # | 坑 | 症状 | 解法 |
|---|---|---|---|
| 1 | RNOH 0.84 无 worklets UI runtime | 想走原生移植 → 无头文件、无 runSync | 改 JS 回退(第 4--5 章) |
| 2 | file: symlink 库自带 node_modules | Metro 报 Unable to resolve module react-native |
拷贝入库 + 清嵌套 node_modules(9.1) |
| 3 | 库内 yarn install 装了 devDeps react-native@0.83 | 与宿主 0.84 双版本并存 | 见上;单测工具链与宿主依赖树隔离 |
| 4 | Metro 文件树缓存 | 目录/文件冲突、旧解析残留 | --reset-cache |
| 5 | RN 类型并集没有 'harmony' | TS2367 | Platform.OS as string |
| 6 | RN 事件类型多入口同名副本 | 类型"不相关" | ComponentProps<typeof TextInput> 推导 |
| 7 | onChangeText 不带光标 | selection 无从谈起 | estimateRawSelection 估算 + 文档化边界(6.3) |
| 8 | hdc kill 后设备掉线不归队 | list targets Empty | 物理重连,别反复 kill(10.4) |
12. 适配文档与技能沉淀
12.1 双语适配文档
README.OpenHarmony.md(英)/ README.OpenHarmony_CN.md(中)按技能模板交付六件套:
- 能力说明 + 三平台实现对比表(iOS/Android/鸿蒙机制差异一目了然);
- 能力差异声明(执行线程、光标估算边界、worklet 编译豁免------第 5.3 节那三条);
- 版本配套表(RN ↔ RNOH ↔ @rnoh/react-native-openharmony ↔ DevEco);
- 接入步骤(纯 JS 版:无 autolinking,只讲安装 + Metro + 产物断言);
- 使用示例(与 iOS/Android 一字不差);
- 注意事项。
12.2 技能仓库回填
适配经验按"做完即沉淀"的惯例回填 oh-react-native/rnoh-skills:
- SKILL.md 新增「4. 判断适配形态」小节:按库架构(纯 TurboModule / Fabric 自定义视图 / worklets·核心组件装饰型)决定要不要写原生模块------把"先分类再动手"变成流程强制步骤;
- references/EXAMPLES.md 新增案例二(本库完整过程),与既有案例一(screenshot-aware TurboModule 原生适配)形成互补对照:同一条技能树覆盖"原生模块"与"JS 回退"两条分支。
提交 dd390ed 已推送 main。
13. 总结与展望
13.1 一句话总结
面对一个"看起来必须要有 worklets UI runtime 和原生装饰视图才能活"的库,鸿蒙适配的破局点不是硬造原生能力,而是识别出库作者自己已经认可的 JS 降级路径(web 先例),用平台文件机制把它平移过来,再尽力保住 selection 语义与 onChangeText 契约。
13.2 这套方法论的可复用性
| 场景 | 结论 |
|---|---|
| 依赖 worklets/reanimated 的库要适配 RNOH 0.84 | 先查该版本线 worklets 移植状态,没有就评估 JS 线程直调 |
| 需要"装饰/拦截"RN 核心组件的库 | RNOH 核心组件无公开拦截口 → 走 JS 回退或改架构 |
| 纯 TurboModule / 自绘组件库 | 走原生模块主流程(案例一) |
| 任何平台文件 | .harmony.tsx 与 .web.tsx 同一机制,Metro 自动选中,零侵入 |
13.3 留给未来的升级路径
- 当 RNOH 版本线补上 worklets UI runtime(如 0.82 移植线升级),可评估把原生装饰视图 +
runSync架构搬回来,届时 harmony 变体退居"降级后备"; - harmony 变体的光标估算可在 RN 提供
onChangeText携带选区信息后进一步收紧; - 建议在技能仓库持续维护"RNOH 版本线 × 能力矩阵"(worklets / 组件扩展口 / API level),让同类库的决策从"现场调研"变成"查表"。
14. 附录:RNOH 学习资源与相关组织
想深入学习 RNOH 三方库鸿蒙适配,可以围绕下面两个组织及其仓库展开(按"先能力、再方法、后案例"的顺序食用)。
14.1 本项目
- react-native-transformer-text-input(鸿蒙适配版) :https://atomgit.com/oh-react-native/react-native-transformer-text-input
- 含
src/TransformerTextInput.harmony.tsx实现、README.OpenHarmony.md / _CN.md 双语文档与本次适配 commit,是"JS 回退适配(案例二)"的可运行参照。
- 含
14.2 CPF-RN 组织(RNOH 能力与版本线的源头)
组织主页:https://atomgit.com/CPF-RN
| 仓库 | 地址 | 学习价值 |
|---|---|---|
| ohos_react_native(React Native 鸿蒙化仓库) | https://atomgit.com/CPF-RN/ohos_react_native | RNOH 各版本线(0.72/0.77/0.82/0.84...)与配套信息;版本能力矩阵的维护处 |
| skills(RN 开发技能集) | https://atomgit.com/CPF-RN/skills | rnoh-lib-adaptation(本文用的适配工作流)、rnoh-lib-interface-analyzer、rnoh-lib-adaptation-submit 等技能与真实案例 |
| rntpc_react-native-worklets-core | https://atomgit.com/CPF-RN/rntpc_react-native-worklets-core | Worklets 运行时(UI 线程执行 JS 函数)鸿蒙移植,Reanimated 的底层依赖 |
| rntpc_react-native-reanimated | https://atomgit.com/CPF-RN/rntpc_react-native-reanimated | reanimated 4 鸿蒙适配版(UI 线程动画),worklets 的上游消费者 |
说明:CPF-RN 是 RNOH(React Native OpenHarmony)技术栈的能力维护与版本线组织;鸿蒙适配做"平台能力调研"(判断某能力在对应版本线是否可用、worklets 移植进度、核心组件扩展口)时,应先查这里。
14.3 oh-react-native 组织(三方库鸿蒙适配成果的落地组织)
组织主页:https://atomgit.com/oh-react-native
| 仓库 | 地址 | 学习价值 |
|---|---|---|
| rnoh-skills(技能镜像/衍生) | https://atomgit.com/oh-react-native/rnoh-skills | rnoh-lib-adaptation 的 SKILL.md 已回填"判断适配形态"小节与 EXAMPLES.md 案例二 |
| react-native-screenshot-aware | https://atomgit.com/oh-react-native/react-native-screenshot-aware | TurboModule 事件库原生适配(案例一,0-1 完整流程) |
| react-native-transformer-text-input | https://atomgit.com/oh-react-native/react-native-transformer-text-input | worklets 装饰库 JS 回退适配(案例二,即本文) |
说明:oh-react-native 是鸿蒙适配成果仓库的落地组织,两个组织配合:CPF-RN 提供 RNOH 能力/技能("上游"),oh-react-native 沉淀适配好的三方库与案例("下游")。
14.4 建议学习路径
- 先读 CPF-RN/skills 中
rnoh-lib-adaptation的 SKILL.md(工作流)与 references/EXAMPLES.md(两个对照案例); - 再到 oh-react-native 看案例仓库源码 + 双语文档(案例一读原生模块链路,案例二读本文的 JS 回退链路);
- 遇到"某能力在 0.84 有没有"之类的问题,回 CPF-RN/ohos_react_native 查版本矩阵或提 issue。
本文档为适配过程技术记录,随仓库存档,不入提交。