从 0 到 1:react-native-transformer-text-input 鸿蒙化适配实录

从 0 到 1:react-native-transformer-text-input 鸿蒙化适配实录

一个「Fabric 装饰视图 + Worklets UI Runtime」型 RN 三方库,在 RNOH 0.84 上从「看起来没法移植」到「纯 JS 平台变体跑通全流程」的完整记录。

配套技能:rnoh-lib-adaptationhttps://atomgit.com/CPF-RN/skillshttps://atomgit.com/oh-react-native/rnoh-skills)

适配后仓库:https://atomgit.com/oh-react-native/react-native-transformer-text-input (含 README.OpenHarmony.md 双语文档与本次 commit)

目录

  1. 背景:一个怎样的库,为什么值得适配
  2. 原库架构深度拆解(JS 侧 + 原生侧 + C++ 公共层)
  3. 对外接口与通信契约分析
  4. 鸿蒙适配前的平台能力调研:三条路都被堵死的"死局"
  5. 架构决策:为什么最终选择「JS 平台变体回退」
  6. 核心实现:TransformerTextInput.harmony.tsx 逐段拆解
  7. 最小改动:Transformer.ts 对 harmony 放宽 worklet 校验
  8. 静态验证:tsc 与 jest 全绿
  9. 宿主接入实录:RNOH084Demo 的两次"移植事故"
  10. 构建与真机验证:从 assembleHap 到 UI 断言
  11. 踩坑清单(按痛苦程度排序)
  12. 适配文档与技能沉淀
  13. 总结与展望
  14. 附录: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 灵活性与原生手感

实现上它深度依赖两样东西:

  1. react-native-worklets 的 UI WorkletRuntime(把 JS 函数搬到 UI 线程同步执行的能力);
  2. 一个能"包住" 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)

iOSTransformerTextInputDecoratorView.mm):继承 RCTViewComponentView,但真正的魔法在挂载后:

  • addTextInputObservers:拿到第一个子视图(RCTTextInputComponentView),通过 KVC 掏它的私有 _backedTextInputView(UITextView/UITextField),把它的 textInputDelegate 换成自己 ,原 delegate 存为 _baseDelegate
  • 之后所有 textInputDidChange 先经过装饰视图:读当前 value + selection → rntti::RunTransformer 同步执行 → 写回 attributedText + 选区 → 再转发给 _baseDelegate
  • 卸载时把 delegate 还给 TextInput,清理 _transformer

AndroidTransformerTextInputDecoratorView.kt):继承 ReactViewGroup,自己实现 TextWatcheronAttachedToWindow 时拿到第一个孩子 ReactEditTextaddTextChangedListener(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()
类型 SelectionTransformerWorkletTransformerTextInputInstance(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.tsPlatform.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 相比:

  1. 执行线程:JS 线程(一个 onChangeText 事件往返内完成校正),非 UI 线程原生回调内同步------最坏多一次事件延迟,无闪烁"保证"弱于原生;
  2. 原始光标 :RN 的 onChangeText 不携带光标,原生侧是读真光标,JS 侧只能估算(详见 6.3);
  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 类型处理的两个工程细节

  1. Platform.OS 并集没有 'harmony' :RN 0.83 的 TS 类型里 Platform.OS 是有限字面量并集,!== 'harmony' 直接比较会报 TS2367。解法是转 string 再比(见第 7 章);
  2. 事件类型用 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: 依赖的真实目录有关。

首次接入按社区惯例用 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 对照,原样保留)

三项断言全部通过:

  1. 变换执行 :原始输入 → 格式化输出(username 的 @ 前缀 + 小写清洗、date 的 ##/##/#### 掩码);
  2. onChangeText 语义 :回读的是变换后的值,与 iOS/Android 原生路径行为一致(原生路径也是回写后才派发 change);
  3. 对照组不受影响:普通 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(中)按技能模板交付六件套:

  1. 能力说明 + 三平台实现对比表(iOS/Android/鸿蒙机制差异一目了然);
  2. 能力差异声明(执行线程、光标估算边界、worklet 编译豁免------第 5.3 节那三条);
  3. 版本配套表(RN ↔ RNOH ↔ @rnoh/react-native-openharmony ↔ DevEco);
  4. 接入步骤(纯 JS 版:无 autolinking,只讲安装 + Metro + 产物断言);
  5. 使用示例(与 iOS/Android 一字不差);
  6. 注意事项。

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 本项目

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-analyzerrnoh-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 建议学习路径

  1. 先读 CPF-RN/skills 中 rnoh-lib-adaptationSKILL.md(工作流)与 references/EXAMPLES.md(两个对照案例);
  2. 再到 oh-react-native 看案例仓库源码 + 双语文档(案例一读原生模块链路,案例二读本文的 JS 回退链路);
  3. 遇到"某能力在 0.84 有没有"之类的问题,回 CPF-RN/ohos_react_native 查版本矩阵或提 issue。

本文档为适配过程技术记录,随仓库存档,不入提交。

相关推荐
不爱吃糖的程序媛1 小时前
React Native 三方库鸿蒙适配实战:react-native-emoji-popup(Fabric 自定义组件)从 0 到 1
react native·harmonyos·fabric
贾伟康2 小时前
【口算王|19】HarmonyOS ArkTS 回归测试实战:覆盖启动、空数据、异常输入和重复点击
软件测试·harmonyos·arkts·回归测试·hypium
贾伟康2 小时前
【句匠|09】HarmonyOS ArkTS 学习统计实战:汇总正确率、连续学习和薄弱类型
harmonyos·arkts·appstorage·学习统计·本地统计
贾伟康2 小时前
【口算王|17】HarmonyOS ArkTS 亮暗色与视觉令牌实战:集中颜色、间距和交互状态避免页面割裂
harmonyos·arkts·arkui·深色模式·ui设计
搬砖的kk2 小时前
从 0 到 1:react-native-screenshot-aware 鸿蒙适配实战(RNOH 0.84)
react native·华为·harmonyos
贾伟康2 小时前
【口算王|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
harmonyos·arkts·数据持久化·状态管理·preferences
贾伟康2 小时前
【口算王|18】HarmonyOS ArkTS 权限与隐私实战:让 module.json5、功能说明和拒绝路径一致
harmonyos·arkts·权限管理·隐私合规·module.json5
ChinaDragonDreamer2 小时前
HarmonyOS:6.0 新增和增强特性
harmonyos·鸿蒙
Georgewu3 小时前
【HarmonyOS AI】 通用文字识别详解
harmonyos