陀螺仪10ms采一次,UI跟着抖了100Hz

一个体感交互页面,陀螺仪和加速度计同时订阅,目标采样间隔 10ms。第一版逻辑很直接:sensor.on 的回调里拿到数据,立刻给 @State 赋值,界面跟着刷新。单传感器调试时页面能跑,两个传感器一起上,几秒钟之后滑动开始掉帧,十几秒后应用直接崩溃,日志里没有任何业务异常,堆栈落在原生侧。你把采样频率调低,崩溃概率小了一点,但没有消失。你开始怀疑是传感器硬件的问题,或者 NAPI 桥接写错了。

问题不在硬件,也不在桥接本身,在于你把"采样频率"和"UI 刷新频率"当成了同一件事。10ms 采一次数据是合理的,但 10ms 更新一次 UI 状态是不合理的。传感器回调的频率应该由物理世界决定,UI 的刷新频率应该由屏幕决定,两者之间必须有一层缓冲和节流。你把它们焊死在同一条回调链上,高频数据就会拖着组件树一起跑,跑到系统替你踩刹车。

interval 的单位是纳秒,写 20 不是 20 毫秒

先把这个最常见的误判钉死。sensor.on 的 interval 参数单位是纳秒,不是毫秒。官方文档的原话是:该传感器支持的最小采样周期为 5000000 纳秒,最大采样周期是 200000000 纳秒;大于最大值时以最大值上报,小于最小值时以最小值上报。换算过来,加速度计最快 5ms(200Hz),最慢 200ms(5Hz)。

这意味着 { interval: 20 } 想表达"20 毫秒"是错误的。20 纳秒远低于下限,系统会按最小周期 5ms 上报,功耗和回调压力都比你预期的高一个数量级。反过来,把 interval 调到比 200ms 更大也不会更省电,系统仍然按 200ms 上报。省电只能靠减少订阅路数或者改用 once 取单点,不能靠把周期拉到超出上限。

正确的写法是把毫秒换算成纳秒再传:

typescript

复制代码
import { sensor } from '@kit.SensorServiceKit';

// 50Hz = 20ms = 20,000,000ns,在 5ms~200ms 区间内
const INTERVAL_NS = 20_000_000;

sensor.on(sensor.SensorType.GYROSCOPE, {
  interval: INTERVAL_NS
}, (data: sensor.GyroscopeResponse) => {
  // 回调里只做一件事:写入普通缓冲,不碰 @State
  this.latestGyro = { x: data.x, y: data.y, z: data.z };
});

interval 在订阅参数里,不在回调里。不同设备支持的传感器范围可能不同,稳妥的做法是在订阅之前先 getSensorList() 确认目标传感器存在,并从 SensorInfo 读取它的最小和最大上报间隔,而不是假设所有设备都适合同一个数值。订阅一个不存在的传感器时,sensor.on 可能直接抛 BusinessError,所以能力探测要放在订阅之前,而不是靠 try/catch 兜底。

回调里直接 setState,组件树替你扛了每一次物理采样

第二层问题比单位更根本。sensor.on 的回调默认在 JS 主线程执行,所以它不存在 NAPI 那种跨线程崩溃的问题。但"线程安全"不等于"性能安全"。回调里直接给 @State 赋值,每一次物理采样都会触发一次状态变更通知、一次组件重渲染调度。10ms 一次,就是 100Hz 的状态变更频率,而屏幕通常只有 60Hz 或 120Hz。中间的几十次更新对用户来说不可见,但组件树、diff 算法和布局管线全部要走一遍。

崩溃的堆栈落在原生侧,不代表根因在原生侧。高频回调把主线程的时间切片切碎,渲染任务排不上队,原生图形管线的队列持续积压,最终触发系统级的保护。你把频率调低之后崩溃概率降低但没有消失,正是因为 50Hz 或 30Hz 对主线程来说仍然偏高,只是积压得慢了一些。

正确的分界是:传感器回调只负责"把数据接住",UI 只负责"按屏幕节奏取最新值"。两者之间隔一层缓冲,缓冲满了覆盖最旧数据,UI 按固定间隔批量取走。官方给出的参考结构是 Native 侧用环形缓冲接数据,ArkTS 侧每 200ms 批量取一次,页面只消费最新的一帧。

typescript

复制代码
// 回调只写环形缓冲,不做任何 UI 操作
private ring: Array<{ x: number; y: number; z: number; ts: number }> = [];
private readonly RING_SIZE = 20;

private onGyro = (data: sensor.GyroscopeResponse): void => {
  if (this.ring.length >= this.RING_SIZE) {
    this.ring.shift();               // 满后覆盖最旧,不是丢最新
  }
  this.ring.push({ x: data.x, y: data.y, z: data.z, ts: Date.now() });
};

// UI 侧按批次取,而不是每条回调取
private startUiBatch(): void {
  this.batchTimer = setInterval(() => {
    if (this.ring.length === 0) return;
    const last = this.ring[this.ring.length - 1];  // 只消费最新值
    this.latestX = last.x;                          // @State 赋值
    this.latestY = last.y;
    this.latestZ = last.z;
    this.ring = [];                                 // 清空已消费
  }, 200);
}

ring.shift() 和 ring = [] 都是普通数组操作,不触发任何响应式更新。latestX 这类 @State 只在 200ms 的批次回调里赋值,UI 的刷新频率从 100Hz 降到了 5Hz,而传感器仍然以 10ms 的精度在采集。如果业务算法需要全部采样,完整数据在环形缓冲或后台处理链路里,UI 展示层不需要为算法精度负责。

DisplaySync 和 postFrameCallback,比固定 interval 更贴屏幕

