1. 前提

项目背景

  • 项目运行在 React Native 0.62.2
  • RN 页面在 Android 宿主 App 中加载
  • 开发环境下命令启动 Metro:node node_modules/react-native/local-cli/cli.js start
  • 页面里使用递归 setTimeout(..., 3000) 做轮询刷新
  • App 中开启 Debug 模式后,可以在 http://localhost:8081/debugger-ui/ 看到 JS 执行情况

发现问题

在模拟移动设备的系统时间更改进行调试时,React Native 页面出现以下异常:

  • 系统时间为正常日期时,页面刷新正常
  • 把设备系统时间调到昨天,页面数据几乎不刷新
  • 把设备系统时间调到明天,页面刷新速度异常快

值得注意的是,发现现象只出现在开发环境的 Debug 模式下。

先说结论:

在 RN Remote Debug 模式下,JS 在 Chrome 里运行,但 timer 和网络等能力仍依赖 App Native。只要 JS 侧时间和设备侧时间不同步,setTimeout 就可能不再是你以为的那个 setTimeout

2. RN 页面里的 JS 如何执行

在 RN 开发环境中,需要先区分几个概念:

运行开发环境

npm run start

  • 实际运行 node node_modules/react-native/local-cli/cli.js start
  • 启动 Metro,用于提供 JS bundle

执行 npm run start 只表示 Metro 服务启动了,并不代表 JS 一定在 Chrome 中执行。真正改变 JS 执行位置的是 Debug JS Remotely

未开启 Debug JS Remotely 时,JS 执行模型如下:

Android App 进程

Native UI

Native Modules

App 内 JS Engine
JSC / Hermes

执行 RN JS bundle

业务组件 / 状态逻辑 / timer 注册

此时 JS bundle 在 Android App 进程内执行Date.now() 来自设备内的 JS runtime。

开启 Debug JS Remotely 后,执行模型变为:

WebSocket

WebSocket

Android App 进程

Native UI

Native Modules

Metro debugger proxy

Chrome debugger-ui

Web Worker

执行 RN JS bundle

业务组件 / 状态逻辑 / timer 注册

此时 RN 业务 JS 实际运行在 Chrome 页面创建的 Web Worker 中。也就是说,Remote Debug 不只是“多了一个控制台”,而是 JS 的执行位置真的从 App 进程切到了 Chrome 环境。

这会影响以下行为的运行环境:

  • console.log
  • 断点调试
  • JS stack trace
  • Date.now()
  • setTimeout 注册 timer 时传入 Native 的 JS 时间

其中,Date.now() 的来源变化是本次问题的关键。

JS 中的 Timer 在开发环境下的执行链路

业务侧有一个重要背景:页面大量使用递归 setTimeout(..., refreshTime) 做轮询刷新。

fetchData() {
	this.timer = setTimeout(() => {
  		this.fetchData();
	}, 3000);
}

在 RN Android 中,timer 并不是完全由 JS runtime 独立完成,而是由 JS 注册、Native 调度,再回调 JS 执行。

非 Debug JS Remotely 模式

未开启 Remote Debug 时,JS 在 Android App 内执行。timer 链路如下:

业务代码调用 setTimeout

JSTimers.js
Date.now 来自 Android 设备

NativeTiming.createTimer

TimingModule.java

JavaTimerManager.java

使用设备时间计算 adjustedDuration
adjustedDuration = Math.max(0, remoteTime - deviceTime + duration)

ReactChoreographer frame callback

callTimers 回调 JS

执行业务刷新逻辑

此时 JSTimers.js 中的 Date.now() 和 Native 侧的 SystemClock.currentTimeMillis() 都来自 Android 设备,时间同源。

RN 之所以要计算这两个时间的差值,是因为 timer 不是在 JS 里独立倒计时完成的。JS 调用 setTimeout 时,只是把“timer 创建时的 JS 时间”和“希望延迟多久”一起传给 Native;真正负责调度的是 Native。

Native 收到 timer 请求时,时间已经过去了一小段。为了让 timer 尽量在 JS 期望的目标时间触发,Native 不能简单地从收到请求那一刻再完整等待 duration,否则 JS 到 Native 的通信耗时会被额外算进去。

