一、引言:异步方案的痛点
在 React Native 中实现一个"限制只能输入某些字符"的输入框(比如只能输入字母),传统方案的流程是:
- 用户输入 → TextWatcher 触发
onChange事件 - JS层收到事件 → 执行格式化函数 → 再setState
- state 变化 → 通过
valueprop 回写到原生层
这个方案有一个致命问题:步骤 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 初始化链路
关键点:
install()只在 App 生命周期执行一次,通过模块级installPromise保证setFormat在组件 mount 时注册,组件 unmount 时通过removeFormat清理- C++ 层用
unordered_map<int, shared_ptr<jsi::Function>>存储函数,key 是 viewTag
2.2 运行时链路
用户输入时,数据从原生到 JS 再返回原生的完整流程:
整条链路是同步的。从 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');
原生层根据架构分别实现。新架构下 ViewManager 和 Package 需要额外标注 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) { ... }
}
共享的 FormatModuleImpl 和 SyncFormatEdittextView 放在 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 包,其中包含jsi和reactnative库ReactAndroid::jsi:链接 JSI 库,提供jsi::Runtime、jsi::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
}
这里做了两件事:
- 从
ReactApplicationContext获取 JSI Runtime 的 C++ 指针(runtimeRef) - 获取
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 + 重新注册。这意味着:
- ❌不要在组件内直接定义内联函数:
typescript
// 错误:每次 render 都会创建新函数,导致反复注册
<SyncFormatEdittextView
format={(text, pos) => {
// ...
}}
/>
- ✅使用 useCallback 保持引用稳定:
typescript
// 正确:format 引用稳定,不会反复注册
const format = useCallback((text: string, pos: number) => {
return { text: text.toUpperCase(), cursorPos: pos };
}, []);
<SyncFormatEdittextView format={format} />
- ✅或者定义到模块级:
typescript
// 模块级函数,引用永远不变
const myFormat = (text: string, pos: number) => {
return { text: text.toUpperCase(), cursorPos: pos };
};
function MyComponent() {
return <SyncFormatEdittextView format={myFormat} />;
}
5.2 C++ 侧:存储 JS 函数
FormatHostObject 的 get 方法拦截 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 是格式化后文本中的字符索引。
- 实现手机号格式化
13812345678→138-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 };
};
- 只允许输入字母
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;
}
这里有两个精妙的设计:
-
先拷贝后释放锁 :在调用
invokeSync之前,先把shared_ptr<jsi::Function>拷贝出来,然后释放 mutex。这样 JS 线程执行格式化函数时,不会阻塞其他线程对formatFns_的访问。 -
异常兜底 :
catch (...)捕获所有异常,确保即使 JS 格式化函数抛错,也不会崩溃,而是原样返回输入文本。 -
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 以后不支持老架构
- 格式化函数必须是同步的,不能有异步操作

