编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 序卷方法论

  • 数据的本质

  • 运行时模型

  • 并发的设计

  • 内存的真相

  • 交互和系统

    • README
    • 1.窗口核心设计思想
    • 2.视图加载渲染设计
      • 0.60帧变6帧事故
        • 0.1 投诉滑动卡顿
        • 0.2 老板的灵魂三问
        • 0.3 慢动作回放
        • 0.4 事故揭示
        • 0.5 五个递进追问
        • 0.6 三层解药预演
      • 1.视图渲染本质
        • 1.1 像素的物理本质
        • 1.2 帧缓冲区
        • 1.3 双缓冲与撕裂
        • 1.4 视图渲染管线
        • 1.5 帧/刷新率/VSync
        • 1.6 为何是 60Hz
        • 1.7 VSync 的中转站
        • 1.8 16.6ms 死亡线
        • 1.9 跳帧 vs 掉帧
      • 2.视图加载生命周期
        • 2.1 LayoutInflater 解析
        • 2.2 为何 inflate 慢
        • 2.3 视图树构建
        • 2.4 视图加载优化
        • 2.5 视图四态状态机
      • 3.测量布局绘制
        • 3.1 为何必须分三阶段
        • 3.2 Measure 尺寸协商
        • 3.3 Layout 位置确定
        • 3.4 Draw 像素绘制
        • 3.5 三阶段复杂度陷阱
        • 3.6 三阶段算法签名
      • 4.GPU 与硬件加速
        • 4.1 CPU 画 vs GPU 画
        • 4.2 DisplayList 录制
        • 4.3 图层与合成
        • 4.4 硬件加速代价
        • 4.5 渲染线程跨端对照
      • 5.重绘与失效传播
        • 5.1 invalidate vs requestLayout
        • 5.2 脏区域最小化
        • 5.3 重绘风暴案例
      • 6.视图复用与回收
        • 6.1 RecyclerView 四级缓存
        • 6.2 ViewHolder 模式
        • 6.3 复用鬼影问题
      • 7.异步渲染离屏缓冲
        • 7.1 主线程绘制锁链
        • 7.2 离屏 Bitmap/SurfaceView
        • 7.3 Skia/Metal/Vulkan
      • 7B.声明式 vs 命令式
        • 7B.1 命令式内在矛盾
        • 7B.2 声明式核心思想
        • 7B.3 五大声明式框架对比
        • 7B.4 声明式代价
        • 7B.5 开发者总记忆
      • 8.跨平台渲染对比
        • 8.1 Android 视图树
        • 8.2 iOS CALayer
        • 8.3 Web 渲染树
        • 8.4 Flutter 自绘引擎
        • 8.5 RN 桥接架构
        • 8.6 LVGL 软光栅
        • 8.7 跨端渲染阶段矩阵
      • 9.经典陷阱与反模式
        • 9.1 onDraw 里 new 对象
        • 9.2 过度绘制
        • 9.3 嵌套布局爆炸
        • 9.4 主线程加大图
        • 9.5 动画中 requestLayout
        • 9.6 cornerRadius 离屏
        • 9.7 强制同步布局
        • 9.8 build 里耗时操作
        • 9.9 过度 recompose
      • 10.渲染设计哲学
        • 10.1 三层认知阶梯
        • 10.2 优化决策清单
        • 10.3 设计哲学
        • 10.4 跨端术语对照
        • 10.5 本卷章节呼应
        • 10.6 延伸阅读
    • 3.图形渲染管线原理
    • 4.手势事件设计灵魂
    • 5.消息机制设计思想
    • 6.跨进程的通信设计
    • 7.组件生命周期管理
    • 8.页面导航与路由设计
    • 9.响应式数据绑定设计
    • 10.国际化适配的设计
  • 内功
  • 交互和系统
杨充
2026-05-13
目录

2.视图加载渲染设计

# 2.视图加载渲染设计

📍 本篇位置:第 5 卷 · 交互与系统 · 第 2 篇(屏幕呈现四部曲之"画") 🎯 核心矛盾:声明式 UI 描述 vs 命令式像素填充 —— 你写"长啥样"很轻,框架"每帧把它变成像素"非常重,所有优化都是为了在 16.6ms 里走完这一切 🧭 设计灵魂:所有 GUI 框架的渲染都是**「测量 → 布局 → 绘制 → 合成」四阶段流水线**——区别只在「谁来做」(CPU/GPU/编译器)、「何时做」(同步/异步/增量)、「做多少」(命令式全量/声明式增量) 🌐 跨平台覆盖:Android(View/ViewRootImpl/Skia) · iOS(UIView/CALayer/Core Animation) · Web(DOM/CSSOM/Compositor) · Flutter(Widget/Element/RenderObject 三棵树) · Compose / SwiftUI(声明式增量) · 嵌入式(LVGL 软光栅) · 游戏引擎(Scene Graph/Immediate Mode) 🔗 延伸阅读:← 5.1 窗口核心设计思想 (opens new window) · → 5.3 图形渲染管线原理 (opens new window) · → 5.4 手势事件设计灵魂 (opens new window) · → 5.7 组件生命周期管理 (opens new window) 💡 通用心智:忘掉具体平台的 API,记住一句话——视图渲染 = 「描述树 + 测量协商 + 位置布局 + 绘制录制 + 合成上屏」五步流水线。Android 的 measure/layout/draw、iOS 的 layoutSubviews/display、Web 的 Reflow/Repaint/Composite、Flutter 的 layout()/paint() 都只是同一条流水线在不同框架的具体落地。

# 目录介绍

  • 0.60帧变6帧事故
    • 0.1 投诉滑动卡顿
    • 0.2 老板的灵魂三问
    • 0.3 慢动作回放
    • 0.4 事故揭示
    • 0.5 五个递进追问
    • 0.6 三层解药预演
  • 1.视图渲染本质
    • 1.1 像素的物理本质
    • 1.2 帧缓冲区
    • 1.3 双缓冲与撕裂
    • 1.4 视图渲染管线
    • 1.5 帧/刷新率/VSync
    • 1.6 为何是 60Hz
    • 1.7 VSync 的中转站
    • 1.8 16.6ms 死亡线
    • 1.9 跳帧 vs 掉帧
  • 2.视图加载生命周期
    • 2.1 LayoutInflater 解析
    • 2.2 为何 inflate 慢
    • 2.3 视图树构建
    • 2.4 视图加载优化
    • 2.5 视图四态状态机
  • 3.测量布局绘制
    • 3.1 为何必须分三阶段
    • 3.2 Measure 尺寸协商
    • 3.3 Layout 位置确定
    • 3.4 Draw 像素绘制
    • 3.5 三阶段复杂度陷阱
    • 3.6 三阶段算法签名
  • 4.GPU 与硬件加速
    • 4.1 CPU 画 vs GPU 画
    • 4.2 DisplayList 录制
    • 4.3 图层与合成
    • 4.4 硬件加速代价
    • 4.5 渲染线程跨端对照
  • 5.重绘与失效传播
    • 5.1 invalidate vs requestLayout
    • 5.2 脏区域最小化
    • 5.3 重绘风暴案例
  • 6.视图复用与回收
    • 6.1 RecyclerView 四级缓存
    • 6.2 ViewHolder 模式
    • 6.3 复用鬼影问题
  • 7.异步渲染离屏缓冲
    • 7.1 主线程绘制锁链
    • 7.2 离屏 Bitmap/SurfaceView
    • 7.3 Skia/Metal/Vulkan
  • 7B.声明式 vs 命令式
    • 7B.1 命令式内在矛盾
    • 7B.2 声明式核心思想
    • 7B.3 五大声明式框架对比
    • 7B.4 声明式代价
    • 7B.5 开发者总记忆
  • 8.跨平台渲染对比
    • 8.1 Android 视图树
    • 8.2 iOS CALayer
    • 8.3 Web 渲染树
    • 8.4 Flutter 自绘引擎
    • 8.5 RN 桥接架构
    • 8.6 LVGL 软光栅
    • 8.7 跨端渲染阶段矩阵
  • 9.经典陷阱与反模式
    • 9.1 onDraw 里 new 对象
    • 9.2 过度绘制
    • 9.3 嵌套布局爆炸
    • 9.4 主线程加大图
    • 9.5 动画中 requestLayout
    • 9.6 cornerRadius 离屏
    • 9.7 强制同步布局
    • 9.8 build 里耗时操作
    • 9.9 过度 recompose
  • 10.渲染设计哲学
    • 10.1 三层认知阶梯
    • 10.2 优化决策清单
    • 10.3 设计哲学
    • 10.4 跨端术语对照
    • 10.5 本卷章节呼应
    • 10.6 延伸阅读

# 0.60帧变6帧事故

# 0.1 投诉滑动卡顿

某资讯类 App 2021 年改版了首页,把原来的列表改成了"信息流瀑布流"——卡片里有图、有标题、有作者头像、有点赞数、有评论预览。设计师对效果很满意,开发也按设计稿一比一还原,QA 在测试机上跑也没报问题。

灰度上线后第二天,舆情监控爆了:

"新版本太卡了,滑一下手机要愣 1 秒" "我手机才换的,怎么用你们 App 跟用 5 年前的破手机似的" "已卸载,等你们修好再来"

工程师调出代码,坚信代码没问题:

<!-- 卡片布局 item_news_card.xml -->
<LinearLayout android:orientation="vertical">
    <RelativeLayout>           <!-- 头像 + 作者名 -->
        <ImageView .../>
        <TextView .../>
    </RelativeLayout>
    <FrameLayout>              <!-- 封面图 -->
        <ImageView .../>
        <LinearLayout>         <!-- 浮在图上的标签 -->
            <TextView .../>
        </LinearLayout>
    </FrameLayout>
    <LinearLayout>             <!-- 标题 + 摘要 -->
        <TextView .../>
        <TextView .../>
    </LinearLayout>
    <RelativeLayout>           <!-- 点赞、评论、分享 -->
        <LinearLayout> ... </LinearLayout>
        <LinearLayout> ... </LinearLayout>
        <LinearLayout> ... </LinearLayout>
    </RelativeLayout>
</LinearLayout>

"代码就这样啊,每个卡片不就是几个 View 嘛,怎么就卡了?"

# 0.2 老板的灵魂三问

问题 1:你算过一个卡片有多少个 View 吗?

工程师:嗯……大概十几个吧。
老板:你打开 Android Studio 的 Layout Inspector 数一下。
工程师:(数完)……一个卡片 23 个 View,屏幕里能显示 4 个卡片,
       加上 Toolbar、Tab 栏,整个屏幕大约 110 个 View。
老板:那你知道一帧 16.6ms,要走完 measure → layout → draw 三遍吗?
     110 个 View 你嵌套了 5 层,measure 一次理论上要遍历多少次?
工程师:……(脸色发白)

问题 2:QA 测试机为什么测不出来?

工程师:QA 用的是高端机 Pixel 5,我自己也用顶配机调试。
老板:用户的手机平均水平是什么?中低端千元机。
     CPU 性能差 3 倍、内存带宽差 5 倍、GPU 差 10 倍——
     在你机器上 5ms 渲染完,到用户手里就是 50ms。
工程师:那怎么办?我又不能买一堆千元机来测。
老板:你可以打开"开发者选项 → GPU 呈现模式分析",看每一帧的渲染时间柱状图。
     那个柱子超过绿线(16ms)几次,用户就感觉"卡"几次。

问题 3:为什么这种 Bug 在产品早期没发现?

工程师:旧版列表也有图、有文字,怎么就没卡过?
老板:旧版每个 item 是 3 层嵌套、8 个 View;新版是 5 层嵌套、23 个 View。
     View 数量是 3 倍,嵌套深度是 1.5 倍——
     measure 的时间复杂度大致是 O(View数 × 深度),
     总复杂度差了 4-5 倍。这不是"加点东西",这是"质变"。
工程师:……

# 0.3 慢动作回放

打开 Systrace,把滑动时的一帧扒开看:

一帧的预算:16.6ms(60Hz 屏幕)
─────────────────────────────────────────────────────
[输入事件分发]      0.5ms   ← 用户手指坐标传到 App
[doFrame 开始]
  ├─ Animations    1.2ms   ← 动画推进
  ├─ Traversal
  │   ├─ measure   ★ 8.4ms  ← 嵌套布局递归测量(杀手在这)
  │   ├─ layout    ★ 4.1ms  ← 嵌套布局递归布局
  │   └─ draw      ★ 6.2ms  ← 23 个 View × 4 张卡片 × 各种圆角阴影
  └─ Sync to RT    1.8ms
[GPU 合成 + 显示]   3.5ms
─────────────────────────────────────────────────────
合计:              25.7ms     ★ 超出 9.1ms

后果:丢帧 → 这一帧显示上一帧的内容 → 用户看到"卡顿"
连续滑动 1 秒:
  理论应该出 60 帧
  实际只出 ≈ 39 帧
  → 这就是"60 帧变 6 帧"用户的主观感受根因

View 树嵌套深度引发的雪崩:

LinearLayout(根)                      ← 第 1 次 measure
  ├─ RelativeLayout (头像区)          ← 第 2 次 measure
  │    ├─ ImageView                   ← 第 3 次 measure
  │    └─ TextView                    ← 第 3 次 measure
  ├─ FrameLayout (封面区)             ← 第 2 次 measure
  │    ├─ ImageView                   ← 第 3 次 measure
  │    └─ LinearLayout                ← 第 3 次 measure
  │         └─ TextView               ← 第 4 次 measure
  ├─ LinearLayout (文本区)            ← 第 2 次 measure
  │    ├─ TextView                    ← 第 3 次 measure(且 RelativeLayout 会触发二次测量!)
  │    └─ TextView                    ← 第 3 次 measure
  └─ RelativeLayout (操作区)          ← 第 2 次 measure
       ├─ LinearLayout × 3            ← 第 3 次 measure × 3
            └─ ...                    ← 第 4 次 × 9

★ RelativeLayout 内部还会触发"两轮 measure"
   → 实际 measure 调用次数比"View 数"多一倍以上

# 0.4 事故揭示

工程师对"渲染"的直觉建立在**"View 摆好就显示出来了"**的朴素心智模型上:

我以为:
  写好 XML → setContentView → 屏幕显示
  中间发生了什么不需要我关心

实际:
  写好 XML → LayoutInflater 反射创建 View → measure 递归遍历
  → layout 递归遍历 → draw 递归遍历 → DisplayList 录制
  → GPU 上传 → 合成 → 显示
  每一步都是 CPU/GPU 时间的真实消耗,每 16.6ms 必须全部走完

