React Native-SyncFormatEdittext:用 JSI 实现零闪烁的实时文本格式化

一、引言:异步方案的痛点

在 React Native 中实现一个"限制只能输入某些字符"的输入框(比如只能输入字母),传统方案的流程是:

  1. 用户输入 → TextWatcher 触发 onChange 事件
  2. JS层收到事件 → 执行格式化函数 → 再setState
  3. state 变化 → 通过 value prop 回写到原生层

这个方案有一个致命问题:步骤 2 和 3 是异步的 。在事件从原生发到 JS、再从 JS 回写到原生的这段时间里,用户会看到未格式化的原始文本"闪一下",然后才变成格式化后的文本。这就是闪烁问题

更糟糕的是,如果格式化函数会改变光标位置(比如输入手机号时自动跳过空格),光标还会"跳来跳去",体验很差。

本文介绍的 react-native-sync-format-edittext 组件,通过 JSI(JavaScript Interface)同步调用 解决了这个问题。核心思路是:在 TextWatcher 的 afterTextChanged 回调中,同步调用 JS层 格式化函数,把结果立刻写回 Editable,整个过程在一个事件循环内完成,用户感知不到任何闪烁和延迟。

如果你对这个组件感兴趣,可以在 GitHub 上查看完整源码:react-native-sync-format-edittext

安装yarn add @azsxdc12356/react-native-sync-format-edittext

效果演示

原生 sync-format-edittext

二、整体流程架构

2.1 初始化链路

sequenceDiagram autonumber participant JS as JS层 participant TM as TurboModule participant KT as Kotlin层 participant CP as C++层 JS->>TM: TurboModuleRegistry.getEnforcing('FormatModule').install() TM->>KT: 获取JSI Runtime和CallInvokerHolder KT->>CP: JNI调用nativeInstall() CP->>CP: 保存callInvoker,并注册__formatModule Note over JS,CP: 组件mount后 JS->>CP: __formatModule.setFormat(tag, fn) CP->>CP: 存入formatFns_[viewTag]

关键点:

  • install() 只在 App 生命周期执行一次,通过模块级 installPromise 保证
  • setFormat 在组件 mount 时注册,组件 unmount 时通过 removeFormat 清理
  • C++ 层用 unordered_map<int, shared_ptr<jsi::Function>> 存储函数,key 是 viewTag

2.2 运行时链路

用户输入时,数据从原生到 JS 再返回原生的完整流程:

sequenceDiagram autonumber participant U as 用户 participant V as View视图 participant KT as Kotlin层 participant CP as C++层 participant JSF as JS格式化函数 U->>V: 输入文字 V->>KT: TextWatcher.onTextChanged KT->>KT: 记录光标位置 V->>KT: afterTextChanged触发格式化 KT->>CP: nativeFormatText JNI调用 CP->>JSF: invokeSync JSI同步调用 JSF-->>CP: 返回{text, cursorPos} CP-->>KT: 返回JSON字符串 KT-->>V: 返回FormatResult V->>V: s.replace()替换文本 V->>U: 显示格式化后的文本

整条链路是同步的。从 TextWatcher 触发到文本替换完成,都在同一个 UI 线程事件循环中,没有异步等待,所以不会闪烁。

防递归机制afterTextChanged 中调用 s.replace() 会再次触发 TextWatcher。C++ 层通过 isFormatting 标志位阻止递归:

kotlin 复制代码
override fun afterTextChanged(s: Editable?) {
    if (isFormatting) return  // 正在格式化,跳过递归
    // ... 执行格式化 ...
    isFormatting = true
    s?.replace(0, s.length, result.text)  // 会再次触发 TextWatcher
    // 但因为 isFormatting == true,递归调用直接 return
    isFormatting = false
}

防循环机制 :JS 层通过 value prop 回写时,lastFormattedText 跳过重复值:

kotlin 复制代码
if (currentText == lastFormattedText && selectionStart == lastFormattedCursorPos) {
    onFormatListener?.invoke(currentText, lastFormattedCursorPos)
    return
}

2.3 新老架构兼容

React Native 从 0.68 开始引入新架构(Fabric + TurboModule),最低应该能支持到 0.76(因为 Codegen 在这里有一次改版),但很多项目仍在使用老架构。本组件通过 sourceSets 实现同时支持:

groovy 复制代码
// android/build.gradle
sourceSets {
  if (rootProject.hasProperty("newArchEnabled") &&
      rootProject.getProperty("newArchEnabled") == "true") {
    main.java.srcDirs += "src/newarch/java"
  } else {
    main.java.srcDirs += "src/oldarch/java"
  }
}

