接手一个三年没动过的老页面打开文件第一眼是四屏长的 frame 计算横屏一套、竖屏一套、字号变了再补一套改一个内边距要顺着手动把所有origin.y往上挪。这几年我反复在两类工程之间来回切换一类是纯手算坐标的老代码另一类是用 Masonry 这类自动布局框架写的新页面体感差异非常直接。Masonry 在 iOS 生态里属于那种用顺了就回不去的布局工具它把冗长到几乎无法阅读的NSLayoutConstraint调用压缩成一条条能一眼看懂的链式表达式。这篇内容适合三类人正在维护 Objective-C 老页面、被原生约束 API 折磨的开发者想搞清楚make、update、remake到底差在哪里的同学以及被Unable to simultaneously satisfy constraints日志反复打断节奏、想彻底弄明白约束系统运行逻辑的人。下面我把这些年积累的用法、踩过的坑和排查链路完整讲一遍能直接抄的部分我会给出代码。1. Masonry 解决的究竟是什么问题把三种写法摆在一起看1.1 同一个卡片头部三代写法的真实对照先不聊原理把一段最常见的布局摆出来左边一个 40×40 的头像右边标题标题距头像 12pt右边距容器 16pt整体垂直居中。用早期手算 frame 的写法你得在layoutSubviews里写CGRectMake还要考虑父视图宽度变化时的重算时机一旦字号或内边距调整整段坐标全部重来。用原生NSLayoutConstraint的写法光是给这四个元素建立约束就需要构造四条constraintWithItem:attribute:relatedBy:toItem:attribute:multiplier:constant:每条参数六七个写完自己都不想读第二遍。Masonry 的写法是这样的[self.avatarView mas_makeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.contentView).offset(16); make.centerY.equalTo(self.contentView); make.width.height.mas_equalTo(40); }]; [self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.avatarView.mas_right).offset(12); make.right.equalTo(self.contentView).offset(-16); make.centerY.equalTo(self.avatarView); }];读一遍就知道在说什么这是 Masonry 最核心的价值它没有发明新能力它只是把约束的表达成本降到了人能接受的量级。这一点很关键很多同学误以为引入框架就获得了某种更强大的布局引擎实际上底层生成的仍然是一条条NSLayoutConstraint运行时开销和手写原生约束完全一致。理解这一点后面讨论性能、讨论冲突排查时就不会跑偏。1.2 语法糖下面包着的东西Maker、Constraint、ViewConstraint 三层顺着调用链往下看一层。mas_makeConstraints:接收一个 block参数类型是MASConstraintMaker *这个 maker 对象在 block 执行期间收集你写的每一条约束描述block 结束后同步调用内部的install把收集到的东西真正转换成NSLayoutConstraint并挂到视图上。make.left返回的是一个MASConstraint实例这个实例持有我要约束的是哪个视图的哪个属性这层信息接着.equalTo(...)补上参照物.offset(...)、.multipliedBy(...)、.priority(...)依次补上参数最后这条描述被塞进 maker 的数组里等着统一安装。搞清楚这三层之后很多以前觉得玄乎的行为就顺了。比如为什么mas_makeConstraints调完之后视图的translatesAutoresizingMaskIntoConstraints自动变成NO——因为 maker 在安装阶段顺手帮你改了防止自动转换出来的约束和你手写的打架。再比如为什么有些写法会编译不过equalTo后面的参数在 Masonry 里被重载成了接受id类型传基本类型要么用mas_equalTo让它帮你装箱要么自己包成(值)这是equalTo和mas_equalTo最本质的区别不是什么推荐用哪个的风格问题。1.3 它不是银弹哪些场景我会主动避开 Masonry用了这么多年我并不会在所有地方都上 Masonry。有三类场景我会绕开它。第一类是高频刷新的列表单元如果一个 cell 在滚动过程中要反复重建约束那不如在初始化时把约束建好、之后只改constant甚至退回去用 frame 更省事。第二类是纯绘制型视图比如自定义图表、画笔轨迹这些本来就该在drawRect:里算坐标套约束反而多一层求解成本。第三类是强动画场景里的逐帧位移约束动画的插值是整体求解做几十个元素的错峰位移不如直接改transform来得干脆。判断标准其实很简单布局关系是描述性的还是过程性的。描述性的——元素之间的相对关系稳定只是尺寸和位置随内容变化——用 Masonry 收益最大。过程性的——位置每帧都要根据外部状态重新推演——老老实实写坐标。这个边界想清楚了就不会出现引入了框架反而更难维护的抱怨。2. 约束表达式的数学本质读懂它才不会被日志教做人2.1 一条约束就是一个线性方程不管用哪种写法落到系统里都长成同一个样子item1.attribute1 item2.attribute2 × multiplier constantmultiplier默认是 1constant默认是 0relatedBy默认是等于。你写的make.width.equalTo(self.contentView).multipliedBy(0.5).offset(-8)翻译过来就是我的宽度等于父视图宽度的一半再减 8。注意这个方程是双向的约束不是在设置尺寸而是在声明关系系统拿到一堆方程后统一求解最终解出来的值才写回 frame。这个视角很重要它解释了一个新手最常问的问题——为什么我明明给某个 label 设了宽度约束它还是被压扁了因为可能存在另一条优先级更高的方程在同时生效系统只能打破其中一条。relatedBy除了等于还有大于等于和小于等于对应 Masonry 里的greaterThanOrEqualTo和lessThanOrEqualTo。这三个关系配上优先级构成了整个约束系统的表达能力。我见过不少同学只会用等于遇到至少留 8pt 间距但不能超过容器这种需求就卡住其实一行make.width.lessThanOrEqualTo(self.contentView).offset(-16)就解决了。2.2 优先级约束系统内部的服从顺序每条约束的优先级取值 1 到 10001000 是必需级别required低于 1000 的都是可选级别。系统求解时优先满足高优先级实在满足不了就打破低优先级的同时在控制台打印那段熟悉的日志。Masonry 里可以用.priority(750)传具体数值也可以用.priorityHigh()、.priorityMedium()、.priorityLow()三个语义化快捷方法。和优先级配套的还有两个容易被忽略的属性contentHuggingPriority抗拉伸和contentCompressionResistancePriority抗压缩。它们不是约束而是视图基于自身内容尺寸intrinsicContentSize向系统表达我希望多大的倾向。举个我踩过的例子一行里放两个 label左边内容短、右边内容长结果左边的被拉得很宽右边的被压成了省略号。原因就是左边 label 的抗拉伸优先级默认 250右边的抗压缩优先级默认 750两者冲突时系统选择了拉伸左边。解决办法是把左边 label 的抗拉伸优先级提到比右边高[self.shortLabel setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];提示横向排列的多个文本元素打架时先别急着加约束先检查这两个优先级。约束数量越少后期排查越轻松。2.3 从 setNeedsLayout 到 layoutSubviews约束什么时候真正生效很多人对约束的生效时机没有清晰概念导致在错误的生命周期里读 frame读到一堆 0。完整链路是这样的你添加或修改约束后视图被标记为需要重新布局setNeedsLayout当前 runloop 结束前系统调用layoutSubviews在这一步把约束解算成具体的 frame 写回视图然后把结果传递给子视图。这条链路带来两个实操上的结论。第一个改完约束立刻读 frame 是读不到新值的中间隔了一次布局传递必须手动触发[self.view layoutIfNeeded]才能立即生效。第二个在layoutSubviews里改约束要非常小心因为改约束又会触发新的布局处理不当会陷入循环。我一般的做法是把约束的创建放在init或didMoveToSuperview这类只执行一次的位置把约束参数的调整放在明确的业务回调里绝不放进布局回调。另外约束的解算是从根视图向下推进的父视图的尺寸确定之后子视图才有解算依据。所以如果一个视图的约束依赖于自身内容尺寸而内容尺寸又是异步拿到的就必须在内容就绪后显式标记布局脏了否则界面上看到的还是旧布局。3. make、update、remake 三个方法的真实差别3.1 mas_makeConstraints只负责新增从不管旧的这个方法的行为极其简单把 block 里写的约束全部转成NSLayoutConstraint挂上去不检查是否已经存在同类约束。所以第一次写没问题第二次对同一个视图再调一次屏幕上就会出现两套约束同时生效能解出来还好解不出来就是一段冲突日志。我见过一种很典型的误用在 cell 的configWithModel:里调mas_makeConstraints因为作者觉得每次数据变了重新布局一下。滚动几屏之后这个 cell 上挂了十几套约束冲突日志刷满控制台滑动也跟着掉帧。这类问题的修复方式不是加判断而是明确区分布局关系的建立属于初始化参数调整属于业务更新两者不该混在同一个方法里。3.2 mas_updateConstraints 的匹配规则决定了它的适用边界mas_updateConstraints会遍历已有约束找出与本条描述同类的那一条把参数改掉找不到同类的就新增一条。所谓同类判断依据是三个要素约束的第一个属性、约束关系等于/大于/小于、参照对象。这个规则带来的结果是它可以改constant、改multiplier也可以把参照对象从同一个视图的某个属性换成另一个属性但它改不掉约束所针对的那个属性本身。比如你原来约束的是top后来想改成约束bottomupdate匹配不上会直接给你新增一条bottom约束原来的top还在。// 初次建立 [self.panel mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self.contentView); make.top.equalTo(self.headerView.mas_bottom); make.height.mas_equalTo(120); }]; // 后续只改高度参照对象和属性都没变匹配得上 [self.panel mas_updateConstraints:^(MASConstraintMaker *make) { make.height.mas_equalTo(240); }];上面这段是update的正确用法。凡是改动涉及换属性或换参照视图我的建议一律切到remake。3.3 mas_remakeConstraints 与约束对象的显式持有mas_remakeConstraints做的事是先移除该视图上的所有约束再按 block 重新建一套。它是三种方法里最重的但语义最清晰适合那种布局形态会整体切换的场景比如横竖屏切换、折叠展开、编辑态与浏览态互换。不过重建约束也是有代价的旧的NSLayoutConstraint对象被销毁、新的被创建并安装这个过程中系统要重新解算整棵子树。如果一个页面上有几十个视图同时remake那一帧的耗时是可以测出来的。我通常更倾向于第三条路——把关键的约束存成属性需要时直接改参数甚至uninstall掉再装回去property (nonatomic, strong) MASConstraint *panelHeightConstraint; // 建立时保存引用 [self.panel mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self.contentView); make.top.equalTo(self.headerView.mas_bottom); self.panelHeightConstraint make.height.mas_equalTo(120); }]; // 之后直接改不经过 maker self.panelHeightConstraint.offset(240); // 注意这里是替换而非累加 [self.view setNeedsLayout];需要说清楚一点offset()在约束对象上的行为是设置绝对值不是累加所以连续调两次不会变成 360。另外这个做法的前提是你确实持有了一条有效安装的约束如果中途remake过旧的引用就失效了再改它不会有任何效果——这是个很隐蔽的问题我在项目里遇到过两次最后都是靠加断言把它堵住的。关于循环引用顺便澄清一个常见误解mas_makeConstraints的 block 是同步执行的执行完就释放maker 不会长期持有 block所以 block 里直接写self并不会造成循环引用。真正需要留意的是你主动把约束对象存成strong属性时约束对象和视图之间的持有关系以及uninstall之后引用变成悬空对象的情况。4. 约束冲突排查一条完整的定位链路4.1 先读日志里被打破的那一条控制台给出的冲突日志info量很大但结构是固定的。开头那句Unable to simultaneously satisfy constraints后面会列出所有参与冲突的约束每条形如NSLayoutConstraint:0x... UILabel:0x...height 20 (active)最后一行会明确告诉你Will attempt to recover by breaking constraint以及被打破的是哪一条。先看被打破的那条这是最短路径。因为它代表了系统的取舍结果往往和你的预期相反。如果被打破的是一条你自己写的、优先级是 required 的约束说明存在另一组必需约束把它挤掉了这时候再往上翻找到同属 required 级别的那几条看它们之间是不是真的无法同时成立。我处理过的案例里超过一半是两个方向相反的等值约束都设了 required比如同时约束了height 40和top a.bottom 8加bottom b.top - 8而 a、b 之间的距离是 30那 40 这个高度就必然要被打破。4.2 符号断点与视图层级把日志和界面上的元素对上日志里全是内存地址光看日志很难知道是界面上的哪个控件。两个手段配合使用最有效。第一个是符号断点。在 Xcode 的断点面板添加一个 Symbolic Breakpoint符号填UIViewAlertForUnsatisfiableConstraints在 Action 里加一条调试命令打印参数命中时就能在冲突发生的那一刻停下来此时用po查看当前的视图层级和约束列表比事后翻日志准得多。这个断点的好处是能拿到现场还能顺着调用栈看出来是哪段代码触发了这次布局。第二个是给视图打标记。Masonry 生成的约束本身没有标识但你可以通过视图的accessibilityIdentifier或者一个带业务语义的 tag 来辅助定位然后在 lldb 里对着日志中的地址执行对应的查询。更狠一点的做法是给视图分类重写描述方法把业务标识拼进去这样日志里的UILabel:0x7f8...就会变成你自己看得懂的名字。这个技巧在约束数量上百的复杂页面上收益极大虽然有点侵入性但只在 Debug 配置下启用就没有副作用。4.3 几类高频冲突的成因与处理方式冲突表现常见成因处理方式高度约束被打破上下两条边界约束固定了高度又额外加了高度约束去掉多余的高度约束或把高度降到低优先级宽度被压成 0横向排列的空间不足抗压缩优先级低于其他元素调整 compression resistance或用lessThanOrEqualTo留出弹性多行文本被截断缺少明确的宽度约束intrinsicContentSize算不出来给文本视图补上左右边界约束滚动视图内容高度为 0内部子视图没有形成从顶到底的完整链条补齐首尾约束让内容高度可推导动画结束后位置回弹动画改的是 frame 而不是约束下一次布局把值覆盖了在动画块中改约束并调用layoutIfNeeded这张表是我这些年出现频率最高的五类。特别说一下表格最后一行这是新手最容易困惑的现象动画做完了视图啪地跳回原位。根本原因是约束系统在下一次布局时会重新解算把你手动设置的 frame 覆盖掉。正确做法永远是改约束、再在动画块里触发一次布局传递让系统自己把中间值插出来。5. 三类高频布局场景的落地模板5.1 自适应高度的多行文本块多行 label 的高度自适应是个老生常谈的话题但真正写对的人不多。核心要求只有一条让宽度先确定下来高度才能推导出来。也就是说左右边界约束必须存在高度约束必须不存在。[self.descLabel mas_makeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.contentView).offset(16); make.right.equalTo(self.contentView).offset(-16); make.top.equalTo(self.titleLabel.mas_bottom).offset(8); make.bottom.equalTo(self.contentView).offset(-16); // 撑开容器 }];配合self.descLabel.numberOfLines 0这一套就足够让容器跟着文本长度走。有一个历史遗留问题值得提一句在很老的系统版本上多行 label 需要额外设置preferredMaxLayoutWidth才能正确计算换行后的高度现代版本基本不需要了但如果你维护的代码里有这行不要随手删先确认目标系统版本。在列表场景里还有一个细节cell 的自适应高度依赖内部约束链完整闭合如果某个分支上的视图没有接到这条链上cell 的高度就会算歪。我的习惯是画完布局后检查一遍从最顶部的元素到最底部的元素是不是有一条连续不断的约束路径。5.2 等比与固定比例的容器比例布局是 Masonry 的高光场景一行multipliedBy解决。常见的几种写法// 宽高比 16:9 [self.bannerView mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self.contentView); make.top.equalTo(self.navBar.mas_bottom); make.height.equalTo(self.bannerView.mas_width).multipliedBy(9.0 / 16.0); }]; // 三等分 NSMutableArray *items [NSMutableArray array]; for (NSInteger i 0; i 3; i) { UIView *item [UIView new]; [self.contentView addSubview:item]; [item mas_makeConstraints:^(MASConstraintMaker *make) { make.top.bottom.equalTo(self.contentView); make.width.equalTo(self.contentView).multipliedBy(1.0 / 3.0); if (i 0) { make.left.equalTo(self.contentView); } else { make.left.equalTo(items[i - 1].mas_right); } }]; [items addObject:item]; }这里有个容易出错的点用multipliedBy(1.0 / 3.0)做三等分浮点误差会累积最右边那一块和容器右边界之间可能差半个像素。视觉上看不出来但如果这三个块各自还有描边缝隙就会暴露。稳妥的做法是让最后一块的右边界对齐容器宽度任由系统去解。5.3 约束驱动的动画动画的正确流程是三步先把当前布局强制生效再改约束最后在动画块里再触发一次布局。// 第一步让初始约束落地 [self.view layoutIfNeeded]; // 第二步改约束 [self.panelHeightConstraint setOffset:240]; // 第三步在动画块里触发插值 [UIView animateWithDuration:0.28 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{ [self.view layoutIfNeeded]; } completion:nil];第一步如果省掉会怎样初始约束还没解算视图的 frame 还是零动画的起点就不对看起来像是从左上角飞过来。这个坑我踩过不止一次后来养成了固定习惯只要是约束动画前面一定先来一次layoutIfNeeded。另外动画过程中如果涉及多个约束同时变化注意它们的优先级要保持一致否则会出现某一条先到位、另一条还在路上的割裂感。真遇到这种情况把动画拆成两段或者用UIViewPropertyAnimator做更细粒度的控制。6. 选型对照Masonry、SnapKit、原生 Anchor 与那个同名的瀑布流库6.1 三套写法在同一段布局上的差异如果项目是纯 Objective-CMasonry 基本是默认选项如果是 Swift 项目同作者维护的 SnapKit 语法几乎一模一样只是把方法名里的mas_前缀去掉了迁移成本极低。原生 Anchor 是系统自带的方案NSLayoutAnchor提供了一套类型安全的链式 API写起来比原始的constraintWithItem舒服很多但相比 Masonry 还是啰嗦。维度MasonrySnapKit原生 Anchor语言Objective-CSwift两者皆可语法长度短短中等类型安全弱大量 id中强更新约束update/remake 语义明确同左需自行管理引用依赖成本引入第三方库引入第三方库零调试友好度中中高约束可设 identifier原生 Anchor 有一个被低估的优势可以给约束设identifier冲突日志里直接显示你起的名字排查效率完全不同。如果你的项目约束冲突频发、又不想引入第三方库Anchor 是值得考虑的。反过来如果团队里 Objective-C 代码占比高、页面结构复杂Masonry 的开发效率优势是实打实的尤其是在参照对象多、需要频繁调整优先级的地方。我的实际选择习惯是新起的 Swift 项目用 SnapKit维护中的 Objective-C 项目保持 Masonry 不动只有在个别对启动性能敏感、约束数量极少的模块里才退回原生写法。6.2 老项目迁移的现实路径有人问过要不要把 Masonry 全量换成原生 Anchor。我的答案是除非有明确的技术债偿还计划否则不要为了换而换。这两者的底层产物是同一类对象运行时表现没有本质差异迁移的收益主要在少一个依赖成本却是把每个页面的布局代码重写一遍还得重新回归测试。如果一定要迁我建议的路径是分三步走。第一步冻结新增新页面一律用目标方案老页面不动。第二步从约束最少的简单页面开始替换每次只改一个页面改完立刻回归。第三步处理那些改不动的复杂页面把它们单独列出来评估很多时候你会发现这些页面本身就该重构迁移反而成了重构的契机。6.3 别搜错文档Masonry 瀑布流布局是另一个东西最后提一个我遇到过好几次的尴尬搜 Masonry 布局 的时候出来的结果一半是 iOS 的约束框架另一半是前端那个做瀑布流排版的同名库。这两个东西除了名字一样没有任何关系。瀑布流那个 Masonry 负责把不等高的卡片按列堆叠常用于图片墙、商品列表这类场景iOS 这个 Masonry 负责的是约束求解。找文档的时候带上语言关键词能省掉大量翻错页的时间。如果你确实在做瀑布流这种不规则排列iOS 端的常规做法是用UICollectionView配自定义UICollectionViewLayout手动计算每个 item 的 frame这种场景反而不适合套约束因为每个 item 的尺寸都由布局对象统一算好了再套一层约束纯属浪费。这也是我在第 1 节说的那条边界——描述性的关系用约束过程性的计算写坐标。我自己在实际项目里的体会是Masonry 这类框架真正的价值不在于少写几行代码而在于它逼着你把布局当成一种关系去描述而不是一串坐标去维护。当你习惯了用约束表达意图之后回过头看那些靠累加origin.y堆出来的老代码会发现它们的维护成本高得离谱而且每次改动都像是在拆炸弹。