这个错位,本质上是"声明式 XML"和"命令式渲染"之间的张力:

视角 你看到的 实际发生的
写代码 声明式 XML,描述"长啥样" 一棵静态描述
框架内部 把 XML 翻译为 View 树 上百个对象 + 引用关系
每一帧 "重新展示这个 View 树" 递归遍历 + 测量 + 布局 + 绘制 + GPU 上传

整个视图渲染设计的核心矛盾就藏在这里:

"声明长啥样"看起来很轻,"每帧把它变成像素"非常重。中间所有优化机制,都是为了在 16.6ms 里走完这一切。

# 0.5 五个递进追问

带着"60 帧变 6 帧"的事故,整篇文章其实就是在回答下面五个递进的问题:

追问 答案章节
为什么屏幕能看到东西?像素是怎么来的? §1
XML 到 View 的过程为什么这么慢? §2
为什么必须 measure → layout → draw 三阶段?合一不行吗? §3
既然慢,能不能让 GPU 帮忙?硬件加速到底加速了什么? §4 / §7
不同平台(iOS/Web/Flutter)面对同样问题,给出的答案为什么不同? §8

# 0.6 三层解药预演

后面会展开,这里先把三把"解药"清单列出来,让读者带着对照感往下读:

解药 1(拍平 View 树):
   减少嵌套深度,用 ConstraintLayout 替代多层 LinearLayout
   → measure 复杂度从 O(深度²) 降到 O(View 数)
   代价:XML 可读性下降,约束关系需要仔细设计

解药 2(异步预加载):
   把 inflate / 大图解码搬到子线程
   → 主线程腾出时间专心 measure/layout/draw
   代价:需要 AsyncLayoutInflater、需要 Bitmap 池

解药 3(自绘引擎):
   抛弃系统 View,自己用 Canvas/Skia 直接画
   → 一个 onDraw 干完,没有递归遍历
   代价:失去无障碍、复用、动画系统的红利
   典型代表:Flutter / 微信朋友圈 Feed

带着这次事故的"具体感",进入正题——你将看到,所有抽象的"渲染管线、硬件加速、复用回收"原理,最终都能落到这次卡顿事故的根因图上。


# 1.视图渲染本质

要理解"视图渲染",先要理解屏幕是怎么显示一个像素的。这是后续所有优化的物理基石。

# 1.1 像素的物理本质

一块手机屏幕(以常见 1080×2400 分辨率为例)由 2,592,000 个像素组成。每个像素由三个子像素(红、绿、蓝)构成,每个子像素能输出 256 级亮度(8 位)。每个像素需要 24 位(3 字节)来描述颜色,加上 alpha 通道就是 32 位(4 字节)。

一帧画面的像素总数据量:
   1080 × 2400 × 4 = 10.36 MB

如果要 60 帧/秒:
   10.36 MB × 60 = 622 MB/s 的数据带宽

→ 你的每次"滑一下",屏幕背后就有 622 MB/s 在流动

这个数字说明了为什么渲染必须靠硬件加速——纯 CPU 一秒钟搬运 622 MB 像素数据,再加上"算每个像素该是什么颜色"的计算,根本不可能在 16.6ms 内完成。

# 1.2 帧缓冲区

屏幕显示的本质是:显示控制器周期性地从一块叫"帧缓冲区"的内存里读像素数据,转成电信号驱动屏幕。

内存中的帧缓冲区          显示控制器                 屏幕
┌─────────────────┐                              ┌──────┐
│ 像素 (0,0): RGB │  ──→ 60 次/秒读取       ──→ 电信号 │      │
│ 像素 (0,1): RGB │                              │      │
│ ...             │                              │      │
│ 像素 (n,n): RGB │                              └──────┘
└─────────────────┘

App 渲染的本质,就是"在 16.6ms 内把帧缓冲区填好"。 你写的 XML、调用的 setText()、加载的图片,最终都是要变成帧缓冲区里那 1000 万个像素的 RGB 值。

# 1.3 双缓冲与撕裂

如果只有一块帧缓冲区,会发生什么?

时刻 T1:屏幕正在读取第 800 行像素
时刻 T2:CPU 正好在重新写入第 600 行
        → 屏幕这一帧上半是新画面、下半是旧画面 → "撕裂"

解决方案是双缓冲(Double Buffering):

   FrontBuffer (屏幕正在读)        BackBuffer (CPU 正在写)
   ┌──────────┐                    ┌──────────┐
   │ 当前帧    │  ←─ 屏幕读取       │ 下一帧    │ ← App 写入
   └──────────┘                    └──────────┘

   写完后:交换两个 buffer 的角色(swap)

为了让"swap"发生在屏幕没在读的时候,引入了 VSync 信号——屏幕每完成一次刷新,发一个信号,告诉系统"现在可以 swap 了"。这就是 §1.3 要展开的核心。

# 1.4 视图渲染管线

从像素到视图——>视图渲染管线就是"从应用代码到屏幕像素"的完整流水线。Android 平台的简化版本:

flowchart TB
    A[应用代码<br/>setText/setImage] --> B[View 树更新<br/>invalidate/requestLayout]
    B --> C[Choreographer<br/>等待 VSync]
    C --> D[doFrame 触发]
    D --> E[Measure<br/>测量尺寸]
    E --> F[Layout<br/>确定位置]
    F --> G[Draw<br/>录制 DisplayList]
    G --> H[Sync 到 RenderThread]
    H --> I[GPU 上传纹理]
    I --> J[GPU 合成图层]
    J --> K[BackBuffer 写入]
    K --> L[VSync 到来 swap]
    L --> M[屏幕显示]

关键洞察:这条管线是生产者-消费者结构——应用是生产者(产帧)、屏幕是消费者(每 16.6ms 消费一帧)。如果生产者跟不上消费者,就丢帧;如果生产者太快,多余的帧浪费掉。VSync 就是这条流水线的"节拍器"。

# 1.5 帧/刷新率/VSync

三个基本概念的精确定义

名词 含义 决定方
刷新率(Refresh Rate) 屏幕每秒读取帧缓冲区多少次 硬件(屏幕)
帧率(Frame Rate / FPS) App 每秒生成多少帧 软件(App + 系统)
VSync 屏幕完成一帧刷新时发出的信号 硬件 → 系统 → App

它们的关系是"目标-能力-同步"三角:

   屏幕能力(刷新率)       App 能力(帧率)
   ─────────────         ─────────────
   60Hz、90Hz、120Hz       60FPS、30FPS……
        ↓                       ↓
        └──── VSync 同步 ─────┘
              (以慢的为准)

如果屏幕 60Hz、App 跑得动 60FPS,完美——每 16.6ms 屏幕读一帧、App 也产一帧,丝滑。

如果屏幕 60Hz、App 只能跑 30FPS,App 每两次 VSync 才产一帧 → 用户看到每帧都"展示了 33ms" → 主观感受"卡顿"。

如果屏幕 120Hz、App 只能跑 60FPS,奇数帧屏幕没东西可读,重复显示上一帧 → 高刷新率屏的红利没拿到。

# 1.6 为何是 60Hz

读者可能会问:为什么屏幕选择 60Hz 而不是 30Hz 或 200Hz?这背后有人眼生理学的根因:

人眼的视觉暂留:
   ≥ 24Hz:感觉"流畅"(电影 24fps 的根因)
   ≥ 30Hz:感觉"基本不卡"
   ≥ 60Hz:感觉"丝滑"
   ≥ 90Hz:能感受到"更跟手"
   ≥ 120Hz:能感受到,但提升幅度递减
   ≥ 240Hz:竞技玩家能感知,普通人难分辨

60Hz 是 1990 年代 CRT 显示器年代留下的工业标准,平衡了"流畅感"和"成本/带宽"。这也解释了为什么 16.6ms 这个魔法数字成了所有移动开发的死亡线。

# 1.7 VSync 的中转站

Android 4.1(Project Butter)引入了 Choreographer,把 VSync 信号包装成可消费的"帧节拍":

// 简化的 Choreographer 工作机制
class Choreographer {
    void onVsync(long frameTimeNanos) {
        // 1. 处理输入事件(触摸事件分发)
        doCallbacks(CALLBACK_INPUT, frameTimeNanos);
        // 2. 推进动画
        doCallbacks(CALLBACK_ANIMATION, frameTimeNanos);
        // 3. 触发遍历(measure/layout/draw)
        doCallbacks(CALLBACK_TRAVERSAL, frameTimeNanos);
        // 4. Commit
        doCallbacks(CALLBACK_COMMIT, frameTimeNanos);
    }
}

关键设计:所有渲染相关任务"对齐到 VSync"——不在乱七八糟的时刻执行,而是在帧的节拍上集中执行。这是把"乱节拍"变"齐节拍"的经典调度优化。

# 1.8 16.6ms 死亡线

为什么是 16.6ms?

60 FPS = 60 帧 / 1000ms = 1 帧 / 16.66ms

这意味着:App 必须在 16.6ms 内完成"接收输入 + 计算 + 测量 + 布局 + 绘制 + GPU 上传"全部工作。 超出,就丢帧。

16.6ms 实际能用多少?并不是所有 16.6ms 都给 App 用。系统也要消耗:

一帧 16.6ms 的真实分配(Android 典型场景):
─────────────────────────────────────────
[输入事件]         0.5-1ms    ← 系统层
[动画系统]         0.5-1ms    ← 系统层
[App 业务代码]     可用预算
[App 测量布局绘制] 可用预算
[同步到渲染线程]   1-2ms      ← 系统层
[GPU 合成]         2-4ms      ← 系统层
─────────────────────────────────────────
留给 App 的实际预算:≈ 8-12ms

这就是为什么很多老工程师说"主线程一个任务超过 8ms 就要警觉"——不是 16ms 是死亡线,而是 8ms 才是安全线。

# 1.9 跳帧 vs 掉帧

偶尔一帧 17ms:    用户感觉不到
连续 2 帧 17ms:   用户能感觉"轻微滞后"
连续 5 帧 17ms:   用户明确感觉"卡了一下"
某帧 100ms:       用户感觉"卡死",可能去 kill 进程

Android 9+ 引入 ANR 阈值是 5 秒,但用户的耐心阈值早在 200ms 就用光了。 这是为什么"流畅"是产品体验的基线,而不是优化项。

表层认知 深层认知
"60 帧就是流畅" 60 帧只是"不卡"的最低线,120 帧才是"丝滑"
"App 全权使用 16.6ms" App 实际只能用 8-12ms,系统占走一半
"刷新率 = 帧率" 刷新率是硬件能力、帧率是软件实际,取小值生效
"VSync 是个信号" VSync 是整个渲染调度的节拍器,所有任务对齐它

带着对"16.6ms 死亡线"的物理理解,下面 §2 我们看具体的"加载视图"过程是怎么烧时间的。

# 2.视图加载生命周期

# 2.1 LayoutInflater 解析

回到 §0 事故现场——23 个 View 的卡片是怎么"从 XML 变出来"的?答案藏在 LayoutInflater.inflate() 里。

# inflate 的三大步骤

// 简化版 inflate 内部流程
public View inflate(int resource, ViewGroup root) {
    // ① 解析 XML(IO + 反序列化)
    XmlPullParser parser = res.getLayout(resource);
    
    // ② 递归创建 View 对象(反射 + 构造)
    View result = createViewFromTag(parser);
    rInflate(parser, result, attrs);  // 递归处理子节点
    
    // ③ 应用属性(属性解析 + setter 调用)
    return result;
}

第 ① 步:XML 解析

XML 文件不是直接存 ASCII,而是被 AAPT 工具预编译为二进制 XML(节省解析时间)。但即使是二进制,也要:

  • 从 APK 里读出来(IO,可能从磁盘)
  • 解析为 Token 流(CPU)
  • 拿 Token 流递归构建 View 树

第 ② 步:反射创建 View

// LayoutInflater 内部
View createView(String name, ...) {
    Class<?> clazz = mContext.getClassLoader().loadClass(name);
    Constructor<?> constructor = clazz.getConstructor(Context.class, AttributeSet.class);
    return (View) constructor.newInstance(mContext, attrs);
}

loadClass + getConstructor + newInstance 三连反射调用,每次约 0.3-1ms。一个 23 个 View 的卡片就是 7-23ms 的反射开销——这一项就足以打爆一帧的预算。

第 ③ 步:属性应用

每个 XML 属性(android:layout_width、android:textColor...)都要:

  • 从 AttributeSet 里查找
  • 通过 TypedArray 解析为对应类型
  • 调用对应的 setter

一个 TextView 大约有 50+ 个属性,全部解析约 0.5-2ms。

# 实测数据

社区有人做过严谨测试(红米 Note 千元机,2018 年):

View 数 inflate 时间
10 12ms
50 60ms
100 120ms
200 280ms

inflate 是线性增长,但常数极大。一个 100 View 的复杂列表 item,光 inflate 就吃掉 7 帧。

# 2.2 为何 inflate 慢

知道了"哪三步"还不够,要追问"为什么慢"。

# 慢源 1:反射的开销

JVM 反射调用比直接调用慢 10-100 倍:

直接 new TextView(ctx):     ~100ns
反射 newInstance:           ~10000-30000ns(含权限检查、参数封箱、安全检查)

为什么必须用反射? 因为 XML 里写的是字符串 "TextView",编译期不知道具体类型——必须运行时根据字符串查类。这是"声明式 XML"的天然代价。

# 慢源 2:属性解析的查表 + 类型转换

<TextView android:textColor="@color/red" />

这一行属性背后的工作:

① "@color/red" → 资源 ID(编译期完成)
② 资源 ID → 资源表查找 → ColorStateList 对象
③ TypedArray.getColorStateList() → 类型检查 + 封装
④ TextView.setTextColor() → 内部 invalidate

每个属性都要走这一遭。 50 个属性 × 100 个 View = 5000 次查表。

# 慢源 3:主线程阻塞

inflate 是完全同步的。它运行在主线程,期间无法处理触摸事件、无法推进动画。

用户操作时间线:
   触摸 ──→ inflate 60ms ──→ 响应
                ↑
        这 60ms 屏幕没有反馈,用户感觉"按下没反应"

# 三条解药

解药 1:AsyncLayoutInflater(异步 inflate)
   把 inflate 搬到子线程,inflate 完后回主线程 attach
   收益:主线程零阻塞
   代价:某些 View(带 Handler 的)不能异步 inflate

