React Native iOS 模拟器启动白屏:一次从日志到主线程采样的排查实录

10 阅读8分钟

React Native iOS 模拟器启动白屏:一次从日志到主线程采样的排查实录

案例环境:React Native 0.72.4、@react-native-community/datetimepicker 8.2.0、iOS 26.4 模拟器、x86_64 模拟器进程(Apple Silicon 上经 Rosetta 翻译)。本文记录的是一次具体排查;不同版本的线程行为和根因可能不同。

先说结论

App 已经编译、安装并创建了进程,但 React Native 准备原生模块时,RNDateTimePickerManagerinit 中提前创建 UIDatePicker。在这次 iOS 26.4 x86_64 模拟器运行中,主线程长时间停在日期选择器的系统字体初始化调用链,导致首屏无法绘制,看起来就像“App 启动后一直白屏”。

把日期选择器改为真正需要时再初始化,并确保 UIKit 控件仍在主线程创建后,同一台 iOS 26.4 模拟器上的首页可以正常显示。这验证了启动白屏的修复,但不等于已经验证所有日期选择器交互。

这次最值得复用的不是某一行补丁,而是排查路径:先判断问题发生在哪一层,再用线程栈找阻塞点,最后在原环境复测。

现象:日志很多,但没有明确的异常

最初还遇到过 Bugly 的模拟器架构链接错误。那是构建阶段的问题;处理后,Xcode 已能完成编译,应用也能安装和启动。随后出现的是另一个问题:模拟器中只有白屏,进程没有立即退出。

控制台能看到类似信息:

warning: empty dSYM file detected
warning: failed to mark memory(GRAPHICS)
objc: Class AKAlertImageURLProvider is implemented in both AuthKit and AuthKitUI
`UIScene` lifecycle will soon be required
An empty string is not a valid group container identifier
Running application Daling072 ({ initialProps = {}; rootTag = 1; })
Failed to send CA Event for app launch measurements ... FirstFramePresentationMetric

这里有个常见陷阱:最后出现的日志,不一定是导致白屏的日志。

日志当时能得出的结论
empty dSYM调试符号不完整;不能据此推断首屏为何不显示。
AuthKit 类重复来自模拟器的系统框架;没有证据表明项目重复定义了这些类。
UIScene 提醒生命周期兼容性提示,不是当前阻塞位置。
App Group 标识为空可能是需要单独处理的配置问题;但它本身没有证明是这次白屏的原因。
Running application ... rootTag = 1React Native 调用了应用入口,不代表已经完成首帧绘制
FirstFramePresentationMetric 发送失败可以作为“首帧没正常出现”的伴随现象,不能倒推它是根因。

既然 App 没退出,而界面迟迟不出现,下一步应该问:主线程、JS 线程和 React Native 的渲染队列分别在做什么?

第一步:先检查 JS 开发服务器

Debug 构建白屏时,先排除最常见的 Metro 连接问题:

curl -sS http://localhost:8081/status

正常情况下会返回类似 packager-status:running。还可以检查 bundle 请求是否成功;以下命令会实际请求并丢弃 bundle 内容,只打印 HTTP 状态码:

curl -sS -o /dev/null -w '%{http_code}\n' \
  'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false'

本次 Metro 正常,bundle 请求返回了 200。这不能排除所有 JS 逻辑问题,但让“Metro 未启动或 bundle 根本没取到”的假设失去优先级。

如果这里失败,就先检查 Metro、模拟器与宿主机的网络、8081 端口,以及 App 实际请求的 bundle URL;不要急着分析 UIKit。

第二步:白屏时采样,而不是只盯着控制台

在 Xcode 中可以点击调试器的“暂停”,展开各线程调用栈。命令行也可以对仍在运行的 App 做采样:

# 找到目标 App 的进程 ID;如有多个同名进程,要先确认是哪一个
pgrep -fl Daling072

# 将 <PID> 换成目标进程 ID;输出文件放在临时目录
sample <PID> 5 -file /tmp/rn-ios-white-screen.sample.txt

# 在报告里找项目代码和关键线程
rg -n 'com.apple.main-thread|com.facebook.react.JavaScript|ShadowQueue|RNDateTimePicker' \
  /tmp/rn-ios-white-screen.sample.txt

这次采样持续约 4 秒,报告中的主线程 4,122 次样本都落在同一条调用链。删去系统内部的中间层后,核心路径是:

主线程
└─ RCTCxxBridge 准备原生模块
   └─ RCTModuleData 创建模块实例
      └─ RNDateTimePickerManager init
         └─ RNDateTimePicker initWithFrame
            └─ UIDatePicker initWithFrame
               └─ UILabel 默认字体初始化
                  └─ CoreText / Rosetta JIT

