
说来也巧我入行做的第一个性能优化任务就是跟一个叫“Caveman”的内部库打交道。当年 iOS 端一个信息流页面滚动掉帧Instruments 一抓全是 Auto Layout 的约束求解把 CPU 烧得冒烟。后来架构师丢过来一句把布局层换成 Caveman不满足约束、不搞 Auto Layout直接自己算坐标。Caveman 这个名字听起来很原始解决的却是移动端 UI 性能里最现代、最头疼的问题——布局引擎的效率和跨端一致性。它后来以 Yoga 的名字开源成了 React Native 的布局基石。这篇文章我想把 Caveman 从诞生到拆解的完整脉络捋一遍包括它的核心设计、实际接入方法以及我在迁移过程中踩过的那些坑希望能帮到正在做布局性能优化、或者对跨端 UI 引擎感兴趣的朋友。1. 为什么会有 Caveman从 Auto Layout 卡顿说起1.1 卡顿的元凶约束求解的指数级开销在 iOS 开发里Auto Layout 的本质是“约束求解”。你给每个视图设置 leading、trailing、top、bottom、等宽等高之类的约束系统就把它抽象成一个线性方程组用 Cassowary 算法去迭代求解。这个方案本身很优雅写起来也直观但它有两个致命的性能问题。第一个问题是“全局性”一个视图的约束变化可能会牵动整个视图层级里所有相关约束重新求解。尤其在一个复杂 cell 里几十个 label、imageView 相互约束一次小小的 content 变化可能触发整棵视图树的 layout 重新计算。第二个问题是“解析成本高”Cassowary 求解过程涉及大量的 add/subtract/multiply 运算加上优先级处理CPU 开销远高于直接算 frame。我当时实测过一个双列瀑布流卡片单个 cell 内约 20 个约束在 iPhone 6 上做一次完整 layout 大约要 0.8 毫秒。看起来不高但滚动时 60fps 的帧预算只有 16.7 毫秒而一个屏幕同时有 6 到 8 个 cell 在做布局光布局就占掉 5 到 6 毫秒。再加渲染、解码、反射帧率直接就崩了。1.2 换赛道不求解约束直接算坐标系Caveman 的思路其实很朴素既然约束求解贵那就不求解既然 Auto Layout 做全局关系推导那我干脆只用父子层级关系让每个子视图问一下父视图“你有多宽多高”然后按一套线性规则算出自己的 frame。这个规则就是 flexbox 那套东西主轴方向、对齐方式、flexGrow、flexShrink、flexBasis。你把它理解成“从方程组求解”变成“单行道开车”。Auto Layout 允许视图 A 和 B 互相约束形成环状依赖Caveman 直接把布局限制成树状单向流动父节点决定子节点的可用空间子节点测量自己的尺寸后上报父节点再根据所有子节点尺寸做分配。整个过程只需要一次 depth-first 遍历没有迭代没有方程组性能自然高一个量级。1.3 为什么偏偏选 flexbox而不是 Grid 或绝对布局这里有个问题为什么 Caveman 选用了 flexbox 的规则集我理解有两个决定性原因。第一flexbox 天然适合“一维上的动态分配”。移动端 UI 最常见的需求就是“一行里几个元素怎么分布”“一个容器高度不定时如何撑开”这些都是 flexbox 的舒适区。而 Grid 擅长二维整体规划在列表流场景里反而不灵活。第二Web 端已经有大量工程师熟悉 flexboxFacebook 内部做 RN 时让前端工程师无缝迁移的成本最低。在 2014 年那个时间点flexbox 已经从“新鲜的 CSS 特性”变成“前端基本功”选它等于默认拥有了一批熟练工。当然Flexbox 不是银弹。Caveman 早期在复杂主从布局上确实不如 Auto Layout 聪明但它换来的是“可预期的性能和跨端一致的行为”这个取舍在移动 App 场景里完全值得。2. 核心设计拆解一次测量处处布局2.1 布局树的单向数据流Caveman 的核心数据结构是一棵纯 C 实现的节点树。每个 YGNode早期叫 Node只保存自己的 styleflexDirection、padding、margin、border 等和 children 列表。它不持有 UIView/UIView 子类不关心渲染只管“算坐标”。这样做有三个好处。第一个好处是可以跨端共用。同样的 style 树在 iOS 上算出的坐标和在 Android 上算出的坐标完全一致避免了“两套代码两个效果”的系统性偏差。第二个好处是计算可以脱离主线程。因为 C 节点树不依赖 UIKit你可以在后台线程直接跑 YGNodeCalculateLayout算完再把结果一次性提交到主线程更新 frame。第三个好处是方便缓存。节点本身是纯数据序列化、深拷贝、快照都比较容易这在做复杂列表预布局时特别有用。这里贴一个最小化的树结构示例让大家感受一下YGNodeRef root YGNodeNew(); YGNodeStyleSetFlexDirection(root, YGFlexDirectionColumn); YGNodeStyleSetWidth(root, 320); YGNodeStyleSetHeight(root, 480); YGNoderef header YGNodeNew(); YGNodeStyleSetHeight(header, 60); YGNodeInsertChild(root, header, 0); YGNoderef body YGNodeNew(); YGNodeStyleSetFlexGrow(body, 1); YGNodeInsertChild(root, body, 1);这个三段式布局在 Caveman 里就相当于root 是 320x480 的容器header 固定 60 高body 把剩余空间全部吃掉。没有约束没有优先级就是一次减法计算机执行起来就是一个减法指令的量级。2.2 测量与布局分离Layout 的两阶段Caveman 在计算上分两个阶段Measure 和 Layout。Measure 阶段是“自底向上”的。子节点先根据自身内容文本长度、图片尺寸等计算出自己的 intrinsic size然后父节点综合所有子节点的尺寸、flexGrow 等属性决定每个子节点最终分到多少空间。Layout 阶段是“自顶向下”的。父节点把自己的 frame 和 padding 等扣除后把剩余区域切成若干块逐个子节点分配并确定它们在父坐标系里的绝对坐标。我拿日常的“新闻卡片”来举例cell 的宽度由 tableView 宽度决定这就是一次 top-down 传值卡片里有标题 label、摘要 label、时间 label它们的宽度是多少、谁占几行就得靠子节点先测出自己的高度然后 bottom-up 传上去。Caveman 先做一次整树自底向上的 measure再做一次自顶向下的 layout最后每个节点都会拿到 4 个值left、top、width、height。这里有别于 Auto Layout 的一点是Caveman 的 measure 只做一次性计算不做迭代优化。如果子节点的尺寸因为字体加载、图片下载发生了变化你需要主动标记 dirty也就是告诉布局引擎“这里缓存失效了”下次计算才会重新 measure。2.3 缓存、脏标记与局部布局优化纯 C 的好处之一就是缓存策略可以做得非常激进。Caveman 在 measure 阶段会把每个节点的计算结果缓存起来。如果节点没有 marked dirty且自身的 style 没变父节点的可用空间没变那它可以跳过整棵子树的 measure直接复用上一次的结果。这就带来一个重要的工程策略局部布局更新。在传统 Auto Layout 中更新一个 label 的 text 往往导致整个 view 树重新布局。在 Caveman 中你只需要把这个 label 节点标脏然后对它的父节点重新计算布局。如果父节点的尺寸没有变化那么更上层的节点甚至不需要重算直接把子树的布局结果贴回去就行了。实现这个策略的关键是 dirty 传播路径要精确。Caveman 的 dirty 是从被修改节点向上传播到 root 的但 layout 计算可以只在 dirty 路径上做。我在项目里封装过一套“局部布局管理器”把需要更新的节点标记 dirty然后从最近的一个尺寸未变的祖先节点开始重新计算布局耗时直接从 0.8ms 降到 0.1ms 级别。滚动流畅度的提升非常明显。2.4 从 Caveman 到 Yoga开源和生态演进Caveman 最开始只是 Facebook iOS App 内部的一个 C 布局库后来 React Native 项目需要跨端布局方案就把这套东西抽出来开源名字叫 Yoga。Yoga 在 Caveman 的基础上做了一些重要改进增加了 better text measurement 接口、完善了 directionLTR/RTL支持、优化了 flexbox 的 edge cases同时保持 API 层面的稳定。Yoga 的跨端能力是它最核心的价值。同一份 style 描述在 iOS、Android、Windows、Web 端有完全一致的布局结果。这让“design to code”的还原度大幅提升。很多团队从“iOS 一套、Android 一套、Web 再一套”的三套布局逻辑收敛到“一套 flexbox 描述 各端渲染适配”。这个演进路线后来也被 Flutter 等框架验证是可行的。现在如果你们团队想评估是否用 Yoga 替换系统的布局方案我建议从三个维度考察一是有多少页面是动态布局、高性能要求的二是现有的 UI 代码是否已经能抽象成树状层级三是团队成员对 flexbox 的熟悉程度。这三个问题都偏正面那迁移的价值就比较大。3. 实操用 Caveman/Yoga 跑通一个跨端布局3.1 最小环境搭建和编译如果你只是想先感受一下 Caveman 的布局能力不需要完整接入 iOS/Android 工程。Yoga 仓库里有纯 C 的 API 和对应的底层实现你可以直接拉源码编译一个命令行工具。git clone https://github.com/facebook/yoga.git cd yoga mkdir build cd build cmake .. -DYOGA_BUILD_TESTSON make -j4编译完之后你可以写一个最简单的 C 程序来验证布局结果。下面这个例子构造了一个 100x100 的容器内部放一个 50x50 的子节点然后计算布局并打印坐标#include stdio.h #include yoga/Yoga.h int main(void) { YGNodeRef root YGNodeNew(); YGNodeStyleSetWidth(root, 100); YGNodeStyleSetHeight(root, 100); YGNodeRef child YGNodeNew(); YGNodeStyleSetWidth(child, 50); YGNodeStyleSetHeight(child, 50); YGNodeInsertChild(root, child, 0); YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); printf(child layout: left%.1f top%.1f width%.1f height%.1f\n, YGNodeLayoutGetLeft(child), YGNodeLayoutGetTop(child), YGNodeLayoutGetWidth(child), YGNodeLayoutGetHeight(child)); return 0; }输出结果是非常直觉的child layout: left0.0 top0.0 width50.0 height50.0这一步跑通以后你就对“布局树 计算 取坐标”的流程有了完整感知。真正的核心不在 API而在于你理解到Caveman/Yoga 只负责计算渲染和交互都是你自己的事。3.2 flexbox 三大金刚flexGrow、flexShrink、flexBasis实际做布局的时候90% 的动态尺寸问题都能用 flexGrow、flexShrink、flexBasis 这三个属性解决但很多人对它们的优先级理解是模糊的。这里我用自己的话梳理一遍。flexBasis 是“初始主轴尺寸”。它可以是固定像素也可以是 auto。如果是 auto那就用节点自身的 width/height 属性作为初始尺寸。flexGrow 解决的是“空间有富余时怎么分”。所有子节点的 flexBasis 加起来如果小于父容器主轴尺寸多出来的空间就按 flexGrow 的比例分配。flexShrink 解决的是“空间不够时怎么缩”。所有子节点的 flexBasis 加起来如果大于父容器主轴尺寸缺少的空间就按 flexShrink 的比例压缩。举个例子一个 300px 宽的容器里放三个子节点flexBasis 分别是 100、100、100flexGrow 是 0、1、2。三个初始尺寸一共 300刚好填满容器flexGrow 不生效。如果容器宽度改成 360多出 60px那么第二个子节点分到 60 * 1/3 20第三个分到 60 * 2/3 40最终宽度是 120、140。这个规则用一次计算就能完成没有任何迭代。我经常把 flexGrow/flexShrink 理解成“按权重瓜分盈余/缺口”而不是“等比例缩放”。这两个概念在维护复杂页面时区分得越清楚越不容易写出让人困惑的布局。3.3 常见布局模式的落地滚动容器、两栏自适应、动态高度先说滚动容器。在 Caveman/Yoga 里滚动容器本身不需要有固定高度。你只需要在容器节点上设置固定宽度高度设为 undefined然后让子节点自由决定高度。父节点的 layout 高度会沿着子节点的内容高度自动撑开。YGNodeStyleSetWidth(scrollContainer, 300); YGNodeStyleSetHeight(scrollContainer, YGUndefined); for (auto item : items) { YGNodeRef child wrap(item); YGNodeStyleSetWidth(child, 300); YGNodeInsertChild(scrollContainer, child, index); } YGNodeCalculateLayout(scrollContainer, 300, YGUndefined, YGDirectionLTR);计算完成后 scrollContainer 的 layout height 就是所有子节点高度的总和你用这个高度驱动 UIScrollView 的 contentSize 即可。注意第二个参数传入 YGUndefined这会强制引擎把高度从“固定值”切到“内容自适应模式”。两栏自适应是另一个高频场景。左栏固定 80pt右栏吃掉剩余宽度。做法是左栏 flexBasis 80、flexGrow 0、flexShrink 0右栏 flexGrow 1。这样左栏不管容器宽多少都固定在 80右栏自动适配。这个写法比 Auto Layout 里的“equal width 优先级”要直观得多而且计算成本低一个数量级。动态高度就更典型了。一个介绍卡片标题行数不固定、摘要行数不固定整体高度必须随内容变化。在 Yoga 里你需要给文本节点设置一个 measure 函数告诉引擎“我有多宽多高”。引擎在 measure 阶段会回调你的函数根据传入的可用宽度计算出文本实际需要的高度。YGNodeRef textNode YGNodeNew(); YGNodeSetMeasureFunc(textNode, measureText);measure 函数里你根据文本内容、字体、可用宽度算出精确的 boundingRect然后通过 YGSizeMake(width, height) 返回。这样父节点就能根据文本的真实高度去排后续布局而不是靠预估行数打补丁。实际项目里这个机制是撑起“图文混排流”的关键。4. 踩坑实录从迁移到线上问题排查4.1 痛点一minHeight 和 maxHeight 的交互陷阱Caveman/Yoga 在实现上对 min-height、max-height 的处理和 Web 端浏览器并不完全一致。最容易踩的坑是一个子节点设置了 flexGrow 为 1同时又设置了 minHeight当父容器空间充足时子节点会按 flexGrow 撑满剩余空间当空间不足时minHeight 会强制它保持最小尺寸。但如果你同时设置了 maxHeight 且小于 minHeight那行为就是未定义的——不同端可能给出不同结果。我的经验是minHeight 和 maxHeight 尽量不要和 flexGrow/flexShrink 混用。如果一定要设置就在同一个节点上只保留一个约束。比如卡片底部的操作栏我固定用 minHeight 保证按钮可点击区域不小于 44pt但不给它 flexGrow这样不管上面内容多高操作栏都不会被压缩到不可用同时也不会因为 flexGrow 去抢空间。4.2 痛点二measure 函数被频繁调用导致性能劣化Yoga 的 measure 机制虽然强大但如果你在 measure 函数里做了昂贵操作性能会直接崩掉。我碰到过一个案例团队在 measure 函数里调用 UIFont preferredFontForTextStyle 动态字体每次 measure 都触发一次系统查询和字体排版计算结果一个 cell 的布局耗时从 0.3ms 涨到了 3ms列表滚动立刻掉帧。解决方法是引入两级缓存。第一级缓存根据文本内容 hash、宽度、字体属性缓存 boundingRect 计算结果第二级缓存针对 Yoga 节点自身如果节点没有标脏就直接返回缓存布局。实际优化后measure 函数 90% 的调用都命中了第一级缓存布局耗时降回 0.4ms。4.3 痛点三脏标记传播不完整布局结果过期这是一个非常隐蔽的问题。Yoga 的 dirty 标记是自动向上传播的但很多人会漏调用YGNodeMarkDirty。当你直接修改节点的 style 属性时Yoga 会帮你标记但如果你通过 measure 函数里的外部变量改变内容比如图片下载完成后更新了图片尺寸引擎并不知道要重新 measure你必须手动调用YGNodeMarkDirty。漏掉这个调用表现通常是第一次布局正确更新数据后 layout 没变化界面出现“旧内容新尺寸”的错乱。排查方法是在每次 setState 更新时统一对所有持有 measure 函数的节点调用YGNodeMarkDirty再触发重新 calculateLayout。这个动作看起来很粗暴但配合缓存机制实际成本很低却能从根上避免过期布局。4.4 常见问题速查表问题现象可能原因排查思路子节点宽度异常被压缩成 0flexShrink 默认值为 1空间不足时会压缩检查 flexShrink、flexBasis必要时设 flexGrow文本高度计算不准内容被裁切measure 函数返回高度不够检查 measure 函数中换行规则、字体行高Android 和 iOS 布局结果不一致两端版本不同或 measure 实现有差异统一 JDK/NDK 版本用 Yoga 自带的布局测试套件做快照对比更新数据后布局不刷新忘记调用 YGNodeMarkDirty数据更新后强制标脏根节点并重新布局列表滚动卡顿CPU 飙高measure 函数做重量级操作加缓存、控制 measure 频率、把布局切到后台线程动态字体变化后布局没适配系统字体变化没有触发 new measure注册 UIContentSizeCategoryDidChangeNotification 后标脏重建这些坑不是 API 层面能完全避免的更多是工程组织层面的问题。我的建议是把布局计算收敛到一个专门的模块中所有更新入口都经过同一个“markDirty relayout”通道不要散落在业务代码里。这样排查问题时你只需要怀疑一个地方。4.5 用 Snapshot 测试守住布局变更的底线最后分享一个我坚持了很久的实践布局快照测试。Yoga 的纯计算特性非常适合做输入输出快照。你可以构造各种排列组合的节点树跑完 calculateLayout 后把所有节点的 frame 输出成 JSON 或文本作为基线文件。任何代码改动后跑一次比对看 diff 是否合理。这套做法在 Caveman 时代支撑了 Facebook 的 UI 自动化测试在 Yoga 时代依然是社区的主流实践。即使项目里没有 CI本地每次改布局代码后跑一遍快照也能避免“改了一个间距结果按钮飞了”的事故。我在实际项目里的体会是Caveman 这类布局引擎最大的价值不是替你把复杂布局变简单而是把“不可控的优化”变成“可控的计算”。只要你的 UI 层级能组织成一棵树它就能给你一份可以缓存、可以测试、可以跨端复用的布局结果。如果你现在的项目正在被 Auto Layout 卡顿困扰或者你需要在多个端之间保持 UI 一致不妨从一个小页面开始尝试这种“树状布局 自绘坐标”的玩法。最后再分享一个小技巧在接入初期保留一套旧布局作为 A/B 对比用线上 App 的页面深度和卡顿率做指标这样你能用数据说服团队里每一个怀疑的人。