原生 View 的桥接方式

JS 层通过 codegenNativeComponent 声明原生组件,新老架构共用同一套 JS 接口:

ts 复制代码
export default codegenNativeComponent<NativeProps>('SyncFormatEdittextView');

原生层根据架构分别实现。新架构下 ViewManagerPackage 需要额外标注 ReactModule

kotlin 复制代码
// src/newarch/.../SyncFormatEdittextViewManager.kt
@ReactModule(name = SyncFormatEdittextViewManager.NAME)
class SyncFormatEdittextViewManager : ReactTextInputManager() {
  override fun getName(): String = NAME

  override fun createViewInstance(context: ThemedReactContext): SyncFormatEdittextView {
    return SyncFormatEdittextView(context)
  }
}

// src/newarch/.../SyncFormatEdittextViewPackage.kt
class SyncFormatEdittextViewPackage : BaseReactPackage() {
  override fun createViewManagers(...): List<ViewManager<*, *>> { ... }

  override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { ... }
}

老架构下继承的是 ReactPackage,不需要 ReactModuleInfoProvider

kotlin 复制代码
// src/oldarch/.../SyncFormatEdittextViewManager.kt
class SyncFormatEdittextViewManager : ReactTextInputManager() {
  override fun getName(): String = NAME

  override fun createViewInstance(context: ThemedReactContext): SyncFormatEdittextView {
    return SyncFormatEdittextView(context)
  }
}

// src/oldarch/.../SyncFormatEdittextViewPackage.kt
class SyncFormatEdittextViewPackage : ReactPackage {
  override fun createViewManagers(...): List<ViewManager<*, *>> { ... }

  override fun createNativeModules(...): List<NativeModule> { ... }
}

TurboModule 的注册差异

新架构下 FormatModule 继承 Codegen 生成的 NativeFormatModuleSpec,并标注 @DoNotStrip 防止 ProGuard 剔除:

kotlin 复制代码
// src/newarch/.../FormatModule.kt
class FormatModule(reactContext: ReactApplicationContext) :
    NativeFormatModuleSpec(reactContext) {

    @ReactMethod
    @DoNotStrip
    override fun install(promise: Promise) { ... }
}

老架构下继承 ReactContextBaseJavaModule

kotlin 复制代码
// src/oldarch/.../FormatModule.kt
@ReactModule(name = FormatModule.NAME)
class FormatModule(reactContext: ReactApplicationContext) :
    ReactContextBaseJavaModule(reactContext) {

    @ReactMethod
    fun install(promise: Promise) { ... }
}

共享的 FormatModuleImplSyncFormatEdittextView 放在 src/main/java/ 下,不区分架构。

接下来我们分章节拆解每一层的实现。

三、JSI 基础:C++ 侧怎么和 JS 对话

在深入实现之前,先介绍本文用到的 JSI 核心概念。JSI 是 React Native 提供的 C++ 层 API,让原生代码可以直接访问 JS 运行时。

3.1 环境配置

使用 JSI 需要在 Gradle 和 CMake 中添加相应配置。

Gradle 配置 (android/build.gradle):

groovy 复制代码
android {
  defaultConfig {
    externalNativeBuild {
      cmake {
        arguments "-DANDROID_STL=c++_shared"  // 使用共享 C++ 运行时
      }
    }
  }

  buildFeatures {
    prefab true  // 启用 Prefab,用于导入 React Native 的 C++ 库
  }

  // 指定 CMake 配置文件路径
  externalNativeBuild {
    cmake {
      path "src/main/cpp/CMakeLists.txt"
    }
  }
}

dependencies {
  implementation "com.facebook.react:react-android"  // React Native 依赖
}

CMake 配置 (android/src/main/cpp/CMakeLists.txt):

cmake 复制代码
cmake_minimum_required(VERSION 3.13)
project(syncformatedittext)

set(CMAKE_VERBOSE_MAKEFILE on)
set(CMAKE_CXX_STANDARD 20)

# 导入 React Native 的 Prefab 包
find_package(fbjni REQUIRED CONFIG)
find_package(ReactAndroid REQUIRED CONFIG)

# 编译共享库
add_library(syncformatedittext SHARED
  FormatHostObject.cpp
  FormatModuleJNI.cpp
)

target_include_directories(syncformatedittext PRIVATE
  ${CMAKE_CURRENT_SOURCE_DIR}
)

