Skip to content

[Tauri][React]性能采集工具数据展示卡顿优化 #47

Description

@nzcv

[Tauri][React]性能采集工具数据展示卡顿优化

Tauri + React 写的 Android 性能采集工具(每秒通过 adb 采集系统内存与进程 PSS 并实时画折线图),数据展示「时不时卡一下」。排查后定位到两个根因:后端同步命令阻塞 UI 主线程 + 前端每次渲染都全量重算图表。

根因 1(主因):Tauri 同步命令阻塞主线程

每秒采集会调用 adb_system_memorycat /proc/meminfo)和 adb_pss_detaildumpsys meminfo,较慢,常需几百毫秒)。这两个原本是同步命令

Tauri v2 官方文档明确说明:

Commands without the async keyword are executed on the main thread unless defined with #[tauri::command(async)].

也就是说,同步命令运行在主线程上,每秒采集都会在 adb 子进程返回前冻结 WebView —— 这正是周期性卡顿的来源。

修复:用 spawn_blocking 把阻塞子进程调用挪到专用线程池

把命令改成 async fn,并通过 tauri::async_runtime::spawn_blocking 执行阻塞逻辑(注意 #[tauri::command(async)] 内部用的是 spawn 而非 spawn_blocking,对长阻塞任务不够,所以显式用 spawn_blocking)。

/// 把阻塞的 adb 逻辑丢到专用线程池,避免冻结 Tauri 主线程/UI。
async fn spawn_adb<T, F>(f: F) -> Result<T, String>
where
    F: FnOnce() -> Result<T, String> + Send + 'static,
    T: Send + 'static,
{
    tauri::async_runtime::spawn_blocking(f)
        .await
        .map_err(|e| format!("adb 任务执行失败: {e}"))?
}

命令薄封装,真正的同步逻辑保留在私有函数里:

#[tauri::command]
pub async fn adb_pss_detail(serial: String, pid: u32) -> Result<PssDetail, String> {
    spawn_adb(move || pss_detail(&serial, pid)).await
}

fn pss_detail(serial: &str, pid: u32) -> Result<PssDetail, String> {
    // ...原同步实现(run_adb + 解析)...
}

提示:把签名里的 &serial(原 String)改成 &str 后,run_adb(&["-s", serial, ...]) 直接传 serial 即可,不要再写 &serial(会变成 &&str)。

根因 2:前端每次渲染全量重算图表

TimelineView每次渲染都会对每个分组的每条曲线扫描全部样本算 Y 轴最大值、并拼接折线点串(Memory Detail 有 18 条曲线 × 最多 330 个样本)。任何无关状态变化(搜索框输入、折叠面板、切换某个分组)都会触发所有图表重算。

修复:拆 React.memo 组件 + useMemo 缓存重计算

  • 把每个图表分组拆成独立的 React.memo 组件 ChartGroup
  • 把「最大值扫描 + 折线点串生成」放进 useMemo,只在样本缓冲 / 时间窗口 / 本组可见性变化时才重算。
  • 用字符串签名 hiddenSig(如 "01001")传递每组显隐状态,而不是每次 toggle 都换 identity 的 Set,这样 React.memo 能按值比较、正确跳过重渲染。
  • 把回调(toggleSeries / setGroupSeries / toggleHidden)用 useCallback 固定 identity,避免破坏记忆化。
const ChartGroup = memo(function ChartGroup({ group: g, samples, startT, windowStartSec, hiddenSig, ... }: ChartGroupProps) {
  const hiddenByIndex = useMemo(
    () => g.series.map((_, i) => hiddenSig[i] === "1"),
    [g.series, hiddenSig]
  );

  // 仅在 samples / 窗口 / 本组可见性变化时重算
  const chart = useMemo(() => {
    let max = 0;
    for (let i = 0; i < g.series.length; i++) {
      if (hiddenByIndex[i]) continue;
      for (const sample of samples) {
        const v = valueAt(sample, g.source, g.series[i]);
        if (v > max) max = v;
      }
    }
    if (max <= 0) max = 1;
    const axisMax = max * 1.1;
    const lines = g.series.map((s, i) => ({
      name: s.name, color: s.color, hidden: hiddenByIndex[i],
      points: hiddenByIndex[i] ? "" : pointsFor(samples, startT, windowStartSec, g.source, s, axisMax),
    }));
    return { axisMax, lines };
  }, [g, hiddenByIndex, samples, startT, windowStartSec]);
  // ...render...
});

父组件渲染时按值传 hiddenSig

{groups.map((g) => (
  <ChartGroup
    key={g.key}
    group={g}
    samples={samples}
    startT={startT}
    windowStartSec={windowStartSec}
    hiddenSig={g.series
      .map((s) => (hiddenSeries.has(seriesKey(g.key, s.name)) ? "1" : "0"))
      .join("")}
    onToggleSeries={toggleSeries}
    onSetGroupSeries={setGroupSeries}
    onToggleHidden={onToggleHidden}
    /* ... */
  />
))}

附带问题:Vite Fast Refresh 报 export 不兼容

改完后 dev 启动报:

[vite] (client) hmr invalidate /src/views/TimelineView.tsx
Could not Fast Refresh ("PARAM_GROUP_KEY" export is incompatible).

原因:React Fast Refresh 要求一个文件只导出组件TimelineView.tsx 同时 export default(组件)和 export const PARAM_GROUP_KEY(普通常量),二者混在一起会让 HMR 失效。

修复:把非组件常量拆到独立模块

新建 src/views/metricGroups.ts,存放 PARAM_GROUPSPARAM_GROUP_KEY 及类型 MetricGroup / Series / SourceTimelineView.tsxMainView.tsx 都从该模块导入。TimelineView.tsx 此后只导出默认组件,Fast Refresh 恢复正常。

推荐组合 / 排查清单

  • Tauri 卡 UI:凡是耗时(IO / 子进程 / CPU)的命令,一律 async fn + tauri::async_runtime::spawn_blocking,别用裸同步 #[tauri::command]
  • React 实时图表卡顿:高频更新的列表/图表拆 React.memo,重计算包 useMemo,回调包 useCallback,集合类状态尽量用「按值比较的签名」传子组件。
  • Fast Refresh 失效:组件文件只导出组件,常量/工具函数/类型拆到单独文件。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions