在这里插入图片描述

📖 引言

打开「民族图鉴」的详情页,你看到了什么?

  • 顶部的封面大图和民族名称
  • 基本信息卡片(宗教、语系、文字、排行)
  • 语言与文字卡片
  • 分布地区卡片
  • 详细介绍卡片

这些内容加起来,有几十个组件、好几层嵌套。滑动的时候,它们是怎么渲染到屏幕上的?为什么有时候滑动会卡顿?为什么改一个属性会导致整个页面重新布局?

这些问题,都和布局与渲染性能有关。

很多开发者对布局和渲染的理解停留在"写代码 → 运行 → 显示"的层面,至于中间发生了什么,并不清楚。但如果你想写出流畅的界面,就必须理解渲染流程——因为大部分卡顿、掉帧,都和渲染流程中的某个环节太慢有关。

你可能会问:

  • 界面是怎么渲染到屏幕上的?渲染流程有哪几步?
  • 什么是重排(Reflow/Layout)?什么是重绘(Repaint/Paint)?
  • 为什么重排重绘耗性能?它们有什么区别?
  • 怎么减少重排重绘?有哪些技巧?
  • 布局层级是不是越深越卡?怎么减少层级?
  • Flex 布局怎么优化?Span 怎么合理使用?
  • renderGroup 是什么?离屏渲染又是什么?
  • 硬件加速是怎么回事?要不要开?

这些问题非常重要。布局和渲染性能直接影响界面的流畅度——FPS 高不高、滑动跟不跟手、动画流不流畅,都和它有关。

本文将带你深入理解布局与渲染性能优化。我们会从渲染流程讲起,逐一解析重排、重绘的原理,然后讲解各种优化技巧,最后结合「民族图鉴」的详情页进行实战优化。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 理解完整的渲染流程:布局 → 绘制 → 合成 → 提交
  • ✅ 深入理解重排(Reflow)和重绘(Repaint)的区别与原理
  • ✅ 掌握减少重排的核心方法与最佳实践
  • ✅ 学会减少组件层级的技巧与判断标准
  • ✅ 掌握 Flex 布局优化与 Span 的合理使用
  • ✅ 理解 renderGroup、离屏渲染、硬件加速的原理与使用场景
  • ✅ 能够独立优化「民族图鉴」详情页的布局性能
  • ✅ 解决布局抖动、列表卡顿、首屏渲染慢等常见问题

💡 需求分析

为什么布局和渲染性能很重要?

想象一下这个场景:

你在「民族图鉴」里浏览民族列表,快速滑动的时候,感觉一顿一顿的,不跟手。点进详情页,往下滑的时候也有点卡。你说不上来哪里不对,但就是觉得"不够流畅"。

这种"不够流畅"的感觉,很大概率是渲染性能的问题。

布局和渲染性能为什么重要?

  1. 直接影响流畅度
    FPS 高不高、掉不掉帧,主要看渲染能不能跟上屏幕刷新率。渲染慢了,FPS 就掉下来了。

  2. 用户感知最强
    用户每天都在滑动、点击、看动画,渲染性能好不好,用户一用就知道。

  3. 优化空间大
    很多应用的布局和渲染都有优化空间——层级太深、重排太多、重绘太频繁。

  4. 是其他优化的基础
    列表优化、动画优化、页面切换优化,本质上都是渲染优化。

💡 想一想:你现在的项目,滑动的时候流畅吗?有没有掉帧的感觉?动画是不是丝滑?


「民族图鉴」的渲染挑战

让我们看看「民族图鉴」有哪些渲染相关的挑战:

1. 详情页布局复杂

  • 头部有封面图、返回按钮、收藏按钮、播放按钮、民族名称、人口信息
  • 下面有好几个卡片,每个卡片里又有网格、标签、文字
  • 整体层级比较深

2. 列表滚动频繁

  • 民族列表有 56 项,网格布局
  • 用户会快速滑动
  • 每个列表项都有文字和图标

3. 有动画效果

  • 启动页动画
  • 列表项点击缩放
  • 页面切换动画

这些场景对渲染性能都有一定要求。如果布局写得不好,就容易出现卡顿。


常见的渲染性能问题

在开始优化之前,我们先看看常见的渲染性能问题有哪些:

问题 现象 原因
布局抖动 元素位置来回跳,大小变来变去 频繁触发布局变化
列表卡顿 滑动的时候掉帧,不流畅 列表项渲染太慢,重绘太频繁
首屏渲染慢 打开页面后白屏时间长 布局太复杂,首帧渲染耗时久
动画不流畅 动画一顿一顿的 动画过程中频繁重排重绘
过度绘制 同一个像素被画了好多次 布局层级深,背景重叠多

