iOS + RN 混编实战总结:桥接、映射、Tab 栏、生命周期、数据处理

这篇记录我们在业务型 App 做 RN 增量迁移时的一些实战经验,重点是可落地,而不是炫技架构。


一、项目背景与目标

我们是存量 iOS(OC/Swift)项目,业务持续迭代。目标不是一次性重写,而是:

  • 增量接入 RN,加快页面迭代
  • 保留原生在鉴权、网络、路由、关键业务流程上的稳定性
  • 保证线上主流程不中断,做到可回滚、可兼容、可观测

二、架构原则:谁负责什么

2.1 职责边界(推荐)

  • 原生负责:鉴权、网络请求、路由/导航、支付、分享、关键流程
  • RN 负责:页面渲染、轻交互、状态编排、模型映射

2.2 这样划分的收益

  • 安全与协议口径统一
  • 问题定位更快(原生链路 vs RN 渲染)
  • 兼容旧模块成本更低

三、桥接设计:统一出口 + 兼容回退

3.1 不要让业务直接调用 NativeModules

建议加一层统一出口,业务只调用出口函数,不直接依赖具体桥接模块。

// weproNativeBridge.ts
import { NativeModules } from 'react-native';

const authBridge = NativeModules.WPAuthBridge;
const legacyBridge = NativeModules.WPOrderTrackBridge;

// 示例:统一消费订单上下文,优先新桥接,失败回退旧桥接
export async function consumePendingTrackContext() {
  if (authBridge?.consumePendingTrackContext) {
    try {
      return (await authBridge.consumePendingTrackContext()) || null;
    } catch {}
  }
  if (legacyBridge?.consumePendingTrackContext) {
    try {
      return (await legacyBridge.consumePendingTrackContext()) || null;
    } catch {}
  }
  return null;
}

3.2 原生桥接返回值要统一语义

建议统一:

  • 页面类:Promise<boolean>
  • 网络类:{ success, body, msg }
  • 异常类:reject(code, message)
RCT_EXPORT_METHOD(requestPost:(NSString *)path
                  params:(NSDictionary *)params
                  resolver:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject) {
  // 成功统一 resolve({ success, body, msg })
  // 失败统一 reject("native_post_error", xxx, nil)
}

四、数据映射:把脏数据处理留在边界层

RN 业务层尽量不要到处写 a?.b?.c ?? '',集中在 mapping 层做转换。

type OrderDTO = {
  id?: string | number;
  status?: string;
  amount?: string | number;
};

type OrderVM = {
  id: string;
  statusText: string;
  amountText: string;
};

export function mapOrder(dto: OrderDTO): OrderVM {
  const statusMap: Record<string, string> = {
    pending: '待处理',
    paid: '已支付',
    failed: '失败',
  };

  const status = String(dto.status || 'pending');
  const amount = Number(dto.amount || 0);

  return {
    id: String(dto.id || ''),
    statusText: statusMap[status] || '未知状态',
    amountText: `¥${amount.toFixed(2)}`,
  };
}

建议:DTO(后端)-> Domain(业务)-> VM(展示)三段式,排障和重构更稳。


五、Tab 栏与双导航栈:最容易踩坑的点

混编常见问题:

  • RN 页面 push 原生页后,TabBar 状态错乱
  • 返回时 TabBar 异常显示/隐藏
  • push 到了错误的导航栈

5.1 处理思路

  • 维护一个 TabBar 目标状态
  • 优先拿真实业务导航栈,不盲目用当前 navigationController
  • 在转场时做主线程二次兜底设置
// 示例:统一设置 tabBar 显隐(简化版)
- (void)wp_setTabBarHidden:(BOOL)hidden {
  UITabBarController *tabController = [self resolveTabController];
  if (!tabController) return;

  tabController.tabBar.hidden = hidden;
  dispatch_async(dispatch_get_main_queue(), ^{
    tabController.tabBar.hidden = hidden; // 转场兜底
  });
}

六、生命周期:避免看起来已登录,实际 token 未就绪

跨端常见时序问题:RN 首屏请求发起时,原生 token 还没准备好。

6.1 处理策略

  • token 多来源解析(UserModule / UserDefaults / 旧字段
  • 请求前短轮询重试(如最多 6 次,间隔 80ms)
  • 暴露登录快照用于排障(只读)
- (void)wp_resolveRequestMetaWithPath:(NSString *)path
                               nonce:(NSString *)nonce
                           timestamp:(NSString *)timestamp
                            maxRetry:(NSInteger)maxRetry
                               delay:(NSTimeInterval)delay
                            resolver:(RCTPromiseResolveBlock)resolve {
  NSString *token = [self resolvedToken];
  if (token.length > 0) {
    resolve(@{@"commonParams": ..., @"authorization": ...});
    return;
  }

  if (maxRetry <= 0) {
    resolve(@{@"commonParams": ..., @"authorization": [NSNull null]});
    return;
  }

  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)),
                 dispatch_get_main_queue(), ^{
    [self wp_resolveRequestMetaWithPath:path
                                  nonce:nonce
                              timestamp:timestamp
                               maxRetry:maxRetry - 1
                                  delay:delay
                               resolver:resolve];
  });
}

七、数据处理与容错:让链路可恢复

7.1 一次性上下文消费(防重复)

例如订单轨迹上下文:读取后立刻清空,避免重复消费。

static NSDictionary *pendingContext = nil;

RCT_EXPORT_METHOD(consumePendingTrackContext:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject) {
  @synchronized ([MyBridge class]) {
    NSDictionary *ctx = pendingContext;
    pendingContext = nil; // 一次性消费
    resolve(ctx ?: [NSNull null]);
  }
}

7.2 关键流程先落盘再跳页

对于复效/补件等长流程,先做本地归档再进入页面,避免中途退出导致状态丢失。


八、排障与观测:没有这层,混编会很痛

建议至少做三件事:

  • 关键桥接调用日志(方法名、参数摘要、耗时、结果)
  • 登录态快照(token 长度、来源、checkLogin 状态)
  • 链路 traceId/callId(RN -> Native -> API 串联)

九、我们踩过的坑(简版)

  • 业务直接调用 NativeModules,后续桥接升级改动面太大
  • 页面 push 到 RN 容器导航栈,导致 TabBar 和回退行为异常
  • token 读取只有单来源,首进页面偶发鉴权失败
  • 没有兼容回退机制,新桥接异常会直接影响主流程

十、落地建议(给想做增量迁移的团队)

  • 先定职责边界,再写桥接
  • 桥接统一出口,避免业务散落调用
  • 新旧能力并存期必须有 fallback
  • 做最小可用观测:日志 + 快照 + 错误码归一
  • 混编治理目标是稳定交付,不是追求架构名词

适用场景

  • 存量 iOS 项目需要增量接入 RN
  • 业务高频迭代,且不能接受一次性重写风险
  • 团队对稳定性、回滚能力有明确要求

不适用场景

  • 纯新项目且团队 RN/原生边界不清
  • 缺少日志与发布治理能力,无法支撑混编复杂度

总结

RN 混编不是简单上 RN 页面,核心是治理跨端边界、时序一致性、导航状态和容错可观测。
业务型 App 的目标不是炫技,而是:可迭代、可回滚、可维护、线上稳

Logo

一站式 AI 云服务平台

更多推荐