# 链接 JSI 和 React Native 库
target_link_libraries(syncformatedittext
  fbjni::fbjni
  ReactAndroid::jsi          # JSI 库
  ReactAndroid::reactnative  # React Native 核心库
)

target_compile_options(syncformatedittext PRIVATE -fvisibility=hidden)
# 支持16kb
target_link_options(syncformatedittext PRIVATE "-Wl,-z,max-page-size=16384")

关键配置说明:

  • prefab true:启用 Android Prefab,让 CMake 能找到 React Native 提供的 C++ 库
  • find_package(ReactAndroid):导入 ReactAndroid 包,其中包含 jsireactnative
  • ReactAndroid::jsi:链接 JSI 库,提供 jsi::Runtimejsi::Value 等类型
  • ReactAndroid::reactnative:链接 React Native 核心库,提供 CallInvoker 等类型

3.2 Runtime --- JS 运行时

jsi::Runtime 代表一个 JS 引擎实例。所有 JSI 操作都需要通过它:

cpp 复制代码
#include <jsi/jsi.h>
using namespace facebook;

// runtime 由 RN 框架提供,我们只需要用它
jsi::Runtime& runtime = /* ... */;

3.3 Value --- JS 值的 C++ 表示

jsi::Value 是 JS 中任意值(string、number、object、function)在 C++ 侧的表示:

cpp 复制代码
// 创建 JS 字符串
jsi::String str = jsi::String::createFromUtf8(runtime, "hello");

// 创建 JS 数字
jsi::Value num(42);

// 创建 JS undefined
jsi::Value undef = jsi::Value::undefined();

3.4 HostObject --- 把 C++ 对象暴露给 JS

jsi::HostObject 让你创建一个 C++ 对象,JS 可以像普通对象一样访问它的属性。

3.4.1 返回和设置 Value

cpp 复制代码
class MyObject : public jsi::HostObject {
public:
    // JS 访问属性时调用
    jsi::Value get(jsi::Runtime& rt, const jsi::PropNameID& name) override {
        auto propName = name.utf8(rt);
        if (propName == "hello") {
            return jsi::String::createFromUtf8(rt, "world");
        }
        return jsi::Value::undefined();
    }

    // JS 设置属性时调用
    void set(jsi::Runtime& rt, const jsi::PropNameID& name, const jsi::Value& value) override {}
};

注册到 JS 全局对象后,JS 侧就可以直接用:

cpp 复制代码
auto obj = std::make_shared<MyObject>();
runtime.global().setProperty(
    runtime,
    "__myObj",
    jsi::Object::createFromHostObject(runtime, std::move(obj)));
javascript 复制代码
// JS 侧
console.log(globalThis.__myObj.hello); // "world"

3.4.2 返回函数

get 方法可以返回一个 jsi::Function,JS 侧调用时会执行 C++ 代码:

cpp 复制代码
jsi::Value FormatHostObject::get(jsi::Runtime& rt, const jsi::PropNameID& name) {
  auto methodName = name.utf8(rt);

  if (methodName == "setFormat") {
    auto self = shared_from_this();
   // 参数分别为jsi runtime、函数名、期望的参数个数、实际的c++函数
    return jsi::Function::createFromHostFunction(
        rt, name, 2,
        [self](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t count) -> jsi::Value {
          if (count < 2) {
            throw jsi::JSError(rt, "setFormat requires 2 arguments: viewTag, formatFn");
          }
          int viewTag = static_cast<int>(args[0].asNumber());
          auto fn = std::make_shared<jsi::Function>(
              args[1].asObject(rt).asFunction(rt));

          std::lock_guard<std::mutex> lock(self->mutex_);
          self->formatFns_[viewTag] = std::move(fn);
          return jsi::Value::undefined();
        });
  }

  if (methodName == "removeFormat") {
    auto self = shared_from_this();
    return jsi::Function::createFromHostFunction(
        rt, name, 1,
        [self](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t count) -> jsi::Value {
          if (count < 1) {
            throw jsi::JSError(rt, "removeFormat requires 1 argument: viewTag");
          }
          int viewTag = static_cast<int>(args[0].asNumber());
          self->removeFormat(viewTag);
          return jsi::Value::undefined();
        });
  }

  return jsi::Value::undefined();
}

3.4.3 返回 Promise

HostObject 也可以返回 Promise