因此 Native 实际要计算的是“距离 JS 原本期望触发时间还剩多久”:

在源码中体现为:

long adjustedDuration = Math.max(0, remoteTime - deviceTime + duration);

例如 JS 在 10:00:00.000 创建一个 3000ms timer,Native 在 10:00:00.100 收到:

remoteTime = 10:00:00.000
deviceTime = 10:00:00.100
duration = 3000

adjustedDuration = remoteTime - deviceTime + duration
                 = -100 + 3000
                 = 2900ms

也就是说,Native 会再等约 2900ms,让最终触发时间仍然尽量接近 JS 创建 timer 后的第 3000ms

因此下面这个差值通常只是一个很小的负数:

remoteTime - deviceTime

最终可以近似理解为:

adjustedDuration ≈ duration

业务中的 3000ms 轮询基本仍按 3 秒触发。

Debug JS Remotely 模式

开启 Remote Debug 后,JS 在 Chrome Web Worker 中执行,但 Native timer 仍然在 Android App 中调度。

timer 链路如下:

业务代码调用 setTimeout

JSTimers.js
Date.now 来自 Chrome / 电脑

NativeTiming.createTimer

通过 debugger proxy / bridge 发送到 App

TimingModule.java

JavaTimerManager.java
deviceTime 来自 Android 设备

计算 adjustedDuration

ReactChoreographer frame callback

callTimers 回调 Chrome Worker 中的 JS

执行业务刷新逻辑

因为此时两端时间来源已经分离:

remoteTime = Chrome / 电脑环境中的 Date.now()
deviceTime = Android 设备中的 SystemClock.currentTimeMillis()

也就是说,remoteTime - deviceTime 不再稳定表示“JS 创建 timer 到 Native 收到 timer 的通信耗时”,而可能混入“电脑系统时间和手机系统时间的差值”。

下面以业务里常见的 3000ms 轮询为例,分两种调整系统时间的情况看。

  • 情况一:Android 设备系统时间调到昨天

假设电脑时间不变,Chrome 中的 JS 仍然认为当前是今天 10:00:00.000,但 Android 设备系统时间被调到昨天 10:00:00.100

此时 JS 创建 timer 时传给 Native 的 remoteTime 大约比 Native 当前的 deviceTime 大 24 小时:

adjustedDuration = remoteTime - deviceTime + duration
                 ≈ 24小时 - 100ms + 3000ms
                 ≈ 24小时 + 2900ms

这意味着业务代码里原本希望 3s 后执行的 timer,会被 Native 认为应该在接近 24小时 后才触发。

页面表现上就是: 轮询请求突然不再按 3 秒刷新,页面看起来像“卡住了”或“数据不刷新”。
此时不是接口没有返回,而是 timer 被排到了很久以后。

  • 情况二:Android 设备系统时间调到明天

假设电脑时间不变,Chrome 中的 JS 仍然认为当前是今天 10:00:00.000,但 Android 设备系统时间被调到明天 10:00:00.100

此时 JS 创建 timer 时传给 Native 的 remoteTime 大约比 Native 当前的 deviceTime 小 24 小时:

adjustedDuration = Math.max(0, remoteTime - deviceTime + duration)
                 ≈ Math.max(0, -24小时 - 100ms + 3000ms)
                 = 0

这意味着业务代码里原本希望 3s 后执行的 timer,会被 Native 修正成“立即触发”。

如果业务代码是递归轮询,那么每次 fetchData() 后重新注册 timer,又会再次被计算成 0ms
页面表现就是:请求频率异常变高,页面持续高频刷新,CPU、网络请求、日志量都可能明显上升。

所以在Remote Debug 下,adjustedDuration 的计算仍然沿用“JS 时间和 Native 时间同源”的假设;当电脑时间和 Android 设备时间不一致时,RN 可能把两台机器的系统时间差误当成通信延迟补偿,导致 timer 被推迟很久或立即触发。