200ms 的 setInterval 是一个务实的起点,但它不和屏幕刷新对齐,在可变刷新率屏幕上可能出现"这一帧没更新,下一帧跳两帧"的轻微不平滑。更贴屏幕的方式是让 UI 消费跟上帧节奏。

displaySync 从 @kit.ArkGraphics2D 导入,可以注册帧回调,在每一帧读取最新缓存值并提交给 @State。UIContext.postFrameCallback 则是下一帧的一次性回调,适合"有新数据时才预约下一帧"的按需模式。两者的共同点是:采集侧只覆盖普通变量中的最新值,帧回调里才做状态赋值。

typescript

复制代码
import { displaySync } from '@kit.ArkGraphics2D';

function consumeLatest(
  read: () => number,
  commit: (v: number) => void
): () => void {
  const sync = displaySync.create();
  let active = true;
  let previous = Number.NaN;

  const frame = (): void => {
    if (!active) return;
    const value = read();           // 读普通变量,不读状态
    if (value !== previous) {
      previous = value;
      commit(value);                // 只在变化时写 @State
    }
  };

  sync.on('frame', frame);
  sync.start();

  return (): void => {
    active = false;
    sync.stop();
    sync.off('frame', frame);       // 退场必须成对取消
  };
}

read 是一个普通函数,返回环形缓冲里的最新值,不经过任何响应式包装。commit 才是 @State 赋值,而且只在值真正变化时执行。active 和代次标记(如果存在重复订阅)都应该是普通变量,不要加状态装饰器。这个模式舍弃了中间的显示值,适合仪表预览;如果算法需要全部采样,完整数据在后台处理完之后再按帧显示结果,帧回调不保证固定硬件刷新率,但它保证每一帧最多只提交一次。

NAPI 侧的高频回调,threadsafe_function 不是可选项

如果传感器订阅放在 Native 层用 NAPI 对接,崩溃会来得更快、更没有规律。硬件回调运行在驱动线程上,直接跨线程调用 JS 函数违反虚拟机的线程规则。必须用 napi_create_threadsafe_function 创建线程安全函数,在硬件回调里通过 napi_call_threadsafe_function 投递数据,由 JS 主线程执行实际的回调。

napi_call_threadsafe_function 返回成功只代表"投递进了队列",不代表回调已经执行。投递的数据所有权必须交给主线程的回调释放,不能在硬件回调返回之后就释放。更稳妥的做法是在 Native 层先做一层采样,每 50ms 只投递一次最新数据,而不是每一条硬件采样都跨语言投递。跨语言调用的固定开销远高于缓冲区里的一次内存写入,把频率降下来比把每次调用优化到极致更有效。

退场不取消订阅,回调在页面走了之后继续跑

传感器订阅是一个典型的"创建了就必须显式销毁"的资源。sensor.off 不会因为页面 aboutToDisappear 就自动执行。页面退出、组件销毁,回调仍然挂在传感器服务上,继续以 10ms 或 50ms 的频率触发,持有页面闭包,消耗 CPU 和电量。反复进出页面,订阅会累积,回调次数会叠加。

退场时的收尾必须显式、成对、在正确的生命周期里。aboutToDisappear 里调 sensor.off,同时清掉批量取数的 setInterval 或 displaySync 的帧回调。如果用了代次标记来隔离重复订阅,off 之后把代次递增,让已经在途的回调失去写状态的资格。

typescript

复制代码
aboutToDisappear(): void {
  sensor.off(sensor.SensorType.GYROSCOPE, this.onGyro);
  sensor.off(sensor.SensorType.ACCELEROMETER, this.onAccel);
  if (this.batchTimer !== -1) {
    clearInterval(this.batchTimer);
    this.batchTimer = -1;
  }
  this.ring = [];
  this.active = false;   // 普通变量,挡住晚到的帧回调
}

sensor.off 和 sensor.on 用同一个回调引用,不要用匿名函数订阅然后指望 off 能按类型匹配到它。clearInterval 必须在 aboutToDisappear 里执行,一个已经挂起的定时器不会因为页面销毁自动失效。

排查顺序:先看 interval 单位,再看回调里做了什么,最后看退场有没有收尾

下次传感器页面卡顿、发热或者崩溃的时候,按这个顺序走,比翻算法代码快。

第一步,打印 interval 的值和单位。如果你写的是 20 或 50,先按纳秒换算成 20_000_000 或 50_000_000,确认落在传感器支持的区间内。低于下限会被夹到最小周期,回调频率比你预期的高得多。

第二步,在传感器回调的第一行打一个计数器,看它在 1 秒内被调了多少次。如果次数和 @State 赋值次数一致,说明你没有做缓冲和节流,UI 正在跟着物理采样频率跑。把回调里的状态赋值全部搬到一个普通变量或环形缓冲,UI 侧按 100ms 到 200ms 的窗口批量取最新值。

第三步,如果用了 NAPI,确认硬件回调里没有直接碰 JS,threadsafe_function 的投递数据是在主线程回调里释放的,不是在工作线程里提前释放的。

第四步,退出页面再重新进入,看回调计数是否翻倍、aboutToDisappear 里的 off 和 clearInterval 是否都执行了。订阅泄漏不会立刻崩溃,它会让下一次进入页面时的回调压力翻倍。

传感器本身不产生卡顿,产生卡顿的是你让 UI 去追传感器的节奏。10ms 采样是物理世界的精度需求,200ms 批量刷新是屏幕的消费节奏,两者之间隔着一层缓冲,不应该焊死在一起。interval 的单位是纳秒,不是毫秒;回调里只接数据,不写状态;退场时 off 和 clear 一个都不能少。下一次陀螺仪页面抖起来的时候,先数一下回调次数和 @State 赋值次数是不是 1:1------答案通常就在那个比值里。