“02-08-原理篇-异步加载与性能优化”,光看这个编号就很有画面感——这是某个系列课程里偏中前段的位置,前面铺垫了基础概念,后面马上就要进入实战。如果你正在按这个系列一步步啃下来,这一章可以说是一堵承重墙:后面做项目遇到的卡顿、白屏、启动慢、内存越跑越高,十有八九都要回到“异步加载”和“性能优化”这两个词上找根因。
我先把话放这儿:异步加载是性能优化里性价比最高的手段之一,但它也是最容易被误用的手段。很多人一看页面卡,就想着把加载过程改成异步,结果首屏反而白屏了;也有人把所有初始化都丢到子线程,结果崩溃率不降反升。这些坑我踩过,也帮别人排查过不少次,所以这篇原理篇我不打算只给你堆概念,而是把异步加载到底怎么工作、性能指标到底怎么量化、不同场景下怎么选策略一次讲透。
不管你是写 Web 前端、Android/iOS 客户端,还是在做游戏或者跨端应用,只要你每天在处理“资源加载”这件事,这篇文章都值得你花十几分钟认真读一遍。
1. 为什么说“异步加载”是性能优化的第一节课:先理解同步到底慢在哪
1.1 从一根水管看同步阻塞
很多人聊异步加载,一上来就讲回调、Promise、async/await,但我觉得第一件事应该是搞清楚:同步加载为什么慢?慢在哪里?
用一个生活化的例子。你去厨房烧水,如果用的是最老式的电热水壶,烧水期间你啥也干不了,只能站在旁边等着水开。这就是同步:任务发出后,当前线程一直在等待结果,期间不能做任何其他事情。如果这个水壶要烧十分钟,你就干等十分钟,这十分钟里洗菜、切菜全都被堵住了。
放到程序里,同步加载就是主线程发起一个耗时操作——比如请求一个接口、读取一张大图、加载一个 SDK——然后主线程就停在那里等待返回。在 Web 端,用户会看到页面白着、按钮点不动;在 Android 端,就是冷启动时白屏好几秒、列表滑动时一卡一卡。
异步加载的思路其实特别朴素:把耗时操作丢出去,不等它完成,主线程先干别的,等结果回来了再回来处理。还是那个厨房的例子,你把水壶换成带自动断电的,按下开关之后去洗菜切菜,水开了水壶自己“回调”一声告诉你。时间还是那些时间,但对你的体感来说,等待没了,效率上来了。
这个“等待没了”的本质,并不是耗时操作真的变快了,而是主线程不再被阻塞,用户能感知到的“卡”和“慢”被消解了一大部分。
1.2 事件循环:所有异步方案的共同调度器
接下来要讲一个底层机制,理解了它,你才能搞明白回调、Promise、async/await 这些方案为什么存在,为什么各有各的问题。
不管是浏览器的 JavaScript 引擎,还是 Node.js 的事件循环,甚至 Android 的 Handler/Looper 机制,背后的核心模型都差不多:主线程是一个死循环,不断从任务队列里取任务来执行。用户点击、网络返回、定时器到点、IO 完成,这些都是一个个事件,被塞进任务队列,等主线程有空了再去处理。
所以你写 setTimeout(callback, 0),并不是说 callback 立即执行,而是说“把这个任务插到队列里,等当前这轮代码跑完了再回来执行”。这就解释了为什么 setTimeout 0 里的代码永远比同步代码后执行——不是延迟,是排队。
在这个机制里,还有一门必修课叫微任务和宏任务。Promise 的 then 回调属于微任务,它会在当前这轮事件循环结束之前就被清空;而 setTimeout、setInterval 这些属于宏任务,要等到下一轮循环。实际操作中,如果你混用这两种任务,执行顺序可能和你直觉想的不一样。我见过不少线上 bug,都是因为有人想当然地认为 Promise 里的代码一定会比 setTimeout 先跑,这个结论在很多场景下是对的,但一旦遇到多层嵌套,就容易出乱子。
1.3 异步不是万能药:先分清 CPU 密集和 IO 密集
我在排查性能问题时,经常看到一种错误:有人把一段纯计算逻辑丢进 setTimeout 或者丢进异步线程,以为这样就不卡了。结果呢?该卡还是卡。
这里的关键是要分清瓶颈的类型。异步加载能解决的问题,主要是 IO 密集型的等待——网络请求、磁盘读写、图片解码这类“等外部资源回来”的操作。它的本质是“等待时间不让主线程闲着”。但如果瓶颈是 CPU 密集型——比如一个超大的循环、复杂的 JSON 解析、视频编解码——这些计算本身就占用 CPU,异步之后它仍然在主线程上跑(或者仍然在抢占 CPU 资源),该卡还是卡。
真正的解法是把 CPU 密集型任务拆到独立的工作线程/Worker 里去执行,主线程只负责接收结果。比如 Web Worker、Android 的线程池、协程的 Dispatchers.Default,都是干这个用的。
所以判断性能问题的第一步,不是上来就改异步,而是先搞清楚:这段代码是在等什么?如果是等待 IO,异步加载能立竿见影;如果是纯计算,你需要的是并行计算或者算法优化;如果两者混在一起,你就得组合使用。这个判断能力,我觉得比记住几个 API 重要得多。
2. 异步加载的三种主流实现方式与选型思路
2.1 回调:最朴素的异步方案,问题出在组合
回调函数是异步编程最原始的形态。一个函数接收一个“事情做完了再叫我”的函数作为参数,这就是回调。比如 Web 早期的 XMLHttpRequest,Node.js 里的 fs.readFile,Android 里的 setOnClickListener,骨子里都是回调。
回调写单个异步任务很简单,但一旦任务之间有了依赖关系,问题就来了。接口 A 返回后要拿着结果去请求接口 B,B 返回后再请求 C,代码就会一层套一层,形成传说中的“回调地狱”。而且错误处理也会变得很难受,每一层都要判断一下“上一步有没有出错”,逻辑一多,代码的可读性直线下降。
更麻烦的是并发组合。你有三个独立的请求,想等它们全部返回后再渲染页面,用回调来实现你就得自己维护一个计数器,每回来一个就 count++,等 count 等于 3 了才执行后续逻辑。这个做法不是不行,但很容易出边界问题,比如有的请求报错了你还要不要等?超时了怎么处理?这些都要自己一步步抠。
2.2 Promise:把异步装进状态机
Promise 的出现,本质上就是给异步任务一个统一的状态模型:每个异步任务要么 pending(进行中),要么 fulfilled(成功),要么 rejected(失败),而且状态一旦确定就不可逆。这个不可逆的特性特别重要,它保证了你在链式调用中拿到的结果不会因为后续操作被篡改。
有了状态模型,组合就成了很自然的事情。Promise.all 可以把多个异步任务并行发起,等全部成功才会进入 then,任何一个失败就会走进 catch;Promise.race 则可以在多个任务里取第一个完成的结果,超时控制通常就是这么做的。这些能力回调用原生写法非常别扭,Promise 却是一行代码的事。
用 Promise 的时候有个细节容易踩坑:then 回调里 return 的值,会成为下一个 then 的输入。很多人不理解这一步,导致链式调用总是拿不到预期数据。记住一条规则——then 方法永远返回一个新的 Promise,你 return 一个值,这个值就会被包成已成功的 Promise;你 return 一个 Promise,那就会等待这个 Promise 完成。理解了这条规则,链式调用的逻辑基本就不会错了。
2.3 async/await:用同步的写法写异步
async/await 就是 Promise 的语法糖,但它解决了 Promise 一个非常实际的问题:可读性。嵌套的 then 链虽然比回调清晰,但一旦业务逻辑复杂,一层层 then 依然是负担。
用 async/await,你可以把异步代码写得像同步代码一样直白。发起请求、拿结果、再发下一个请求,就是一个自上而下的顺序结构,几乎不需要额外的缩进。错误处理也从 .catch 变成了 try/catch,更符合大多数人写代码的直觉。
不过,语法糖有时候也最容易让你掉进性能的坑。比如在 for 循环里直接写 await,每一轮循环都会等待上一轮完成,循环里的请求就变成了串行执行。如果这些请求之间没有依赖关系,这就是纯粹的浪费时间——本来可以并行的三个请求,硬生生被拉成了三倍耗时。
正确做法是先用 map 把所有请求函数发起来,把 Promise 存进数组,再用 Promise.all 统一等待。这个差别在数据量小的时候感知不强,但一旦请求数量上到几十个,速度差距就是秒级的。我排查过不少“接口响应很快但页面加载很慢”的问题,根因就是这个 for 循环里的 await 串行。
2.4 老项目改造,我建议的节奏
如果你手头有一个跑了几年的老项目,想去规范化异步代码,我的建议是不要一刀切重写,那样风险太高了。
标准做法是自底向上改造:先把底层的工具函数、网络库封装改成 Promise 风格,再改业务层的调用方。每改一个函数,回归一个功能点,小步快走。至于 async/await,可以在新写的业务模块里优先使用,老模块逐步迁移。内层的一些一次性回调其实保留也没关系,关键是业务代码的主线逻辑不要纠缠在回调地狱里,那样维护成本太高。
选型本质上不是什么高级话题,它就是在“可读性”和“改动风险”之间取一个平衡。新项目可以直接上 async/await 贯彻到底;老项目则是能 Promise 就 Promise,能局部改动就先小范围试点,别为了追求代码风格统一而引入不可控的回归风险。
3. 性能优化先定指标:帧率、启动耗时、内存与卡顿
3.1 不量化就没有优化:“快”到底由谁说了算
提到性能优化,很多人第一反应是“让页面变得更加流畅”,这句话听起来没毛病,但实际上没法执行。“流畅”是一个主观感受,你觉得卡,别人可能觉得还行;同一台手机你在开发机上跑是顺滑的,用户的老机器上可能就是 PPT。所以正经的性能优化,第一步永远是定义指标,用数据代替感受。
Web 端现在业界基本达成共识的是一套 Core Web Vitals 指标:LCP 衡量最大内容绘制时间,代表首屏的加载速度;INP(取代了老的 FID)衡量页面交互的响应延迟;CLS 衡量布局偏移程度,就是页面元素是不是突然跳来跳去。这三个指标分别对应加载、交互、视觉稳定,基本上覆盖了用户体感的大头。
移动端和游戏端也有自己的硬性指标,冷启动耗时、平均帧率、掉帧率、卡顿率、内存峰值,每一类都有对应的测量手段和工具。我的经验是,无论哪个端,都要先把基础指标采集起来跑一段时间,形成一条“暴露在问题中”的基准线,再去改动代码。没有基准线就做优化,就像闭着眼开车,改完感觉“好像快了”,但没有任何数据能证明。
3.2 移动端的三个硬指标:冷启动、FPS 与卡顿检测
移动端性能优化,我建议重点盯住三个指标。
第一个是冷启动耗时。它直接决定用户对 App 的第一印象。Android 的冷启动链路是从点击图标到首帧可交互,中间涉及 Application 的创建、Activity 的创建、View 的绘制等环节。任何在主线程上的耗时操作——比如同步初始化 SDK、读取大文件、解析配置——都会直接累加到启动时长上,用户感知非常敏感。
第二个是帧率,也就是每秒渲染多少帧的画面。行业标准是 60 FPS,每帧预算大约 16.6 毫秒,超过这个时间就会掉帧。你用 ProMotion 屏幕或者高刷显示器,目标会变成 90 FPS 甚至 120 FPS,每帧预算进一步压缩到 11 毫秒和 8.3 毫秒。掉帧的直接原因通常是主线程被长任务占用,比如列表 item 里做了复杂布局、图片解压放在主线程、JSON 解析没有分包等。
第三个是卡顿率,它衡量的是“每 1000 帧里有多少帧超过了预算”。有些 App 平均帧率看着不错,但实际体验却是偶尔卡一下,这种场景用卡顿率来衡量比平均帧率准确得多。这也是为什么很多游戏团队会关注 P95 帧率而不是平均帧率——P95 帧率低于 30 FPS,说明最差的 5% 场景体验很差,而这个信息在平均值里是看不出来的。
| 指标维度 | 常用测量方式 | 值得注意的细节 |
|---|---|---|
| Web 加载 | LCP / FCP / INP / CLS | 要在真实用户环境采样,实验室数据仅供参考 |
| Android 启动 | adb 抓取冷启动时间 / 打点统计 | 区分冷启动、温启动、热启动,别混着统计 |
| 渲染流畅度 | Choreographer 帧回调 / vsync 间隔 | 关注 P95 帧率,别只盯平均帧率 |
| 卡顿检测 | 主线程 Long Task 监控 | 定位长任务的函数堆栈,而不是只知道“卡了” |
3.3 内存管理:异步加载最容易埋下的雷
很多做性能优化的人盯住了加载速度和帧率,却忽略了内存,这其实是个很大的隐患。异步加载和内存管理天然纠缠在一起:回调函数持有了不该持有的引用,异步线程结束了但对象没有释放,定时器还在跑但页面已经销毁——这些都是内存泄漏的温床。
举几个最常见的场景。在 Android 里,你写了一个 Handler 或者 Runnable,丢到主线程的消息队列里,如果这个 Handler 隐式持有了 Activity 的引用,而 Activity 已经被用户关闭了,这个 Activity 就不会被回收,泄漏就产生了。Web 端也一样,一个已经移除的 DOM 节点如果被闭包引用着,垃圾回收器就拿它没办法。游戏开发里,资源加载完没有及时释放,内存占用越来越高,系统为了腾出空间频繁触发 GC,游戏就开始一顿一顿地卡。
内存优化和异步优化的关系就是:异步加载解决的是“不卡”的问题,内存管理解决的是“长期稳定”的问题。你光看不卡,但内存持续上涨,跑二十分钟后开始卡,本质上还是内存问题。所以每次做性能优化,我都会提醒团队:改完代码先跑一轮内存监控,确认内存峰值和回收曲线都正常,再宣布优化完成。
说到内存管理,我也不得不提一嘴——不管是浏览器的 JS 引擎,还是移动端的 ART 虚拟机,甚至像 Julia 这类偏数值计算的语言环境,性能优化的底层逻辑其实是完全相通的:谁持有资源、什么时候释放、有没有不必要的引用链,这几点想清楚了,内存问题就解决了一大半。
4. 异步加载的四种落地策略:延迟、按需、优先级与预加载
4.1 延迟加载:把非关键路径挪出首屏
延迟加载是我在实际项目里用得最多、见效最快的一种策略。它的核心逻辑很简单:把首屏渲染不需要的东西,往后推迟,让关键路径上的任务先跑完。
Web 端最常见的例子是 script 标签的 async 和 defer。默认情况下,浏览器遇到 script 标签会停下 HTML 解析去下载并执行脚本,这就是同步阻塞。加了 defer,脚本会在 HTML 解析完成后执行,不阻塞解析;加了 async,脚本下载是异步的,下载完立即执行。如果你的脚本不是首屏必须要用的,就用 defer,这样既能保证页面结构先出来,又能等后面再执行脚本逻辑。
框架层面的延迟加载就是路由懒加载和组件动态挂载。Vue 和 React 生态里都有动态 import 的能力,把某个路由对应的代码拆成单独的分包,用户访问到那个路由时才去下载执行。Webpack/Vite 会把这些动态引入的模块自动做代码分割,你的工作量其实很小,收益却很直接:首屏少了几个大包的下载量,加载时间肉眼可见地下降。
移动端也一样,冷启动阶段不需要立即初始化的 SDK,比如埋点、推送、IM 连接,完全可以等首帧渲染完成之后再初始化。这一步做起来不难,但对启动耗时的帮助非常明显。我记得有一次帮一个团队做优化,他们的 Application 里同步初始化了七八个 SDK,光这一步就占了冷启动耗时的一半,改成分阶段初始化之后,启动时间直接砍掉了几百毫秒。
4.2 按需加载:用到了才加载,用不到的坚决不碰
延迟加载和按需加载听起来很像,但侧重点不同。延迟加载是“晚一点加载”,按需加载是“要不要加载由用户说了算”。
最典型的按需加载场景是图片懒加载。一个信息流页面可能有几百张图片,但用户屏幕里只显示那么七八张,如果打开页面就全部加载,浪费带宽不说,还会拖慢页面速度。正确的做法是,监听滚动事件或者用 IntersectionObserver 观察图片是否进入视口,进入视口了才真正开始加载。现代浏览器都支持 IntersectionObserver,性能比监听 scroll 事件好得多,因为它是在浏览器合成层面触发的,不会频繁唤醒主线程。
框架层的按需加载,就是把一些低频模块拆出来,用户第一次用到时就地加载。比如一个复杂的富文本编辑器,用户可能一个月都用不上一次,没必要打进首屏包里。等他真正点击“编辑”按钮时再去下载,虽然那一次会慢一点点,但换来的是绝大部分用户的整体速度提升,这笔账是划算的。
不过按需加载有个坑要提醒:不能把核心依赖也按需化。比如页面初始化必须要用的 SDK、公共方法库,这类依赖如果被错误地做成按需加载,首屏反而会变成“先白屏、再加载、再执行”的体验,得不偿失。判断标准就一句话:如果业务主流程的第一环需要它,它就必须在关键路径上同步或者尽早加载。
4.3 优先级调度:把关键资源排到最前面
优先级调度是我觉得很多开发者做得不够好的一环。大家知道要异步加载,但异步加载并不意味着所有东西一视同仁地排队。资源之间是有轻重缓急的,首图永远比轮播图的第二张重要,首屏接口永远比用户还没看到的列表页数据重要。
Web 端有 preload 和 prefetch 两个指令可以配合使用。preload 是告诉浏览器“这个资源当前页面马上就要用了,请尽快加载”;prefetch 是告诉浏览器“这个资源以后可能要用,你可以在空闲时间加载”。这两者的使用场景不能搞混,preload 用错会抢占关键资源带宽,prefetch 用少了又起不到预取效果。
移动端的优先级调度可以做得更细。比如首页有多个接口,一个负责首屏内容,一个负责用户头像信息,一个负责广告位,这三个请求同时发出,但你在业务层可以给首屏内容的回调绑定到唯一的渲染入口,其他请求就算先回来也先缓存,不要在首帧绘制前打扰主线程。像协程或者启动框架里常见的做法,就是把任务划分成主线程关键路径、主线程非关键路径、子线程后台任务三个等级,按等级来分配资源。
游戏端的做法更直观。进场景时先把场景模型和贴图资源排到最高优先级,特效、音效、UI 资源排到第二梯队,某些远景资源甚至可以等人物走近了再加载。资源表就是一张优先级清单,加载任务按这张表来调度,能有效避免“场景都进去了,主角模型还没加载出来”的尴尬。
4.4 预加载:用空闲时间换未来时间
预加载和延迟加载看着矛盾,一前一后,但它俩服务的场景完全不同。延迟加载是把不着急的往后放,预加载是把“未来大概率会用到”的东西提前拿过来。
最经典的预加载场景是翻页。你正在看一篇图文内容,用户大概率会往下翻,那下一篇内容就可以提前拉取。短视频 App 做预加载做得更极致:当前视频在播放时,下一条视频已经开始在后台缓冲了,所以手指一滑,新视频马上就出来了。这个体验差异,直接影响用户的留存率。
预加载的另一大类是图片预解码。图片从网络下载完成之后,还需要解码成位图,这个过程也很耗时。很多图片框架支持预解码:等用户滑动到某张图片之前,图片已经在后台解码完毕,真正需要显示时直接使用,省去了卡顿等待。
不过预加载有个“度”的问题。预加载太多,浪费流量,用户根本不看的内容你也拉了;预加载太久,资源过期了,加载回来发现已经不是用户需要的东西。所以预加载一定要设置上限、要有取消机制、要结合用户行为数据来做预测。比如用户滑到第三条视频才开始预加载第五条,固定预取三条封顶,这样既保证了流畅体验,也不会无限浪费资源。
5. 实战复盘:一个资讯客户端冷启动的异步加载改造
5.1 先定位瓶颈:Application 初始化为什么会拖慢一切
去年我帮一个团队做了次性能优化,对象是一个资讯类 Android App,问题描述非常简单:冷启动太慢,用户点开图标要盯着白屏超过两秒。
拿到问题后我第一件事不是改代码,而是跑性能分析,把冷启动过程完整录下来。很快瓶颈就清楚了:Application 的 onCreate 里面,串行地初始化了网络库、崩溃上报、埋点、数据库、推送、IM 连接,这六项加起来足足占了 800 多毫秒。接着首屏 Activity 的 onCreate 里又同步做了一堆数据库查询和一个登录状态刷新请求,耗时又是 500 毫秒左右。这些时间叠在一起,两秒多的白屏就有了出处。
另外一个隐藏问题在图片加载:首页列表一次性把可见区域和预加载区域的所有图片都拉起来了,几百张图片同时排队解码,内存直接飙升到接近危险线,系统频繁做 GC,首屏渲染完之后的滑动体验也是一顿一顿的。
5.2 三步改造:串行转并行、初始化分散、图片分级
定位清楚之后,改造方案分三步走。
第一步是把 Application 的初始化拆成分级策略。网络库和崩溃上报这两项必须在主线程同步完成,因为它们影响所有后续功能;埋点、数据库先放到子线程初始化,推送和 IM 直接延迟到首帧渲染完成之后,用一个主线程 IdleHandler 在空闲时再处理。这里有个细节要特别说明:不是所有 SDK 都能随便丢子线程,有些 SDK 的内部实现要求主线程创建 Handler,你在子线程初始化虽然不报错,但后续的某些操作会出问题。所以每个 SDK 都要先看文档确认它的线程要求,不能一刀切。
第二步是把首屏接口从串行改成并行。原来的逻辑是先请求登录状态,等返回后再请求首页列表,实际上登录状态和首页列表没有任何数据依赖,完全可以同时发出。改造之后,两个请求并行,再通过逻辑合并,首屏数据的到达时间被压缩到了最慢的那个请求的耗时。这个改动不大,但对首屏渲染的加快非常明显。
第三步是图片加载分级。图片框架的加载优先级分为“可见区域图片”和“不可见区域图片”,可见的用最高优先级拉取,不可见的用低优先级慢慢加载,同时设置内存缓存上限,避免一次性解码大量图片造成内存紧张。这个策略执行之后,列表滚动的掉帧问题几乎消失了。
5.3 结果与复盘:收益、代价和取舍
改造完成之后又跑了两个星期的灰度测试,数据对比下来:冷启动平均耗时从 2.1 秒降到了 1.1 秒,接近一半的优化;首帧渲染之后的一秒内,无操作卡顿率从 30% 降到了 10% 以下;内存峰值基本持平,没有因为并发请求增加而上涨,说明并发控制做得还算到位。
但这次改造也让我意识到一个很重要的点:性能优化永远是在做取舍。我们把推送和 IM 的初始化延迟了,结果是首屏快了几百毫秒,但推送消息的到达会有微小的延迟、IM 的连接建立晚了一点。这个代价在资讯类 App 里是可以接受的,但如果是一个即时通讯 App,这就是不可接受的体验下降。
所以任何方案都不能脱离业务场景去做。优先级调度的本质,其实是产品逻辑的技术化表达——你觉得什么功能对用户最重要,就应该把它的资源优先级排得最高。这个判断,最终还是要落到懂业务的人手里。
6. 常见问题与排查技巧实录
6.1 异步改造后出现白屏、首屏闪烁
这是我见过最多的问题:团队兴高采烈地做了一轮异步加载改造,结果一上线,首屏反而出现了短暂的白屏,或者内容一会儿有一会儿没,闪来闪去。
根因几乎都是同一个:改造的时候把不该异步化的关键路径也异步了。比如首屏内容依赖某个接口数据,你把页面渲染逻辑挂在了异步回调里,但回调之前页面是空的,用户看到的就是一块白屏。
排查看三处:第一,首屏的关键请求有没有被错误地放到低优先级队列;第二,有没有延迟到“用户点击之后”才初始化核心组件;第三,页面的骨架屏或者默认状态有没有提前渲染。异步加载的正确姿势是“非关键路径尽量异步,关键路径保持最短同步链路”,而不是把所有东西都异步化。
6.2 并发请求一多,后端告警
异步加载做并发优化时,经常会出现一个意外收获——后端服务告警了。原来串行的请求改成并发后,同一时间点的请求数可能翻了好几倍,后端如果没做过容量评估,或者依赖的下游服务不够健壮,就会开始报错超时。
这种情况不是方案错了,而是并发没有控制好。解决办法是在请求层加信号量或者并发限制,把同时进行的请求数量控制在合理范围。比如一个页面就算有二十个模块要加载,同一时刻只允许五个请求在途,其他请求排队等待。这样既享受了并发提速,又不会把后端打成筛子。
另外一个经验是给请求设计超时和降级策略。并发请求里只要有一个超时,就要有明确的兜底逻辑,不能因为一个次要接口超时,让整个页面一直转圈加载。
6.3 内存越跑越高,Handler 却在回调
内存泄漏的排查稍微有点门槛,但套路是固定的。在 Android 端,用 Memory Profiler 抓一次内存快照,然后操作页面、退出页面,再抓一次快照,对比一下新增的对象里有没有本该被回收的页面对象。如果退出页面之后,页面的 Activity 实例还在内存里,那基本可以确定有泄漏。
常见泄漏源包括:Handler 消息队列里还挂着 Runnable,而 Runnable 匿名内部类隐式持有了 Activity 引用;网络回调持有页面引用但请求还没返回;各种 Listener 注册了忘了反注册;定时器任务没取消。Web 端的泄漏排查思路也类似,只是工具换成了浏览器 DevTools 的 Memory 面板,通过记录堆快照来对比对象持有量。
修复手法无非是:该清理的清理、该反注册的反注册、该置空的置空。真正考验功夫的是能不能在写代码的时候就意识到“这个引用会不会被长期持有”,这一点还真得靠经验积累。
6.4 性能对比前后数据震荡,无法证明优化有效
性能优化的最后一步是验证。但你会发现,性能数据天生噪点大,今天跑一次快,明天跑一次慢,很难证明你的优化到底有没有效。
我的建议是三条:第一,对比测试要在同一台设备上进行,最好是中低端设备,高端机性能冗余太大,体现不出差异;第二,每轮测试至少跑五次以上,取中位数(P50)而不是平均值,平均值容易受极端值干扰;第三,用小流量灰度发布,让真实用户的数据来告诉你答案,比如同一版本在实验组和对照组同时运行,对比关键指标是否有显著差异。
还有一个容易被忽视的点是控制变量。你优化的是加载逻辑,结果研发同学同期还改了图片压缩策略、后端缓存策略,那最后的数据就说不清楚是谁的功劳。性能优化的验证阶段,一定要靠灰度逻辑把变量隔离开。
我个人做了这么多年性能相关的事情,最大的体会是:异步加载不是一种技巧,而是一种思维习惯。它要求你在写每一行代码之前都下意识地想一下——这个操作一定要现在做吗?一定要在主线程做吗?有没有更合适的时机和线程?这种习惯一旦养成,你写的代码天然就会变快,而不是等出了问题再回头匆忙地打补丁。
最后再分享一个实用的小技巧:性能优化不要贪多,一次只改一个变量。这轮只优化启动初始化,下轮只优化图片加载,每一轮都能看到清晰的数据变化,也方便出问题时快速回滚。你只要坚持这么干,即便你的优化手段不是最顶级的,产出的结果也一定比一次性大改动要稳定得多。