这些问题,我们都会在本文中找到解决方案。


🛠️ 核心实现

步骤1:渲染流程解析

界面是怎么从代码变成屏幕上的像素的?这个过程叫做渲染流程

理解渲染流程非常重要——只有知道了中间发生了什么,才能知道哪里可以优化。

HarmonyOS ArkUI 的渲染流程,大致可以分为四步:

布局(Layout) → 绘制(Paint) → 合成(Composite) → 提交(Submit)

下面我们逐一讲解。


1.1 第一步:布局(Layout)

布局阶段的任务是:计算每个组件的位置大小

输入:组件树 + 约束条件
输出:每个组件的位置(x, y)和尺寸(width, height)

布局过程是递归的

  1. 先计算父组件的大小和位置
  2. 然后根据父组件的约束,计算子组件的大小和位置
  3. 子组件又有自己的子组件,继续递归下去
  4. 直到所有组件的位置和大小都确定了

影响布局的属性

  • 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 重排为什么耗性能?

重排之所以耗性能,是因为重排的成本是递归的

一个元素的布局变了,会发生什么?

  1. 这个元素自己要重新计算布局
  2. 它的所有子元素也要重新计算(因为父元素变了,子元素的位置也可能变)
  3. 它的兄弟元素可能也要重新计算(因为这个元素大小变了,可能挤到旁边的元素)
  4. 甚至它的父元素也要重新计算(因为子元素变大了,父元素可能也要变大)

简单说就是:一个元素重排,可能导致整棵子树都重排,甚至影响整个页面。

重排的成本取决于什么?

  • 元素的层级:层级越深,重排成本越高
  • 子元素的数量:子元素越多,重排成本越高
  • 布局的复杂度:Flex/Grid 比普通布局计算量大

💡 经验法则:越靠近根节点的元素重排,影响越大、成本越高。所以尽量避免在根节点附近做频繁的布局变化。


2.3 重排的触发时机

重排什么时候会发生?