解药 2:预加载(Preload)
   App 启动时空闲期,提前 inflate 常用布局到缓存
   收益:到使用时是 O(1) 取出
   代价:内存占用增加

解药 3:代码生成(X2C / Litho / Compose)
   用注解处理器把 XML 编译为等价 Java 代码
   收益:消灭反射 + XML 解析,速度提升 5-10 倍
   代价:构建复杂度增加,调试难度增加

Jetpack Compose 走的是更激进的路线——直接砍掉 XML,让 UI 用 Kotlin 函数声明,编译器把它转成增量更新的 Tree。这是从根本上重新设计渲染架构。

# 2.3 视图树构建

inflate 完成后,App 拿到的是一棵View 树——根 View 持有子 View 引用,子 View 持有孙 View 引用,递归下去。

         Activity 的 DecorView
                  │
          ┌───────┴───────┐
       StatusBar      ContentView
                          │
                  ┌───────┴───────┐
                Toolbar         RecyclerView
                                    │
                          ┌─────────┴─────────┐
                      ItemCard 1          ItemCard 2 ...
                          │
                  ┌───────┼───────┐
              Avatar    Title   Cover ...

# 树的两个关键属性

1. 父子引用:每个 View 持有 mParent,每个 ViewGroup 持有子 View 列表。这让"事件分发"、"焦点遍历"、"递归 measure" 成为可能。

2. LayoutParams 的协商语义:每个 View 持有 LayoutParams,描述"我希望多大"——但这只是子的请求,最终大小由父的 measure 决定。

// 子 View 的 layout_width = "match_parent"
// 含义:我希望和父一样宽
// 但实际宽 = 父的 measure 算出来给我的宽

// 父 View 的 onMeasure 会调用:
child.measure(widthSpec, heightSpec);
// widthSpec 由父根据自身宽度和子的 LayoutParams 推导

这种"协商式"测量是 Android 视图系统的核心设计,§3.2 详谈。

# 2.4 视图加载优化

# 优化清单(按性价比排序)

优化 收益 成本 适用场景
减少 View 数量 ★★★★★ 低 普适
减少嵌套深度 ★★★★★ 低 列表 item
ViewStub 延迟加载 ★★★★ 低 不常显示的子 View
AsyncLayoutInflater ★★★ 中 启动页、首页
预加载缓存 ★★★ 中 高频使用的布局
ConstraintLayout 替代嵌套 ★★★★ 中 复杂布局
Compose 重写 ★★★★★ 极高 新项目或重大重构
自绘 View 替代 ViewGroup ★★★★ 高 性能极致场景

# 真实案例:把 23 → 8 的瘦身

回到 §0 事故,工程师最终的修复方案:

<!-- 改造前:23 个 View,5 层嵌套 -->
<LinearLayout>
    <RelativeLayout> <!-- 头像区 -->
        <ImageView/> <TextView/>
    </RelativeLayout>
    <FrameLayout> <!-- 封面区 -->
        <ImageView/>
        <LinearLayout><TextView/></LinearLayout>
    </FrameLayout>
    ...
</LinearLayout>

<!-- 改造后:8 个 View,1 层嵌套 -->
<androidx.constraintlayout.widget.ConstraintLayout>
    <ImageView id="avatar" />
    <TextView id="author" />
    <ImageView id="cover" />
    <TextView id="tag" />        <!-- 直接约束在 cover 右上 -->
    <TextView id="title" />
    <TextView id="summary" />
    <LinearLayout id="actions"/>  <!-- 操作区保留一层 -->
</androidx.constraintlayout.widget.ConstraintLayout>

改造效果:

  • View 数:23 → 8(-65%)
  • 嵌套深度:5 → 2(-60%)
  • inflate 时间:35ms → 12ms(-66%)
  • 滑动帧率:39FPS → 58FPS(接近满帧)

# 2.5 视图四态状态机

不同平台对 View "从生到死" 用不同的回调名,但抽象后是同一个四态状态机——所有 GUI 框架的视图都在这四态之间迁移:

stateDiagram-v2
    [*] --> Detached: 构造完成(new 出来)
    Detached --> Attached: 加入视图树
    Attached --> Measured: 父调 measure()
    Measured --> Drawn: layout + draw 完成
    Drawn --> Measured: invalidate / 数据变化
    Drawn --> Detached: 移出视图树
    Attached --> Detached: 移出(未测过就移)
    Detached --> [*]: GC / dispose

四态在六端的对应回调——任何应用开发者都能在自己的框架里找到对应位置:

通用状态 Android(View) iOS(UIView) Web(Custom Element) Flutter(RenderObject) Compose LVGL
Detached 构造完成、未加 ViewGroup init 后未 addSubview new 出节点未 appendChild RenderObject 构造完未 attach 未进入 Composition lv_obj_create 后无 parent
Attached onAttachedToWindow didMoveToWindow connectedCallback attach(owner) onAttached Effect lv_obj_set_parent 之后
Measured onMeasure 完成 → getMeasuredWidth() 可用 sizeThatFits: 后 Reflow 完成 performLayout() 完成 Measurable.measure() lv_obj_refr_size() 完成
Drawn onDraw 录制完 → DisplayList 就绪 display/drawLayer: 完成 Paint 完成 → Layer 就绪 paint(canvas, offset) Modifier.drawBehind 完成 lv_draw_* 提交完
回 Measured requestLayout() setNeedsLayout 改 width/height markNeedsLayout() 状态变化触发 recompose lv_obj_invalidate
回 Drawn invalidate() setNeedsDisplay 改 color/transform markNeedsPaint() recompose 后 draw lv_obj_invalidate
Detached onDetachedFromWindow willMoveToWindow:nil disconnectedCallback detach() onDispose lv_obj_del

这五端共享的"不变量":

  1. 单调性:状态只能按 Detached → Attached → Measured → Drawn 单向推进,不能跳跃(不能不 measure 就 draw)
  2. 对称性:Attached / Detached 必须成对——你被加进来,就必然要被移出去
  3. 可逆性:Measured / Drawn 可以来回(这就是 invalidate / requestLayout 的根本)
  4. 幂等性:连续两次 invalidate() 等价于一次(系统会合并脏标记)

通用 inflate / build 接口契约——剥离所有平台,"把描述变成视图节点" 的最小 API:

interface ViewBuilder {
    // 从声明(XML / DSL / JSX)创建视图节点
    View build(Description desc, Context ctx);
    
    // 把节点挂入视图树(Detached → Attached)
    void attach(View child, ViewGroup parent);
    
    // 触发测量(Attached → Measured)
    Size measure(View v, Constraints constraints);
    
    // 触发布局 + 绘制(Measured → Drawn)
    void layout(View v, Rect bounds);
    void draw(View v, Canvas canvas);
    
    // 移出(任何态 → Detached)
    void detach(View v);
}

任何平台的视图加载流程都能映射到这套接口:

通用步骤 Android iOS Web Flutter
build() LayoutInflater.inflate() init(coder:) document.createElement() Widget.createElement()
attach() ViewGroup.addView() addSubview() parent.appendChild() Element.mount()
measure() View.measure() sizeThatFits() Reflow 阶段 RenderObject.layout()
layout() View.layout(l,t,r,b) layoutSubviews() Layout 阶段 RenderObject.layout()
draw() View.draw(canvas) drawRect: / display Paint 阶段 RenderObject.paint()
detach() removeView() removeFromSuperview() parent.removeChild() Element.deactivate()

给所有应用开发者的总记忆:

不管你在做什么应用,请记住——屏幕上每一个能被看见的"控件 / 元素 / 节点",都必经"挂载 → 测量 → 布局 → 绘制"四步。它叫 View 还是 UIView 还是 div 还是 RenderObject 不重要,四步流水线才重要。


# 3.测量布局绘制

# 3.1 为何必须分三阶段

读者可能会问:既然要画一个 View,为什么不能一次性"画完"?要分 measure、layout、draw 三步?

# 探索过程:从"一次画完"到"必须分阶段"

假设方案 A:不分阶段,直接画

draw(view, x=0, y=0):
   把 view 画在 (0,0)
   遍历子 view,挨个画在(0,0), (50,0), (100,0)...

这套思路对绝对定位(每个 View 写死 x/y/w/h)的系统是工作的——但现实中:

<TextView android:layout_width="wrap_content" />
                        ↑
            "我的宽度由内容决定"
            ↑
           需要先知道"内容"才能算宽度
           需要先知道"我多宽"才能给我画

假设方案 B:每个 View 自己算自己的尺寸

问题:

<LinearLayout android:orientation="horizontal">
    <TextView android:layout_width="wrap_content" />   <!-- A:随内容 -->
    <TextView android:layout_width="match_parent" />   <!-- B:占满剩下的 -->
</LinearLayout>

B 的宽度依赖 A 的宽度(B = 父 - A),所以测量必须有顺序:先测父能给多少、再下发到子、子算完反报父、父最终决定。

这个"协商过程"必须先于"画"完成——这就是为什么 measure 是第一个阶段。

假设方案 C:测量完直接画

为什么 measure 完还要 layout?

measure 算出每个 View "多大",但没说"在哪"。
   TextView 算出 100×40,但放在父的(0,0)还是(50,30)?
   → 这是 layout 决定的

measure 算 size,layout 算 position——两个阶段管两件事。

最后才是 draw——拿着 size 和 position 画进画布。

# 三阶段的因果链

flowchart LR
    M[Measure<br/>"我多大"] --> L[Layout<br/>"我在哪"]
    L --> D[Draw<br/>"长啥样"]
    M -. 必须先 .- L
    L -. 必须先 .- D

这是个严格的拓扑顺序。任何一阶段提前修改另一阶段的输入,整条链就要重来——这正是 §5.1 要展开的 requestLayout 与 invalidate 的边界。

# 3.2 Measure 尺寸协商

# MeasureSpec:父子之间的"语言"

Measure 阶段的核心数据结构是 MeasureSpec——一个 32 位整数,高 2 位是模式、低 30 位是尺寸:

模式 含义 典型来源
EXACTLY "你必须就是这么大" match_parent、写死 dp
AT_MOST "你最多这么大" wrap_content
UNSPECIFIED "你想多大就多大" ScrollView 的子视图

这是父对子说的话。父说了"你最多 300dp",子根据自己的内容决定要 200dp 还是 300dp,然后报告给父。

# Measure 的递归过程

// ViewGroup.onMeasure 的核心模板
protected void onMeasure(int widthSpec, int heightSpec) {
    for (View child : children) {
        // 1. 父根据自己的 spec + 子的 LayoutParams,推导子的 spec
        int childWidthSpec = getChildMeasureSpec(widthSpec, padding, child.layoutParams.width);
        int childHeightSpec = getChildMeasureSpec(heightSpec, padding, child.layoutParams.height);
        // 2. 让子 measure
        child.measure(childWidthSpec, childHeightSpec);
    }
    // 3. 父根据所有子的尺寸,算自己的尺寸
    int totalWidth = sumOf(child.getMeasuredWidth() for child in children);
    int totalHeight = max(child.getMeasuredHeight() for child in children);
    // 4. 报告自己的尺寸
    setMeasuredDimension(totalWidth, totalHeight);
}

# "二次测量"陷阱

某些 ViewGroup(如 RelativeLayout、LinearLayout 带 weight)会对子做两次测量:

第一次 measure:算每个子"自然尺寸"
第二次 measure:根据 weight 比例重新分配空间,重新 measure

→ 子的 onMeasure 被调用两次
→ 如果子内部还有 RelativeLayout,孙的 onMeasure 被调用 4 次
→ 嵌套 5 层 → 32 次(指数爆炸)

这就是 §0 事故里"V 数 23 但 measure 调用 80+ 次"的根因。修复方法:

方案 A:用 ConstraintLayout(一次测量搞定)
方案 B:避免 LinearLayout 嵌套使用 weight
方案 C:用 weight=1 时设 layout_width=0dp(避免歧义)

# Measure 的复杂度分析

理想情况(无二次测量):    O(N),N 是 View 总数
有二次测量的嵌套:           O(N × 2^D),D 是嵌套深度

例:N=100, D=5
   理想:100 次
   有二次测量:100 × 32 = 3200 次
   差距:32 倍

这是为什么"扁平化布局"是性能优化的第一原则——它直接砍掉了指数项。

# 3.3 Layout 位置确定

Layout 阶段比 measure 简单——拿着 measure 阶段算好的尺寸,确定每个 View 的左上角坐标。

// ViewGroup.onLayout 模板
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childLeft = paddingLeft;
    int childTop = paddingTop;
    for (View child : children) {
        int childWidth = child.getMeasuredWidth();
        int childHeight = child.getMeasuredHeight();
        child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
        childLeft += childWidth;  // 横向排列
    }
}

# Layout 的输出:四个数

每个 View 经过 layout 后,得到 mLeft, mTop, mRight, mBottom 四个像素坐标。这些坐标是相对父 View 的,不是屏幕绝对坐标。

DecorView (0,0,1080,2400)
   └── ContentView (0,160,1080,2400)
          └── RecyclerView (0,200,1080,2300)
                 └── ItemCard (0,0,1080,400)   ← 相对 RecyclerView
                        └── Avatar (20,20,80,80) ← 相对 ItemCard

屏幕绝对坐标 = 一路加上去。这个递归累加在事件分发、滚动计算时频繁发生。

# 3.4 Draw 像素绘制

# Draw 的六步

View.draw() 内部是一个标准化的六步流程:

// View.draw 简化版
public void draw(Canvas canvas) {
    // 1. 绘制背景
    drawBackground(canvas);
    // 2. 保存 Canvas 状态(用于 fading edge)
    if (hasFading) saveLayer();
    // 3. 绘制自身内容(onDraw)
    onDraw(canvas);
    // 4. 绘制子 View(dispatchDraw)
    dispatchDraw(canvas);
    // 5. 绘制 fading edge
    if (hasFading) drawFading(canvas);
    // 6. 绘制装饰(滚动条、前景)
    onDrawForeground(canvas);
}

关键点:父 View 先画自己(onDraw),再画子 View(dispatchDraw)——这是深度优先后序遍历,符合"画家算法"(远的先画、近的后画)。

# Canvas 是什么

Canvas 不是真正的画布,而是绘制命令的接收器:

canvas.drawRect(...);     // 不是真画矩形
canvas.drawText(...);     // 不是真画文字
canvas.drawBitmap(...);   // 不是真画图

