开篇:性能问题的三种表现
鸿蒙应用的性能问题最终都会落到三种可观察的表现上:启动慢、界面卡、内存涨。三者常常同源,却需要完全不同的工具与思路去定位。
启动慢通常是主线程在 onCreate 或 onWindowStageCreate 里做了重活;界面卡多半是渲染管线被长任务打断,或列表复用了错误的组件;内存涨则往往来自未注销的监听与长生命周期的 context 引用。这三类问题都不会在开发阶段自动暴露,必须靠工具量化。
本文按"度量、启动、渲染、内存、包体积"的顺序展开,工具以 DevEco Profiler 为主,命令行以 hdc 与 hilog 为辅。示例基于 API 12 及以上。并发相关的前置知识见 鸿蒙并发模型 TaskPool 与 Worker
。
一、性能指标与度量口径
优化之前先约定口径,否则"快了 30%“这种结论没有意义。
| 指标 | 定义 | 参考目标 | 主要影响因素 |
|---|---|---|---|
| 冷启动耗时 | 进程创建到首帧 | 越低越好,秒级内 | 初始化任务、依赖加载 |
| 帧率 | 每秒渲染帧数 | 稳定 60 帧 | 主线程任务、布局层级 |
| 丢帧率 | 超时帧占比 | 越低越好 | 长任务、同步 IO |
| 峰值内存 | 运行期内存上限 | 避免触顶被回收 | 图片、缓存、泄漏 |
| 包体积 | 安装包大小 | 越小越好 | 资源、依赖、混淆 |
| 功耗 | 单位时间能耗 | 后台越低越好 | 长连接、定时任务 |
冷启动耗时是最值得优先优化的指标,因为它直接决定用户的第一印象,而且启动阶段做的每一个多余动作都会被放大到所有用户身上。
二、DevEco Profiler 与命令行工具
2.1 Profiler 泳道
DevEco Profiler 把采集结果按泳道组织,理解每条泳道的能力是定位问题的前提。
| 泳道 | 采集内容 | 适合排查 |
|---|---|---|
| CPU | 线程占用与调用栈 | 长任务、主线程阻塞 |
| 内存 | 堆分配与对象分布 | 泄漏、峰值过高 |
| 网络 | 请求时序与流量 | 重复请求、大包 |
| 启动 | 冷启动阶段拆分 | 启动瓶颈定位 |
| 帧率 | 每帧耗时与丢帧 | 卡顿、掉帧 |
使用时的一条经验是:先在"帧率"泳道找到丢帧的时间点,再切到"CPU"泳道看那个时间点上主线程在跑什么。直接盯 CPU 泳道容易迷失在噪声里。
2.2 hdc 与 hilog
hdc 是设备调试桥,hilog 是系统日志。两者在无 IDE 场景下是唯一的观测手段。
hdc list targets # 查看已连接设备
hdc shell hilog -T MyApp -x # 抓取指定 TAG 的日志
hdc shell hilog -L E # 按级别过滤,只保留错误
hdc file recv /data/app/el2/100/base/com.example.todoapp/haps/entry/files/cache.json ./ # 拉取应用沙箱文件
日志要成体系:统一用 hilog 并固定 domain 与 TAG,按模块划分 TAG,关键路径打点带上耗时。
import { hilog } from '@kit.PerformanceAnalysisKit';
export function timed(tag: string, label: string, task: () => void): void {
const start = Date.now();
task();
const cost = Date.now() - start;
hilog.info(0x0000, tag, `%{public}s cost %{public}d ms`, label, cost);
}
注意 hilog 的格式串里要用 %{public}s 而不是 %s,后者会被当作隐私数据过滤掉,日志里只剩占位符。这是排查时最容易浪费时间的坑。
三、启动优化
3.1 冷启动与热启动
冷启动指进程不存在时的完整启动,热启动指进程存活时重新进入前台。优化的重点永远在冷启动,因为热启动几乎没有可优化空间。
冷启动的时间轴大致是:进程创建、AbilityStage 创建、UIAbility 的 onCreate、onWindowStageCreate 里 loadContent、首帧渲染。其中 onCreate 与 loadContent 之间的任何同步耗时都会直接推迟首帧。
3.2 AppStartup 启动框架
API 12 引入了 AppStartup 启动框架,把启动任务声明化,并自动处理任务之间的依赖与并发。
{
"startupTasks": [
{
"name": "InitLog",
"srcEntry": "./ets/startup/InitLogTask.ets",
"runOnThread": "mainThread",
"waitOnMainThread": false
},
{
"name": "InitConfig",
"srcEntry": "./ets/startup/InitConfigTask.ets",
"dependencies": ["InitLog"],
"runOnThread": "taskPool"
}
]
}
import { StartupTask, common } from '@kit.AbilityKit';
export default class InitConfigTask extends StartupTask {
async init(context: common.AbilityStageContext): Promise<void> {
// 初始化配置,避免在主线程做重活
await this.loadRemoteConfig();
}
private async loadRemoteConfig(): Promise<void> {
// 实际项目在此发起配置拉取
}
}
runOnThread 决定任务跑在主线程还是 TaskPool,waitOnMainThread 决定首帧是否等待该任务。把非关键任务设为不阻塞首帧,是启动优化里收益最大的一步。
3.3 延迟加载与懒初始化
不是所有初始化都必须放在启动阶段。判断标准是"首屏是否用得到”:首屏用不到的模块,全部挪到首屏渲染之后。
@Entry
@Component
struct Index {
@State ready: boolean = false;
aboutToAppear(): void {
// 首帧渲染完成后再做重初始化
setTimeout(() => {
this.initHeavyModules();
}, 0);
}
private initHeavyModules(): void {
// 埋点、推送、本地数据库预热等
this.ready = true;
}
build() {
Column() {
Text(this.ready ? '已就绪' : '加载中')
}
}
}
用 setTimeout(..., 0) 把任务推到当前帧之后执行,是一个简单有效的技巧。更规范的做法是使用 onDidBuild 回调或启动框架的延迟任务。
四、渲染性能优化
4.1 @Reusable 组件复用
列表滚动卡顿的头号原因是每滚动一项就重新创建组件。@Reusable 让组件实例被回收进复用池,下次直接复用而不是重建。
@Reusable
@Component
struct ArticleItem {
@State title: string = '';
@State subtitle: string = '';
aboutToReuse(params: Record<string, Object>): void {
this.title = params.title as string;
this.subtitle = params.subtitle as string;
}
build() {
Column({ space: 4 }) {
Text(this.title).fontSize(16).maxLines(1)
Text(this.subtitle).fontSize(12).fontColor('#666666').maxLines(2)
}
.width('100%')
.padding(12)
.alignItems(HorizontalAlign.Start)
}
}
aboutToReuse 是复用的关键:复用时不会重新执行 aboutToAppear,所有依赖外部数据的字段都必须在 aboutToReuse 里重新赋值,否则会显示上一个条目的内容。
4.2 LazyForEach 与 cachedCount
ForEach 会一次性构建所有子项,LazyForEach 只构建可视区域附近的项。长列表必须用后者。
class ArticleDataSource implements IDataSource {
private listeners: DataChangeListener[] = [];
private items: string[] = [];
totalCount(): number {
return this.items.length;
}
getData(index: number): string {
return this.items[index];
}
registerDataChangeListener(listener: DataChangeListener): void {
this.listeners.push(listener);
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener);
if (pos >= 0) {
this.listeners.splice(pos, 1);
}
}
push(item: string): void {
this.items.push(item);
this.listeners.forEach((l: DataChangeListener) => l.onDataAdd(this.items.length - 1));
}
}
@Entry
@Component
struct ArticleList {
private source: ArticleDataSource = new ArticleDataSource();
build() {
List() {
LazyForEach(this.source, (item: string) => {
ListItem() {
ArticleItem({ title: item, subtitle: '摘要' })
}
}, (item: string) => item)
}
.cachedCount(3)
.width('100%')
.height('100%')
}
}
cachedCount(3) 表示在可视区域外额外缓存 3 项。这个值不是越大越好:缓存越多,内存占用越高,快速滚动时的构建压力也越大。经验值在 2 到 5 之间,需要结合单项复杂度调整。
4.3 @Track 精确刷新
默认情况下,对象状态变量的任一属性变化都会触发整个组件重建。@Track 可以把刷新粒度精确到属性级。
@Observed
class Profile {
@Track name: string = '';
@Track avatar: string = '';
bio: string = '';
}
@Component
struct ProfileCard {
@ObjectLink profile: Profile;
build() {
Column() {
Text(this.profile.name)
Text(this.profile.bio)
}
}
}
被 @Track 标记的属性变化会触发刷新,未标记的属性变化则不会。因此 @Track 的使用原则是:标记会独立变化的字段,把不变的字段排除在外。
4.4 布局扁平化
布局层级每多一层,测量与布局的开销就多一轮。三条实用规则:
- 能用
Row与Column解决就不要用嵌套的Stack。 - 避免用空容器做间距,用
space或margin。 if与ForEach尽量放在容器内部,减少容器重建。
五、内存优化
5.1 常见泄漏来源
| 泄漏来源 | 表现 | 处理方式 |
|---|---|---|
| 事件监听未注销 | 页面销毁后回调仍触发 | 在 aboutToDisappear 中 off |
| 定时器未清除 | 内存与耗电持续增长 | clearInterval 或 clearTimeout |
| 长生命周期持有 context | 整个 Ability 无法释放 | 用弱引用或只传必要字段 |
| 全局缓存无上限 | 内存单调增长 | 加 LRU 与容量上限 |
| 大图片未压缩 | 峰值内存触顶 | 按展示尺寸解码 |
5.2 内存快照与对比
DevEco Profiler 的内存泳道支持抓取快照并做 diff。排查泄漏的标准流程是:进入页面、退出页面、手动触发 GC、再抓一次快照,对比两次快照中数量持续增长的对象类型。
一个高频场景是网络请求回调持有页面引用。页面已经销毁,但请求的回调闭包里仍然引用着状态变量,导致整个组件树无法回收。解决方式是在 aboutToDisappear 里取消未完成的请求。
六、包体积优化
包体积直接影响下载转化率,也间接影响启动耗时(资源越多,加载越慢)。
| 手段 | 收益 | 代价 |
|---|---|---|
| 开启代码混淆 | 显著 | 崩溃栈需符号表还原 |
| 资源精简与去重 | 中等 | 需要人工梳理 |
| 拆分为 HSP 动态包 | 中等 | 增加运行时加载复杂度 |
| 图片转 WebP 并压缩 | 显著 | 需要重新导出资源 |
| 移除未使用的依赖 | 视情况 | 需要依赖分析 |
混淆的配置与风险在鸿蒙应用上架与签名打包那一篇里有详细说明。这里只强调一点:混淆后必须上传符号表,否则线上崩溃将无法定位。
七、实战:列表页优化前后
把上面的手段串成一个完整例子。优化前的写法是 ForEach 加普通组件,优化后改为 LazyForEach 加 @Reusable。
@Reusable
@Component
struct RowItem {
@State title: string = '';
aboutToReuse(params: Record<string, Object>): void {
this.title = params.title as string;
}
build() {
Row() {
Text(this.title)
.fontSize(16)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
}
}
@Entry
@Component
struct OptimizedList {
private source: ArticleDataSource = new ArticleDataSource();
build() {
List() {
LazyForEach(this.source, (item: string) => {
ListItem() {
RowItem({ title: item })
}
}, (item: string) => item)
}
.cachedCount(4)
.width('100%')
.height('100%')
}
}
这组改动带来的收益通常体现在三个方面:首屏渲染的项数从全量降到可视区域加缓存,滚动时不再重建组件实例,内存峰值随可视项数而不是总项数增长。
值得注意的是,性能优化要建立在测量之上。cachedCount 从 3 调到 4 是否更好,取决于单项的构建成本与设备内存,只有用 Profiler 对比才能得出结论。盲目套用"最佳实践"往往适得其反。
可访问性与性能之间也存在张力:为了屏幕朗读而增加的语义信息会带来额外开销,如何在两者间取舍,可以参考 Flutter 可访问性实践 中的权衡思路。页面生命周期的回调时机决定了初始化能推迟到哪一步,详见 鸿蒙应用与页面生命周期 。
八、常见坑清单
- 在
build里new对象或调用函数,导致每帧重建。 ForEach未提供稳定 key,复用失效,列表滚动时闪烁。@Reusable组件忘记实现aboutToReuse,复用时显示上一条数据。Image未指定宽高,图片加载完成后布局抖动。- 事件监听注册了却未
off,页面销毁后仍持有引用。 - 定时器未清除,后台持续消耗电量。
- 缓存容器没有容量上限,长时间运行后内存单调增长。
cachedCount设置过大,快速滚动时构建压力反而更高。- 在
onCreate里同步读取大文件或发起网络请求。 - 混淆后未上传符号表,线上崩溃堆栈无法还原。
- 用 Profiler 长时间采集本身会拖慢应用,导致数据失真。
- 只优化了调试包而未验证发布包,混淆引入的问题被遗漏。
小结
性能优化的方法论可以压缩成三句话:先用 Profiler 量化,再针对瓶颈动手,最后用同一口径复测。启动阶段把非关键初始化挪出主线程或推迟到首帧之后,渲染阶段用 @Reusable 与 LazyForEach 把开销从"总量"降到"可视量",内存阶段靠快照对比找持续增长的对象。三条底线是:build 内不做副作用,所有监听与定时器都要配对清理,任何优化结论都必须有前后对比数据支撑。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。