一探 mas_updateConstraints 究竟

原文 : 与佳期的个人博客(gonghonglou.com)

Masonry 的链式编程对 iOS UI 添加约束简直好用的不得了,想必在使用上大家也都早已烂熟于心。只是对我来讲很早之前就有个更新约束的问题要好好搞搞清楚,就是题目里的 mas_updateConstraints: 方法。在 View 初始化时会添加一系列约束控制布局,而随时更改约束来移动位置也是日常需求,但其中关于这个方法的某些设计并不是直观想象的那样,现在,终于有时间写篇博客一探究竟。

当前环境:Xcode 10.1、Simulator iPhone XS 12.1、Masonry 1.1.0

探索

首先来看一段布局代码:topView 和 bottomView 上下排列、左右对齐,topView 和 rightView 左右排列、上下对齐。

[self.topView mas_makeConstraints:^(MASConstraintMaker *make) {
   make.centerY.equalTo(self.view);
   make.left.equalTo(self.view).offset(50);
   make.size.mas_equalTo(CGSizeMake(100, 30));
}];
[self.bottomView mas_makeConstraints:^(MASConstraintMaker *make) {
   make.top.equalTo(self.topView.mas_bottom).offset(50);
   make.left.equalTo(self.topView);
   make.size.mas_equalTo(self.topView);
}];
[self.rightView mas_makeConstraints:^(MASConstraintMaker *make) {
   make.top.equalTo(self.topView);
   make.right.equalTo(self.view).offset(-50);
   make.size.mas_equalTo(self.topView);
}];

从12行可以看出 rightView 的 top 依赖于 topView,现在有个需求是将 rightView 与 bottomView 顶部对齐。如图:


updateConstraints.png

记得我第一次遇到这种问题的时候想当然的将 rightView 的 top 依赖于 bottomView 了:

[self.rightView mas_updateConstraints:^(MASConstraintMaker *make) {
    make.top.equalTo(self.bottomView);
}];

结果是报错、崩溃还是页面错落我倒是忘记了,总之是不能满足需求。当前在 Xcode 10.1、Simulator iPhone XS 12.1 环境下我试了下是页面错落。所以我将代码改成了这样:

[self.rightView mas_updateConstraints:^(MASConstraintMaker *make) {
   make.top.equalTo(self.topView).offset(80);
}];

这里当然可以使用 mas_remakeConstraints: 方法,只不过该方法的意思是清空所有约束重新添加(下文会有源码体现),相比 mas_updateConstraints: 性能低了不少,况且项目开发中往往全部约束散落在各处,难免会有遗漏或者约束错乱的情况。所以我的做法是在 mas_updateConstraints: 方法里不更改约束依赖的对象,而通过计算出一个合适的偏移量来更改 offset 值。所以得出了一个结论:

Masonry 的 mas_updateConstraints: 方法不能更改约束依赖的对象,可以通过计算偏移量来更新布局。

但这究竟是为什么呢?让我们来看一下 Masonry 的 mas_updateConstraints: 方法都做了些什么工作:

- (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *))block {
    self.translatesAutoresizingMaskIntoConstraints = NO;
    MASConstraintMaker *constraintMaker = [[MASConstraintMaker alloc] initWithView:self];
    constraintMaker.updateExisting = YES;
    block(constraintMaker);
    return [constraintMaker install];
}

constraintMaker 的 updateExisting 属性设置为 YES 之后执行了 install 方法:

- (NSArray *)install {
    if (self.removeExisting) {
        NSArray *installedConstraints = [MASViewConstraint installedConstraintsForView:self.view];
        for (MASConstraint *constraint in installedConstraints) {
            [constraint uninstall];
        }
    }
    NSArray *constraints = self.constraints.copy;
    for (MASConstraint *constraint in constraints) {
        constraint.updateExisting = self.updateExisting;
        [constraint install];
    }
    [self.constraints removeAllObjects];
    return constraints;
}