// 它做的是:把这些命令录制成 DisplayList(详见 §4.2)
// 真正画到像素的是后续的 GPU 阶段

这是一个**"命令模式"** + "延迟执行" 的设计——绘制命令先录制,再批量交给 GPU 执行。

# 3.5 三阶段复杂度陷阱

# 一帧的总复杂度

单帧总耗时 ≈ Measure时间 + Layout时间 + Draw时间
         ≈ O(View数 × Measure倍数) 
         + O(View数) 
         + O(像素数)

Measure 是嵌套循环 + 二次测量 → 最容易爆 Layout 是单次遍历 → 通常不是瓶颈
Draw 是 onDraw 内的工作量 → 取决于绘制命令复杂度(圆角、阴影、blur)

# 三阶段的优化优先级

1. 先优化 Measure(拍平嵌套、避免 RelativeLayout/weight)
2. 再优化 Draw(减少 onDraw 内分配、避免过度绘制)
3. Layout 一般无需优化
4. 极致场景:自绘替代整棵子树

# 这一段的认知跃迁

表层认知 深层认知
"渲染就是画" 渲染是"测量→定位→绘制"三阶段,每阶段独立优化
"Measure 就是算大小" Measure 是父子协商,可能因 weight/RelativeLayout 二次测量
"嵌套深一点没关系" 嵌套 + 二次测量是指数级复杂度
"Draw 慢是因为内容多" Draw 慢更多因为"过度绘制"(同一像素被画 N 次)

# 3.6 三阶段算法签名

剥离所有平台,任何 GUI 框架的渲染三阶段都能被定义为同样的三个函数签名——这是本卷最值得贴在 IDE 旁边的"渲染原理通用名片":

// 阶段 1:测量——"给我约束,我告诉你我多大"
function measure(view: View, constraint: Constraints) -> Size {
    if view.isLeaf:
        return view.intrinsicSize(constraint)        // 文本/图片自然尺寸
    else:
        for child in view.children:
            measure(child, deriveChildConstraint(constraint, child.layoutParams))
        return combineChildSizes(view.children)      // 父根据子算自己
}

// 阶段 2:布局——"给我矩形,我把孩子放进去"
function layout(view: View, bounds: Rect) {
    view.bounds = bounds
    if not view.isLeaf:
        positions = computeChildPositions(view, view.children)
        for (child, pos) in zip(view.children, positions):
            layout(child, Rect(pos, child.measuredSize))
}

// 阶段 3:绘制——"给我画布,我录制绘制命令"  
function draw(view: View, canvas: Canvas) {
    canvas.save()
    canvas.translate(view.bounds.left, view.bounds.top)
    view.onDraw(canvas)                              // 自己先画
    for child in view.children:
        draw(child, canvas)                          // 再画子(深度优先后序)
    canvas.restore()
}

这三个签名是「跨端不变量」——任何 GUI 框架都能把自己映射上去:

阶段 Android iOS Web (Blink) Flutter Compose Qt
measure 签名 View.onMeasure(widthSpec, heightSpec) sizeThatFits(_:) / intrinsicContentSize LayoutObject::computeSize() RenderBox.performLayout() → size = ... MeasurePolicy.measure(measurables, constraints) QWidget::sizeHint()
measure 入参 MeasureSpec(模式+尺寸) CGSize 约束 LayoutConstraint BoxConstraints(min/max) Constraints(min/max) QSize 约束
measure 出参 setMeasuredDimension(w, h) 返回 CGSize 设置 width/height 设置 size 返回 MeasureResult 返回 QSize
layout 签名 View.onLayout(changed, l,t,r,b) layoutSubviews() LayoutObject::layout() RenderBox.performLayout()(同 measure) Placeable.placeAt(x, y) QLayout::setGeometry()
draw 签名 View.onDraw(Canvas) drawRect: / display LayoutObject::paint(GraphicsContext) RenderObject.paint(PaintingContext, Offset) DrawScope.draw() QWidget::paintEvent()

最值得记忆的两个"通用约束"概念:

  1. Constraints(约束)下传,Size(尺寸)上报——这是所有 GUI 框架 measure 阶段的统一语义。父告诉子"你最多 / 最少多大"(下传),子算完告诉父"我实际多大"(上报)。

    • Android:MeasureSpec(约束)下传,measuredWidth/Height(尺寸)上报
    • Flutter:BoxConstraints 下传,Size 上报
    • Compose:Constraints 下传,MeasureResult 上报
    • Web(CSS):父的 available size 下传,子的 intrinsic size 上报
  2. Canvas 是命令录制器,不是画布——所有现代 GUI 框架的 Canvas/PaintingContext/GraphicsContext 本质都是命令录制器,真正绘制发生在后续 GPU 阶段。这就是 §4.2 DisplayList 在六端的统一存在:

框架 录制结构 后续提交目标
Android DisplayList (RenderNode) RenderThread → Skia → GPU
iOS CALayer 的 backing store Render Server → Metal → GPU
Web (Blink) Paint Records (cc::DisplayItemList) Compositor Thread → Skia → GPU
Flutter Scene (Layer Tree) Raster Thread → Skia/Impeller → GPU
Compose DrawScope → 转 Android Canvas → DisplayList 同 Android
Qt QPaintEngine 命令 QBackingStore → 后端 GPU

给所有应用开发者的总结:

任何视图框架的渲染都遵循「Constraints 下传 + Size 上报 + Canvas 命令录制 + GPU 提交」的四步。 学会这套抽象,下次接触任何新框架——iOS UIKit、Flutter、Compose、SwiftUI、Qt、WebGPU——你都不会迷路。


# 4.GPU 与硬件加速

# 4.1 CPU 画 vs GPU 画

# 软件渲染时代(Android 3.0 之前)

App 的所有绘制都由 CPU 完成:

CPU 拿到一个 drawRect 命令:
   for y in rect.top..rect.bottom:
       for x in rect.left..rect.right:
           framebuffer[y*width + x] = color

CPU 画一个 1080×2400 全屏矩形:

2,592,000 像素 × 1 个写操作 = 2.6M 次写入
单核 CPU @ 1GHz:约 2.6ms(仅写)
加上颜色混合(alpha)、抗锯齿:5-10ms

画一张图片:解码 + 缩放 + 滤镜,可能 50-200ms。这就是为什么早期 Android(< 3.0)滚动列表很卡——所有像素 CPU 一个一个填。

# 硬件加速(GPU)的引入

Android 3.0(Honeycomb)默认开启 GPU 加速。GPU 的特点:

维度 CPU GPU
核心数 4-8 几百到几千
单核能力 强 弱
适合任务 串行、复杂分支 并行、批量数据
像素填充 慢 极快(专用硬件单元)

画一个矩形:GPU 把 2.6M 像素分给 1000 个核同时填 → 微秒级。画一张图片:上传纹理后,缩放、blur 都是硬件单元一个指令搞定。

# 关键转变:从"画到内存"到"录命令给 GPU"

GPU 加速后,CPU 不再直接写 framebuffer,而是:

CPU:把绘制意图序列化为命令(DisplayList)
GPU:解析命令、执行批量像素填充、写入 framebuffer

这个转变是 §4.2 DisplayList 的核心。

# 4.2 DisplayList 录制

# DisplayList 是什么

DisplayList 是一份"绘制命令的序列化记录",类似:

DisplayList for ItemCard:
   ① drawColor(white)           // 背景
   ② drawBitmap(avatar, 20, 20) // 头像
   ③ drawText("作者", 100, 35)  // 作者名
   ④ drawBitmap(cover, 0, 80)   // 封面
   ⑤ drawText("标题", 20, 320)  // 标题
   ...

关键性质:DisplayList 一旦录制,只要 View 内容不变,下一帧可以直接重放——不需要重新走 onDraw。

# 为什么 DisplayList 能加速?

场景 1:视图不变,只是平移

没有 DisplayList:
   每帧都重新调 onDraw → 重新 measure 文本宽度、重新算路径
   
有 DisplayList:
   onDraw 不重跑,直接修改"平移矩阵"
   GPU 在矩阵变换后直接重放命令
   → 滚动列表的 90% 工作量被消除

场景 2:批量绘制合并

GPU 喜欢"一次画 1000 个矩形"而不是"画 1000 次矩形"。DisplayList 收集所有命令后,可以批量提交:

朴素:1000 次 GL 调用(每次有进入 GPU 的开销)
DisplayList 优化:1 次批量提交 1000 个矩形
→ 性能提升数十倍

# onDraw 的真实身份

很多人误解 onDraw 是"画到屏幕"。其实它是"录制 DisplayList":

// 你写的 onDraw
public void onDraw(Canvas canvas) {
    canvas.drawCircle(50, 50, 30, paint);
    // ↑ 实际是:DisplayListCanvas.drawCircle()
    // 把这个命令存到 DisplayList,并不真画
}

这就是为什么 onDraw 里 new 对象那么致命——onDraw 每帧都跑(如果有动画),new 对象就是每秒 60 次 GC 压力。详见 §9.1。

# 4.3 图层与合成

# 图层(Layer)

复杂 UI 由多个图层合成:

最终屏幕 = 状态栏图层 + Toolbar 图层 + 内容图层 + Dialog 图层 + 软键盘图层
            ↑                                          
            每个独立的 Surface(GPU 内存中的纹理)

每个图层独立渲染、独立缓存,GPU 在合成阶段把它们按 Z 序叠加:

flowchart TB
    L1[图层1: 状态栏] --> S[SurfaceFlinger 合成]
    L2[图层2: Toolbar] --> S
    L3[图层3: 内容] --> S
    L4[图层4: Dialog] --> S
    S --> FB[FrameBuffer]
    FB --> SC[屏幕]

# View 的图层模式

每个 View 可以选择三种图层模式:

模式 含义 适用
LAYER_TYPE_NONE 无独立图层,跟父一起渲染 默认
LAYER_TYPE_HARDWARE 独立 GPU 图层 需要 alpha 动画的 View
LAYER_TYPE_SOFTWARE 独立 CPU Bitmap 复杂自绘但少更新的 View

关键案例:alpha 动画

默认情况下,对一个 ViewGroup 做 alpha 动画:
   每帧都要重新画整个 ViewGroup 及其子树(且每个像素 × alpha)
   
设置 LAYER_TYPE_HARDWARE:
   ViewGroup 渲染到独立纹理(一次性)
   每帧 GPU 只对这张纹理整体应用 alpha(一行命令)
   → 性能提升 10 倍

但动画结束后必须把 layer 改回 NONE,否则那张纹理永远占着 GPU 内存。

# 4.4 硬件加速代价

# 代价 1:GPU 内存占用

每张纹理占 GPU 内存(VRAM):

1080×2400 ARGB 纹理 = 10.4 MB
20 个图层(典型 App)= 200+ MB GPU 内存

低端机 GPU 内存可能只有 1GB,App 不能无限制创建图层。

# 代价 2:上传带宽

CPU 算好的内容(比如位图)要传到 GPU:

PCIe / 内存总线带宽 ≈ 5-20 GB/s
传一张 1080p 位图 ≈ 10MB / 10GB/s = 1ms
传 50 张 = 50ms ★ 一帧预算就没了

这是为什么"位图缓存"是关键——已经在 GPU 的纹理不要再传第二次。

# 代价 3:某些 API 不支持硬件加速

Canvas.drawPath()                ← 复杂路径,部分参数不支持
Canvas.drawTextOnPath()           ← 不支持
Paint.setXfermode()                ← 部分混合模式不支持
Paint.setMaskFilter() (BlurMaskFilter) ← 不支持

碰到这些 API 会自动回退到软件渲染——表现为帧率突然跳水。这是很多自绘 View 的隐藏陷阱。

# 代价 4:渲染线程的复杂性

GPU 加速引入了独立的 RenderThread:

主线程:onDraw → 录制 DisplayList → 同步给 RenderThread
RenderThread:解析 DisplayList → 调 OpenGL/Vulkan → GPU

两个线程之间的同步(Sync)本身就有开销。在 §0 的事故 trace 里,"Sync to RT"花了 1.8ms,这个时间是 GPU 加速的"固定税"。

# 这一段的认知跃迁

表层认知 深层认知
"硬件加速 = 更快" 硬件加速通过"录制+批量+并行"三种方式提速,但有内存代价
"onDraw 就是画" onDraw 是"录命令",真正的画在 GPU
"alpha 动画很卡" alpha 动画不卡——前提是用 LAYER_TYPE_HARDWARE 暂时锁定纹理
"GPU 万能" GPU 在某些 API 上会回退软件渲染,且 VRAM 有限

# 4.5 渲染线程跨端对照

所有现代 GUI 框架都把渲染拆成 3 个线程角色——这是 60 FPS 时代的必然结果。三角色名字各异,职责完全一致:

flowchart LR
    UI[UI 线程<br/>业务逻辑 + 录制命令] -->|Sync| RT[渲染线程<br/>解析命令 + 调 GPU API]
    RT -->|GPU 队列| GPU[GPU 硬件<br/>并行像素填充]
    GPU --> DISP[屏幕]
    INPUT[输入] --> UI
    VSYNC[VSync] --> UI
    VSYNC --> RT

三个角色的跨端命名对照:

角色 Android iOS Web (Chromium) Flutter Qt
UI 线程(业务+录制) Main Thread Main Thread Main Thread (Renderer) Platform Thread + UI Thread GUI Thread
渲染线程(提交 GPU) RenderThread (API 21+) Render Server(独立进程) Compositor Thread Raster Thread RenderThread (Qt Quick)
GPU 队列处理 HWUI / Skia → GLES/Vulkan Core Animation → Metal Viz Compositor → Skia Skia/Impeller → Metal/Vulkan RHI → 后端

这套架构的核心哲学——所有平台殊途同归:

UI 线程只负责"算 + 录命令",不直接调 GPU;渲染线程拿命令批量提交 GPU。

这样设计的根本原因:UI 线程上还有触摸事件、动画、生命周期、网络回调……让它直接调阻塞的 GPU API 必死无疑。

为什么 Android 直到 API 21 才默认开 RenderThread?

Android 1.x-3.x:单核 CPU,分线程开销 > 收益
Android 4.0:HardwareAccelerated 默认开启,但仍在 UI 线程
Android 4.1:Project Butter 引入 Choreographer 节拍器
Android 5.0:RenderThread 默认开启 ★
   → 主线程录完 DisplayList 就解放,渲染搬到 RT
   → 即使主线程卡 8ms,动画仍可在 RT 推进

iOS Core Animation 的"超前设计":

