ArkTS 组件传对象还是拆属性?我测了帧率,结果和直觉反着来
"对象整体传进组件,渲染帧率反而比拆成单个属性高 15% 左右。"
斌哥听完我的结论,放下咖啡杯,表情有点像在看一个说胡话的人。我理解他的反应------因为三天前我也这么看自己。
事情是这样的。上周我在重构 雷达鸭鸿蒙版 的一个政策列表页,每条政策卡片都是一个自定义组件 PolicyCard。一开始我按"最佳实践"把 policy 对象拆成 title、region、date、summary 四个 @Prop 分别传进去,心想这样 ArkTS 的依赖收集应该更精细,不必要的重绘会更少。
结果列表一滑动,肉眼可见的掉帧。
我先是怀疑 LazyForEach 的问题,毕竟列表有 200 多条数据。但把数据源砍到 20 条,卡顿还在。然后怀疑是图片加载,去掉图片后还是卡。那天下午我盯着 Profiler 看了两个多小时,喝了四杯水,上了三趟厕所,还是没想明白问题出在哪。
真正让我锁定方向的,是 DevEco Studio 的 Frame 视图。我切到真机调试,发现滑动过程中平均每帧耗时 12-13ms,时不时蹦到 16ms 以上。而在另一个页面------那个直接把整个对象塞给子组件的页面------同样的数据量,平均帧耗时只有 9-10ms。
等一下,这里我漏说一个前提。ArkTS 的 @Prop 装饰器,官方文档说"用于父子组件的单向同步"。我的理解是:拆得越细,子组件依赖收集的粒度越细,父组件更新一个无关字段时,子组件不会跟着重绘。这逻辑在 Vue 和 React 里都是成立的,我在之前的项目里也确实受益于这种细粒度拆分。
但 ArkTS 的实现逻辑不完全一样。
为了验证,我写了三个版本的 PolicyCard 来做对比测试。数据都是同一批,布局完全一样,唯一的区别就是传参方式。
版本 A:拆属性传(我一开始的写法)
typescript
@Component
struct PolicyCardA {
@Prop title: string;
@Prop region: string;
@Prop date: string;
@Prop summary: string;
build() {
Column() {
Text(this.title).fontSize(16).fontWeight(FontWeight.Bold)
Text(`${this.region} · ${this.date}`).fontSize(12).fontColor('#999')
Text(this.summary).fontSize(14).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.padding(12)
.backgroundColor('#fff')
.borderRadius(8)
.width('100%')
}
}
父组件调用的时候长这样:
typescript
PolicyCardA({
title: item.title,
region: item.region,
date: item.date,
summary: item.summary
})
版本 B:对象整体传
typescript
interface PolicyItem {
title: string;
region: string;
date: string;
summary: string;
}
@Component
struct PolicyCardB {
@Prop policy: PolicyItem;
build() {
Column() {
Text(this.policy.title).fontSize(16).fontWeight(FontWeight.Bold)
Text(`${this.policy.region} · ${this.policy.date}`).fontSize(12).fontColor('#999')
Text(this.policy.summary).fontSize(14).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.padding(12)
.backgroundColor('#fff')
.borderRadius(8)
.width('100%')
}
}
版本 C:@ObjectLink(我中间尝试的"进阶"写法)
typescript
@Observed
class PolicyItemModel {
title: string = '';
region: string = '';
date: string = '';
summary: string = '';
constructor(data: PolicyItem) {
this.title = data.title;
this.region = data.region;
this.date = data.date;
this.summary = data.summary;
}
}
@Component
struct PolicyCardC {
@ObjectLink policy: PolicyItemModel;
build() {
Column() {
Text(this.policy.title).fontSize(16).fontWeight(FontWeight.Bold)
Text(`${this.policy.region} · ${this.policy.date}`).fontSize(12).fontColor('#999')
Text(this.policy.summary).fontSize(14).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.padding(12)
.backgroundColor('#fff')
.borderRadius(8)
.width('100%')
}
}
父组件配合版本 C 需要把原始数据包成 PolicyItemModel:
typescript
this.policyList = rawData.map(item => new PolicyItemModel(item));
测试条件:Mate 60 Pro 真机,HarmonyOS NEXT 5.0,列表 200 条,快速上下滑动,每轮测试 5 秒。
Profiler 数据(五轮测试的中位数):
| 写法 | 平均帧耗时 | 掉帧次数 (>16ms) |
|---|---|---|
| A 拆属性 | 12.8ms | 23 次 |
| B 传对象 | 10.1ms | 7 次 |
| C @ObjectLink | 11.5ms | 14 次 |
数据摆在这儿,B 最快,A 最慢。我反复测了五轮,中间还重启了一次手机排除缓存干扰,结果稳定得让我有点慌------因为这意味着我过去两周写的那些拆属性传的组件,全都可以优化。
为什么?
我翻了 ArkTS 的编译产物和官方一些内部文档的只言片语,结合自己的理解,大概是这样的:拆属性传的时候,每个 @Prop 都会独立建立一条依赖收集链路。父组件里的数据源稍微动一下------哪怕只是排序变了------四个 @Prop 要分别触发更新判定。ArkTS 的响应式系统在这个阶段的开销,比"直接替换整个对象引用"要大。
传对象的时候,子组件只监听一个 @Prop 引用的变化。数据源重排时,父组件给子组件换的是一个新的对象引用,子组件收到后整件重绘。听起来更"粗暴",但实际帧率更高。因为省去了多路依赖追踪的细粒度比对成本。
@ObjectLink 按理说应该更高效,因为它直接监听对象内部属性的变化,不需要父组件整体刷新。但测试下来它排第二。我猜是因为 @Observed 的代理开销在列表场景下被放大了------200 个卡片就是 200 个代理对象,每个属性访问都要走一层拦截。这个开销在复杂布局里被进一步放大。
说句题外话,鸿蒙的 Profiler 在真机上偶尔会丢帧数据,我测这五轮里有两次开头几秒没采到数据,得重来。这种小毛病挺让人无奈的,但比起之前用 Android Studio 调 HarmonyOS 应用的经历,已经好太多了。
所以我现在写 ArkTS 列表组件,如果子组件不需要反向修改数据,我直接传对象,不再拆了。除非某个属性真的需要单独做细粒度控制------那种情况我会用 @ObjectLink,但不会为了"理论上更好"而主动拆分。
回头一看,这也不是什么高深问题。就是 ArkTS 的响应式实现机制和 Vue、React 不完全一样,我把别的框架的经验直接平移过来,结果翻车了。这种"经验迁移翻车"在我转 ArkTS 的这半年里,已经不是第一次了。
你写 ArkTS 的时候,有过类似的经验迁移翻车吗?
关于我
我叫老三,一个写了十年代码的前端 + 鸿蒙 ArkTS 水手。
目前主业做 Taro 多端项目,业余时间全泡在 AI 自动化和独立开发上------不是因为多热爱加班,而是打心底觉得,程序开发这件事正在被 AI 重构,我不跟上就会被甩下。
这个账号记录的就是我在这条路上的真实经历:踩过的坑、推翻过的方案、以及偶尔值得高兴的小进展。不写教科书,不讲大道理,只分享我自己试过、做过、确认过的东西。
如果你也在写代码,或者也在思考 AI 时代开发者该往哪走------欢迎留言聊聊,一起摸索。
本文遵循 MIT 协议,转载请注明出处。