这段代码 mas_remakeConstraints: 方法也会调用,只不过是将 removeExisting 属性设为 YES,第2行的 if 判断正是上文提到的清空所有约束。后半段代码则是调用 install 方法更新约束,该方法则是调用自 MASConstraint 的子类:MASViewConstraint。下边是关键实现代码

    MASLayoutConstraint *existingConstraint = nil;
    if (self.updateExisting) {
        existingConstraint = [self layoutConstraintSimilarTo:layoutConstraint];
    }
    if (existingConstraint) {
        // just update the constant
        existingConstraint.constant = layoutConstraint.constant;
        self.layoutConstraint = existingConstraint;
    } else {
        [self.installedView addConstraint:layoutConstraint];
        self.layoutConstraint = layoutConstraint;
        [firstLayoutItem.mas_installedConstraints addObject:self];
    }
}

- (MASLayoutConstraint *)layoutConstraintSimilarTo:(MASLayoutConstraint *)layoutConstraint {
    // check if any constraints are the same apart from the only mutable property constant

    // go through constraints in reverse as we do not want to match auto-resizing or interface builder constraints
    // and they are likely to be added first.
    for (NSLayoutConstraint *existingConstraint in self.installedView.constraints.reverseObjectEnumerator) {
        if (![existingConstraint isKindOfClass:MASLayoutConstraint.class]) continue;
        if (existingConstraint.firstItem != layoutConstraint.firstItem) continue;
        if (existingConstraint.secondItem != layoutConstraint.secondItem) continue;
        if (existingConstraint.firstAttribute != layoutConstraint.firstAttribute) continue;
        if (existingConstraint.secondAttribute != layoutConstraint.secondAttribute) continue;
        if (existingConstraint.relation != layoutConstraint.relation) continue;
        if (existingConstraint.multiplier != layoutConstraint.multiplier) continue;
        if (existingConstraint.priority != layoutConstraint.priority) continue;

        return (id)existingConstraint;
    }
    return nil;
}

逻辑很简单,根据 updateExisting,也就是之前的标识更新布局的赋值来寻找已存在的约束,如果约束存在则执行更新操作,如果约束不存在则当成一条新的约束添加给 View。

关键就在于 layoutConstraintSimilarTo: 查找约束时的判断方法,22~29行,竟然是根据这些条件来判断,这就是为什么在 mas_updateConstraints: 方法里更改约束对象后造成页面错乱的原因,因为它找不到这条约束就把它当成一条新的约束添加到 View 上,导致约束冲突。

从这一点出发的话那我们首先想到的解决这个问题的方案就不再是更改偏移量了,而是通过设置约束的优先级来解决约束冲突的问题。

而且,如果遇到 View 出现重复约束时,比如:

make.top.equalTo(self.topView).offset(10);
make.top.equalTo(self.topView).offset(20);

仅仅通过在 mas_updateConstraints: 方法里更改某条约束的偏移量并不能起到精确控制的作用。所以这种情况下设置优先级也许是个更好的解决方案。关于设置优先级值得注意的有:

  • 不特殊设置时约束的优先级默认是 UILayoutPriorityRequired,也就是最高的
  • 优先级的设值范围在 0~1000 之间,超出这个范围则会崩溃:

2019-01-18 13:00:09.449167+0800 MasonryTest[54681:15704474] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'It's illegal to set priority:1200. Priorities must be greater than 0 and less or equal to NSLayoutPriorityRequired, which is 1000.000000.'

所以我们应该将想要更新的约束提前设置一个较低的优先级,再在 mas_updateConstraints: 方法里更新约束并对新的约束设置一个高于原来约束的优先级,且低于 1000。例如:

[self.rightView mas_makeConstraints:^(MASConstraintMaker *make) {
   make.top.equalTo(self.topView).priorityLow();
}];
    
[self.rightView mas_updateConstraints:^(MASConstraintMaker *make) {
   make.top.equalTo(self.bottomView).priorityHigh();
   // or
   // make.top.equalTo(self.bottomView).priority(800);
}];

最后,认识几种优先级类型:

// 1000
static const MASLayoutPriority MASLayoutPriorityRequired = UILayoutPriorityRequired;
// 750
static const MASLayoutPriority MASLayoutPriorityDefaultHigh = UILayoutPriorityDefaultHigh;
// 500
static const MASLayoutPriority MASLayoutPriorityDefaultMedium = 500;
// 250
static const MASLayoutPriority MASLayoutPriorityDefaultLow = UILayoutPriorityDefaultLow;
// 50
static const MASLayoutPriority MASLayoutPriorityFittingSizeLevel = UILayoutPriorityFittingSizeLevel;

后记

推荐阅读更多精彩内容