3. 问题归因

  • 开发环境下,开启了dubug模式。
  • RN Debug JS Remotely 模式下,JS 在 Chrome 中执行,Native timer 在 Android App 中调度。
  • RN 0.62.2 Android timer 创建时,会把 JS 的 Date.now() 传给 Native。
  • Native 使用 JS 时间和设备时间计算 adjustedDuration。
  • 当 Android 设备系统时间被改到昨天或明天,而 Chrome 所在电脑时间不变时,RN 会把两端 wall-clock 的时间差误判为 JS 到 Native 的通信延迟。

关闭 Debug 后恢复正常,是因为 JS 回到 Android App 内执行,Date.now()SystemClock.currentTimeMillis() 重新来自同一设备。

4. 扩展思考

Debug 模式下 App 与 Chrome 如何通信?

开启 Debug JS Remotely 后,App 和 Chrome 通常不是直接点对点通信,而是通过 Metro 的 debugger proxy 转发。

通信链路如下:

WebSocket

WebSocket

Android App
role=client

Metro debugger proxy
:8081/debugger-proxy

Chrome debugger-ui
role=debugger

Web Worker
创建 JS runtime

执行 RN JS bundle

App 侧连接的是:

ws://<MetroHost>:8081/debugger-proxy?role=client

Chrome debugger-ui 侧连接的是:

new WebSocket(
  'ws://' + window.location.host + '/debugger-proxy?role=debugger&name=Chrome'
)

Metro 作为中间代理,把 App 和 Chrome 之间的消息互相转发。

Chrome debugger-ui 收到 App 发来的消息后,会处理类似:

{"id":0,"method":"prepareJSRuntime"}

随后创建 JS runtime,也就是 Web Worker。

当 App 要加载业务 bundle 时,会发送:

{
  "id": 1,
  "method": "executeApplicationScript",
  "url": "http://localhost:8081/index.bundle?...",
  "inject": {}
}

Worker 中执行:

importScripts(message.url);

因此,开启 Remote Debug 后,RN 的业务 JS bundle 实际是在 Chrome 页面里的 Web Worker 中执行的。

Debug 模式下 HTTP 请求也是 Chrome 浏览器发出的吗?

虽然 JS 在 Chrome Web Worker 中执行,但正常 RN 业务代码里的 HTTP 请求通常不是 Chrome 直接发出的,而是通过 RN 的 Native 网络模块在 App 侧发出。

RN 初始化时会替换浏览器原生的 XMLHttpRequestfetch

polyfillGlobal('XMLHttpRequest', () => require('../Network/XMLHttpRequest'));
polyfillGlobal('fetch', () => require('../Network/fetch').fetch);

RN 源码注释中也说明了原因:Chrome 原生 XHR 有 CORS 限制,所以 RN 使用自己的网络实现。

实际请求链路如下:

Chrome Web Worker 中的业务 JS

fetch / XMLHttpRequest

RN polyfill
替换浏览器原生网络 API

RCTNetworking.sendRequest

NativeNetworkingAndroid.sendRequest

Android NetworkingModule

OkHttp
App 进程中发起真实 HTTP 请求

接口服务器

响应返回 Android Native

结果回传给 Chrome Worker 中的 JS

所以 Remote Debug 下要区分:

  • JS 执行位置:Chrome Web Worker
  • HTTP 请求实际发送位置:Android App Native / OkHttp

这也解释了为什么很多 RN 请求在 Chrome Network 面板中看不到真实接口请求,因为接口请求不是 Chrome 发出的。

6. 结论

React Native 0.62.2 的 Android timer 在 Remote Debug 场景下依赖 JS 侧传入的 wall-clock 时间,并在 Native 侧与设备时间做差来修正 delay。

这个设计在电脑和设备时间一致时没有明显问题,但一旦手动修改 Android 设备系统时间,就会导致 timer 调度严重失真。关闭 Remote Debug 后或者在 release 包里,不会出现这个问题。

本问题并不是普通业务刷新逻辑错误,也不是接口缓存问题,而是以下因素共同造成的开发环境异常:

  • RN Debug JS Remotely
  • JS 在 Chrome Web Worker 执行
  • Native timer 在 Android App 调度
  • 两端系统时间不一致
  • 业务大量递归 setTimeout 轮询
Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