社区里有一类反复出现的求助:某个菜单栏小工具刚装上时很利索,用一段时间开始变卡,点开菜单要等一下;笔记本合盖放一晚,掉电比没有它的时候明显。这类反馈通常没有日志也没有复现步骤,只有一句”越来越卡”。把它当成某款产品的偶发缺陷来处理没有意义——所有长期驻留在后台的应用(菜单栏工具、同步客户端、剪贴板历史、窗口管理器一类)花的都是同四笔钱,区别只在于有没有人替这四笔账设过上限。

常驻应用的四笔开销:唤醒次数、内存曲线、磁盘写入与能耗档位

先做一个区分,它决定后面所有排查方向。界面卡顿是主线程被占住,用户说的是”点开要等一下”;系统代价是唤醒与资源占用,用户说的是”发热、掉电、整机发钝”。两者的度量口径、定位工具和修法都不一样,混在一起讨论只会得到”重装一下试试”这种没有信息量的结论。

一、四项预算:口径、工具与超预算的表现

预算这个词要按字面理解:先定义允许花多少,再测量实际花了多少。四项预算里最容易搞错的是 CPU 那一项——要看单位时间被唤醒的次数和累计运行时长,而不是瞬时占用率。

预算项 该看的口径 用什么看 超预算时用户的说法
CPU 唤醒频次 + 累计时长,瞬时占用率只能做旁证 活动监视器按进程排序、多次采样的 top、时间剖析工具 “风扇转””机身温”
内存 稳态值与增长斜率是否收敛,不是打开时的峰值 按间隔采样脏内存,画曲线看是否封顶 “开久了越来越钝”
磁盘 I/O 每分钟写入次数与字节数,是否每次改动都全量重写 文件系统活动观察、写入路径日志 “机械盘机型上尤其明显”
能耗 后台唤醒间隔、是否阻止深度睡眠、节流档位是否生效 睡眠断言列表、按进程的能量统计 “合盖一晚掉电”

占用率 3% 听起来毫无威胁,但如果这 3% 是以每秒若干次的方式分摊出去的,代价就完全不同:每次唤醒都要把核心从低功耗状态拉回来、频率爬坡、冷热缓存重新填、周边部件跟着离开省电状态。也就是说,**”每次唤醒”本身有一笔固定成本,它不只取决于占用率高低**。这就是为什么一个功能很轻的工具可以成为整机发热的来源,也是为什么”平均占用不高”不能作为验收结论。

采样动作不需要复杂,难的是坚持”空闲态测量”而不是”操作时测量”:

pmset -g assertions                      # 谁在阻止系统进入深度睡眠
sudo powermetrics --samplers cpu_power -i 5000 -n 3 # 按进程看能量影响(参数以本机工具为准)
top -l 6 -o cpu -n 20 # 连采 6 次,看常驻项是否稳定排在前面

三条命令都要在没有主动操作的状态下跑。人脑前的操作占用是功能成本,空闲时的占用才是预算问题。

二、轮询还是事件:这张判断表应该贴在代码里

后台应用的绝大部分预算超支,最后都能追溯到”本该等通知的事被写成了定时器”。反过来说,也确实有一部分信息只能靠轮询拿到,硬要事件驱动只会写出更复杂的假事件。所以先把边界划清。

想知道的东西 系统里有没有可用通知 建议做法 只能轮询时的控制
前台应用切换、窗口聚焦变化 有(工作区与应用激活类通知) 事件回调里只更新必要状态 不需要轮询
睡眠/唤醒、显示器增减、深浅外观切换 有 订阅后做一次性重算 不需要轮询
某个目录的内容变化 有(文件系统事件机制) 订阅事件 兜底扫描间隔放到分钟级
网络是否可达 有可达性回调 回调改状态,由状态触发重试 请求失败按指数退避,不每秒试
剪贴板内容是否变了 通常只能轮询 单条定时器,先做便宜的比较(变化计数或内容哈希),有变化才处理 空闲放大间隔,用户交互时收紧
其它应用的私有状态(菜单栏条目、别的进程里的窗口列表) 没有,只能主动查询,且跨进程查询本身有代价 只在界面即将展示前查询一次 严禁放进高频定时器
远端服务的配置或版本 没有系统级通知 自己拉,间隔按真实变更频率定 加随机抖动,避免与后端发布同频共振

轮询一旦无法避免,有四条纪律:把所有轮询合并到一条时间线上(三个各跑各的定时器,唤醒次数是三个之和,工作量却常常不如一次合并);给定时器设置容差,让系统有机会把多个唤醒合并成一次;按前后台和”有没有变化”做自适应退避,连续若干次无变化就指数放大间隔;界面关闭或进入后台时降档,而不是保持同一频率。

三、反复刷新里的常见浪费

