做 React Native 的 Android 原生模块开发,最头疼的往往不是某个 SDK 本身怎么调,而是好几个异步任务凑在一起时怎么编排。早几年我负责 RN 端的原生能力层,经常要串起“初始化推送 SDK → 拉取本地缓存配置 → 再并行刷新三个数据源 → 统一回传给 JS”这类流程,用回调一层套一层,代码丑、容易出错,排查日志还得靠猜。后来把 Facebook 开源的 Bolts 库引进来,整个任务编排才清爽起来。这篇就聊聊我在 React Native 的 Android 端集成 Bolts 的完整经验,从 Task 与 Promise 的对应关系、链式调用、并行聚合到取消与生命周期,再附上几个我实际踩过的坑,希望能给同样在做原生模块或混合开发的兄弟一些参考。
如果你还没接触过 Bolts,可以先记住一句话:它就是 Android 上的 Promise 实现,只不过比 JS Promise 更“老派”,更像一个直接映射 Java 编程习惯的任务调度器。它兼容老到不能再老的 API 14,RN 0.40 之后的版本也在大量运用它的思路。下面的内容默认你已经会写基础的 RN 原生模块,知道ReactContextBaseJavaModule和@ReactMethod是怎么回事。
1. 为什么会想到在 RN 的 Android 原生层用 Bolts
1.1 一个真实的异步编排场景
还是先讲个实际场景吧。当时我们 App 里的 RN 页面要展示订单列表,进入页面前需要原生层帮忙完成三件事:读取本地 token 并校验、初始化 IM 长连接 SDK、申请定位权限并获取一次精确位置。这三件事里有的是顺序依赖(token 校验完才能启动 IM),有的是可以并行的(定位和其他两个互不干扰)。假如全部用 Callback 写:
- 步骤 B 依赖步骤 A,又要在 UI 线程做,就得包一层
runOnUiThread - 并行那两路要在回调里再包一层计数器
- 任何一步报错,都得在一个统一地方收集,不然 JS 层根本不知道发生了啥
这种代码不是不能写,是写着写着就容易出现“回调里套回调里套回调”,团队里别人接手时维护成本特别高。我用 Bolts 之后,问题变成了这种形态:
Task.callInBackground(() -> loadToken()) .onSuccessTask(token -> initIM(token)) .onSuccessTask(ignored -> Task.whenAll( Arrays.asList(refreshLocation(), fetchUserInfo()) )) .continueWith(task -> { promise.resolve(resultOf(task)); return null; });从结构上说,顺序、并发、错误处理在一条链里摆得明明白白。你只需要看一眼这段代码,就能知道业务上有哪些依赖关系、哪些能并行跑、最终结果往哪走。对比一下改造成本和后续收益,是值得的。
1.2 Bolts 的核心概念速记
Bolts 的老家是 Facebook 的 Parse 团队,后来随 Parse 开源的一部分一直维护至今。Android 端通常叫 Bolts-Android,又被拆成bolts-tasks和bolts-applinks。我们 RN 开发基本只用bolts-tasks,也就是 Task 相关的部分。核心概念其实只有四个:
- Task:代表一个异步操作的结果,可以理解为 Android 版的 Promise。它不像 Promise 那样天然是热任务,创建 Task 本身不代表任务已经启动,需要配合
TaskCompletionSource或callInBackground启动。 - TaskCompletionSource:手动控制的“启动器”,持有 Task 引用,由你决定什么时候
setResult或setError。 - Continuation:Task 完成后的回调函数,通过
continueWith、onSuccess、continueWhile等方式挂载。 - CancellationTokenSource / CancellationToken:取消信号,用于在任务运行途中主动中断。
跟 JS Promise 最大的不同是,Bolts 的每个 continuation 可以指定执行线程。默认是在任务完成时的那个线程继续执行,但你完全可以通过Task.UI_THREAD_EXECUTOR把回调扔回主线程。这一点在 Android 这种“UI 操作必须主线程,耗时操作必须后台线程”的环境里,真的很方便。
1.3 为什么不直接用 Java 8 的 CompletableFuture 或 RxJava
如果你现在问我这个问题,很正常,因为 CompletableFuture 确实也做得不错。但放在 RN 原生模块这个特定场景,Bolts 有几个不可替代的考虑:
| 维度 | Bolts Task | CompletableFuture |
|---|---|---|
| API Level | 没有最低 API 限制,老项目友好 | 需要 API 24+ 或用 desugaring,老项目麻烦 |
| 线程调度 | continueWith直接指定 Executor,顺手 | 要手动拎出 executor,略绕 |
| 与 RN Promise 交互 | 结构对称,几乎一一对应 | 也能映射,但多了一层类型转换 |
| 依赖体积 | bolts-tasks只有几十 KB,几乎没有成本 | JDK 自带,无体积成本 |
再说 RxJava,那是个大而全的响应式框架,如果项目里顺便要用 Observable 做流式数据,当然可以,但仅为了做任务编排、错误处理和并行聚合,引入一套完整的 RxJava 链,多少有点杀鸡用牛刀。Bolts 的定位恰恰是“小而美的 Promise 调度器”,重心全在任务本身。我在 RN 模块里需要的不是响应式流,而是把几个异步步骤稳定地串起来,所以选它。
1.4 它与 RN 的 Promise 到底如何对接
稍等,这里要先把关系理清。RN 的 JS 层有 Promise,原生 Java 层如果希望 JS 能await nativeMethod(),方法签名里需要声明com.facebook.react.bridge.Promise参数。你可以在原生方法里直接调promise.resolve,也可以在内部把 Bolts 的 Task 执行完后再 resolve。所以 Bolts 和 RN Promise 不是替代关系,而是“后台任务调度 + 前台结果通信”的配合关系。理解这一点,后面所有代码就好懂了。
2. 先从工程接入开始:依赖与最小封装
2.1 Gradle 依赖该加哪一行
注意别一股脑把整个 Bolts 都拉进来。比较省心的做法是在android/app/build.gradle的 dependencies 里加:
implementation 'com.parse.bolts:bolts-tasks:1.4.0'如果你只加bolts-android,它会把bolts-applinks也带进来,里面包含 manifest 相关的自动处理组件,对我们纯做 RN 模块的人来说基本用不上,偶尔还会和项目里已有的 ContentProvider 声明打架。所以优先只引bolts-tasks。同步完成后,在 Android Studio 里一眼就能确认库进来了:外部依赖里会出现com.parse.bolts:bolts-tasks:1.4.0。
2.2 RN 原生模块的样板结构
我们先准备一个最普通的原生模块叫BoltsDemoModule:
public class BoltsDemoModule extends ReactContextBaseJavaModule { private final ReactApplicationContext reactContext; public BoltsDemoModule(ReactApplicationContext reactContext) { super(reactContext); this.reactContext = reactContext; } @Override public String getName() { return "BoltsDemo"; } @ReactMethod public void fetchToken(Promise promise) { // 后续把 Bolts 写进来 } }不要忘记在 Package 里注册这个模块,否则 JS 侧NativeModules拿不到对象。注册时通常是在createNativeModules里return Arrays.asList(new BoltsDemoModule(reactContext))。这个问题不大,但初学者最容易漏,白屏排查又查不到原生报错时,先回头看这一步。
JS 侧这样调用:
import { NativeModules } from 'react-native'; const result = await NativeModules.BoltsDemo.fetchToken();原生方法一旦有Promise参数,RN 的 bridge 就自动认为这是异步方法。你可以在方法体里直接 resolve 或 reject,也可以把它交给任意后台线程去完成。关键是别阻塞 JS 线程,别把 100ms 以上的操作放在方法体主流程里直接跑。
2.3 第一个封装:读取 SharedPreferences 再回传
最容易理解的例子是读本地缓存。假设我们要在后台线程读 token,读完通过 Promise 返回给 JS。用 Bolts 写:
@ReactMethod public void fetchToken(Promise promise) { Task.callInBackground(() -> { SharedPreferences sp = reactContext.getSharedPreferences("rn_config", Context.MODE_PRIVATE); return sp.getString("access_token", ""); }).continueWith(task -> { if (task.isFaulted()) { promise.reject("FETCH_TOKEN_ERROR", task.getError()); } else { promise.resolve(task.getResult()); } return null; }); }这里的Task.callInBackground会丢到 Bolts 自己的全局后台线程池执行,IO 操作不会卡住 JS 线程。continueWith的返回值用 null 就行,因为我们已经把结果交给 Promise,不再需要新的 Task 向下传播。如果你写 Java 8 lambda 不顺手,也可以写匿名内部类,效果一样。
提示:不要在主线程直接读 SharedPreferences。早期有的项目这么干,小文件没问题,但团队里一旦有人往里面写大对象,UI 掉帧和 ANR 就来了。用 Bolts 把这类 IO 丢到后台线程,是成本最低的解法。
3. 链式调用与错误传播:把回调地狱捋直
3.1 串行场景:token 校验完再初始化 IM
回到开头的业务。第一步校验 token,第二步初始化 IM,必须顺序执行,第二步的参数依赖第一部的返回结果。Bolts 里最合适的是onSuccessTask:
Task.callInBackground(this::validateToken) .onSuccessTask(token -> initIMConnection(token)) .continueWith(task -> { if (task.isFaulted()) { promise.reject("INIT_FAILED", task.getError()); } else { promise.resolve(true); } return null; });onSuccessTask的含义是:上一个 Task 成功完成后,进入这个方法,你返回一个新的 Task,作为流水线上的下一道工序。如果上一步失败了,整个链会直接跳到continueWith的错误处理,中间步骤全部跳过。这个行为跟 JS Promise 的then是一致的,直觉上很容易迁移。
3.2 三种 continuation 的取舍
刚开始用 Bolts,最容易混的是continueWith、onSuccess、continueWhile到底该用哪个。我说一下日常经验:
| 方法 | 适用场景 | 注意 |
|---|---|---|
continueWith | 无论成功失败都要执行,适合统一收尾、释放资源、上报日志 | 必须自己判断isFaulted/isCancelled |
onSuccessTask | 上一步成功后继续执行下一步,并返回新 Task | 最常用,写顺序逻辑推荐 |
onSuccess | 上一步成功后,返回一个普通值,不需要新 Task | 适合转换结果、做纯计算 |
continueWhile | 根据条件循环执行某异步任务,类似 async/await 里的 while | 注意要有出口,别写成死循环 |
这里有个细节:continueWith里面如果直接返回一个非 null 的 Task,那个 Task 会成为下一个 continuation 的上一步。用 null 表示“不关心后续链”。如果是在链的末尾,可以返回 null。如果在中间返回 null,却还想继续往下链,Bolts 会自动把 null 包成一个已完成 Task。所以不要怕,链不会断。
3.3 错误传播:不用到处 catch
Bolts 的设计里,异常沿着 Task 链一路向下传播,直到某个 continuation 显式处理。我们在 RN 原生层只需要在链的末端统一转成 Promise 的 reject 就好。那些真正需要特殊处理的错误,才在中间加一个onError或continueWith里按isFaulted()分支处理。
Task.callInBackground(this::validateToken) .onSuccessTask(token -> initIMConnection(token)) .continueWith(task -> { if (task.isCancelled()) { promise.reject("TASK_CANCELLED", "task has been cancelled"); } else if (task.isFaulted()) { Throwable error = task.getError(); promise.reject("TASK_FAILED", error.getMessage(), error); } else { promise.resolve(task.getResult()); } return null; });注意 reject 的第三个参数可以带 Throwable,JS 侧的error对象里会自动带上nativeStackAndroid,排查原生异常时会方便不少。这是我后来才学到的用法。如果只传 message,错误堆栈信息会少一大截。
3.4 说一个线程切不过来的典型事故
链式调用写顺手以后,有个坑特别容易犯:continueWith的默认执行线程是上一个任务结束的线程,也就是后台线程池的线程。如果你在 continuation 里直接操作reactContext.getCurrentActivity()并更新 UI,大概率在 Android 高版本上会收到CalledFromWrongThreadException。
修正方式有两种。第一种用Task.UI_THREAD_EXECUTOR:
continueWith(task -> { // 更新道具或 UI 状态 return null; }, Task.UI_THREAD_EXECUTOR)第二种是把 UI 操作丢给 RN 的UiThreadUtil.runOnUiThread。二者选哪个都行,我习惯在原生模块里统一用Task.UI_THREAD_EXECUTOR,因为它的语义和前面整段链式调度保持一致,代码里不会一会儿 Runnable 一会儿 Task,读起来更舒服。
4. 并行聚合、竞速与取消:RN 场景里的高级玩法
4.1 用 whenAll 把一路并行收拢回来
和 JS 的Promise.all对应,Bolts 有Task.whenAll。但有个细节要提醒:Task.whenAll(List<? extends Task<?>>)返回的是Task<Void>,它只能告诉你“全都完成了”,不会替你把每个 Task 的结果重新收集成一个 List。想拿到每个任务的结果,常见做法是让每个 Task 自己把结果写进预先准备好的下标数组或并发安全的容器里。
final int N = 3; final Object[] results = new Object[N]; List<Task<Void>> tasks = Arrays.asList( Task.callInBackground(() -> { results[0] = loadUserInfo(); return null; }), Task.callInBackground(() -> { results[1] = loadSettings(); return null; }), Task.callInBackground(() -> { results[2] = loadDeviceFingerprint(); return null; }) ); Task.whenAll(tasks) .continueWith(task -> { if (task.isFaulted()) { promise.reject("LOAD_DATA_FAILED", task.getError()); } else { promise.resolve(results); } return null; });每个分支独立抛错,只要有一个任务失败,whenAll返回的 Task 也会进入 faulted 状态。如果业务上想“部分成功也接受”,可以对每个子任务悄悄捕获错误,把错误标记也放进结果数组里,由 JS 层去判断。这个取舍要看场景:支付类必须全成功后放行,列表展示类可以考虑“能拿到多少展示多少”。
4.2 whenAny:主备接口竞速的降级策略
有些场景下我们需要先到先得,例如主接口慢、备用接口快,希望 JS 层尽快拿到一个可用结果。Task.whenAny就是干这个的,它返回Task<Task<?>>,后者表示最先完成那个任务。
Task<Task<?>> any = Task.whenAny( Arrays.asList(mainRequest(), backupRequest()) ); any.continueWith(task -> { if (task.isFaulted()) { promise.reject("ALL_REQUESTS_FAILED", task.getError()); return null; } Task<?> winner = task.getResult(); if (winner.isFaulted()) { promise.reject("WINNER_FAILED", winner.getError()); } else { promise.resolve(winner.getResult()); } return null; });这里要稍微留意getResult()的类型。因为两个请求类型可能不同,whenAny给的Task<Task<?>>是通配外形,拿到的winner是Task<?>,做强转之前最好用instanceof或断言确认。我在生产代码里通常先查winner.isFaulted(),再强转到具体类型,避免 ClassCastException。
4.3 取消信号:组件卸载时别再让人家干活了
RN 页面里组件销毁后,原生任务其实还在后台跑,这是一种隐性资源浪费,尤其当任务里带着网络连接或定位请求时,效果特别明显。Bolts 的CancellationTokenSource可以做到“主动喊停”。
原生模块里维护一个CancellationTokenSource:
private CancellationTokenSource cts = new CancellationTokenSource(); @ReactMethod public void startHeavyTask(Promise promise) { Task.callInBackground(() -> { while (true) { if (cts.getToken().isCancellationRequested()) { return null; } // 做点耗时的循环 } }).continueWith(t -> { if (t.isCancelled()) { promise.reject("TASK_CANCELLED", "cancelled by user"); } else if (t.isFaulted()) { promise.reject("TASK_FAILED", t.getError()); } else { promise.resolve(true); } return null; }); } @ReactMethod public void cancelHeavyTask() { cts.cancel(); }JS 侧在组件componentWillUnmount或useEffect的清理函数里调用NativeModules.BoltsDemo.cancelHeavyTask()即可。注意:取消不是中断正在执行的线程,它更像一个协作式标记,任务体内部需要周期性检查isCancellationRequested()才能优雅退出。如果任务是阻塞调用(比如死等某个回调),取消机制帮不了太多,那就要靠超时策略来补位。
4.4 超时怎么处理
Bolts 没有内置的timeoutAPI,这也是它保持“小而美”的原因之一。我们可以用一个小技巧实现超时:把原始任务和Task.delay(3000)塞进whenAny,谁先完成谁说了算。
Task<Task<?>> race = Task.whenAny( Arrays.asList(originalTask, Task.delay(3000)) ); race.continueWith(task -> { Task<?> winner = task.getResult(); if (winner == originalTask) { promise.resolve(originalTask.getResult()); } else { promise.reject("TIMEOUT", "task timeout after 3s"); } return null; });注意originalTask要引用同一个 Task 实例,不要新建一个Task.callInBackground(...),否则对象相等判断对不上。这种写法简单但不算完美:原始任务超时后没有被取消,它仍在后台跑。真要严格取消,还是需要配合CancellationTokenSource,让任务体内部响应取消信号。
5. 踩坑记录:从白屏、线程错乱到 Promise 重复回调
5.1 启动白屏和初始化任务调度
搜索“react native 启动白屏”的人应该不在少数。这个问题的本质是:JS 引擎从加载 Bundle 到首帧渲染是需要时间的,原生初始化和网络请求如果也在同一时间段内抢舞台,白屏会更明显。我们当时的处理方式是:在ReactActivity创建时,用原生侧把最关键的三个初始化动作(读取本地配置、建立长连接、初始化埋点 SDK)通过 Bolts 串成一个 Task 链先跑掉,等 JS 侧首帧准备好需要数据时,这些初始化已经完成了一半以上。如果实在保底,原生层可以先显示一个轻量的 ProgressBar 兜住用户视线,首帧渲染完再移除。
一个可复用的思路是:把初始化任务放进一个静态的Task单例,页面反复进出不用重复执行:
private static volatile Task<Void> sharedInitTask; private static Task<Void> ensureSharedInit() { if (sharedInitTask == null) { synchronized (BoltsDemoModule.class) { if (sharedInitTask == null) { sharedInitTask = Task.callInBackground(() -> { initPushSDK(); initIM(); initBury(); return null; }); } } } return sharedInitTask; }这种“初始化前置”的思路,本质上是把耗时尽量前移,给 JS 首帧争取时间窗口。Bolts 的价值在于:我们可以把原本散落的初始化逻辑按依赖顺序、并发关系统一表达,而不是在 Activity 生命周期里插一堆自增自减的状态标记。
5.2 主线程 executor 与 RN bridge 的冲突
有一次 QA 反馈:某个原生方法调用后 JS 回调永远不执行。排查了半天,发现我在continueWith(..., Task.UI_THREAD_EXECUTOR)里调用了promise.resolve。理论上 promise.resolve 可以在任意线程执行,但当任务量激增、主线程被 UI 帧卡住时,这种回调会排队等待,导致 JS 侧看起来像“卡死了”,实际是主线程迟迟处理不到这条 resolve。
排查链路是这样的:先看 Logcat,确认原生方法已经走到promise.resolve之后;再到 JS 侧加超时日志,确认回调没进 JS;最后怀疑是不是主线程被占满,抓了一份 Trace 文件,果然主线程上有大段 UI 测量和布局的耗时。应对方式也很简单:凡是纯数据返回、不需要碰 UI 的 Promise 回调,尽量在后台线程直接 resolve,不要先用UI_THREAD_EXECUTOR绕一圈。只有确实要更新原生 UI 组件的内容才切主线程。这也再一次提醒:搞清楚“回调里到底要干什么”比背执行规则更重要。
5.3 Promise 重复 resolve / reject 引发的崩溃
Task 链在并行场景下的一个隐患是:当你用whenAny或两个分支同时收尾时,可能不小心对同一个 Promise 调用了两次 resolve/reject。RN 的 Bridge 对重复 settle 的处理在不同版本上不一样——有的版本直接静默忽略,有的版本会抛异常,表现为 Java 层IllegalStateException: Promise already settled。
我自己的做法是加一个AtomicBoolean:
private final AtomicBoolean settled = new AtomicBoolean(false); private void safeResolve(Promise promise, Object result) { if (settled.compareAndSet(false, true)) { promise.resolve(result); } } private void safeReject(Promise promise, String code, Throwable t) { if (settled.compareAndSet(false, true)) { promise.reject(code, t.getMessage(), t); } }凡是可能多路返回的方法,都先用它包一层。代码看起来多三行,却省了不少线上崩溃采集。尤其当你做超时竞速时,原始任务晚到一步,但晚到的那一步里如果还有promise.resolve,就很容易踩中重复 settle。
5.4 内存泄漏:continuation 的生命周期比 Activity 长
最后一个坑尤其隐蔽。Bolts 的continueWith会持有 continuation 对象,而 continuation 里如果直接引用了 Activity 或某个大型视图,任务还没跑完,Activity 已经销毁,内存就一直被吊着。旋转屏幕几次后,Dump Java Heap 能明显看到一堆旧 Activity 实例。
修正习惯有两条:
- continuation 里优先通过
WeakReference<T>取外部对象,或者只依赖ReactApplicationContext这类 App 级单例。 - 在页面销毁时,把对应的
CancellationTokenSource.cancel()调出来,让链快速进入 cancelled 分支,释放 continuation。
很多团队把 RN 原生模块写得“接口能用就行”,不太关心生命周期,但线上 OOM 往往就是这类细节一天天堆出来的。原生模块本身虽然是单例,可它内部的异步任务链却完全可以超出模块所在页面的生命周期,这一点必须时刻心里有数。
最后再分享一个小技巧:在 RN 和原生交互的这一层,我后来把 Bolts 用成了一个统一的“原生异步编排层”,凡是要做异步组合的原生方法,对外都只暴露一个 Promise 接口,内部一律用 Task 串并行。这样 JS 侧看到的永远是清晰的异步接口,原生侧又不会退化成回调地狱。如果你也在做 RN 的 Android 原生模块,可以先把这三个能力练熟:callInBackground + onSuccessTask组成串行链,whenAll + whenAny处理并行聚合,CancellationTokenSource管生命周期,之后再遇到的复杂流程基本都能在这套骨架上搭出来。希望这篇文章能帮你少踩几个我踩过的坑。