cpp 复制代码
jsi::Value MyHostObject::get(jsi::Runtime& rt, const jsi::PropNameID& name) {
  auto methodName = name.utf8(rt);

  if (methodName == "addAsync") {
    return jsi::Function::createFromHostFunction(
        rt, name, 2,
        [](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t count) -> jsi::Value {
          if (count < 2) {
            throw jsi::JSError(rt, "addAsync requires 2 arguments: a, b");
          }
          double a = args[0].asNumber();
          double b = args[1].asNumber();

          // 获取promise的构造函数
          auto Promise = rt.global().getPropertyAsFunction(rt, "Promise");

          // 创建promise的参数 executor: (resolve, reject) => { resolve(a + b) }
          auto executor = jsi::Function::createFromHostFunction(
              rt,
              jsi::PropNameID::forAscii(rt, "executor"),
              2,
              [a, b](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t) -> jsi::Value {
                auto resolve = args[0].asObject(rt).asFunction(rt);
                resolve.call(rt, jsi::Value(a + b));
                return jsi::Value::undefined();
              });

          // 返回一个新的Promise(executor)
          return Promise.callAsConstructor(rt, std::move(executor));
        });
  }

  return jsi::Value::undefined();
}
javascript 复制代码
// JS 侧
const result = await globalThis.__myObj.addAsync(1,1);

3.5 invokeSync --- 同步调用 JS 代码

CallInvoker::invokeSync 让你在 JS 线程上同步执行一段 C++ 回调,回调中可以调用 JS 函数:

cpp 复制代码
auto callInvoker = /* 从 RN 框架获取 */;

// 同步在 JS 线程中执行
callInvoker->invokeSync([](jsi::Runtime& runtime) {
    // 这里可以调用 JS 函数、读写 JS 对象
    auto fn = /* 某个 JS 函数 */;
    auto jsResult = fn->call(runtime, jsi::Value(42));
    // jsResult 就是 JS 函数的返回值
});

这就是实现"同步格式化"的关键:invokeSync 会阻塞当前线程,直到 JS 函数执行完毕并返回结果

对js进行任何操作都一定要运行在js线程中,否则会引起崩溃!!!

对js进行任何操作都一定要运行在js线程中,否则会引起崩溃!!!

对js进行任何操作都一定要运行在js线程中,否则会引起崩溃!!!

四、JSI 绑定初始化:把 C++ 函数注册到 JS

有了 JSI 基础知识,我们来看第一步:怎么把格式化模块注册到 JS 运行时。

4.1 JS 层:触发初始化

组件加载时,JS 侧立即调用原生模块的 install() 方法:

typescript 复制代码
// src/SyncFormatEdittextView.native.tsx
import NativeFormatModule from './NativeFormatModule';

const installPromise: Promise<void> = (() => {
  try {
    return NativeFormatModule.install();
  } catch (e) {
    console.warn('FormatModule install failed:', e);
    return Promise.reject(e);
  }
})();
...
// src/NativeFormatModule.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  install(): Promise<void>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('FormatModule');

installPromise 是模块级变量,整个 App 生命周期只执行一次。后续所有组件都等待这个 Promise 完成。

4.2 Kotlin 层:获取 JSI Runtime 指针

TurboModule 的 install() 方法最终调到 FormatModuleImpl.install()

kotlin 复制代码
// FormatModuleImpl.kt
fun install(): Boolean {
    ...
    val runtimeRef = reactContext.javaScriptContextHolder?.get() ?: 0L
    ...
    val callInvokerHolder = reactContext.jsCallInvokerHolder
        ?: throw Exception("CallInvokerHolder not available")

    nativeInstall(runtimeRef, callInvokerHolder)
    _installed = true
    _installedRuntimeRef = runtimeRef
    return true
}