常驻工具的界面很小,于是刷新逻辑很容易被写得”反正也不大”。问题恰恰出在这里。

  • 每次展开菜单都重建全部条目。条目多、且每个条目还要计算状态时,点开的等待就来自这里。应该延迟构建,只对可见部分计算,展开时才补齐。
  • 每枚图标单独异步加载。异步本身没错,错在没有占位与批量:一批回调在不同帧里到达,视觉上是图标逐个跳出并伴随抖动,实际开销是多次重绘。绘制结果应按倍率缓存,而不是每次现画。
  • 把字符串测量、日期格式化、正则编译留在热路径里。这类操作单次极便宜,乘以刷新频率就不再便宜,而且多数在主线线程上执行。
  • 状态文案每秒变化,触发整条状态栏重排。能显示的信息量有限,刷新频率高于可读频率就是纯浪费。
  • 列表数据源整体重赋而不是做差异更新。全量重赋会丢掉选中状态,也会让滚动位置抖动。
  • 启动路径在主线程同步枚举外部卷、探测网络、跨进程查询外部状态。后果不是启动慢,而是”点开就转圈”,并且在外接存储掉线、网络超时的时候变成永久卡住。正确的做法是分档:先出可交互的壳,再补齐依赖外部条件的内容。

四、缓存没有上限的几种形态

“越用越卡”里属于内存的那一半,多数不是传统意义上的泄漏,而是有界增长没人定界。典型形态是历史记录、剪贴板条目、窗口与图标的映射表、缩略图、日志缓冲。它们的共同点是:设计动机是”下次查起来快”,而”下次”是否真的会发生,没人验证。

处理办法要具体到四条:

  1. 条数上限与字节上限同时设。只设条数的缓存,碰上大对象(图片、序列化数据)照样把内存吃穿。
  2. 淘汰策略写明是 LRU、TTL 还是两者组合,并定义”淘汰时连带释放什么”——定时器、观察注册、文件句柄往往比内存本身更贵。
  3. 缓存 key 不能用时间戳或随机标识。这类 key 每次都是新的,命中率恒为零,缓存退化成一个只进不出的容器。
  4. 检查重复注册。每次界面刷新都添加监听而没有对应移除,是稳态增长最隐蔽的来源,表现为”内存随使用次数阶梯上升”,而且不会崩,只会越来越钝。

区分两种情况的判据是采样曲线:泄漏是不封顶的线性增长;有界增长是先升后平。后者不算 bug,但如果平台上线是”平的”在几十兆,那说明上限定得太随意。缓存上限是设计约束,要写进文档,而不是等人从内存曲线上发现问题。

五、权限、接口变更与重试风暴

这一层最容易被忽略,因为它只在系统升级或用户改了权限之后才暴露。

访问受限区域被拒绝时,如果代码的处理是”下一次再试”,就会形成重试风暴:每次刷新都试、每次都被拒、每次被拒都写一条日志,结果同时踩中 CPU、能耗和磁盘三项预算。正确形态是熔断——连续失败后按指数退避停止尝试,并在界面里给出可读状态(需要用户授权、已被拒绝、稍后自动重试),把恢复动作交给用户而不是交给定时器。

权限请求的时机同样属于预算问题。在启动路径上批量索要权限,会把首次启动变成一串弹窗;在用户意图明确的时刻索要,一次交互就能完成。而系统接口变更后新增的兼容分支,也要按同样标准核算:每条分支都有自己的轮询频率和缓存策略,”为了兼容”不是无限降级、无限多试一次的理由。

写入行为有两处要收:配置在界面每次变化时全量序列化落盘,应该改成去抖后合并写,并用”写临时文件再原子替换”避免半份文件;日志要滚动和分级,否则常驻工具会在几周后写出一个几百兆的文件。还有一处是自我拉起——如果部署了守护逻辑,重启次数必须有上限并留告警,否则崩溃循环会以”后台一直有活动”的形态出现,用户完全看不出发生了什么。

六、把预算写进文档:自查清单、判断顺序与洹拓视角

给开发者的自查清单,每一条都能被验收,不是态度问题:

  1. 定时器成表:间隔、容差、启动条件、停止条件、前后台降档规则逐项写明,表外不允许存在定时器。
  2. 每个缓存写明上限与淘汰策略,包含淘汰时连带释放的资源清单。
  3. 主线程白名单:跨进程查询、磁盘枚举、网络请求一律不进主线程,需要时给可取消的异步入口。
  4. 空闲态采样进回归:改动前后各采一段,比对唤醒频次、脏内存斜率、每分钟写入次数三项。
  5. 失败路径有退避与熔断,且状态对用户可见。
  6. 合盖测试:同样环境下,带这个工具与不带它各放一晚,比对掉电差异;这是方法,不预设数字。
  7. 卸载即干净:登录项注销、监听移除、文件与句柄释放,不留残余。

给使用者的一套判断顺序,比”重装试试”有用:

  1. 先用系统工具确认是不是它的占用,按能耗与占用排序看,不要凭感觉归因。
  2. 分辨”点开转圈”与”整体变钝”:前者指向主线程同步工作,后者指向内存或磁盘。
  3. 做单变量实验:只关掉这一项常驻,其它不动,观察一晚,一次改一个变量才叫实验。
  4. 记下复现条件:是否外接显示器、是否挂了外部卷、是否是合盖唤醒之后,条件不同结论不同。
  5. 反馈时给”现象 + 采样截图 + 复现条件”三件套,而不是形容词。

洹拓科技的桌面端和企业侧设备管理都做过,两边得到的教训是同一条:这类产品的用户投诉从来不指向功能,指向的是”合盖一晚掉电””点开就转圈”这两件事,而它们在设计阶段完全可以用四项预算写住。所以我们把后台占用当成功能项写进设计文档——预算没定的功能不排期,定时器表外的定时器不给合并。一个常驻小工具是否可信,看的不是它能不能跑,而是它空闲时花掉多少,以及这件事有没有人负责过。