[Tauri][React]性能采集工具数据展示卡顿优化
Tauri + React 写的 Android 性能采集工具(每秒通过 adb 采集系统内存与进程 PSS 并实时画折线图),数据展示「时不时卡一下」。排查后定位到两个根因:后端同步命令阻塞 UI 主线程 + 前端每次渲染都全量重算图表。
根因 1(主因):Tauri 同步命令阻塞主线程
每秒采集会调用 adb_system_memory(cat /proc/meminfo)和 adb_pss_detail(dumpsys 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_GROUPS、PARAM_GROUP_KEY 及类型 MetricGroup / Series / Source;TimelineView.tsx 与 MainView.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 失效:组件文件只导出组件,常量/工具函数/类型拆到单独文件。
[Tauri][React]性能采集工具数据展示卡顿优化
根因 1(主因):Tauri 同步命令阻塞主线程
每秒采集会调用
adb_system_memory(cat /proc/meminfo)和adb_pss_detail(dumpsys meminfo,较慢,常需几百毫秒)。这两个原本是同步命令。Tauri v2 官方文档明确说明:
也就是说,同步命令运行在主线程上,每秒采集都会在 adb 子进程返回前冻结 WebView —— 这正是周期性卡顿的来源。
修复:用
spawn_blocking把阻塞子进程调用挪到专用线程池把命令改成
async fn,并通过tauri::async_runtime::spawn_blocking执行阻塞逻辑(注意#[tauri::command(async)]内部用的是spawn而非spawn_blocking,对长阻塞任务不够,所以显式用spawn_blocking)。命令薄封装,真正的同步逻辑保留在私有函数里:
根因 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,避免破坏记忆化。父组件渲染时按值传
hiddenSig:附带问题:Vite Fast Refresh 报 export 不兼容
改完后 dev 启动报:
原因:React Fast Refresh 要求一个文件只导出组件。
TimelineView.tsx同时export default(组件)和export const PARAM_GROUP_KEY(普通常量),二者混在一起会让 HMR 失效。修复:把非组件常量拆到独立模块
新建
src/views/metricGroups.ts,存放PARAM_GROUPS、PARAM_GROUP_KEY及类型MetricGroup/Series/Source;TimelineView.tsx与MainView.tsx都从该模块导入。TimelineView.tsx此后只导出默认组件,Fast Refresh 恢复正常。推荐组合 / 排查清单
async fn+tauri::async_runtime::spawn_blocking,别用裸同步#[tauri::command]。React.memo,重计算包useMemo,回调包useCallback,集合类状态尽量用「按值比较的签名」传子组件。