这里做了两件事:

  1. ReactApplicationContext 获取 JSI Runtime 的 C++ 指针(runtimeRef
  2. 获取 CallInvokerHolder,用于后续在 JS 线程上同步执行代码

注意 _installedRuntimeRef 的设计:在 React Native 热重载(HMR)时,JS Runtime 会重建,此时 runtimeRef 会变化,需要重新安装。

4.3 C++ 层:注册全局对象

JNI 方法接收 Runtime 指针和 CallInvoker,创建 FormatHostObject 并注册到 JS 全局对象。

重要原则(说三遍):操作 JS 运行时的代码,必须在 JS 线程中执行!必须在 JS 线程中执行!必须在 JS 线程中执行!

runtime.global().setProperty() 是 JSI 操作,它读写的是 JS 引擎内部的对象。如果不在 JS 线程中执行,会导致数据竞争和崩溃 。所以 installFormatModule 必须通过 callInvoker->invokeSync() 包装,确保在 JS 线程中运行。

cpp 复制代码
// FormatModuleJNI.cpp

// 实际的注册逻辑(在 JS 线程中执行)
static void installFormatModule(
    jsi::Runtime& runtime,
    const std::shared_ptr<CallInvoker>& callInvoker) {
  auto hostObject = std::make_shared<FormatHostObject>(callInvoker);
  FormatHostObject::setInstance(hostObject.get());

  runtime.global().setProperty(
      runtime,
      "__formatModule",
      jsi::Object::createFromHostObject(runtime, std::move(hostObject)));
}

// JNI 入口:从 Kotlin 调用过来时,先切到 JS 线程
JNIEXPORT void JNICALL
Java_com_syncformatedittext_FormatModuleImpl_nativeInstall(
    JNIEnv* env, jclass clazz,
    jlong jsiRuntimeRef, jobject callInvokerHolder) {
  ...
  auto runtime = reinterpret_cast<facebook::jsi::Runtime*>(jsiRuntimeRef);
  auto invoker = facebook::jni::alias_ref<facebook::react::CallInvokerHolder::javaobject>{ reinterpret_cast<facebook::react::CallInvokerHolder::javaobject>(callInvokerHolder)};
  // 从java的object拿到C的invoker
  auto callInvoker = invoker->cthis()->getCallInvoker();

  // 关键:通过 invokeSync 在 JS 线程中执行 installFormatModule
  callInvoker->invokeSync([runtime, callInvoker](facebook::jsi::Runtime&) {
    facebook::react::installFormatModule(*runtime, callInvoker);
  });
}

注册完成后,JS 侧就可以通过 globalThis.__formatModule 访问这个 C++ 对象了。

五、格式化函数注册:把 JS 函数存到 C++ 层

JSI 绑定初始化完成后,接下来是组件挂载阶段:把用户传入的格式化函数注册到 C++ 层。

5.1 JS 侧:注册格式化函数

typescript 复制代码
// src/SyncFormatEdittextView.native.tsx
useEffect(() => {
  if (!format || !viewRef.current) return;

  let mounted = true;
  const currentRef = viewRef.current;

  // formatModule 和 tag 在 .then() 外部获取,cleanup 可直接复用
  const formatModule = (globalThis as any).__formatModule as
    | {
        setFormat(viewTag: number, fn: FormatFn): void;
        removeFormat(viewTag: number): void;
      }
    | undefined;
  const tag = findNodeHandle(viewRef.current);

  installPromise.then(() => {
    if (!mounted || !viewRef.current) return;
    if (!formatModule) return;

    if (tag) {
      // 把格式化函数注册到 C++ 层
      formatModule.setFormat(tag, format);
    }
  });

  return () => {
    mounted = false;
    if (!formatModule || !currentRef) return;
    if (tag) {
      formatModule.removeFormat(tag);
    }
  };
}, [viewRef.current, format]);

重要:format 函数要保持稳定

useEffect 的依赖数组是 [viewRef.current, format]。每次 render 时如果 format 引用变化,就会触发 cleanup + 重新注册。这意味着:

  1. ❌不要在组件内直接定义内联函数
typescript 复制代码
// 错误:每次 render 都会创建新函数,导致反复注册
<SyncFormatEdittextView
  format={(text, pos) => {
    // ...
  }}
/>
  1. ✅使用 useCallback 保持引用稳定
typescript 复制代码
// 正确:format 引用稳定,不会反复注册
const format = useCallback((text: string, pos: number) => {
  return { text: text.toUpperCase(), cursorPos: pos };
}, []);

<SyncFormatEdittextView format={format} />
  1. ✅或者定义到模块级
typescript 复制代码
// 模块级函数,引用永远不变
const myFormat = (text: string, pos: number) => {
  return { text: text.toUpperCase(), cursorPos: pos };
};

function MyComponent() {
  return <SyncFormatEdittextView format={myFormat} />;
}

5.2 C++ 侧:存储 JS 函数

FormatHostObjectget 方法拦截 JS 对 setFormat 属性的访问,返回一个 C++ 函数:

cpp 复制代码
// FormatHostObject.cpp
jsi::Value FormatHostObject::get(jsi::Runtime& rt, const jsi::PropNameID& name) {
  auto methodName = name.utf8(rt);

  if (methodName == "setFormat") {
    auto self = shared_from_this();
    return jsi::Function::createFromHostFunction(
        rt, name, 2,
        [self](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t) -> jsi::Value {
          int viewTag = static_cast<int>(args[0].asNumber());
          // 把 JS 函数转成 shared_ptr 存起来
          auto fn = std::make_shared<jsi::Function>(
              args[1].asObject(rt).asFunction(rt));

          std::lock_guard<std::mutex> lock(self->mutex_);
          self->formatFns_[viewTag] = std::move(fn);
          return jsi::Value::undefined();
        });
  }

  if (methodName == "removeFormat") {
    // ... 类似结构,调用 self->removeFormat(viewTag)
  }

  return jsi::Value::undefined();
}

formatFns_ 是一个 unordered_map<int, shared_ptr<jsi::Function>>,key 是 viewTag,value 是用户传入的格式化函数。用 shared_ptr 是为了后续调用时可以安全地拷贝出来,避免在持锁时调用 JSI。

六、运行时格式化:核心链路

这是整个方案的核心。当用户输入文字时,数据经过以下链路:

6.1 TextWatcher 捕获输入

kotlin 复制代码
// SyncFormatEdittextView.kt
addTextChangedListener(object : TextWatcher {
    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
        if (isFormatting) return
        rawCursorPos = if (count > 0) start + count else start
    }

    override fun afterTextChanged(s: Editable?) {
        if (isFormatting) return
        val currentText = s?.toString() ?: ""

        // 1. 防循环:如果文本和上次格式化结果相同,跳过(如 JS 层 value prop 回写)
        // lastFormattedText 用于识别 JS 层回写的重复值,避免无限循环
        if (currentText == lastFormattedText && selectionStart == lastFormattedCursorPos) {
            onFormatListener?.invoke(currentText, lastFormattedCursorPos)
            return
        }

        val module = formatModule
        val viewTag = id

        // 2. 预检查:模块未加载、viewTag 无效、或未注册格式化函数时,直接转发事件
        if (module == null || viewTag <= 0 || !module.hasFormat(viewTag)) {
            onFormatListener?.invoke(currentText, selectionEnd.coerceAtLeast(0))
            return
        }

        try {
            val result = module.formatText(viewTag, currentText, rawCursorPos)

            // 3. 优化分支:格式化后文本没变,只更新光标(跳过 replace,避免不必要的 TextWatcher 递归)
            if (result.text == currentText) {
                val pos = result.cursorPos.coerceIn(0, currentText.length)
                if (selectionStart != pos) {
                    setSelection(pos)
                }
                lastFormattedText = result.text
                lastFormattedCursorPos = pos
                onFormatListener?.invoke(result.text, pos)
                return
            }

            // 4. 文本有变化:钳位光标位置,执行替换
            // isFormatting 标志位防止 s.replace() 触发的递归(见下文说明)
            val newCursorPos = result.cursorPos.coerceIn(0, result.text.length)
            isFormatting = true
            s?.replace(0, s.length, result.text)  // 会再次触发 TextWatcher,但 isFormatting 挡住
            setSelection(newCursorPos)
            rawCursorPos = newCursorPos
            isFormatting = false

            lastFormattedText = result.text
            lastFormattedCursorPos = newCursorPos
            onFormatListener?.invoke(result.text, newCursorPos)
        } catch (e: Exception) {
            // 5. 异常兜底:格式化出错时,原样转发当前文本
            onFormatListener?.invoke(currentText, selectionEnd.coerceAtLeast(0))
        }
    }
})