iOS 从初代(2007)就用 Render Server(独立进程!)做合成
   → 应用进程崩溃,状态栏 / 系统动画照常运转
   → 比 Android 早 8 年实现"渲染与业务隔离"
   → 这就是 iOS 动画给人"始终丝滑"的工程根因

Chromium 的"四线程"极致:

Main Thread       ← JS / DOM / Layout
Compositor Thread ← 滚动、合成
Raster Thread     ← 光栅化
GPU Process       ← 与 GPU 通信(独立进程)

→ 你滚动页面时即使 JS 卡住,滚动仍能在 Compositor Thread 推进
→ 这是 Web 平台"主线程卡死页面仍能滚"的根因

给所有应用开发者的实战心法:

任何时候你看到一个 GUI 框架"主线程卡了但动画还在转",背后一定是"渲染线程独立"在起作用。

反过来:如果你的动画一卡顿就和主线程同步卡死,要么没用对 API(如 Android 直接调 Canvas 软件渲染),要么这个框架根本没有渲染线程隔离(早期嵌入式 GUI / 老版 RN Bridge)。


# 5.重绘与失效传播

# 5.1 invalidate vs requestLayout

回到 §0 事故现场——为什么有时改一个 setText 卡得要命,有时改一个 setBackgroundColor 没事?秘密就在这两个方法的差别。

# invalidate vs requestLayout 的精确边界

方法 触发什么 适用变更
invalidate() 只重画 Draw 颜色、文字内容(宽度不变时)、图片切换
requestLayout() 重新 Measure + Layout + Draw 任何尺寸变化、padding 变化、子 View 增删

关键洞察:invalidate 是"便宜的",requestLayout 是"昂贵的"——后者要走完整三阶段。

# 哪些 setter 会触发哪个

// 只触发 invalidate(廉价)
setBackgroundColor()      ← 颜色变了,尺寸不变
setAlpha()                ← 透明度变了
setRotation()             ← 旋转变了
setVisibility(INVISIBLE)  ← 还占位,不需要重 layout

// 触发 requestLayout(昂贵)
setText("新文本")         ← 文本可能改变 wrap_content 宽度
setVisibility(GONE)       ← 不占位了,影响兄弟布局
setPadding()              ← 自身可用空间变了
setLayoutParams()         ← 直接改尺寸约束

// 既触发 requestLayout 又触发 invalidate
setLayoutParams() (尺寸变化)
addView() / removeView()

# 失效传播的"涟漪效应"

当一个子 View 调 requestLayout:

ChildView.requestLayout()
   ↓
   父.requestLayout()    ← 因为子可能让父变大
   ↓
   爷.requestLayout()    ← 因为父可能让爷变大
   ↓
   ...一路传到 ViewRootImpl
   ↓
   下一帧 doFrame:从根开始重新 Measure → Layout → Draw 整个失效路径

这就是为什么"在嵌套深的列表里频繁 setText 会卡"——每次 setText 都让整条链上的祖先都要重新 measure。

# 一个真实坑:滚动中调 setVisibility

// 列表 item 里的代码
override fun onBindViewHolder(holder, position) {
    if (data.hasTag) {
        holder.tagView.visibility = VISIBLE   // 触发 requestLayout
    } else {
        holder.tagView.visibility = GONE      // 触发 requestLayout
    }
}

每次复用 ViewHolder 都会让一条链 requestLayout,滚动时会把 RecyclerView 的滚动平滑性打散。修复:

// 方案 A:用 INVISIBLE 替代 GONE(保留位置,无需 layout)
tagView.visibility = if (data.hasTag) VISIBLE else INVISIBLE

// 方案 B:宽度高度提前在 XML 写死(不用 wrap_content)
// 这样 setText 也只触发 invalidate

// 方案 C:用占位 View 而非动态显示/隐藏

# 5.2 脏区域最小化

# 脏区域(Dirty Region)

不是每次 invalidate 都重画整个屏幕。系统会算出最小重画区域:

View A 调用 invalidate()
   ↓
   View A 的 mPrivateFlags 标 PFLAG_DIRTY
   ↓
   一路向上传递,每个父记录"被脏的子区域"
   ↓
   ViewRootImpl 拿到一个矩形:脏区域
   ↓
   只让这个矩形内的 View 重画
屏幕 1080×2400:

    ┌───────────────┐
    │               │
    │   ┌───┐       │   ← View A 脏了,
    │   │ A │       │      只重画这个矩形
    │   └───┘       │
    │               │
    └───────────────┘

收益:屏幕的 90% 区域不用重画 → CPU/GPU 工作量大幅下降。

# Clip 优化

GPU 在重放 DisplayList 时也用裁剪:

DisplayList 有 100 条命令
脏区域只覆盖 10 条命令对应的视图
→ GPU 跳过另外 90 条(通过 Clip 测试)

# 这一机制的"破坏者"

某些操作会让脏区域优化失效:

1. 整窗动画(屏幕级 Translation)
   → 整屏脏,无法局部优化
   
2. 频繁 invalidate 不同位置
   → 多个脏矩形合并成大矩形 → 接近全屏脏

3. 透明度动画
   → 透明区域下方也要重画 → 脏区域穿透多层

# 5.3 重绘风暴案例

# 案例:聊天消息列表的 64 帧/秒发热

某社交 App 进入聊天页面,CPU 占用瞬间打到 100%、机身发烫。开发者一筹莫展——代码看起来很正常。

Systrace 揭示真相:

每秒 doFrame 触发 64 次(高于 60 因为 Choreographer 有时会赶帧)
每帧触发 RecyclerView 的全量 measure + layout
每次都要遍历 50 个 ViewHolder

根因代码:

// 在线状态 dot 的实现
public class OnlineDotView extends View {
    Paint pulsePaint;
    
    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawCircle(...);
        invalidate();  // ★ 罪魁祸首
    }
}

onDraw 里再 invalidate() —— 形成自激式重绘:每帧都把自己标脏,每帧都重画。50 个 ViewHolder 都有这个 dot,整页 50 个 View 每帧都要重画。

修复:

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawCircle(..., currentRadius);
    if (animating) {
        currentRadius = computeNextRadius();
        // 用 ValueAnimator 推进,而不是 onDraw 里递归
        if (System.currentTimeMillis() - lastFrameTime < 16) {
            postInvalidateOnAnimation();  // ✅ 对齐 VSync
        }
    }
}

# 重绘风暴的诊断清单

1. 打开 开发者选项 → GPU 呈现模式分析
   看到柱子持续高于绿线 → 怀疑重绘风暴

2. 命令行:adb shell dumpsys gfxinfo <package>
   看 "Janky frames" 比例 > 5% 即异常

3. Systrace:抓 5 秒,看 doFrame 频率
   稳定状态下不应该持续 60 帧/秒(应该是触发驱动)

4. 自动化检测:
   重写 invalidate(),统计每秒调用次数,超阈值告警

# 6.视图复用与回收

# 6.1 RecyclerView 四级缓存

# 为什么需要复用

如果列表有 1000 个 item,朴素实现要创建 1000 个 ViewHolder:

1000 × (inflate 30ms + bind 5ms) = 35 秒
加上 1000 × 23 个 View = 23000 个对象 = 几百 MB
→ 用户进列表卡 35 秒 + 直接 OOM

核心思想:屏幕上同时只能显示 ~10 个 item,复用这 10 个就够了。这就是 RecyclerView 的核心设计。

# 四级缓存详解

       ┌─────────────────────────────────────────┐
       │                屏幕                      │
       │  ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐         │ ← 屏幕内的 ViewHolder
       │  └───┘ └───┘ └───┘ └───┘ └───┘         │
       └─────────────────────────────────────────┘
                          │
                  ┌───────┴───────┐
                  ↓               ↓
          ┌───────────────┐ ┌───────────────┐
          │ Scrap (一级)  │ │ Cache (二级)  │
          │ 即将复用       │ │ 默认 size=2   │
          └───────────────┘ └───────────────┘
                                  │
                          ┌───────┴───────┐
                          ↓               ↓
                  ┌───────────────┐ ┌───────────────┐
                  │ ViewCacheExt   │ │ RecycledPool  │
                  │ (三级 自定义)  │ │ (四级 类型池) │
                  └───────────────┘ └───────────────┘
级别 名称 复用方式 是否需 onBindViewHolder
1 Scrap 屏内"暂存" ❌ 不需要重 bind
2 Cache 离屏不远(默认 2) ❌ 不需要重 bind
3 ViewCacheExtension 业务自定义 取决实现
4 RecycledViewPool 全 Adapter 共享 ✅ 需要重 bind

这个分级设计的精妙之处:

Scrap:滚动一个像素就移出屏幕 → 立刻可能要回来 → 不重 bind 最快
Cache:滚动两屏就回来 → 数据可能没变 → 不重 bind 节约
Pool:滚动一万个 item → 数据肯定不一样 → 重 bind 重用 ViewHolder 对象

它解决了"复用激进 vs 保守"的两难——按"离开时间长度"分级处理。

# 6.2 ViewHolder 模式

# ViewHolder 是什么

public class NewsItemViewHolder extends RecyclerView.ViewHolder {
    TextView title;
    ImageView avatar;
    
    public NewsItemViewHolder(View itemView) {
        super(itemView);
        title = itemView.findViewById(R.id.title);
        avatar = itemView.findViewById(R.id.avatar);
    }
}

# 它解决了什么真实问题

问题 1:findViewById 是慢的

findViewById 实现:
   从 ViewGroup 的 children 数组开始递归查找
   平均 O(View数 / 2)
   100 个 View 的 item,每次 findViewById 约 0.05-0.2ms
   每个 item 5 个 findViewById = 0.25-1ms
   滚动每秒切换 10 个 item = 2.5-10ms 浪费

ViewHolder 把 findViewById 从"每次 bind"提前到"创建时"——只在 onCreateViewHolder 调一次。

问题 2:垃圾回收压力

没有 ViewHolder:
   每次滚动一个 item 出屏:丢弃整个 View
   每次滚动一个 item 入屏:重新 inflate 整个 View
   → 每秒 GC 压力大
   
有 ViewHolder + 复用:
   View 对象被重用
   → GC 压力极低

# ViewHolder 的"形状契约"

复用的前提是所有 ViewHolder 形状一致:

// 复用时:从池里拿一个 NewsItemViewHolder
holder = pool.getViewHolder(VIEW_TYPE_NEWS);

// 假设这个 holder 之前展示的是新闻 A,现在要展示新闻 B
// 因为它们都是 NewsItemViewHolder(同一布局),
// 只需要 setText、setImage 即可
onBindViewHolder(holder, position);

如果布局不同(比如有的 item 是新闻、有的是广告),用 getItemViewType() 区分类型,每种类型独立池。

# 6.3 复用鬼影问题

# 鬼影现场

社交 App 的列表,用户报告:

"我滑下去看到自己的头像出现在别人的消息上"

代码:

@Override
public void onBindViewHolder(holder, position) {
    User user = data.get(position);
    // 异步加载头像
    api.loadAvatar(user.id, new Callback() {
        public void onSuccess(Bitmap bm) {
            holder.avatar.setImageBitmap(bm);  // ★ 鬼影根源
        }
    });
}

事故时序:

T1: position=10 复用 holderX → 异步请求用户 10 的头像
T2: 用户飞速滑动,holderX 复用给 position=50 → 显示用户 50
T3: T1 的回调到了 → setImageBitmap(用户 10 的头像)
    → holderX 当前在 position=50,显示出"用户 50 的位置上是用户 10 的头像"

# 三种修复方案

// 方案 1:取消旧请求
override fun onBindViewHolder(holder, position) {
    holder.cancelPreviousLoad()
    val user = data[position]
    holder.currentLoadId = api.loadAvatar(user.id) { bm ->
        if (holder.currentLoadId == thisRequest)  // 校验
            holder.avatar.setImageBitmap(bm)
    }
}

// 方案 2:tag 校验
override fun onBindViewHolder(holder, position) {
    val user = data[position]
    holder.avatar.tag = user.id
    api.loadAvatar(user.id) { bm ->
        if (holder.avatar.tag == user.id)  // 比对 tag
            holder.avatar.setImageBitmap(bm)
    }
}

// 方案 3:用专业图片库(Glide/Coil/Picasso)
override fun onBindViewHolder(holder, position) {
    Glide.with(holder.avatar)
         .load(user.avatarUrl)
         .into(holder.avatar)   
    // Glide 内部已经处理了取消旧请求 + tag 校验
}

Glide / Coil 等专业图片库存在的根本理由就是这个鬼影问题——业界踩坑十年得出的标准答案。

# 这一段的认知跃迁

表层认知 深层认知
"RecyclerView 就是带复用的列表" RecyclerView 是 4 级缓存的精细调度系统
"ViewHolder 就是缓存 View 引用" ViewHolder 解决 findViewById + GC 双重问题
"复用就是同一个 View 显示不同数据" 复用引发"鬼影"——异步任务必须感知复用

# 7.异步渲染离屏缓冲

# 7.1 主线程绘制锁链

回到 §0 事故现场——为什么 inflate、measure、draw 全在主线程?

# 历史原因

Android 1.0(2008)选择"主线程渲染"是因为:

当时手机是单核 CPU
分线程的开销 > 收益
GPU 还没普及

但 2010 年后多核普及、GPU 加速引入,主线程渲染就成了历史包袱。

# 主线程"绑死"的真实代价

你在主线程:
   setOnClickListener,处理触摸事件
   onResume,处理生命周期
   inflate,加载布局
   measure / layout / draw,渲染
   网络回调(如果忘记切线程)
   GC 也偶尔停在主线程

→ 任何一项卡 200ms,用户都能感知
→ 任何一项卡 5s,ANR

这就是为什么 Android 一直在"把工作搬出主线程":

  • API 11:HardwareAccelerated(GPU 渲染搬到 RenderThread)
  • API 21:RenderThread 默认开启
  • API 28:AsyncLayoutInflater(异步 inflate)
  • Compose(2021):状态驱动 + 增量渲染,进一步减轻主线程

# 7.2 离屏 Bitmap/SurfaceView

# 离屏渲染(Off-screen Rendering)

把绘制结果先画到一张内存里的 Bitmap,再把 Bitmap 一次性贴到屏幕:

传统:
   每帧从头画 → CPU 重复劳动
   
离屏:
   一次性把复杂内容画到 Bitmap(哪怕花 100ms)
   每帧只是"贴 Bitmap"(< 1ms)
   → 内容不变时性能极佳