与此同时,React Native 的 ShadowQueue 在等待主线程;JS 线程在事件循环里等待,没有看到它在持续执行 JS 计算。这个组合比“控制台有一条 warning”强得多:首屏所需的主线程没有返回,其他工作也无法继续完成。

报告里出现 Rosetta JIT,只能说明该进程是翻译运行的 x86_64 进程,以及采样时调用链到达那里。它不足以单独证明 Rosetta 本身存在 bug,也不足以断言所有 iOS 26.4 模拟器都会如此。我们定位到的是“这个环境中,启动期创建日期选择器的路径持续阻塞”。

第三步:用对照实验缩小范围

同一份 x86_64 应用在临时的 iOS 18.3 模拟器上能进入应用界面,而在 iOS 26.4 模拟器上白屏。这排除了“这份构建在所有模拟器上都无法运行”的猜测,让我们更有理由检查系统版本、架构翻译与原生控件初始化之间的交互。

对照实验的价值是缩小范围,不是直接给系统定罪。真正决定修复位置的,仍然是上一节的主线程调用栈。

第四步:沿调用栈读源码

RNDateTimePickerManager 原先在管理器创建时就实例化日期选择器,结构大致如下:

- (instancetype)init {
  if (self = [super init]) {
    _picker = [RNDateTimePicker new];
  }
  return self;
}

问题在于:React Native 启动时会准备原生模块。即使首页没有日期选择器,管理器的初始化也可能提前运行,于是 UIDatePicker 被放进了首屏的关键路径。采样里的 RCTCxxBridge → RNDateTimePickerManager init → UIDatePicker 正好与代码吻合。

修复思路是推迟这个用于 shadow view 测量的 _picker 的创建。当前项目补丁中的关键代码是:

- (RCTShadowView *)shadowView
{
  if (_picker == nil) {
    RCTUnsafeExecuteOnMainQueueSync(^{
      if (self->_picker == nil) {
        self->_picker = [RNDateTimePicker new];
      }
    });
  }

  RNDateTimePickerShadowView* shadowView = [RNDateTimePickerShadowView new];
  shadowView.picker = _picker;
  return shadowView;
}

这里有两个要点:

  1. 按需创建:启动时只注册/准备管理器,不立即创建 UIDatePicker。需要日期选择器的 shadow view 时才创建。
  2. 遵守 UIKit 线程要求:shadow view 的调用线程不能想当然地视为主线程。创建 UIKit 控件时显式切到主线程;这里需要同步拿到 _picker,所以不是简单改成异步派发。

直接改 node_modules 会在重装依赖后丢失,所以这项修改保存在项目的日期选择器补丁。项目已有 postinstall: patch-package,安装依赖时会重放补丁。升级 datetimepicker 时应重新审查补丁是否还适用,不能直接假设新版本仍有相同实现。

第五步:在原环境验证,而不只看编译成功

验证分为三层:

  1. 补丁可用:检查补丁格式和工作区改动,例如 git apply --reverse --check patches/@react-native-community+datetimepicker+8.2.0.patchgit diff --check
  2. 工程可编译:用原来的 iOS 26.4 模拟器目标重新编译,确认 ObjC 改动能通过编译和链接。
  3. 运行结果改变:安装最终构建,在原来白屏的模拟器上重新冷启动;等待启动过渡后,确认首页实际显示。

这次三层均通过。第一次立即截图仍可能抓到启动过渡中的黑屏,所以要稍等片刻并观察最终画面,不能拿一张过早的截图下结论。

仍有一个明确的验证边界:尚未在 iOS 26.4 模拟器里打开日期选择器并完成选择。 当前改动解决了“未使用日期选择器也在启动时初始化它”的问题;如果实际打开日期选择器时仍卡住,需要把那个交互作为新的复现步骤继续采样。

下次遇到白屏,可以照着这个顺序做

构建/安装失败?
├─ 是 → 先看编译、链接、架构和签名错误
└─ 否 → App 进程启动后还活着吗?
   ├─ 否 → 找崩溃报告和异常堆栈
   └─ 是 → Metro 与 bundle 请求是否正常?
      ├─ 否 → 查开发服务器、端口、网络和 bundle URL
      └─ 是 → 白屏时暂停或采样进程
         ├─ JS 线程持续忙 → 查 JS 循环、同步计算、渲染逻辑
         ├─ 主线程持续忙/等待 → 顺栈查原生模块和 UIKit
         └─ 线程都在等待 → 查线程间依赖、同步派发、锁和回调

这个流程的核心是:用可观察的线程状态决定下一步,而不是按日志出现的先后顺序猜测。 对启动问题尤其要区分三个里程碑:进程创建、React Native 调用应用入口、首帧真正显示。它们不是同一件事。