代码中有几个值得注意的设计:

hasFormat 预检查 :在调用 formatText(涉及 JNI + JSI)之前,先通过 hasFormat 检查该 viewTag 是否注册了格式化函数。如果没有注册,直接走快速路径转发事件,避免不必要的跨语言调用。

文本未变的优化分支 :当格式化函数返回的文本与当前文本相同时(比如用户输入的字符本身就是合法的),跳过 s.replace() 调用。这样既避免了不必要的 TextWatcher 递归触发,也减少了一次 isFormatting 的开关操作,只更新光标位置即可。

coerceIn 光标钳位result.cursorPos.coerceIn(0, result.text.length) 确保光标位置不会越界。如果 JS 格式化函数返回了一个超出文本长度的光标位置(比如格式化后文本变短了),钳位可以防止 setSelection 抛出 IndexOutOfBoundsException

rawCursorPos = newCursorPos :替换文本后同步更新 rawCursorPos,确保下一次 onTextChanged 触发时,rawCursorPos 的值是基于格式化后文本的,不会出现光标位置错乱。

try-catch 异常兜底 :整个格式化逻辑包裹在 try-catch 中,即使 formatText 内部出错(比如 JS 引擎异常),也不会导致 App 崩溃,而是降级为原样转发当前文本和光标位置。