常见的触发时机

  1. 添加/删除元素

    // 会触发重排
    this.items.push(newItem);
    
  2. 改变元素大小

    // 会触发重排
    this.width = 200;
    
  3. 改变元素位置

    // 会触发重排
    this.marginLeft = 20;
    
  4. 改变内容

    // 文字变了,宽度可能变,会触发重排
    this.text = '新的内容';
    
  5. 窗口大小变化
    屏幕旋转、分屏等,会导致整个页面重排。

  6. 读取布局属性
    这个很多人不知道——当你读取某些布局属性的时候,浏览器/框架为了给你准确的值,会强制先执行一次重排。这叫做强制同步布局(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: absoluteposition: 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 渲染。

怎么利用硬件加速?

  1. 多用 transform 和 opacity(GPU 合成,最快)
  2. 多用图片(图片是纹理,GPU 擅长处理)
  3. 少用复杂的绘制效果(大量文字、复杂路径、渐变等)
  4. 合理分层(让 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(详细介绍卡片)

发现的问题

  1. 头部的半透明遮罩可以优化
    用一个 Column 充当初始遮罩,有点浪费。能不能直接给 Image 加个遮罩效果?

  2. GridItem 内部层级有点深
    GridItem → Column → Row + Text。能不能简化?

  3. 卡片都是 Column 套内容
    每个卡片都是一个 Column,里面有标题和内容。可以考虑抽成组件,但层级优化空间不大。

  4. 有些地方可以用 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 第四步:验证效果

优化完了,怎么验证效果?

  1. 用 Profiler 测 FPS
    上下滑动详情页,看 FPS 有没有提升

  2. 测首屏渲染时间
    打开详情页,看首帧渲染时间有没有减少

  3. 对比内存
    renderGroup 会增加一点内存开销,看看内存变化大不大

  4. 实际体验
    自己用一下,感觉有没有更流畅


步骤9:常见问题与解决方案

最后,我们来聊聊布局与渲染的常见问题。


9.1 问题一:布局抖动

现象
页面加载的时候,元素的位置或大小来回变化,"抖"几下才稳定下来。

原因

  • 内容是动态加载的,加载过程中布局不断变化
  • 图片没有固定宽高,加载完成后把布局撑开
  • 数据更新导致布局频繁变化

解决方案

  1. 给图片设置固定宽高

    // ✅ 好:固定宽高,图片加载前后布局不变
    Image(src)
      .width(100)
      .height(100)
      .objectFit(ImageFit.Cover)
    
  2. 用占位图/骨架屏
    数据加载前先显示占位,布局位置占好,数据来了再填充。这样布局不会变。

  3. 批量更新数据
    不要一条一条更新数据,一次性更新完,减少重排次数。

  4. 预留空间
    如果知道内容大概有多大,先把空间预留好。


9.2 问题二:列表卡顿

现象
列表滑动的时候掉帧,不流畅,特别是快速滑动的时候。

原因
这个我们下一篇会详细讲,这里先简单列一下:

  • 列表项太复杂,渲染慢
  • 图片太大,解码和绘制慢
  • 滑动过程中频繁重排重绘
  • 没有用懒加载,一次性渲染太多项

解决方案(先列大纲,下一篇细讲)

  • 简化列表项布局,减少层级
  • 图片优化(尺寸、格式、懒加载)
  • 用 LazyForEach 懒加载
  • 减少滑动过程中的状态变化
  • 列表项用固定高度

9.3 问题三:首屏渲染慢

现象
打开一个新页面,要等很久才能看到内容,白屏时间长。

原因

  • 页面布局太复杂,层级太深
  • 首屏要渲染的内容太多
  • 数据加载慢,阻塞渲染
  • 主线程做了太多事情

解决方案

  1. 简化首屏布局
    首屏只显示最核心的内容,次要内容懒加载。

  2. 骨架屏/占位图
    先显示骨架屏,让用户知道"正在加载",比白屏体验好。

  3. 数据预加载
    提前加载数据(比如上一个页面就开始加载)。

  4. 异步加载
    耗时操作放后台,不要阻塞主线程。

  5. 按需渲染
    首屏只渲染可见区域,其他的滚动到了再渲染。


9.4 问题四:动画不流畅

现象
动画一顿一顿的,不丝滑。

原因

  • 动画过程中频繁重排重绘
  • 动画属性选得不对(用了 left/top 而不是 transform)
  • 同一时间太多动画在跑
  • 主线程被其他事情阻塞了

解决方案

  1. 用 transform 和 opacity 做动画
    这两个属性只触发合成,不触发重排重绘,性能最好。

    // ✅ 好:用 transform 做位移动画
    .translate({ x: this.offsetX, y: 0 })
    .animation({ duration: 300 })
    
    // ❌ 不好:用 position 做位移动画
    .position({ x: this.left, y: 0 })
    .animation({ duration: 300 })
    
  2. 减少同时运行的动画数量
    不要一下子开一堆动画,GPU 也忙不过来。

  3. 动画期间避免做其他耗时操作
    动画跑的时候,就不要加载数据、计算东西了。

  4. 用硬件加速
    确保动画走 GPU 合成。

  5. 适当降低动画复杂度
    如果实在卡,简化动画效果——比如从 3D 变 2D,从多个元素变单个元素。


9.5 问题五:过度绘制

现象
同一个像素被绘制了多次,浪费 GPU 性能。

原因

  • 布局层级深,一层叠一层
  • 背景色重复设置(父组件有背景,子组件又有)
  • 图片下面又有背景色(图片不透明的话,背景白画了)

怎么检查过度绘制?

  • 用开发者选项里的"调试 GPU 过度绘制"
  • 不同颜色代表不同的绘制次数:
    • 蓝色:绘制了 2 次(还可以)
    • 绿色:绘制了 3 次(还行)
    • 淡红:绘制了 4 次(有点多)
    • 深红:绘制了 5 次及以上(要优化)

解决方案

  1. 减少布局层级
    这个前面讲过了。

  2. 去掉不必要的背景

    // ❌ 不好:父组件有背景,子组件又设了同样的背景
    Column() {
      Text('内容')
        .backgroundColor('#fff')  // 如果父组件已经是白色,这个就多余了
    }
    .backgroundColor('#fff')
    
  3. 不透明的图片下面不用加背景
    如果图片是不透明的,图片下面的背景是看不到的,白画了。

  4. 用裁剪代替多层叠加
    有些效果用裁剪实现,比叠好几层性能好。

💡 经验法则:大部分页面 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 错位、内存增长等常见问题

列表是应用中最常见的界面,也是性能问题最多的地方。把列表优化做好,应用的流畅度会提升一个档次。


🔗 相关链接


💡 提示:布局和渲染性能优化的核心是"减少工作量"——能不算的就不算,能不画的就不画,能复用的就复用。理解了渲染流程,知道每一步的成本,你就能判断哪种写法性能更好。记住:transform 永远是动画的首选,减少层级永远是对的,批量操作永远比逐个操作好。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