HarmonyOS应用《民族图鉴》开发第63篇:布局与渲染性能优化——减少重绘重排的艺术

📖 引言
打开「民族图鉴」的详情页,你看到了什么?
- 顶部的封面大图和民族名称
- 基本信息卡片(宗教、语系、文字、排行)
- 语言与文字卡片
- 分布地区卡片
- 详细介绍卡片
这些内容加起来,有几十个组件、好几层嵌套。滑动的时候,它们是怎么渲染到屏幕上的?为什么有时候滑动会卡顿?为什么改一个属性会导致整个页面重新布局?
这些问题,都和布局与渲染性能有关。
很多开发者对布局和渲染的理解停留在"写代码 → 运行 → 显示"的层面,至于中间发生了什么,并不清楚。但如果你想写出流畅的界面,就必须理解渲染流程——因为大部分卡顿、掉帧,都和渲染流程中的某个环节太慢有关。
你可能会问:
- 界面是怎么渲染到屏幕上的?渲染流程有哪几步?
- 什么是重排(Reflow/Layout)?什么是重绘(Repaint/Paint)?
- 为什么重排重绘耗性能?它们有什么区别?
- 怎么减少重排重绘?有哪些技巧?
- 布局层级是不是越深越卡?怎么减少层级?
- Flex 布局怎么优化?Span 怎么合理使用?
- renderGroup 是什么?离屏渲染又是什么?
- 硬件加速是怎么回事?要不要开?
这些问题非常重要。布局和渲染性能直接影响界面的流畅度——FPS 高不高、滑动跟不跟手、动画流不流畅,都和它有关。
本文将带你深入理解布局与渲染性能优化。我们会从渲染流程讲起,逐一解析重排、重绘的原理,然后讲解各种优化技巧,最后结合「民族图鉴」的详情页进行实战优化。
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解完整的渲染流程:布局 → 绘制 → 合成 → 提交
- ✅ 深入理解重排(Reflow)和重绘(Repaint)的区别与原理
- ✅ 掌握减少重排的核心方法与最佳实践
- ✅ 学会减少组件层级的技巧与判断标准
- ✅ 掌握 Flex 布局优化与 Span 的合理使用
- ✅ 理解 renderGroup、离屏渲染、硬件加速的原理与使用场景
- ✅ 能够独立优化「民族图鉴」详情页的布局性能
- ✅ 解决布局抖动、列表卡顿、首屏渲染慢等常见问题
💡 需求分析
为什么布局和渲染性能很重要?
想象一下这个场景:
你在「民族图鉴」里浏览民族列表,快速滑动的时候,感觉一顿一顿的,不跟手。点进详情页,往下滑的时候也有点卡。你说不上来哪里不对,但就是觉得"不够流畅"。
这种"不够流畅"的感觉,很大概率是渲染性能的问题。
布局和渲染性能为什么重要?
-
直接影响流畅度
FPS 高不高、掉不掉帧,主要看渲染能不能跟上屏幕刷新率。渲染慢了,FPS 就掉下来了。 -
用户感知最强
用户每天都在滑动、点击、看动画,渲染性能好不好,用户一用就知道。 -
优化空间大
很多应用的布局和渲染都有优化空间——层级太深、重排太多、重绘太频繁。 -
是其他优化的基础
列表优化、动画优化、页面切换优化,本质上都是渲染优化。
💡 想一想:你现在的项目,滑动的时候流畅吗?有没有掉帧的感觉?动画是不是丝滑?
「民族图鉴」的渲染挑战
让我们看看「民族图鉴」有哪些渲染相关的挑战:
1. 详情页布局复杂
- 头部有封面图、返回按钮、收藏按钮、播放按钮、民族名称、人口信息
- 下面有好几个卡片,每个卡片里又有网格、标签、文字
- 整体层级比较深
2. 列表滚动频繁
- 民族列表有 56 项,网格布局
- 用户会快速滑动
- 每个列表项都有文字和图标
3. 有动画效果
- 启动页动画
- 列表项点击缩放
- 页面切换动画
这些场景对渲染性能都有一定要求。如果布局写得不好,就容易出现卡顿。
常见的渲染性能问题
在开始优化之前,我们先看看常见的渲染性能问题有哪些:
| 问题 | 现象 | 原因 |
|---|---|---|
| 布局抖动 | 元素位置来回跳,大小变来变去 | 频繁触发布局变化 |
| 列表卡顿 | 滑动的时候掉帧,不流畅 | 列表项渲染太慢,重绘太频繁 |
| 首屏渲染慢 | 打开页面后白屏时间长 | 布局太复杂,首帧渲染耗时久 |
| 动画不流畅 | 动画一顿一顿的 | 动画过程中频繁重排重绘 |
| 过度绘制 | 同一个像素被画了好多次 | 布局层级深,背景重叠多 |
这些问题,我们都会在本文中找到解决方案。
🛠️ 核心实现
步骤1:渲染流程解析
界面是怎么从代码变成屏幕上的像素的?这个过程叫做渲染流程。
理解渲染流程非常重要——只有知道了中间发生了什么,才能知道哪里可以优化。
HarmonyOS ArkUI 的渲染流程,大致可以分为四步:
布局(Layout) → 绘制(Paint) → 合成(Composite) → 提交(Submit)
下面我们逐一讲解。
1.1 第一步:布局(Layout)
布局阶段的任务是:计算每个组件的位置和大小。
输入:组件树 + 约束条件
输出:每个组件的位置(x, y)和尺寸(width, height)
布局过程是递归的:
- 先计算父组件的大小和位置
- 然后根据父组件的约束,计算子组件的大小和位置
- 子组件又有自己的子组件,继续递归下去
- 直到所有组件的位置和大小都确定了
影响布局的属性:
- width / height
- padding / margin
- flex / grid 布局属性
- align / justify
- 等等——只要会影响位置或大小的,都会影响布局
布局是渲染流程中最耗时的步骤之一。因为它是递归的,父组件一变,所有子组件都要重新算。
1.2 第二步:绘制(Paint)
绘制阶段的任务是:根据布局阶段算出的位置和大小,把组件的内容画出来。
输入:每个组件的位置、大小、样式
输出:一层层的绘制指令(画矩形、画文字、画图片...)
绘制的内容包括:
- 背景色、背景图
- 边框、圆角
- 文字
- 图片
- 阴影、渐变
- 等等——所有视觉效果
绘制也是递归的,从父组件到子组件,一层层画。
影响绘制的属性:
- color / backgroundColor
- border / borderRadius
- shadow / gradient
- fontSize / fontFamily
- opacity
- 等等——只要会影响外观但不影响布局的,都只影响绘制
💡 注意:有些属性既影响布局又影响绘制,比如 padding(影响大小,也影响内容位置)。
1.3 第三步:合成(Composite)
合成阶段的任务是:把绘制好的一层层内容,合成最终的画面。
输入:多个图层(Layer)的绘制结果
输出:最终的一帧画面
为什么要分层?因为:
- 有些内容变化频繁,有些不怎么变
- 分层后,变化的层单独重绘,不变的层可以复用
- 动画可以单独在一个层,不影响其他层
合成的好处:
- 性能更好:只重绘变化的层
- 动画更流畅:动画层单独处理,用 GPU 加速
1.4 第四步:提交(Submit)
提交阶段的任务是:把合成好的画面交给 GPU,显示到屏幕上。
这一步通常很快,不是优化重点。
1.5 一帧的时间预算
现在的手机屏幕一般是 60Hz,也就是每秒刷新 60 次,每帧的时间预算是 16.6ms(1000 / 60 ≈ 16.6)。
这 16.6ms 要做什么?
| 阶段 | 大致耗时 | 说明 |
|---|---|---|
| JS 逻辑执行 | 几 ms | 状态更新、事件处理 |
| 布局计算 | 1~5ms | 取决于布局复杂度 |
| 绘制 | 2~6ms | 取决于绘制内容多少 |
| 合成 + 提交 | 1~3ms | 通常比较快 |
| 总计 | ~10ms | 剩下的时间是余量 |
如果某一帧的处理时间超过了 16.6ms,就会出现掉帧——这一帧没赶上,下一帧才显示,用户就会觉得卡。
💡 高刷屏的挑战:现在很多手机是 90Hz、120Hz 甚至 144Hz,每帧的时间预算更短(120Hz 只有 8.3ms)。对渲染性能的要求更高了。
步骤2:什么是重排(Reflow/Layout)?
理解了渲染流程,接下来我们讲两个最重要的概念:重排和重绘。
先讲重排。
2.1 重排的定义
重排(Reflow,也叫 Layout):当组件的布局属性(位置、大小)发生变化时,需要重新计算布局,这就是重排。
说人话:元素的位置或大小变了,要重新算一遍它在哪、多大,以及它的子元素在哪、多大。
哪些属性变化会触发重排?
| 属性类型 | 举例 |
|---|---|
| 尺寸相关 | width, height, padding, margin, border-width |
| 位置相关 | left, top, right, bottom, position |
| 布局相关 | display, flex-direction, justify-content, align-items |
| 内容相关 | 文字内容变化、图片加载完成 |
| 其他 | 字体大小、行高 |
只要会影响元素位置或大小的属性,变化时都会触发重排。
2.2 重排为什么耗性能?
重排之所以耗性能,是因为重排的成本是递归的。
一个元素的布局变了,会发生什么?
- 这个元素自己要重新计算布局
- 它的所有子元素也要重新计算(因为父元素变了,子元素的位置也可能变)
- 它的兄弟元素可能也要重新计算(因为这个元素大小变了,可能挤到旁边的元素)
- 甚至它的父元素也要重新计算(因为子元素变大了,父元素可能也要变大)
简单说就是:一个元素重排,可能导致整棵子树都重排,甚至影响整个页面。
重排的成本取决于什么?
- 元素的层级:层级越深,重排成本越高
- 子元素的数量:子元素越多,重排成本越高
- 布局的复杂度:Flex/Grid 比普通布局计算量大
💡 经验法则:越靠近根节点的元素重排,影响越大、成本越高。所以尽量避免在根节点附近做频繁的布局变化。
2.3 重排的触发时机
重排什么时候会发生?
常见的触发时机:
-
添加/删除元素
// 会触发重排 this.items.push(newItem); -
改变元素大小
// 会触发重排 this.width = 200; -
改变元素位置
// 会触发重排 this.marginLeft = 20; -
改变内容
// 文字变了,宽度可能变,会触发重排 this.text = '新的内容'; -
窗口大小变化
屏幕旋转、分屏等,会导致整个页面重排。 -
读取布局属性
这个很多人不知道——当你读取某些布局属性的时候,浏览器/框架为了给你准确的值,会强制先执行一次重排。这叫做强制同步布局(Forced Synchronous Layout)。
步骤3:什么是重绘(Repaint/Paint)?
讲完了重排,再讲重绘。
3.1 重绘的定义
重绘(Repaint,也叫 Paint):当组件的外观属性发生变化,但布局没变的时候,只需要重新绘制外观,这就是重绘。
说人话:元素的样子变了,但位置和大小没变,只需要重新画一下就行。
哪些属性变化只会触发重绘?
| 属性类型 | 举例 |
|---|---|
| 颜色相关 | color, backgroundColor, border-color |
| 外观相关 | borderRadius, box-shadow, outline |
| 透明度 | opacity |
| 图片相关 | background-image, object-fit |
| 文字样式 | text-decoration, font-style(注意:font-size 会触发重排) |
注意:这些属性变化不会影响布局,所以只会触发重绘,不会触发重排。
3.2 重排一定会触发重绘
这里有一个重要的关系:
重排一定会触发重绘,但重绘不一定会触发重排。
为什么?
- 重排是位置大小变了,那外观当然也要重新画,所以会触发重绘
- 重绘只是外观变了,位置大小没变,所以不会触发重排
所以,重排的成本比重绘高得多。 因为重排 = 布局计算 + 重绘,而重绘只有绘制。
成本对比(大致估计,仅供参考):
| 操作 | 相对成本 | 说明 |
|---|---|---|
| 重绘 | 1x | 只重新画 |
| 重排 | 3~10x | 重新算布局 + 重绘 |
| 合成层变化 | 0.1x | 只改变合成层属性,最快 |
所以,性能优化的一个重要方向就是:尽量用低成本的操作,代替高成本的操作。
比如:
- 能用重绘解决的,就不要用重排
- 能用合成层解决的,就不要用重绘
3.3 合成层(Composite Layer)
刚才提到了合成层,这是什么?
合成层可以理解为"独立的图层"。某些特殊的属性(比如 transform、opacity)变化时,不需要重排也不需要重绘,只需要在合成阶段调整一下图层就行。
为什么这么快?
因为合成是 GPU 做的,GPU 特别擅长做这种事情——移动图层、改变透明度,对 GPU 来说就是小菜一碟。
哪些属性变化只触合成,不触发重排重绘?
| 属性 | 说明 |
|---|---|
| transform | 平移、缩放、旋转、倾斜 |
| opacity | 透明度 |
| filter(部分) | 某些滤镜效果 |
这是性能最好的属性变化方式! 如果做动画,尽量用 transform 和 opacity,不要用改变 width/height/left/top 的方式。
💡 划重点:做动画的时候,优先用 transform 和 opacity。因为它们只触发合成,不触发布局和绘制,性能最好。
步骤4:减少重排的方法
重排是渲染性能的头号杀手。那怎么减少重排呢?
下面我们讲几种核心方法。
4.1 方法一:用 transform 代替位置变化
这是最经典、也是最有效的优化手段。
不好的做法:用 left/top/margin 改变位置
// ❌ 不好:会触发重排
@State left: number = 0;
// 动画过程中不断改变 left
this.left = 100;
// 布局里
.position({ x: this.left, y: 0 })
每次 left 变,都会触发重排——因为位置变了嘛。如果是动画,一秒钟要变 60 次,那就要重排 60 次,不卡才怪。
好的做法:用 transform.translate 改变位置
// ✅ 好:只触发合成,不触发重排重绘
@State offsetX: number = 0;
// 动画过程中不断改变 offsetX
this.offsetX = 100;
// 布局里
.translate({ x: this.offsetX, y: 0 })
用 transform 的话,元素的实际位置(布局位置)没变,只是视觉上移动了。所以不会触发重排,甚至不会触发重绘,只需要 GPU 合成的时候移动一下图层就行。性能非常好。
transform 能做什么?
translate:平移(代替 left/top)scale:缩放(代替 width/height)rotate:旋转skew:倾斜
这些操作都只触发合成,性能非常好。做动画的时候优先用。
💡 注意:transform 移动的是视觉位置,不影响布局。也就是说,这个元素在文档流里的位置还是原来的位置,只是看起来移动了。这一点在做交互的时候要注意。
4.2 方法二:批量修改
如果要做多次布局变化,不要一个一个改,尽量一次性改完。
不好的做法:一个属性一个属性地改
// ❌ 不好:每次修改都可能触发重排
this.width = 100;
this.height = 200;
this.marginLeft = 10;
this.padding = 5;
每次修改一个属性,框架都可能触发一次重排。改 4 次就可能重排 4 次。
好的做法:批量修改,一次性生效
// ✅ 好:把所有变化放在一起,只重排一次
@State style: { width: number, height: number, marginLeft: number, padding: number } = {
width: 100,
height: 200,
marginLeft: 10,
padding: 5
};
// 一次性更新所有属性
this.style = { width: 200, height: 300, marginLeft: 20, padding: 10 };
或者用一个变量控制,只改一次:
// ✅ 好:用一个状态控制显示/隐藏,只重排一次
@State show: boolean = false;
// 切换显示状态
this.show = !this.show;
核心思想:减少重排的次数——能一次搞定的,就分多次。
4.3 方法三:离线操作
如果要做一系列复杂的 DOM 操作(比如添加很多元素),可以先把元素"拿下来",操作完了再"放回去"。
在 Web 开发中,常用的方式是用 display: none 把元素藏起来,操作完了再显示。因为 display: none 的元素不参与布局,操作它不会触发重排。
在 ArkUI 中,类似的思路是:
- 先把数据准备好,再一次性更新
- 用条件渲染,准备好了再显示
举个例子:
// ❌ 不好:循环里每次 push 都可能触发重排
for (let i = 0; i < 100; i++) {
this.items.push(createItem(i));
}
// ✅ 好:先在本地构建好数组,再一次性赋值
const newItems = [];
for (let i = 0; i < 100; i++) {
newItems.push(createItem(i));
}
this.items = newItems; // 只触发一次重排
核心思想:在"看不见"的地方做操作,做完了再显示出来。这样中间过程的重排都省了。
4.4 方法四:避免强制同步布局
什么是强制同步布局?
简单说就是:JS 先修改了样式(导致布局变了),然后立刻读取布局属性。这时候框架为了给你准确的值,不得不立刻执行一次重排。
正常情况下,重排是异步的——框架会把多次变化合并起来,在合适的时候一起重排。但如果你在修改后立刻读取,框架就没办法合并了,只能立刻重排。
不好的做法:
// ❌ 不好:修改后立刻读取,导致强制同步布局
this.width = 200;
const currentWidth = this.element.width; // 这里会强制重排
好的做法:
// ✅ 好:读取在修改之前,或者不要在修改后立刻读
const currentWidth = this.element.width; // 先读
this.width = 200; // 再改
或者:
// ✅ 好:用自己存的数据,不要读 DOM 的布局属性
const currentWidth = this.width; // 用自己的状态,不用读 DOM
this.width = 200;
核心思想:避免"写 → 读"的模式。先读后写,或者不要读。
💡 在 ArkUI 中:因为我们用的是声明式 UI,状态驱动视图,所以强制同步布局的问题比 Web 开发少很多。但如果用了自定义组件或者直接操作 DOM,还是要注意这个问题。
4.5 方法五:用绝对定位脱离文档流
如果一个元素需要频繁移动(比如弹窗、抽屉、悬浮按钮),可以让它脱离文档流。
脱离文档流的元素,重排的时候不会影响其他元素——因为它不在正常的布局流里。
实现方式:
position: absolute或position: fixed(Web)- ArkUI 中的
Position布局
这样,这个元素的重排只会影响它自己,不会影响页面上的其他元素,成本就低多了。
步骤5:减少组件层级
组件层级(也叫 DOM 层级、视图层级)是影响渲染性能的另一个重要因素。
为什么层级深了会慢?
- 布局的时候,递归深度更深
- 绘制的时候,一层层画,过度绘制更多
- 重排的时候,影响的子树更大
所以,减少层级是布局优化的重要方向。
5.1 能一层解决的,不要套多层
这是最基本的原则。
不好的做法:为了方便,随便套容器
// ❌ 不好:没必要的嵌套
Column() {
Row() {
Column() {
Text('Hello')
}
}
}
上面这段代码,里面三层其实只有一层是有用的。完全可以简化:
// ✅ 好:直接一层
Text('Hello')
.padding(16)
.backgroundColor('#fff')
怎么判断层级合不合理?
- 问自己:这一层容器是必须的吗?能不能去掉?
- 如果只是为了加个 padding/margin/background,能不能直接加在子组件上?
- 如果是一层 Column 套一层 Row,能不能合并?
💡 经验法则:一个页面的层级最好控制在 10 层以内,列表项最好控制在 5 层以内。当然这只是参考,不是硬性规定。
5.2 「民族图鉴」详情页层级分析
让我们看看「民族图鉴」详情页的层级:
Column(根容器)
├─ Stack(头部区域)
│ ├─ Image(背景图)
│ ├─ Column(遮罩)
│ └─ Column(内容)
│ ├─ Row(按钮行)
│ ├─ Blank
│ ├─ Text(名称)
│ ├─ Row(别名)
│ └─ Row(人口)
└─ Scroll
└─ Column(内容区)
├─ Column(基本信息卡片)
│ ├─ Text(标题)
│ └─ Grid(信息网格)
│ └─ GridItem × 4
│ └─ Column(信息项)
│ ├─ Row(标签行)
│ └─ Text(值)
├─ Column(语言卡片)
├─ Column(地区卡片)
└─ Column(介绍卡片)
最深的地方有几层?
算一下:根 Column → Scroll → Column → Grid → GridItem → Column → Row → Text。最深有 8 层。
8 层其实还好,不算特别深。但还有优化空间——比如 GridItem 里的 Column 和 Row 能不能简化?
5.3 减少层级的技巧
技巧一:合理使用 Flex 布局
Flex 布局很强大,很多时候一层 Flex 就能搞定,不用套多层。
比如,要实现"左边一个图标,右边文字,文字有两行":
// ❌ 不好:套了好多层
Row() {
Image(...)
Column() {
Text('标题')
Text('副标题')
}
}
其实这个结构是合理的,不算过度嵌套。但如果是下面这种,就有问题了:
// ❌ 过度嵌套:没必要的 Column 套 Row
Column() {
Row() {
Column() {
Text('内容')
}
}
}
技巧二:用 padding 代替 margin 嵌套
有时候为了加个外边距,就套一层容器。其实很多时候直接加 padding 就行。
// ❌ 不好:为了加间距套一层
Column() {
Column() {
Text('内容')
}
.padding(16)
}
// ✅ 好:直接加 padding
Text('内容')
.padding(16)
技巧三:用组合属性
ArkUI 的属性支持链式调用,一个组件可以加很多属性。不要为了加个背景色就套一层 Column。
// ❌ 不好:为了加背景套一层
Column() {
Text('内容')
}
.backgroundColor('#fff')
.padding(16)
.borderRadius(8)
// ✅ 好:直接加在 Text 上
Text('内容')
.backgroundColor('#fff')
.padding(16)
.borderRadius(8)
技巧四:自定义组件抽取
把复杂的布局抽成自定义组件,虽然层级没减少,但代码更清晰,也方便优化。
💡 注意:减少层级不是"为了减少而减少"。可读性和可维护性也很重要。如果减少层级会导致代码很难懂,那还是保持清晰的结构比较好。层级优化到合理范围就行,不用追求"极致扁平"。
步骤6:布局优化技巧
除了减少重排和减少层级,还有一些布局优化的技巧。
6.1 Flex 布局优化
Flex 是我们最常用的布局方式。怎么让 Flex 布局性能更好?
技巧一:尽量用固定尺寸
如果 Flex 子项的尺寸是固定的,布局计算会快很多。如果是动态的(比如 layoutWeight(1)),Flex 需要多轮计算才能确定最终尺寸。
// 更快:固定宽度
Row() {
Text('左')
.width(100)
Text('右')
.layoutWeight(1) // 只有一个是动态的
}
// 更慢:多个动态宽度
Row() {
Text('左')
.layoutWeight(1)
Text('中')
.layoutWeight(1)
Text('右')
.layoutWeight(1)
}
当然,该用动态宽度还是要用,只是知道有这么回事就行。
技巧二:避免 Flex 嵌套太深
Flex 套 Flex,每一层都要计算,嵌套深了性能会下降。
技巧三:合理使用 flexShrink 和 flexGrow
这些属性会影响 Flex 的计算复杂度。如果不需要弹性收缩/增长,就不要设置。
6.2 避免过深嵌套
这个我们前面讲过了,这里再强调一下。
嵌套深的危害:
- 布局计算慢(递归深度大)
- 重排影响大(一个父元素重排,所有子元素都要重算)
- 过度绘制严重(一层叠一层)
- 代码难维护
怎么检查嵌套深度?
- 用 DevEco Studio 的布局检查器
- 自己数一下最深的地方有几层
- 代码审查的时候注意
目标:
- 页面整体:10 层以内
- 列表项:5 层以内
- 简单组件:3 层以内
6.3 合理使用 Span
Span 是用来在 Text 里标记不同样式的文字的。合理使用 Span 可以减少组件数量。
不好的做法:用多个 Text 组件
// ❌ 不好:多个 Text 组件,每个都要布局和绘制
Row() {
Text('姓名:')
.fontColor('#999')
Text('张三')
.fontColor('#333')
.fontWeight(FontWeight.Bold)
}
好的做法:用一个 Text + Span
// ✅ 好:一个 Text 组件,内部用 Span 区分样式
Text() {
Span('姓名:')
.fontColor('#999')
Span('张三')
.fontColor('#333')
.fontWeight(FontWeight.Bold)
}
好处:
- 组件数量少了(从 2 个 Text + 1 个 Row,变成 1 个 Text)
- 布局更简单
- 性能更好
什么时候用 Span?
- 同一行文字,有不同的样式
- 文字混排(加粗、变色、加链接等)
什么时候不要用 Span?
- 布局比较复杂,不是纯文字
- 需要不同的对齐方式
- 有交互(点击事件等,Span 也支持点击,但复杂交互还是用组件好)
步骤7:渲染优化
讲完了布局优化,再讲讲渲染优化。
7.1 renderGroup
renderGroup 是什么?
简单说就是:把一个组件及其所有子组件,作为一个整体来渲染。标记了 renderGroup 的组件,会被当作一个独立的渲染单元。
renderGroup 的好处:
- 减少重绘:如果子组件变化,但整体大小没变,只在组内重绘
- 提升合成效率:可以作为一个整体参与合成
- 适合内容不怎么变的组件:一次绘制,多次复用
什么时候用 renderGroup?
- 组件内容比较固定,不常变化
- 组件内部结构复杂,层级较深
- 组件会频繁整体移动(比如做动画)
怎么用?
@Component
struct MyComponent {
build() {
Column() {
// ... 复杂的内容
}
.renderGroup(true) // 开启 renderGroup
}
}
注意:
- 不是所有组件都适合开 renderGroup
- 开了之后会有一些额外的内存开销(因为要缓存渲染结果)
- 如果组件内容经常变化,开了反而可能更慢(因为缓存没用,还要维护缓存)
💡 经验法则:内容不怎么变的复杂组件,可以考虑开 renderGroup。内容经常变的,就不要开了。
7.2 离屏渲染
离屏渲染(Offscreen Rendering) 是什么?
简单说就是:在屏幕外的缓冲区先画好,然后再一次性贴到屏幕上。
为什么要有离屏渲染?
- 有些效果(比如圆角裁剪、阴影、模糊)需要特殊处理
- 先在离屏缓冲区画好,处理完效果,再贴上去
- 避免每帧都重复计算
离屏渲染的优缺点:
| 优点 | 缺点 |
|---|---|
| 效果好,可以实现复杂视觉效果 | 有额外的内存开销 |
| 一次渲染,多次使用(如果内容不变) | 创建和切换有成本 |
| 减少 CPU 绘制时间(内容不变的话) | 内容经常变化的话,反而更慢 |
什么时候会触发离屏渲染?
- 圆角 + 裁剪(borderRadius + clip)
- 阴影(box-shadow)
- 遮罩(mask)
- 模糊效果
- 等等特殊效果
优化建议:
- 不要滥用圆角、阴影等效果
- 列表项尽量少用阴影(每个都要离屏渲染的话,性能会很差)
- 如果一定要用,确保内容不常变化,这样离屏渲染的缓存能用上
7.3 硬件加速
硬件加速是什么?
简单说就是:用 GPU 来做渲染,而不是用 CPU。
为什么硬件加速快?
- GPU 是专门为图形渲染设计的,并行计算能力强
- 很多渲染操作(比如移动图层、改变透明度、纹理映射)GPU 做起来特别快
- CPU 更擅长通用计算,做图形不如 GPU 专业
现在的手机基本都是硬件加速的,但有些操作还是会走 CPU 渲染。
怎么利用硬件加速?
- 多用 transform 和 opacity(GPU 合成,最快)
- 多用图片(图片是纹理,GPU 擅长处理)
- 少用复杂的绘制效果(大量文字、复杂路径、渐变等)
- 合理分层(让 GPU 合成图层)
💡 注意:硬件加速不是万能的。不是所有操作 GPU 都快——比如大量文字、复杂路径,GPU 可能反而慢。而且 GPU 渲染也有自己的瓶颈,比如显存带宽、填充率等。
步骤8:实战——「民族图鉴」详情页布局优化
讲了这么多理论,让我们来实战一下——优化「民族图鉴」的详情页。
8.1 第一步:分析现状
先看看详情页目前的实现(简化版):
Column(根)
├─ Stack(头部)
│ ├─ Image(封面)
│ ├─ Column(半透明遮罩)
│ └─ Column(内容)
│ ├─ Row(按钮行:返回、收藏、分享、播放)
│ ├─ Blank
│ ├─ Text(名称)
│ ├─ Row(别名行)
│ └─ Row(人口行)
└─ Scroll
└─ Column(内容)
├─ Column(基本信息卡片)
│ ├─ Text(标题)
│ └─ Grid(2×2)
│ └─ GridItem × 4
│ └─ Column
│ ├─ Row(图标+标签)
│ └─ Text(值)
├─ Column(语言文字卡片)
├─ Column(分布地区卡片)
└─ Column(详细介绍卡片)
发现的问题:
-
头部的半透明遮罩可以优化
用一个 Column 充当初始遮罩,有点浪费。能不能直接给 Image 加个遮罩效果? -
GridItem 内部层级有点深
GridItem → Column → Row + Text。能不能简化? -
卡片都是 Column 套内容
每个卡片都是一个 Column,里面有标题和内容。可以考虑抽成组件,但层级优化空间不大。 -
有些地方可以用 Span 减少组件
比如"人口:xxx · #排名",现在用 Row 套多个 Text,能不能用 Span?
8.2 第二步:制定优化方案
优化项一:优化头部遮罩
- 去掉单独的遮罩 Column
- 用 Image 的 overlay 或者直接叠一层(但其实 Stack 里已经叠了)
- 其实这个优化空间不大,Stack 布局本身是合理的
优化项二:简化 GridItem 结构
- GridItem 里的 Column + Row + Text,看看能不能减少一层
- 比如 Row 能不能去掉?
优化项三:用 Span 替代 Row + 多个 Text
- 同一行的多样式文字,用 Span 代替多个 Text 组件
优化项四:给卡片加 renderGroup
- 卡片内容加载完就不怎么变了
- 可以考虑开 renderGroup,减少重绘
8.3 第三步:实施优化
优化一:用 Span 简化人口行
优化前:
Row({ space: 4 }) {
Text($r('app.string.detail_population'))
.fontSize(14)
.fontColor('#FFFFFF')
.opacity(0.85)
Text(this.ethnic!.population)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(` · #${this.ethnic!.populationRank}`)
.fontSize(14)
.fontColor('#FFFFFF')
.opacity(0.7)
}
优化后:
Text() {
Span($r('app.string.detail_population'))
.fontSize(14)
.fontColor('#FFFFFF')
.fontOpacity(0.85)
Span(this.ethnic!.population)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Span(` · #${this.ethnic!.populationRank}`)
.fontSize(14)
.fontColor('#FFFFFF')
.fontOpacity(0.7)
}
效果:
- 组件数量:从 Row + 3 Text = 4 个组件,变成 1 个 Text
- 布局更简单
- 性能更好
优化二:简化基本信息项
优化前:
@Builder
buildInfoItem(icon: string, label: string, value: string): void {
Column({ space: 4 }) {
Row({ space: 4 }) {
Text(icon)
.fontSize(14)
Text(label)
.fontSize(12)
.fontColor('#999')
}
Text(value)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.alignItems(HorizontalAlign.Start)
}
这个结构其实还算合理:图标+标签是一行,值是另一行。两层嵌套是必要的。
但图标和标签那行,能不能用 Span?可以:
优化后:
@Builder
buildInfoItem(icon: string, label: string, value: string): void {
Column({ space: 4 }) {
Text() {
Span(`${icon} `)
.fontSize(14)
Span(label)
.fontSize(12)
.fontColor('#999')
}
Text(value)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.alignItems(HorizontalAlign.Start)
}
效果:
- 减少了一个 Row 组件
- 从 3 个组件(Row + Text + Text)变成 2 个(Text + Text)
- 性能略有提升
优化三:给卡片加 renderGroup
卡片内容加载完就不怎么变了,可以考虑加 renderGroup:
@Builder
buildBasicInfoCard(): void {
Column({ space: 12 }) {
// ... 卡片内容
}
.width('90%')
.padding(16)
.backgroundColor('#fff')
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
.renderGroup(true) // 开启 renderGroup
}
注意:这个优化需要测试效果——如果卡片内容经常变化,开了反而可能慢。但详情页的卡片内容加载完就不变了,所以应该是有收益的。
8.4 第四步:验证效果
优化完了,怎么验证效果?
-
用 Profiler 测 FPS
上下滑动详情页,看 FPS 有没有提升 -
测首屏渲染时间
打开详情页,看首帧渲染时间有没有减少 -
对比内存
renderGroup 会增加一点内存开销,看看内存变化大不大 -
实际体验
自己用一下,感觉有没有更流畅
步骤9:常见问题与解决方案
最后,我们来聊聊布局与渲染的常见问题。
9.1 问题一:布局抖动
现象:
页面加载的时候,元素的位置或大小来回变化,"抖"几下才稳定下来。
原因:
- 内容是动态加载的,加载过程中布局不断变化
- 图片没有固定宽高,加载完成后把布局撑开
- 数据更新导致布局频繁变化
解决方案:
-
给图片设置固定宽高
// ✅ 好:固定宽高,图片加载前后布局不变 Image(src) .width(100) .height(100) .objectFit(ImageFit.Cover) -
用占位图/骨架屏
数据加载前先显示占位,布局位置占好,数据来了再填充。这样布局不会变。 -
批量更新数据
不要一条一条更新数据,一次性更新完,减少重排次数。 -
预留空间
如果知道内容大概有多大,先把空间预留好。
9.2 问题二:列表卡顿
现象:
列表滑动的时候掉帧,不流畅,特别是快速滑动的时候。
原因:
这个我们下一篇会详细讲,这里先简单列一下:
- 列表项太复杂,渲染慢
- 图片太大,解码和绘制慢
- 滑动过程中频繁重排重绘
- 没有用懒加载,一次性渲染太多项
解决方案(先列大纲,下一篇细讲):
- 简化列表项布局,减少层级
- 图片优化(尺寸、格式、懒加载)
- 用 LazyForEach 懒加载
- 减少滑动过程中的状态变化
- 列表项用固定高度
9.3 问题三:首屏渲染慢
现象:
打开一个新页面,要等很久才能看到内容,白屏时间长。
原因:
- 页面布局太复杂,层级太深
- 首屏要渲染的内容太多
- 数据加载慢,阻塞渲染
- 主线程做了太多事情
解决方案:
-
简化首屏布局
首屏只显示最核心的内容,次要内容懒加载。 -
骨架屏/占位图
先显示骨架屏,让用户知道"正在加载",比白屏体验好。 -
数据预加载
提前加载数据(比如上一个页面就开始加载)。 -
异步加载
耗时操作放后台,不要阻塞主线程。 -
按需渲染
首屏只渲染可见区域,其他的滚动到了再渲染。
9.4 问题四:动画不流畅
现象:
动画一顿一顿的,不丝滑。
原因:
- 动画过程中频繁重排重绘
- 动画属性选得不对(用了 left/top 而不是 transform)
- 同一时间太多动画在跑
- 主线程被其他事情阻塞了
解决方案:
-
用 transform 和 opacity 做动画
这两个属性只触发合成,不触发重排重绘,性能最好。// ✅ 好:用 transform 做位移动画 .translate({ x: this.offsetX, y: 0 }) .animation({ duration: 300 }) // ❌ 不好:用 position 做位移动画 .position({ x: this.left, y: 0 }) .animation({ duration: 300 }) -
减少同时运行的动画数量
不要一下子开一堆动画,GPU 也忙不过来。 -
动画期间避免做其他耗时操作
动画跑的时候,就不要加载数据、计算东西了。 -
用硬件加速
确保动画走 GPU 合成。 -
适当降低动画复杂度
如果实在卡,简化动画效果——比如从 3D 变 2D,从多个元素变单个元素。
9.5 问题五:过度绘制
现象:
同一个像素被绘制了多次,浪费 GPU 性能。
原因:
- 布局层级深,一层叠一层
- 背景色重复设置(父组件有背景,子组件又有)
- 图片下面又有背景色(图片不透明的话,背景白画了)
怎么检查过度绘制?
- 用开发者选项里的"调试 GPU 过度绘制"
- 不同颜色代表不同的绘制次数:
- 蓝色:绘制了 2 次(还可以)
- 绿色:绘制了 3 次(还行)
- 淡红:绘制了 4 次(有点多)
- 深红:绘制了 5 次及以上(要优化)
解决方案:
-
减少布局层级
这个前面讲过了。 -
去掉不必要的背景
// ❌ 不好:父组件有背景,子组件又设了同样的背景 Column() { Text('内容') .backgroundColor('#fff') // 如果父组件已经是白色,这个就多余了 } .backgroundColor('#fff') -
不透明的图片下面不用加背景
如果图片是不透明的,图片下面的背景是看不到的,白画了。 -
用裁剪代替多层叠加
有些效果用裁剪实现,比叠好几层性能好。
💡 经验法则:大部分页面 2~3 次过度绘制是正常的,不用追求"零过度绘制"。只要不是大面积的 4 次以上,问题都不大。
⚠️ 常见问题与解答
Q1:布局层级是不是越少越好?
A:总体来说是的,但也不能走极端。
正确的态度:
- 不必要的层级,能去掉就去掉
- 但不要为了减少层级把代码写得很难懂
- 可读性和可维护性也很重要
- 层级控制在合理范围就行,不用追求"极致扁平"
判断标准:
- 这一层是必须的吗?有没有办法合并?
- 如果去掉这一层,代码会不会变得很难懂?
- 去掉之后,性能提升明显吗?
如果去掉一层,代码还是清晰的,性能也更好,那就去掉。如果去掉之后代码变得一团糟,那还是留着吧。
Q2:transform 会不会影响交互?
A:会的,要注意。
transform 是视觉上的变换,不影响布局。也就是说:
- 元素的布局位置(在文档流中的位置)没变
- 只是显示的时候被移动/缩放/旋转了
这会带来什么问题?
- 点击区域:元素看起来在 A 位置,但实际点击区域可能还在 B 位置
- 周围的元素:不会因为这个元素变大了而挤开(因为布局没变)
什么时候用 transform?
- 动画:做完动画就恢复,或者动画不影响布局
- 视觉效果:比如按下时缩小一点(纯视觉反馈)
- 固定位置的元素:比如弹窗、悬浮按钮(不影响正常布局)
如果需要改变元素的实际布局位置,还是要用布局属性(margin、padding、flex 等)。
Q3:所有动画都用 transform 吗?
A:大部分动画是的,但不是全部。
适合用 transform 的动画:
- 位移动画(translate)
- 缩放动画(scale)
- 旋转动画(rotate)
- 透明度动画(opacity)
这些动画性能最好,优先用。
不适合用 transform 的动画:
- 宽度/高度变化(需要布局跟着变)
- 布局相关的动画(比如展开/收起,需要其他元素跟着动)
- 颜色变化(这个用重绘,性能也还可以)
原则:
- 纯视觉的动画,用 transform + opacity
- 需要改变布局的动画,用布局属性(但性能差一点,尽量少用)
Q4:renderGroup 要不要开?
A:看情况。
适合开 renderGroup 的情况:
- 组件内容不常变化
- 组件结构比较复杂(层级深、组件多)
- 组件会被频繁整体移动(比如做动画)
- 同一个组件会被多次复用(比如列表项)
不适合开 renderGroup 的情况:
- 组件内容经常变化
- 组件很简单(就一层)
- 内存很紧张(开了会增加内存开销)
建议:
- 先不开,测一下性能
- 如果发现某个组件重绘很频繁,而且内容不怎么变,再考虑开
- 开了之后测一下效果,确认真的变快了再保留
Q5:怎么知道页面的渲染性能好不好?
A:用工具测 + 实际体验。
工具测量:
- DevEco Profiler:看 FPS、掉帧、渲染管线
- 开发者选项:看过度绘制、GPU 呈现模式
- 手动打点:测首帧时间、页面加载时间
实际体验:
- 快速滑动列表,看跟不跟手
- 打开页面,看有没有明显的延迟
- 播放动画,看流不流畅
- 用低端机测一下,最能发现问题
判断标准:
- FPS 稳定在 55+,很少掉帧 → 优秀
- FPS 在 45~55 之间,偶尔轻微掉帧 → 良好
- FPS 经常低于 45,明显卡顿 → 需要优化
📝 本章小结
核心知识点
本文系统讲解了布局与渲染性能优化:
1. 渲染流程四步走
- 布局(Layout):计算位置和大小
- 绘制(Paint):画内容(文字、图片、背景等)
- 合成(Composite):把多层合成最终画面
- 提交(Submit):交给 GPU 显示
2. 重排(Reflow/Layout)
- 定义:布局属性变化,重新计算位置大小
- 成本:很高(递归计算,影响子树)
- 触发:改变尺寸、位置、布局、内容等
3. 重绘(Repaint/Paint)
- 定义:外观变化,重新绘制
- 成本:中等(比重排低)
- 触发:颜色、边框、阴影等外观属性变化
4. 合成层变化
- 定义:transform、opacity 等属性变化
- 成本:最低(GPU 合成)
- 特点:不触发布局和绘制,性能最好
5. 减少重排的方法
- 用 transform 代替位置/大小变化
- 批量修改,减少重排次数
- 离线操作,不可见的时候改
- 避免强制同步布局(写→读模式)
- 用绝对定位脱离文档流
6. 减少组件层级
- 能一层解决的不要套多层
- 合理使用 Flex 布局
- 用 padding 代替 margin 嵌套
- 用 Span 减少 Text 组件数量
7. 布局优化技巧
- Flex 布局优化:尽量用固定尺寸
- 避免过深嵌套
- 合理使用 Span
8. 渲染优化
- renderGroup:内容不变的复杂组件可以开
- 离屏渲染:圆角、阴影等效果会触发
- 硬件加速:用 GPU 渲染,transform 最快
9. 常见问题
- 布局抖动:固定宽高、占位图、批量更新
- 列表卡顿:简化项、优化图片、懒加载
- 首屏慢:简化布局、骨架屏、预加载
- 动画卡:用 transform、减少动画数量
- 过度绘制:减少层级、去掉不必要背景
最佳实践总结
✅ 做动画优先用 transform + opacity
// ✅ 好:只触发合成,性能最好
.translate({ x: this.offsetX, y: 0 })
.opacity(this.opacity)
.animation({ duration: 300 })
✅ 减少重排次数:批量修改
// ✅ 好:一次性更新,只重排一次
this.style = { width: 200, height: 300, marginLeft: 20 };
✅ 减少层级:能合并就合并
// ✅ 好:直接给组件加属性,不要为了加属性套容器
Text('Hello')
.padding(16)
.backgroundColor('#fff')
.borderRadius(8)
✅ 用 Span 减少组件
// ✅ 好:一个 Text + 多个 Span,代替多个 Text 组件
Text() {
Span('标签:').fontColor('#999')
Span('内容').fontColor('#333').fontWeight(FontWeight.Bold)
}
✅ 图片要设固定宽高
// ✅ 好:固定宽高,避免布局抖动
Image(src)
.width(100)
.height(100)
.objectFit(ImageFit.Cover)
下一步预告
在下一篇文章(第64篇)中,我们将:
- 📋 深入理解为什么列表是性能重灾区
- 🔍 对比 ForEach 和 LazyForEach 的原理区别与性能差异
- ⚙️ 详解 LazyForEach 的工作原理:视口回收、组件复用、懒加载
- 🎯 掌握列表项优化的各种技巧
- 🔑 理解复用机制与 key 的重要性
- 🚀 学习列表性能优化的高级手段
- 🖼️ 掌握列表中的图片优化策略
- 💪 实战优化「民族图鉴」56 个民族列表
- 💡 解决滚动卡顿、白屏闪烁、item 错位、内存增长等常见问题
列表是应用中最常见的界面,也是性能问题最多的地方。把列表优化做好,应用的流畅度会提升一个档次。
🔗 相关链接
- 布局性能优化: 官方文档
- 渲染性能优化: 官方文档
- renderGroup: 官方文档
- Span 组件: 官方文档
- 重排与重绘: Web 开发经典概念,原理类似
💡 提示:布局和渲染性能优化的核心是"减少工作量"——能不算的就不算,能不画的就不画,能复用的就复用。理解了渲染流程,知道每一步的成本,你就能判断哪种写法性能更好。记住:transform 永远是动画的首选,减少层级永远是对的,批量操作永远比逐个操作好。
更多推荐



所有评论(0)