6.2 cursorPos 的计算

JS 层的 format 函数返回 {text, cursorPos} 时,cursorPos格式化后文本中的字符索引

  1. 实现手机号格式化 13812345678138-1234-5678
typescript 复制代码
const formatPhone = (text: string, cursorPos: number) => {
  // 先去掉所有非数字字符
  const digits = text.replace(/\D/g, '');

  // 格式化:每4位加一个空格
  const parts = [];
  for (let i = 0; i < digits.length; i += 4) {
    parts.push(digits.slice(i, i + 4));
  }
  const formatted = parts.join('-');

  // 计算光标位置:原始光标位置 + 新增的分隔符数量
  // 例如原始光标在 7(第8个数字),格式化后应该在 `138-1234-5678` 的第 9 个字符(多一个分隔符)
  const separatorCount = Math.floor((cursorPos - 1) / 4);
  const newCursorPos = cursorPos + separatorCount;

  return { text: formatted, cursorPos: newCursorPos };
};
  1. 只允许输入字母
ts 复制代码
function formatLettersOnly(text: string, cursorPos: number) {
  const letters = text.replace(/[^a-zA-Z]/g, '');
  const beforeCursor = text.slice(0, cursorPos);
  const removedBeforeCursor = beforeCursor.replace(/[a-zA-Z]/g, '').length;
  const newPos = cursorPos - removedBeforeCursor;
  return { text: letters, cursorPos: Math.min(newPos, letters.length) };
}

关键规则

  • cursorPos 是相对于格式化后文本的字符索引
  • 如果格式化后文本变短(如删除非法字符),cursorPos 应该相应减小
  • 如果格式化后插入了分隔符(如空格、-),cursorPos 应该加上分隔符数量

6.3 Kotlin → JNI → C++

module.formatText() 最终调到 C++ 的 nativeFormatText

kotlin 复制代码
// FormatModuleImpl.kt
fun formatText(viewTag: Int, text: String, cursorPos: Int): FormatResult {
    if (!nativeLibLoaded) return FormatResult(text, cursorPos)
    val json = nativeFormatText(viewTag, text, cursorPos)
    return parseFormatResult(json, text, cursorPos)
}
cpp 复制代码
// FormatModuleJNI.cpp
JNIEXPORT jstring JNICALL
Java_com_syncformatedittext_FormatModuleImpl_nativeFormatText(
    JNIEnv* env, jclass clazz, jint viewTag, jstring text, jint cursorPos) {
  auto* instance = facebook::react::FormatHostObject::getInstance();
  ...
  std::string textCpp(env->GetStringUTFChars(text, nullptr));
  ...
  std::string result = instance
      ? instance->formatText(static_cast<int>(viewTag), textCpp, static_cast<int>(cursorPos))
      : "{\"text\":\"" + textCpp + "\",\"cursorPos\":" + std::to_string(cursorPos) + "}";

  return env->NewStringUTF(result.c_str());
}

6.4 C++ 同步调用 JS 函数

这是最关键的部分------通过 invokeSync 在 JS 线程上同步执行格式化函数:

重要原则(说三遍):调用 JS 函数,必须在 JS 线程中执行!必须在 JS 线程中执行!必须在 JS 线程中执行!

fnPtr->call(runtime, ...) 是在调用 JS 引擎中的函数。如果不在 JS 线程中执行,JS 引擎内部状态会被并发修改,导致不可预测的崩溃invokeSync 会阻塞当前线程(UI 线程),等待 JS 线程执行完毕后返回结果,这正是实现同步格式化的关键。