典型场景:

- 复杂图表(K 线图、热力图):用户看的是静态结果
- 自定义高复杂度 View:避免每帧重算
- 模糊背景:blur 一次缓存,反复使用

API:view.setLayerType(LAYER_TYPE_SOFTWARE, null) 强制软件离屏,LAYER_TYPE_HARDWARE 强制 GPU 离屏。

# SurfaceView:跳出 View 系统的"逃生通道"

普通 View 受主线程绑定。SurfaceView 提供独立的渲染表面,可以在子线程渲染:

flowchart LR
    A[主线程] --> B[普通 View 树]
    A --> C[SurfaceView 占位]
    D[子线程] --> E[SurfaceView 真实 Surface]
    B --> F[SurfaceFlinger 合成]
    E --> F
    F --> G[屏幕]

典型应用:

  • 视频播放(每秒 30 帧解码 + 渲染都在子线程)
  • 摄像头预览
  • 游戏(高帧率独立渲染循环)

代价:

  • SurfaceView 不在 View 树里,不能做普通 View 的动画
  • z 序管理复杂(默认在所有 View 之下,需要 setZOrderOnTop)
  • API 24 后有 TextureView / SurfaceView 的进一步演进(如 SurfaceControl)

# 7.3 Skia/Metal/Vulkan

# 图形 API 的代际

OpenGL ES(2003):
   驱动层抽象,状态机式 API
   每次调用都有"驱动检查"开销
   
Vulkan(2016):
   显式控制 GPU
   预编译命令缓冲,运行时几乎零开销
   性能比 OpenGL 高 30-50%(高端机型)
   
Metal(iOS, 2014):
   苹果版本的 Vulkan
   与 iOS 渲染深度集成

# Skia:跨平台的 2D 绘图引擎

Skia 是 Google 开源的 2D 绘图引擎,是 Android、Chrome、Flutter 共同的底层:

你写 Canvas.drawRect(...)
    ↓
Android Canvas API(JNI)
    ↓
Skia 引擎
    ↓
后端选择:
   - GL Backend(OpenGL ES)
   - Vulkan Backend(Android 7+)
   - Metal Backend(iOS via Flutter)

Skia 的存在让"上层 API 不变、底层换 GPU"成为可能——Android 12 把 Skia 默认后端从 GL 换成 Vulkan,应用代码零修改。

# 演进趋势:Compose / SwiftUI 的"声明式 + 增量"

传统命令式:
   你告诉系统"现在 setText、现在 setVisibility"
   系统不知道哪些会变,只能粗粒度重画
   
Compose / SwiftUI 声明式:
   你描述"UI 状态 = f(数据状态)"
   框架对比新旧状态,只重画真正变化的部分
   → "智能脏区域"自动化

这是渲染系统从"命令式"走向"声明式"的范式转变——也是 §0 事故的终极解药:让框架替你算谁该重画。


# 7B.声明式 vs 命令式

# 7B.1 命令式内在矛盾

回到 §0 事故。即使工程师把 23 个 View 改成 8 个,根本矛盾依然存在:

// 用户点了"点赞",你怎么更新 UI?
override fun onLike() {
    article.likeCount++
    findViewById<TextView>(R.id.like_count).text = article.likeCount.toString()  // ★
    findViewById<ImageView>(R.id.like_icon).setImageResource(R.drawable.liked)
    findViewById<View>(R.id.like_bg).setBackgroundColor(Color.PINK)
}

这套写法的根本问题:

你在脑子里维护两份"事实":
  事实 A:业务数据(article.likeCount = 100)
  事实 B:UI 状态(TextView 显示 "100")
  
你必须手动保证 A 改了 B 也跟着改 —— 这是命令式渲染的原罪。

复杂场景下:
  UI 有 N 个组件、数据有 M 个字段
  → 你要维护 N × M 的"谁依赖谁"心智地图
  → 漏一处 = Bug;改多了 = 性能问题

业界 20 年血泪史:

2007 iOS UIKit:    setText / setImage 满天飞
2008 Android:       同上,加上 invalidate / requestLayout 心智负担
2014 React:         "声明式 UI" 横空出世,前端从此分水岭
2018 Flutter:       Widget = build(state),原生平台开始声明式
2019 SwiftUI:       苹果官方拥抱声明式
2021 Jetpack Compose:Google 官方拥抱声明式

这不是"换个 API"——是"换思维方式":从"我命令 UI 怎么变"到"我描述 UI 长啥样,框架算出怎么变"。

# 7B.2 声明式核心思想

心智模型从"命令"切到"函数":

命令式:             UI 是一棵可变的对象树,你直接改它
声明式:             UI = f(state),状态变 → 重算 → 框架 diff → 最小更新

用同样的"点赞"场景对比:

// Compose 写法
@Composable
fun LikeButton(liked: Boolean, count: Int, onLike: () -> Unit) {
    Row(modifier = Modifier
        .background(if (liked) Color.Pink else Color.White)
        .clickable { onLike() }) {
        Icon(if (liked) Icons.Filled.Favorite else Icons.Outlined.Favorite)
        Text("$count")
    }
}

// 调用:状态变了,UI 自动跟着变
var likeCount by remember { mutableStateOf(100) }
var isLiked by remember { mutableStateOf(false) }
LikeButton(isLiked, likeCount, onLike = { 
    isLiked = true
    likeCount++ 
    // ← 不用碰 UI,框架自己 diff
})

声明式渲染的三件套通用模型——任何声明式框架都有这三个东西:

flowchart LR
    S[State 状态] -->|build/compose| D[Description 描述树]
    D -->|diff| P[Patches 最小变更]
    P -->|apply| V[Vsible Tree 真实 UI]
    V -.observe.-> S
组件 React Vue Compose SwiftUI Flutter
State useState / Redux ref / reactive mutableStateOf @State / @Binding setState / Provider
Description Virtual DOM Virtual DOM Composition Tree View Tree Widget Tree
Diff Reconciler (Fiber) patch recompose with skip DynamicView 比较 Element 树 diff
Apply DOM Mutation DOM Mutation RenderNode 增量 UIView 增量 RenderObject 增量

# 7B.3 五大声明式框架对比

React(Web,2013 开创):

function LikeButton({ liked, count, onLike }) {
    return (
        <div onClick={onLike} style={{ background: liked ? 'pink' : 'white' }}>
            <Icon name={liked ? 'heart-filled' : 'heart'} />
            <span>{count}</span>
        </div>
    );
}

核心创新:Virtual DOM + Reconcile。每次 render 生成新虚拟树,与旧树 diff,最小变更施加到真实 DOM。

SwiftUI(iOS,2019):

struct LikeButton: View {
    @Binding var liked: Bool
    @Binding var count: Int
    var body: some View {
        HStack {
            Image(systemName: liked ? "heart.fill" : "heart")
            Text("\(count)")
        }
        .background(liked ? Color.pink : Color.white)
        .onTapGesture { liked.toggle(); count += 1 }
    }
}

核心创新:编译器静态分析 body,自动建立"状态 → 视图"依赖图。比 React 更高效——不用全树 diff,编译期已知哪些 view 依赖哪些 state。

Jetpack Compose(Android,2021):

@Composable
fun LikeButton(liked: Boolean, count: Int, onLike: () -> Unit) {
    Row(Modifier.background(if (liked) Color.Pink else Color.White)
        .clickable(onClick = onLike)) {
        Icon(if (liked) Icons.Filled.Favorite else Icons.Outlined.Favorite)
        Text("$count")
    }
}

核心创新:Kotlin 编译器插件实现 "智能 recompose"——只重新执行依赖了变化 state 的 @Composable 函数。比 React 更智能——状态变化只触发受影响的函数子树。

Flutter(跨端,2018):

class LikeButton extends StatelessWidget {
  final bool liked;
  final int count;
  final VoidCallback onLike;
  
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onLike,
      child: Row(children: [
        Icon(liked ? Icons.favorite : Icons.favorite_border),
        Text('$count'),
      ]),
    );
  }
}

核心创新:三棵树(Widget / Element / RenderObject)+ 自绘引擎。Widget 是描述(廉价、可丢弃),Element 是实例(持久化、负责 diff),RenderObject 是实际渲染(重对象、复用)。

Vue(Web,2014):

<template>
  <div @click="like" :class="{ liked: isLiked }">
    <Icon :name="isLiked ? 'heart-filled' : 'heart'" />
    <span>{{ count }}</span>
  </div>
</template>

核心创新:响应式数据(getter/setter 或 Proxy 劫持)+ 模板编译。比 React 更精确——通过 getter 劫持精准追踪哪些组件依赖哪些数据。

五大框架的"声明式策略"对比:

框架 描述形式 Diff 粒度 依赖追踪方式
React JSX (运行时函数调用) 组件粒度 + Fiber 优先级 显式依赖数组(useEffect deps)
Vue Template (编译时) 组件 + Fine-grained getter/setter 自动追踪
SwiftUI DSL (编译时静态分析) View 粒度 编译期类型分析
Compose DSL (Kotlin 编译插件) @Composable 函数粒度 编译期 state 读取追踪
Flutter DSL (运行时) Element 粒度 显式 setState

# 7B.4 声明式代价

声明式不是银弹,它也有代价——任何"通用学习者"必须知道:

代价 1:心智门槛抬高

命令式:你写"做 A、做 B、做 C"——所见即所得
声明式:你写"状态是 X 时长啥样"——必须先理解 state / props / context 三件套
       + 必须理解 "渲染时机" "重渲染条件" "side effect 时机"
       + 必须理解 "为什么 useEffect 跑了两次" "为什么 state 没更新"

代价 2:性能调优更隐晦

命令式:哪里慢 → 加日志 → 找到 setText 慢
声明式:哪里慢 → 可能是「过度 recompose」「key 用错」「闭包捕获错 state」
       → 工具链门槛更高(React DevTools / Compose Layout Inspector / Flutter DevTools)

代价 3:与原生 API 的"阻抗失配"

- 系统提供的 ListView / RecyclerView / UICollectionView 都是命令式 API
  → 声明式框架必须自己实现等价机制(LazyColumn / List)
  
- 手势识别器、动画系统、无障碍 ── 历史上是命令式设计
  → 声明式框架要么"包一层",要么"重写"

代价 4:编译期 / 运行期取舍

React:     全运行时——灵活但有运行时开销
SwiftUI:   编译期重——静态分析强但灵活性差(动态 view 难写)
Compose:   Kotlin 插件折中——平衡了灵活与性能
Flutter:   运行时三棵树——内存开销较大
Solid/Svelte:编译期消灭 Virtual DOM——性能最强但生态小

# 7B.5 开发者总记忆

命令式 = "我告诉 UI 怎么变";声明式 = "我描述 UI 长啥样"。

命令式是 GUI 的"汇编"——直接、灵活、危险。 声明式是 GUI 的"高级语言"——安全、易维护、有抽象代价。

任何 GUI 框架的演进,最终都会走向声明式——这不是潮流,是 50 年软件工程"声明式比命令式更适合人脑"的总结。

你今天学的"渲染原理",主战场会在 2025-2030 年间彻底切到声明式——提前理解 state → description → diff → apply 的四步通用模型,比记住任何一个框架的 API 都重要。


# 8.跨平台渲染对比

# 8.1 Android 视图树

应用层:View 树(XML 或 Compose)
   ↓
Choreographer 调度
   ↓
ViewRootImpl 三阶段
   ↓
Skia / OpenGL / Vulkan
   ↓
SurfaceFlinger 合成
   ↓
屏幕

特点:

  • 系统提供完整 View 工具箱(TextView、ImageView、RecyclerView...)
  • 优势:开发快、无障碍/输入法/复制粘贴等系统服务无缝集成
  • 劣势:声明式抽象 + 命令式渲染的张力,复杂场景容易卡

# 8.2 iOS CALayer

应用层:UIView
   ↓
每个 UIView 拥有一个 CALayer(核心渲染单元)
   ↓
Core Animation(隐式动画 + 图层合成)
   ↓
Metal
   ↓
显示

关键差异:

维度 Android View iOS UIView/CALayer
渲染单元 View CALayer(更底层)
动画 显式调 ValueAnimator 隐式——改 layer 属性自动动画
离屏渲染 显式 setLayerType 自动(圆角、阴影等触发)
三阶段 measure/layout/draw layout/display(无独立 measure)

iOS 的优势:CALayer 默认每个属性变化都有动画。layer.opacity = 0.5 会自动产生 0.25s 渐变。开发者得到"动画免费"的体验。

iOS 的代价:圆角、阴影、mask 触发离屏渲染——很容易出"屏幕外"性能瓶颈。shouldRasterize 和 cornerRadius 配合不当能让 60FPS 跌到 20FPS。

# 8.3 Web 渲染树

HTML → DOM 树
CSS → CSSOM 树
   ↓ 合并
Render Tree
   ↓
Layout(Reflow)
   ↓
Paint
   ↓
Composite(图层合成)
   ↓
屏幕

Web 的渲染特点:

阶段 Web 术语 Android 类比
计算样式 Recalc Style (隐含在 measure 内)
布局 Layout / Reflow measure + layout
绘制 Paint onDraw
合成 Composite RenderThread + GPU 合成

触发"重排"的代价:

element.style.width = '200px';   // 触发 Reflow(昂贵)
element.style.color = 'red';     // 触发 Repaint(中等)
element.style.transform = 'translate(100px,0)'; // 只 Composite(最便宜)

这就是 Web 性能优化的口诀"能 transform 不要 left/top"——transform 是图层合成,不触发布局。

# 8.4 Flutter 自绘引擎

Dart 代码(声明式 Widget)
   ↓
Element 树(实例化)
   ↓
RenderObject 树(实际渲染)
   ↓
Skia 直接画
   ↓
屏幕

Flutter 最激进的设计:没有任何系统 View——所有像素都是 Skia 自己画。

优势:

  • 跨平台像素级一致(Android/iOS 长得完全一样)
  • 无系统 View 开销,自己控制 measure/layout
  • 60FPS / 120FPS 能力强

劣势:

  • 输入法、文本选择、无障碍要自己实现(早期版本极差)
  • 包体积大(要带 Skia 引擎,约 4-5MB)
  • 丢失了系统 View 的"原生感"(早期版本 ScrollView 滚动手感不对)

# 8.5 RN 桥接架构

JS 代码(声明式)
   ↓
JS Bridge(异步序列化通信)
   ↓
原生 View(Android View / iOS UIView)
   ↓