cpp 复制代码
// FormatHostObject.cpp
std::string FormatHostObject::formatText(int viewTag, const std::string& text, int cursorPos) {
  // 1. 在锁内拷贝函数指针,然后立即释放锁
  // mutex_ 保护 formatFns_ 的读写,拷贝 shared_ptr 后释放锁,避免 JS 线程持锁导致死锁
  std::shared_ptr<jsi::Function> fnPtr;
  {
    std::lock_guard<std::mutex> lock(mutex_);
    auto it = formatFns_.find(viewTag);
    if (it != formatFns_.end()) fnPtr = it->second;
  }
  // mutex_ 已释放,JS 线程不会被阻塞

  if (!fnPtr) return /* 原样返回 JSON */;

  std::string resultJson;

  // 2. 同步在 JS 线程上调用格式化函数
  callInvoker_->invokeSync([&resultJson, &text, cursorPos, fnPtr](jsi::Runtime& runtime) {
    try {
      auto result = fnPtr->call(runtime, {
          jsi::String::createFromUtf8(runtime, text),
          jsi::Value(cursorPos)});

      if (result.isObject()) {
        auto obj = result.asObject(runtime);
        auto resultText = obj.getProperty(runtime, "text").asString(runtime).utf8(runtime);
        auto resultCursor = static_cast<int>(obj.getProperty(runtime, "cursorPos").asNumber());

        // 3. 序列化为 JSON,处理特殊字符转义
        resultJson = serializeToJson(resultText, resultCursor);
      } else {
        resultJson = /* 原样返回 */;
      }
    } catch (...) {
      resultJson = /* 异常时原样返回 */;
    }
  });

  return resultJson;
}

这里有两个精妙的设计:

  1. 先拷贝后释放锁 :在调用 invokeSync 之前,先把 shared_ptr<jsi::Function> 拷贝出来,然后释放 mutex。这样 JS 线程执行格式化函数时,不会阻塞其他线程对 formatFns_ 的访问。

  2. 异常兜底catch (...) 捕获所有异常,确保即使 JS 格式化函数抛错,也不会崩溃,而是原样返回输入文本。

  3. JSON 转义:结果文本中的特殊字符(引号、反斜杠、换行等)需要正确转义,否则 JSON 解析会出错。

6.5 结果返回

C++ 返回的 JSON 字符串经过 JNI 回到 Kotlin,解析后应用到 Editable:

kotlin 复制代码
// FormatModuleImpl.kt
private fun parseFormatResult(json: String, fallbackText: String, fallbackCursor: Int): FormatResult {
    return try {
        val jsonObject = org.json.JSONObject(json)
        val parsedText = if (jsonObject.has("text")) jsonObject.getString("text") else fallbackText
        val parsedCursor = if (jsonObject.has("cursorPos")) jsonObject.getInt("cursorPos") else fallbackCursor
        FormatResult(parsedText, parsedCursor)
    } catch (e: Exception) {
        FormatResult(fallbackText, fallbackCursor)
    }
}

org.json.JSONObject 解析 JSON,保持轻量。解析时外层包裹 try-catch 防止异常。

七、总结

关键设计:

  • JSI 同步调用 :通过 invokeSync 实现原生到 JS 的同步调用,消除异步延迟
  • 防递归机制isFormatting 标志位阻止 s.replace() 触发的递归(见 6.1)
  • 防循环机制lastFormattedText 跳过 JS 回写的重复值(见 6.1)
  • 线程安全shared_ptr + mutex 的组合,避免死锁(见 6.4)

适用场景

这个方案适合需要实时格式化的输入场景:

  • 限制只能输入某些字符
  • 手机号格式化:138-1234-5678
  • 银行卡号格式化:6222 0200 1234 5678

局限性

  • 目前只支持 Android,iOS 尚未实现
  • 最低 React Native 版本应该是(0.76),因为codeGen在这里有一次改版
    • 0.82 以后不支持老架构
  • 格式化函数必须是同步的,不能有异步操作
相关推荐
超人气王1 小时前
JavaScript新手基础入门——this指针指向,一文带你搞清楚
前端·javascript
码上有光1 小时前
c++模板进阶知识讲解(对模板的进一步的运用与理解)
java·前端·c++·特化·模板进阶·偏特化
嘟嘟07171 小时前
Python切片技巧×DeepSeek API:手把手教你打造智能商品文案生成器
前端·javascript
环境工程笔记1 小时前
给 Agent 浏览器任务加一个 Verification Gate:遇到验证页时该如何优雅暂停
前端
一步一个脚印一个坑1 小时前
页面性能监控中”资源加载”指标的深度解析:为什么静态资源加载时间和页面资源加载时间对不上?
前端
是你的小橘呀1 小时前
模型总说瞎话?RAG 技术帮你用私域数据精准 “校准” 大模型
前端
是你的小橘呀1 小时前
同样是处理并发请求,为什么别人的页面丝滑不卡顿?
前端
云水一下1 小时前
HTML5 从入门到精通:不止于标签——HTML5 高级特性,小交互无需 JavaScript
前端·html5
来自上海的这位朋友1 小时前
Spring Boot + MySQL 搭一个多人游戏后端:登录、房间、匹配、对局和成长系统
前端·后端·three.js