原生渲染管线
   ↓
屏幕

RN 的核心赌注:复用原生 View,但用 JS 写逻辑。

架构图:

flowchart LR
    JS[JS 代码] -->|序列化消息| B[Bridge]
    B -->|反序列化| N[原生 View]
    N -->|事件| B
    B -->|序列化事件| JS

优势:UI 是真正的原生 View,原生感觉强。

劣势:JS-原生 之间的 Bridge 是异步的,复杂动画 / 高频交互(每帧都要过 Bridge)会卡。这是 RN 新架构(Fabric + JSI)正在解决的问题——把异步 Bridge 改为同步直接调用。

# 五种架构的设计哲学对比

框架 哲学 适合
Android View "提供完整 UI 工具箱" 标准 App
iOS UIView "图层 + 隐式动画" 体验细腻的 App
Web "文档 + 流式布局" 文档型应用 + 富交互
Flutter "自绘 + 一致性" 跨平台一致的 App
React Native "JS + 原生 View" 大量动态化需求

没有银弹——选哪个取决于"你最在意什么"。

# 8.6 LVGL 软光栅

嵌入式设备(智能手表、家电触屏、车载仪表盘)资源极度受限,没有 GPU、没有线程隔离、单核 MCU。但它们也要 60 FPS 显示——靠的是极简版渲染管线。

LVGL 渲染管线:
┌─────────────────────────────────────────┐
│ 应用代码:lv_label_set_text(label, "Hi")│
└──────────────┬──────────────────────────┘
               ▼
┌──────────────────────────────────────────┐
│ 标记 invalid 区域 (lv_obj_invalidate)    │  ← 相当于 invalidate
└──────────────┬──────────────────────────┘
               ▼
┌──────────────────────────────────────────┐
│ lv_timer_handler() 主循环每帧调用:     │
│  ① 收集所有 invalid 矩形                │
│  ② 合并重叠 → 最小脏区域                │
│  ③ 在脏区域内深度遍历 widget 树         │
│  ④ 逐 widget 调 draw_cb 软件绘制         │
│  ⑤ flush_cb 把 buffer 推到屏幕          │
└──────────────────────────────────────────┘

LVGL 的设计取舍(对比手机平台):

维度 手机平台 LVGL
测量阶段 独立 measure 递归 合并进 layout(一次性算完)
布局 独立 layout 阶段 layout 时直接算矩形
绘制 录制 DisplayList → GPU 直接软件绘制到 framebuffer
合成 SurfaceFlinger / Core Animation 无合成(直接画到唯一 framebuffer)
双缓冲 默认 可选(内存够时开)
脏区域 系统级算 手动算(lv_disp_set_flush_cb)
线程 UI + RenderThread + GPU 单线程(main loop)

关键观察:LVGL 把所有"复杂阶段"合并/砍掉,只保留最核心的"算矩形 + 软件填色 + 推屏幕"三步。这反过来证明了"渲染管线五阶段"的第一性原理——任何阶段都能砍,但不能少了"算位置 + 填颜色 + 上屏"这个最小集。

给嵌入式开发者的提醒:

嵌入式没有 GPU 不代表"渲染原理不同"——它只是把手机平台的"GPU 并行加速 + 多线程隔离 + 分图层合成"几个性能优化全部省掉了。原理完全一致,只是"省事 vs 省钱"的取舍不同。

这也是为什么 LVGL 看似简陋,却能让一个 200MHz Cortex-M 跑出 60FPS 的智能手表 UI——它的渲染管线本身就是为低性能 CPU 设计的最小集。

# 8.7 跨端渲染阶段矩阵

把前 8 节所有平台放在一张"阶段 × 平台"大矩阵里——这是本篇的"跨端字典":

阶段 / 平台 Android iOS Web Flutter RN Compose SwiftUI LVGL
描述层 XML / Compose XIB / SwiftUI HTML+CSS Widget Tree JSX @Composable View body C 代码
加载 LayoutInflater NIB unarchive DOM Parse Widget build JSX → 原生 recompose View 初始化 lv_*_create
测量 onMeasure(spec) sizeThatFits: Style Recalc + Layout performLayout() 原生 Measurable View body 算 layout 内合并
布局 onLayout(l,t,r,b) layoutSubviews Layout (Reflow) performLayout (同) 原生 Placeable.placeAt layout pass layout 内合并
绘制录制 onDraw(Canvas) → DisplayList drawRect: → CALayer Paint → DisplayItemList paint(canvas) → Scene 原生 DrawScope drawRect / metal 直接画 framebuffer
合成 RenderThread → Skia → GPU Render Server → Metal Compositor → Skia → GPU Raster → Skia/Impeller 原生合成 同 Android 同 iOS 无合成
上屏 SurfaceFlinger Display Server Display Compositor Engine 提交 原生 同 Android 同 iOS flush_cb
失效粒度 View UIView Element RenderObject View 包装 @Composable View lv_obj
刷新触发 invalidate / requestLayout setNeedsDisplay / setNeedsLayout style 改 / DOM 改 markNeedsPaint 同 Android/iOS state 变化 @State 变化 lv_obj_invalidate
范式 命令式(→ Compose) 命令式(→ SwiftUI) 命令式(→ React/Vue) 声明式 命令式 声明式 声明式 命令式
GPU 加速 ✅ Skia/GLES/Vulkan ✅ Metal ✅ Skia/GL/Vulkan ✅ Skia/Impeller ✅ 原生 ✅ 同 Android ✅ 同 iOS ❌ 软光栅

把这张表贴在 IDE 旁——下次切换平台时不用"重新学渲染",只需查"这一阶段叫什么"。


# 9.经典陷阱与反模式

# 9.1 onDraw 里 new 对象

# 现场代码

@Override
protected void onDraw(Canvas canvas) {
    Paint paint = new Paint();           // ★ 每帧 new
    paint.setColor(Color.RED);
    Rect rect = new Rect(0, 0, 100, 100); // ★ 每帧 new
    canvas.drawRect(rect, paint);
}

# 为什么是灾难

60 FPS × 2 个对象 = 120 个对象/秒
若 ViewGroup 有 50 个 View 都这样 = 6000 对象/秒
→ Eden 区快速填满 → 频繁 Young GC
→ Young GC 期间主线程暂停 → 掉帧

Lint 工具会直接报警:"Avoid object allocations during draw operations"。

# 修复

// 成员变量持有
private final Paint paint = new Paint();
private final Rect rect = new Rect();

@Override
protected void onDraw(Canvas canvas) {
    paint.setColor(Color.RED);
    rect.set(0, 0, 100, 100);
    canvas.drawRect(rect, paint);
}

# 衍生陷阱

- onMeasure 里 new ArrayList<>             ← 每次 measure 都 new
- getter 里返回 new XXX                     ← 调用方一旦在 onDraw 调用即灾难
- onDraw 里调 String.format()              ← StringBuilder + char[] 大量分配

# 9.2 过度绘制

# 什么是过度绘制(Overdraw)

同一像素被画了多次——只有最后一次有效,前面都浪费。

背景层:白色背景        ← 像素 (100, 100) 被画成白色
卡片背景:灰色          ← 像素 (100, 100) 被画成灰色
图标背景:绿色          ← 像素 (100, 100) 被画成绿色
图标本身:图标色        ← 像素 (100, 100) 最终颜色

→ 同一像素被画 4 次,前 3 次浪费

Android 开发者选项有"调试 GPU 过度绘制"模式:

  • 蓝色:1 次(正常)
  • 绿色:2 次(可接受)
  • 浅红:3 次(需优化)
  • 深红:4 次以上(必须优化)

# 常见过度绘制源

<!-- 反模式:层层背景叠加 -->
<LinearLayout android:background="@color/white">
    <LinearLayout android:background="@color/white"> <!-- 重复 -->
        <FrameLayout android:background="@color/white"> <!-- 重复 -->
            <TextView android:background="@color/white"/> <!-- 重复 -->
        </FrameLayout>
    </LinearLayout>
</LinearLayout>

修复:

<!-- 只在最外层设背景,去掉内层 -->
<LinearLayout android:background="@color/white">
    <LinearLayout>
        <FrameLayout>
            <TextView/>
        </FrameLayout>
    </LinearLayout>
</LinearLayout>

进一步:移除 Window 的默认背景(如果你的 App 已经有自己的背景):

<!-- styles.xml -->
<style name="AppTheme">
    <item name="android:windowBackground">@null</item>  <!-- 减一层 -->
</style>

# 9.3 嵌套布局爆炸

# 现场

<LinearLayout> <!-- 6 层嵌套 -->
    <RelativeLayout>
        <LinearLayout android:weightSum="3">
            <LinearLayout android:layout_weight="1">
                <FrameLayout>
                    <RelativeLayout>
                        ...
                    </RelativeLayout>
                </FrameLayout>
            </LinearLayout>
        </LinearLayout>
    </RelativeLayout>
</LinearLayout>

# 为什么爆炸

  • LinearLayout 带 weight → 二次测量(×2)
  • RelativeLayout → 二次测量(×2)
  • 嵌套 6 层 → 2^6 = 64 倍 measure 次数

# 修复:扁平化 + ConstraintLayout

<androidx.constraintlayout.widget.ConstraintLayout>
    <View id="A" app:layout_constraintStart_toStartOf="parent" .../>
    <View id="B" app:layout_constraintStart_toEndOf="@id/A" .../>
    <View id="C" app:layout_constraintTop_toBottomOf="@id/B" .../>
</androidx.constraintlayout.widget.ConstraintLayout>

ConstraintLayout 用一次约束求解器,避免嵌套 + 二次测量 → 复杂度从 O(2^D) 降到 O(N)。

# 9.4 主线程加大图

# 现场

imageView.setImageBitmap(BitmapFactory.decodeFile(path));

# 隐藏代价

解码一张 4032×3024 的相机原图:
   1. 读文件(IO):50-200ms
   2. 解码 JPEG:100-500ms
   3. 在内存创建 Bitmap:4032×3024×4 = 48MB
   
→ 主线程卡 200ms-700ms
→ 用户看到"按下没反应"

# 修复

// 方案 1:异步解码 + 采样
lifecycleScope.launch(Dispatchers.IO) {
    val opts = BitmapFactory.Options().apply {
        inSampleSize = 4  // 1/4 大小,内存降到 3MB
    }
    val bm = BitmapFactory.decodeFile(path, opts)
    withContext(Dispatchers.Main) {
        imageView.setImageBitmap(bm)
    }
}

// 方案 2:用 Glide / Coil(推荐)
Glide.with(imageView).load(path).into(imageView)
// 内置异步、内存复用、缓存、生命周期管理

# 9.5 动画中 requestLayout

# 现场

ValueAnimator animator = ValueAnimator.ofFloat(0, 100);
animator.addUpdateListener(animation -> {
    float value = (float) animation.getAnimatedValue();
    LayoutParams lp = view.getLayoutParams();
    lp.width = (int) value;  // ★ 触发 requestLayout
    view.setLayoutParams(lp);
});

# 为什么卡

每帧 60 次:
   动画值变化 → setLayoutParams → requestLayout
   → 整个 View 树重 measure + layout + draw
   → 60 帧每帧都做整树工作

# 修复:用 transform 类属性

ValueAnimator animator = ValueAnimator.ofFloat(0, 100);
animator.addUpdateListener(animation -> {
    float value = (float) animation.getAnimatedValue();
    view.setScaleX(value / view.getWidth());  // 只触发 invalidate
});

setTranslationX/Y、setScaleX/Y、setRotation、setAlpha 都不触发 requestLayout——它们只影响绘制矩阵,是"GPU 友好动画"。

# 一句话原则

动画用 transform 类属性,不要改 LayoutParams。

ObjectAnimator 走属性路径,自动选择最优实现:

ObjectAnimator.ofFloat(view, "translationX", 0, 100).start();

# 9.6 cornerRadius 离屏

# 现场

// 看似很无辜的代码
avatarView.layer.cornerRadius = 25
avatarView.layer.masksToBounds = true  // ★ 灾难开关

# 为什么是灾难

iOS 渲染圆角的过程:
   1. 把整个 view 绘制到屏外缓冲区(off-screen buffer)
   2. 在屏外应用 mask(裁掉四角)
   3. 把处理过的缓冲区贴回屏幕

→ 每个圆角 view 都要一次"屏外往返"
→ Instruments 中显示为"Off-screen rendered"黄色警告
→ 列表里有 50 个圆角头像 = 50 次屏外渲染 = 滚动卡顿

# 修复(三种方案,按推荐度排序)

// ✅ 方案 1(推荐):iOS 13+ 用 cornerCurve + maskedCorners
avatarView.layer.cornerRadius = 25
avatarView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMaxYCorner]
avatarView.layer.cornerCurve = .continuous  // 更柔和
// 不开 masksToBounds → 由 GPU 用 shader 实现,零屏外渲染

// ✅ 方案 2:用 Bitmap 预处理圆角
let rounded = avatar.roundedImage(radius: 25)
avatarView.image = rounded
// 图片本身就是圆的,不需要 mask

// ✅ 方案 3:开 shouldRasterize(适合内容不变的复杂 view)
view.layer.shouldRasterize = true
view.layer.rasterizationScale = UIScreen.main.scale
// 把整个 view 缓存为位图,避免每帧重新做屏外渲染
// ⚠️ 但内容一变,缓存就失效

心智模型:

iOS 的"易卡顿三件套":
  cornerRadius + masksToBounds        ← 圆角 mask(触发屏外)
  shadow(不指定 shadowPath)         ← 阴影计算(触发屏外)
  group opacity(透明子树)            ← alpha 混合(触发屏外)

每一个都是"看起来无辜但实际昂贵"的设计选择。

# 9.7 强制同步布局

# 现场

// 看起来很正常的代码——给 100 个 div 设置宽度为内容宽度
const divs = document.querySelectorAll('.item');
for (const div of divs) {
    const w = div.offsetWidth;       // ★ 读:强制同步布局
    div.style.width = (w * 1.1) + 'px';  // 写:触发布局失效
}

# 为什么是灾难

浏览器的渲染管线优化前提:
  - 多次 style 修改 → 攒在一起 → 下一帧统一布局
  - 这样性能最优

你的代码破坏了这个优化:
  第 1 次循环:
    读 offsetWidth → 浏览器:"你要读,我必须立刻算出来" → 强制 Layout
    写 width → 标记下次需要 Layout
  第 2 次循环:
    读 offsetWidth → 上次写过,必须重新 Layout(即使刚算过的)
    ...
  → 100 个 div = 100 次 forced sync Layout = 100 次走完 Render Tree
  → 几百毫秒卡顿

这个陷阱在 Web 圈被称为 "Layout Thrashing"——前端性能优化的头号杀手。

# 修复

// ✅ 修复 1:读写分离
const divs = document.querySelectorAll('.item');

// 先全部读
const widths = Array.from(divs).map(d => d.offsetWidth);

// 再全部写
divs.forEach((d, i) => d.style.width = (widths[i] * 1.1) + 'px');
// → 只触发 1 次 Layout

// ✅ 修复 2:用 requestAnimationFrame 对齐 VSync
requestAnimationFrame(() => {
    const widths = Array.from(divs).map(d => d.offsetWidth);
    requestAnimationFrame(() => {
        divs.forEach((d, i) => d.style.width = (widths[i] * 1.1) + 'px');
    });
});

// ✅ 修复 3:用 ResizeObserver 替代主动测量
new ResizeObserver(entries => {
    for (const entry of entries) {
        // 浏览器在 Layout 完成后批量回调
    }
}).observe(div);

心智模型:

读 offsetWidth/offsetHeight/getBoundingClientRect/scrollTop
   ↓ 任何 "读 layout 信息" 的 API 都是同步阻塞点

写 style.width/height/padding/...
   ↓ 任何 "改可能影响 layout" 的 API 都让缓存失效

读 + 写 + 读 + 写 + ... = layout thrashing
读 + 读 + 读 + 写 + 写 + 写 = 1 次 layout

这与 Android 的 "动画期间 requestLayout"(陷阱五)是同一思想——都是"在错误时机触发昂贵阶段"导致的。

# 9.8 build 里耗时操作

# 现场

class ArticleCard extends StatelessWidget {
  final String content;
  
  @override
  Widget build(BuildContext context) {
    final summary = generateSummary(content);   // ★ 耗时 50ms 的 NLP
    final formattedDate = parseAndFormat(date); // ★ 耗时 5ms 的日期处理
    
    return Column(children: [
      Text(summary),
      Text(formattedDate),
    ]);
  }
}

# 为什么是灾难

Flutter 的 build 方法可能每帧都被调用:
  - 父 widget 重 build → 子 build
  - 状态变化 → 重 build
  - setState 任何地方 → 整子树 build

每次 build 都跑 generateSummary:
  → 60 FPS × 50ms = 完全跑不动
  
即使只在状态变化时 build:
  → 滚动列表里每入屏一个 card 就 50ms → 滑动严重卡顿

# 修复

// ✅ 修复 1:缓存结果(用 const / final)
class ArticleCard extends StatelessWidget {
  final String content;
  final String summary;       // 提前算好
  final String formattedDate;
  
  ArticleCard({required this.content})
    : summary = generateSummary(content),   // 构造时算一次
      formattedDate = parseAndFormat(date);
  
  @override
  Widget build(BuildContext context) {
    return Column(children: [
      Text(summary),
      Text(formattedDate),
    ]);
  }
}

// ✅ 修复 2:StatefulWidget + initState
class ArticleCard extends StatefulWidget {
  // ...
}

class _ArticleCardState extends State<ArticleCard> {
  late String summary;
  
  @override
  void initState() {
    super.initState();
    summary = generateSummary(widget.content);  // 只算一次
  }
  
  @override
  Widget build(BuildContext context) {
    return Text(summary);
  }
}

// ✅ 修复 3:用 FutureBuilder 异步化
FutureBuilder<String>(
  future: compute(generateSummary, content),  // 跑到 isolate
  builder: (ctx, snap) => Text(snap.data ?? '...'),
)

心智模型:

所有声明式框架的 build/render 函数 都要遵循"廉价 + 幂等"原则:
  - 廉价:build 必须 < 1ms,因为可能每帧调用
  - 幂等:build 不能有副作用(不能发网络、不能写文件)

违反这个原则 = 直接破坏声明式框架的性能假设。

这与 React 的"不要在 render 里 fetch"、Compose 的"不要在 @Composable 里阻塞"是同一原则。

# 9.9 过度 recompose

# 现场(Compose)

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    
    Column {
        Text("Count: $count")              // 应只在 count 变时 recompose
        ExpensiveChart(data = chartData)    // ★ 完全不依赖 count,但每次 count 变也重 compose
        Button(onClick = { count++ }) { Text("+") }
    }
}

# 为什么是灾难

Compose 默认行为:
  父函数重 compose → 所有子函数也重 compose
  → ExpensiveChart 每次 count++ 都重 compose
  → 用户感觉点按钮就卡

React 同理:
  父组件 re-render → 所有子组件也 re-render
  除非用 React.memo + useCallback 缓存

# 修复

// ✅ Compose 修复 1:拆分 Composable
@Composable
fun Counter() {
    Column {
        CountDisplay()                  // 自己持有 state
        ExpensiveChart(data = chartData) // 与 count 解耦
        IncrementButton()
    }
}

@Composable
fun CountDisplay() {
    var count by remember { mutableStateOf(0) }
    Text("Count: $count")
}

// ✅ Compose 修复 2:用 derivedStateOf / 让 ExpensiveChart 稳定
@Composable
fun ExpensiveChart(data: List<Int>) {
    // 如果 data 引用没变,Compose 智能跳过
    // 关键:data 必须 stable(数据类 + 不可变集合)
}
// ✅ React 修复
const ExpensiveChart = React.memo(({ data }) => {
    // 只在 data 真的变了才重渲染
});

function Counter() {
    const [count, setCount] = useState(0);
    // 用 useCallback 让函数引用稳定
    const handleClick = useCallback(() => setCount(c => c + 1), []);
    
    return (
        <>
            <span>Count: {count}</span>
            <ExpensiveChart data={chartData} />
            <button onClick={handleClick}>+</button>
        </>
    );
}

# React 闭包陷阱

// ❌ 经典闭包 bug
function Counter() {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        const id = setInterval(() => {
            setCount(count + 1);   // ★ 闭包捕获了 count=0,永远 +1
        }, 1000);
        return () => clearInterval(id);
    }, []);  // 空依赖
}

// ✅ 修复:用函数式 setState
useEffect(() => {
    const id = setInterval(() => {
        setCount(c => c + 1);   // c 是当前值
    }, 1000);
    return () => clearInterval(id);
}, []);

心智模型:

声明式框架的两大新陷阱:
  ① 过度 recompose / re-render:祖先变化 → 全树重算(默认行为)
     → 解药:组件拆分、记忆化、stable 类型
  ② 闭包陷阱:每次 render 函数体都重新执行,闭包捕获的是"那次 render 的 state"
     → 解药:函数式 setState、useRef、ref-based 写法

这两类陷阱在命令式时代不存在——它们是声明式范式的"新原罪"。

# 10.渲染设计哲学

# 10.1 三层认知阶梯

阶段 思维方式 典型工具
初级 "写好 XML 就行" findViewById + setText
中级 "分析 measure/layout/draw 复杂度" Layout Inspector + Systrace
高级 "用声明式 + 增量更新让渲染自动最优" Compose / SwiftUI / Flutter

# 10.2 优化决策清单

问 1:你卡在哪一阶段?
   ├─ inflate 慢 → 异步 inflate / 预加载 / 减少 View 数
   ├─ measure 慢 → 拍平嵌套 / 用 ConstraintLayout
   ├─ layout 慢 → 极少出现,先排除其他
   ├─ draw 慢 → 减少过度绘制 / 优化 onDraw 内分配
   └─ GPU 合成慢 → 减少图层 / 减少透明叠加

问 2:动画时卡?
   ├─ 改 LayoutParams → 改 setTranslation/setScale
   ├─ 复杂自绘 + 动画 → setLayerType(HARDWARE)
   └─ 复杂背景 + alpha → 离屏 Bitmap 缓存

问 3:列表卡?
   ├─ ViewHolder 复用没做 → 改 RecyclerView
   ├─ 异步任务竞态(鬼影)→ Glide / 取消旧请求
   ├─ onBindViewHolder 慢 → 异步化 / 预计算
   └─ Item 复杂 → 减少 View 数 / 自绘合一

问 4:启动慢?
   ├─ inflate 多 → AsyncLayoutInflater + ViewStub
   ├─ Activity 启动多个组件 → 延迟初始化
   └─ 主题闪屏 → SplashScreen API

问 5:根本性卡?
   └─ 考虑迁移到 Compose / Flutter(声明式 + 增量)

# 10.3 设计哲学

"渲染问题"的最优解,往往是"让框架算谁该重画"——把"哪里变了"的判断从程序员脑子里赶出去。

Android 用 invalidate/requestLayout 显式控制,iOS 用 CALayer 隐式动画,Web 用 Reflow/Repaint/Composite 三档,Flutter 用 RenderObject 自绘,Compose/SwiftUI 用声明式 + 增量比较。这一路演进的方向,就是把"什么时候重画、重画哪里"的责任从开发者手里转移到框架。

回到 §0 的"60 帧变 6 帧"事故:真正的"零卡顿"修复不是把 XML 写得更精巧,而是让 UI 描述变成函数式声明——框架算出"这一帧只有点赞数变了,只重画那一个 TextView"。Bug 在源头被消灭,而不是在症状上修补。

# 10.4 跨端术语对照

任何视图开发者必备的「同名异姓」字典——下次接触陌生平台,先查这张表:

通用概念 Android iOS Web Flutter Compose SwiftUI LVGL
描述层(写啥) XML / Compose XIB / SwiftUI / 代码 HTML+CSS Widget Tree @Composable View body C 代码
实例化 View 对象 UIView DOM Element Element 树 Composition Node View struct lv_obj_t
测量入参 MeasureSpec CGSize Containing Block BoxConstraints Constraints proposed Size parent size
测量出参 measuredWidth/Height sizeThatFits 返回 offsetWidth/Height size MeasureResult sizeThatFits 返回 obj size
位置确定 onLayout layoutSubviews Layout pass layout Placeable.placeAt layoutSubviews layout
绘制录制 onDraw → DisplayList drawRect: → CALayer Paint → DisplayItem paint → Scene DrawScope drawRect → metal lv_draw_*
失效(脏标记) invalidate() setNeedsDisplay dirty flag markNeedsPaint recompose @State 变化 lv_obj_invalidate
重布局 requestLayout() setNeedsLayout reflow markNeedsLayout re-measure layout pass lv_obj_refr_size
节拍器 Choreographer + VSync CADisplayLink requestAnimationFrame SchedulerBinding 同 Android 同 iOS lv_timer
GPU 提交 RenderThread → Skia Render Server → Metal Compositor → Skia Raster Thread → Skia 同 Android 同 iOS flush_cb 上 framebuffer
声明式特性 Compose SwiftUI React / Vue 默认 默认 默认 ❌
复用机制 RecyclerView UICollectionView virtual scroll ListView.builder LazyColumn List + ForEach lv_table

把这张表贴在 IDE 旁边——切换平台时不再需要"重新学渲染",只需要查"在新平台里它叫什么"。

# 10.5 本卷章节呼应

5.1 窗口核心设计思想       ─→ 窗口是"画框",View 是"画作"——窗口圈定 Surface,本篇填充像素
5.3 图形渲染管线原理       ─→ 本篇 §4 GPU/合成 在 5.3 详细展开 + Vulkan/Metal 演进
5.4 手势事件设计灵魂       ─→ 触摸事件分发依赖 View 树结构,本篇定义"树",5.4 定义"事件路径"
5.5 消息机制设计思想       ─→ Choreographer + VSync = 消息机制对齐渲染节拍
5.6 跨进程通信设计         ─→ SurfaceFlinger 跨进程 BufferQueue 是零拷贝合成的典范
5.7 组件生命周期管理       ─→ View 的"四态"对应 Activity/ViewController 的生命周期
5.8 页面导航与路由设计     ─→ 路由切换 = View 树整体替换 + 转场动画
5.9 响应式数据绑定设计     ─→ 声明式渲染(§7B)是响应式绑定在 UI 层的极致

跨卷呼应:
- 第 2 卷·序列化数据       ─→ XML inflate 是反序列化的特例
- 第 3 卷·对象访问原理     ─→ 反射开销是 inflate 慢的根因(§2.2)
- 第 4 卷·内存回收机制     ─→ onDraw 里 new 对象引发 GC 风暴(§9.1)
- 第 4 卷·数据拷贝原理     ─→ 离屏 Bitmap 是写时复制的应用(§7.2)

# 10.6 延伸阅读

Android 方向:

  • 官方文档:Rendering and Layout (opens new window)
  • 源码:ViewRootImpl.performTraversals() —— 三阶段调度的核心
  • 书籍:《Android 应用性能实战》(杨臻)
  • 工具:Perfetto / Systrace / Layout Inspector / GPU 调试器

iOS 方向:

  • 官方文档:Drawing and Printing Guide for iOS (opens new window)
  • WWDC:Advanced Graphics with Core Animation / Explore UI animation hitches
  • 工具:Instruments → Core Animation / Time Profiler
  • 书籍:《iOS Core Animation: Advanced Techniques》by Nick Lockwood

Web 方向:

  • 论文:RenderingNG: Chrome's Next-Gen Rendering Architecture
  • 文章:Avoid Large, Complex Layouts and Layout Thrashing (opens new window)
  • 工具:Chrome DevTools → Performance / Layers / Rendering
  • 标准:CSS Painting API (opens new window)

Flutter 方向:

  • 官方:Inside Flutter (opens new window)
  • 引擎源码:flutter/engine/lib/ui/painting.dart
  • 工具:Flutter DevTools → Performance / Widget Inspector

Compose / SwiftUI / React:

  • Compose 官方:Jetpack Compose Phases (opens new window)
  • SwiftUI:Demystify SwiftUI (opens new window)
  • React:React Fiber Architecture (opens new window)

嵌入式 / 跨平台原理书单:

  • LVGL 官方:docs.lvgl.io (opens new window)
  • 书籍:《Computer Graphics: Principles and Practice》(图形学经典)
  • Skia 源码:github.com/google/skia (opens new window)(跨平台渲染引擎事实标准)
上次更新: 2026/07/15, 11:23:11
1.窗口核心设计思想
3.图形渲染管线原理

← 1.窗口核心设计思想 3.图形渲染管线原理→

最近更新
01
audit
07-27
02
C++入门教程全章思考题汇编
07-24
03
12.技术团队建设能力
07-21
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式