阿里、字节:一套高效的iOS面试题(九 - 视图&图像相关 - 上)

3,714 阅读53分钟

视图 & 图像相关

撸面试题中,文中内容基本上都是搬运自大佬博客及自我理解,可能有点乱,不喜勿喷!!!

原文题目来自:阿里、字节:一套高效的iOS面试题

1、Auto Layout的原理,性能如何

  • 本质

iOS 6,Apple 引入了布局算法 Cassowary 并实现了自己的布局引擎 Auto Layout

Cassowary 通过约束来描述视图之间的关系,因此,Auto Layout 不再关注 frame,而是关注视图之间的关系,我们只需要描述出表示视图间布局关系的约束集合。Auto Layout 不止包含了 Cassowary 算法,还包含了布局在运行时的生命周期等一整套引擎系统,用来统一管理布局的创建、更新和销毁。

这一整套布局引擎系统被称为 Layout Engine,它是 Auto Layout 的核心,主导整个界面布局。每个视图在得到自己的布局之前,Layout Engine 将约束通过计算转化为最终的 frame。每当约束改变, Layout Engine 都会重新计算,然后进入 Deferred Layout Pass 去更新 frame,完成之后再次进入监听状态。

  • 原理

view1.attribute1 = view2.attribute * multiplier + 8。约束就是一个方程式。开发者将视图之间的关系描述成一个约束集合,Layout Engine 通过优先级等将这些约束方程式求解,得出的结果就是每个视图的 frame,然后利用 layoutSubviews 一层一层布局。

  • 性能

iOS 12 之后不用担心性能问题。在之前主要是因为更新约束时创建新的 NSISEnginer (AutoLayout 的线性规划求解器)重新求解,嵌套视图在布局过程中需要更新约束,所以简直没眼看。。。查看 从 Auto Layout 的布局算法谈性能

2、UIView & CALayer的区别

  • 联系

每一个 view 中都有一个 layer,view 持有并管理这个 layer,且这个 view 是 layer 的代理。

  • 区别

UIView 负责响应事件,参与响应链,为 layer 提供内容。

CALayer 负责绘制内容,动画。

3、事件响应链

  • Hit-testing

Hit-testing 寻找 hit-test view,也就是触摸事件所在的 view。

发生触摸事件后,系统将事件放到一个由 UIApplicaiton 管理的队列中。UIAplication 将事件发送给 keyWindow,keyWindow 在视图树种寻找一个最合适的 view 来响应事件。

  1. view 自身是否能接受触摸事件;
  2. 触摸点是否在自己范围内;
  3. 从后往前遍历 subviews,重复执行 1 和 2;
  4. 如果没有符合条件的子视图,自己就是最适合的。

但是需要保证视图 isUserInteractionEnabled 为 YES,isHidden 为 NO,alpha 大于 0.01。

两个重要方法:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;   
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
  • 事件传递机制

通过 Hit-testing 找到了触摸 view 之后。调用这个 view 的 touches 方法来做具体处理:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;

如果这个 view 调用了 [super touches] 方法,则事件顺着响应者链向上传递(子视图向父视图)。

其顺序为 view -> superview -> viewController -> rootViewController -> window -> UIApplication。如果到最后的 UIApplication 都没有处理,则丢弃。

4、drawrect & layoutsubviews调用时机

  • drawrect

    1. initWithFrame:,但 frame 不能为 CGRectZero;

    2. 调用setNeedsDisplay or setNeedsDisplayInRect:,在下一次 drawing cycle 绘制;

    3. 当 contentMode 为 UIViewContentModeRedraw 时,修改 frame。

  • layoutSubviews

    1. initWithFrame:,但 frame 不能为 CGRectZero;

    2. setNeedsLayout 标记为需要布局,下次 layout cycle 调用;

    3. layoutIfNeeded 立马布局;

    4. 自身 frame 发生变化;

    5. 添加子视图、子视图 frame 发生变化;

    6. 视图被添加到 UIScrollView、滚动 UIScrollView;

    7. 旋转屏幕(使用自动布局);

  • updateConstraints

    1. initWithFrame:,但是需要重写属性requiresConstraintBasedLayout` 并返回 YES;

    2. setNeedsUpdateConstraint 标记为需要更新约束,下次 layout cycle 调用;

    3. setUpdateConstraintsIfNeeded,立马更新约束。

5、UI的刷新原理

App 在处理交互事件时,会创建新的 UIView,也会修改现有的 View,然后就会更新约束,调整布局,再渲染并显示

  1. setNeedsUpdateConstraints -> updateConstraints

  2. setNeedsLayout -> layoutSubviews

  3. UIView.setNeedsDisplay -> CALayer.setNeedsDisplay -> 等待下一绘制周期

  4. CALayer.display -> 系统绘制流程 / 异步绘制流程

    4.1 系统绘制路程: 创建 backing store + CGContextRef -> Layer.drawInContext: -> delegate.drawInContext -> UIViewAccessibility.drawLayer:inContext: -> UIView(CALayerDelegate).drawLayer:inContext: -> UIView.drawRect

    4.2 异步绘制流程: delegate.displayLayer: -> 其他线程,自己创建位图,自己绘图 -> 最后在主线程 setContents

6、隐式动画 & 显示动画区别

  • 隐式动画

修改独立 layer 的可动画属性时,会有一个从当前值到目标值的动画,这个就是隐式动画。隐式动画是由系统框架完成的。

  • 显示动画

创建 CAAnimation 对象再提交到 layer 执行的动画就是显式动画。

  • 区别

隐式动画一直存在,按需关闭;显示动画需要手动创建。

7、什么是离屏渲染

GPU 在当前屏幕缓冲区之外另开一片内存空间进行渲染操作。

GPU 在绘制时没有回头路,某一个 layer 在被绘制之后就已经与之前的若干层形成一个整体了,无法再次修改。

对于一些有特殊效果的 layer,GPU 无法通过单次遍历就能完成渲染,只能另外申请一片内存区域,借助这个临时区域来完成更加复杂、多次的修改与裁减操作。

  1. 需要创建新的渲染缓冲区,会存在不小的内存开销

  2. 且需要切换渲染上下文 Context Switch,会浪费很多事件。

8、imageName & imageWithContentsOfFile区别

imageNamed 会将使用过的图片缓存到内存中。即使生成的对象被 AutoReleasepool 释放了,这份缓存依然存在。imageNamed 会先尝试从缓存中读取,效率更高,但是会额外增加开销 CPU 的时间。

imageWithContentsOfFile 直接从文件中加载图片,图片不会缓存,加载速度较慢,但是不会浪费内存。

除非某个图片经常使用,否则使用 imageWithContentsOfFile 这种经济的方式。

9、多个相同的图片,会重复加载吗

视加载方式而定:

imageNamed: 会对加载的图片进行缓存。使用时先尝试从缓存加载。

imageWithContentsOfFile: 不会缓存,所以会重复加载。

10、图片是什么时候解码的,如何优化

图片显示流程

  1. 从磁盘拷贝数据到内核缓冲区(系统调用);

  2. 从内核缓冲区拷贝数据到用户控件(进程所在)

  3. 生成 UIImageView,把图像数据赋值给 UIImageView;

  4. 如图像未解码,解码为位图数据;

  5. CATransaction 捕获到 UIImageView 图层树的变化;

  6. 主线程 Runloop 在最后的 drawing cycle 提交 CATransaction

    • 如果数据没有字节对齐,Core Animation 会再拷贝一份数据进行字节对齐;
  7. GPU 处理位图数据,进行渲染。

也就是说,当图片需要显示时,才会被解码。

为什么要解码?

常用的图片格式 PNG、JPEG 等都是压缩格式,而屏幕显示的是位图 bitmap。

怎么解码?

Data Buffer :存储在内存中的原始数据。图像可以以不同格式存储,如 JPEG、PNG。Data Buffer 数据不能用来描述图像的位图像素信息。

Image Buffer :图像在内存中的存储方式,每一个元素描述一个像素点。其存储方式与位图相同,存储在内存中。

Frame Buffer :帧缓存,用于显示到显示器上的。存储在 vRAM(video RAM)中。

将 Data Buffer 转换为 Image Buffer 的过程,就可以称为解码。或者说,将未解码的 CGImage 转换为位图。核心方法为:

CGContextRef
CGBitmapContextCreate(
    void * __nullable data,
    size_t width, size_t height,
    size_t bitsPerComponent,
    size_t bytesPerRow,
    CGColorSpaceRef cg_nullable space,
    uint32_t bitmapInfo);
  • 参数解析
    • data :如果为 NULL,系统会自动分配和释放所需要的内存;如果不为 NULL,它应该指向一片 bytesPerRow * height 字节的内存空间;

    • width / height:图像的高度与宽度;

    • bitsPerComponent :像素的每个颜色分量只用的 bit 数,RGB 空间为一个字节,也就是 8 bits;

    • bytesPerRow :图像每一行使用的字节数,至少为 width * bytes per pixel。指定为 0 / NULL 时,系统不但自动计算该值,还会进行缓存行对齐 cache line alignment 的优化操作;

    • space :颜色空间,一般使用 RGB;

    • bitmapInfo :位图的布局信息,差不多理解为 ARGB(kCGImageAlphaPremultipliedFirst) 还是 RGBA(kCGImageAlphaPremultipliedLast)。若存在透明通道,传入 kCGBitmapByteOrder32Host | kCGImageAlphaPremultipliedFirst,否则传入 kCGBitmapByteOrder32Host | kCGImageAlphaNoneSkipFirst

      请查看 谈谈 iOS 中图片的解压缩Which CGImageAlphaInfo should we use?UIGraphicsBeginImageContextWithOptions - Apple,或者学习下 SDWebImageYYImage

Quartz 2D Programming Guide

何时解码?

图片在被设置到 UIImageView.image 或 layer.contents 中之后,在 layer 被提交到 GPU 之前,CGImage 数据才会被解码。这一步发生在主线程中,无可避免。

如何优化?

提前强制解码。

  • 画布重绘解码

将图片用 CGContextDrawImage() 绘制到画布上,然后把画布的数据取出来当作图片。

if (!cgImage) return NULL;
    
size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);

size_t bytesPerRow = width * 4;
size_t bytesTotal = bytesPerRow * height;

/// 在内存中哪里进行绘制
void *bitmapdata = calloc(bytesTotal, sizeof(uint8_t));
if (!bitmapdata) {
    fprintf(stderr, "Failed to calloc 'bitmapdata'");
    return NULL;
}


/// 颜色空间
/// 此处其实是固定的
static CGColorSpaceRef colorSpaceRef;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
    /// 应该是不需要支持 iOS 8了。。。
    colorSpaceRef = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
});


/**
 *
 * You use this function to configure the drawing environment for rendering into a bitmap. The format for the bitmap is a ARGB 32-bit integer pixel format using host-byte order. If the opaque parameter is YES, the alpha channel is ignored and the bitmap is treated as fully opaque (kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Host). Otherwise, each pixel uses a premultipled ARGB format (kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Host).
 * From https://developer.apple.com/documentation/uikit/1623912-uigraphicsbeginimagecontextwitho?language=occ
 *
 */
/// 移动设备为小端存储。
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
BOOL hasAlpha = CGImageHasAlpha(cgImage);
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;


/// 这里其实与 [CALayer drawInContext:] 和 UIGraphicsBeginImageContext() 相同
CGContextRef context = CGBitmapContextCreate(bitmapdata,    /* 在内存中哪里进行绘制。若传入 NULL,系统会自动为我们分配空间 */
                                             width,      /* 位图宽度 */
                                             height,     /* 位图高度 */
                                             8,             /* 一个像素每个颜色分量所占位数 bits,固定 8 */
                                             bytesPerRow,   /* 位图每行的字节数 bytes,若传入 0,系统会自动计算 */
                                             colorSpaceRef, /* 颜色空间 */
                                             bitmapInfo     /* 位图布局信息 */
                                             );
if (!context) return NULL;

/// 解码
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage); 

/// 从上下文取出解码的图片
CGImageRef rstImage = CGBitmapContextCreateImage(context);

CFRelease(context);

return rstImage;
  • 通过 CGDataProviderCopyData 解码

如小标题

size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);

CGColorSpaceRef colorSpace = CGImageGetColorSpace(cgImage);

size_t bitsPerComponent = CGImageGetBitsPerComponent(cgImage);
size_t bitsPerPixel = CGImageGetBitsPerPixel(cgImage);
size_t bytesPerRow = CGImageGetBytesPerRow(cgImage);

CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(cgImage);

CGDataProviderRef dataProvider = CGImageGetDataProvider(cgImage);
/// 解码
CFDataRef data = CGDataProviderCopyData(dataProvider);
///
CGDataProviderRef newProvider = CGDataProviderCreateWithCFData(data);

/// 使用解压后的数据创建新的 CGImageRef
CGImageRef newImage = CGImageCreate(width,
                                    height,
                                    bitsPerComponent,           /* 像素的颜色分量所占 bits */
                                    bitsPerPixel,               /* 每个像素所占 bits */
                                    bytesPerRow,                /* 每行所占 bytes */
                                    colorSpace,                 /* 颜色空间 */
                                    bitmapInfo,                 /* 位图布局信息 */
                                    newProvider,                /* 图片数据 */
                                    NULL,                       /* 图像解码数组。根据提供的上下限来重映射每个颜色分量 */
                                    false,                      /* 是否插值。是对应像素更平滑 */
                                    kCGRenderingIntentDefault   /* 如何处理不在图形上下文目标颜色空间色域的颜色 */
                                    );
/// 释放中间变量
CFRelease(data);
CFRelease(newProvider);

return newImage;

11、图片渲染怎么优化

优化无非从两个方向着手:空间、时间。

而图像需要被传输到 GPU,所以空间小了时间也就小了。

  • 空间
  1. 限制图片文件大小;

  2. 限制图片的缓存。除非使用频繁,否则不要缓存;

  3. 对网络图片进行降采样(本地图片谁会放那么大的,降采样的同时进行解码);

  4. 使用 Image Asset Catalogs,压缩率较高(缺点是只能通过 imageNamed: 来加载);

  5. 使用 mmap 将文件直接映射到内存中,从磁盘直接访问。NSData 可以假设一块磁盘空间是在内容中的。

  6. 限制渲染格式为 SRGB(每个像素 4 字节),display p3 宽色域为每个像素 8 字节(适用于 iPhone 7 以后的设备);

  7. 使用 UIGraphicsImageRenderer 代替 UIGraphicsBEginImageContextWithOptions

  • 时间
  1. 提前强制解码;

  2. 使用符合尺寸的图片:字节对齐。

12、如果GPU的刷新率超过了iOS屏幕60Hz刷新率是什么现象,怎么解决

  • 什么现象 GPU 绘制完成之后放入帧缓冲区,视频控制器定时读取缓冲区内容,由屏幕显示。

如果缓冲区允许覆盖,则一定会存在某些帧还没有悖视频控制器读取就已经被完全覆盖或者部分覆盖,所以会出现掉帧或者画面撕裂;如果不允许覆盖,直接掉帧。

  • 如何解决

双缓冲机制 + 垂直同步信号 VSync。

搞事情~~~

一、Auto Layout 自动布局

Auto Layout,自动布局。本质上就是一个线性方程解析引擎。

使用 Auto Layout 开发界面,我们需要关注的不再是 frame 的位置与 尺寸,而是各个视图之间的关系。当我们描述出视图与视图之间布局关系的约束集合时,Auto Layout 会解析出每个视图最终的 frame 并应用到各个视图上。

1、Auto Layout 的前世今生

2012 年,Apple 推出了 4 英寸的 iPhone 5,尺寸比例的变化让原本就费时费力的手动布局更加难了。于是,Apple 在同期的 iOS 6 中引入了源于 布局算法 Cassowary 的自动布局引擎 Auto Layout

1997 年,Alan Borning,Kim Marriott,Peter Stuckey 等人发布了论文 《Solving Linear Arithmetic Constraints for User Interface Applications》,文中提出了解决布局问题的 Cassowary constraint-solving 算法,并且将代码发布在 Cassowary 网站 上。

但是,使用 Auto Layout 的语法极其恶心,一个小部件的约束可能要写好几个屏幕,再加上性能问题,就算是 VFL(Visual Format Language)也没有让 Auto Layout 火起来。直到 Masonry (Swift 为 SnapKit)的出现,其简单的链式语法才让 Auto Layout 开始广泛使用。

2、Auto Layout 的本质

Auto Layout 不止包含了 Cassowary 算法,还包含了布局在运行时的生命周期等一整套布局引擎关系,用来统一管理布局的的创建、更新和销毁。

这一整套布局引擎系统被称为 Layout Engine,是 Auto Layout 的核心,主导整个界面布局。

2.1 布局周期 Layout Cycle

Auto Layout 的布局是有延迟的,并非已有约束变化就立马进行布局。这样做的目的是实现批量更新约束和绘制视图,避免频繁遍历视图层级,优化性能。当然,我们可以使用 layoutIfNeeded 来强制立马更新。

在 2020 年的 WWDC 上,Apple 给出这张图片:

Layout Cycle - WWDC 2015, Mysteries of Auto Layout, Part 2

Layout Cycle,布局循环,在应用 Runloop 下循环执行的流程。

Application Run Loop

Runloop 自动收集已经改变的约束,发送给 Layout Engine

然后 Runloop 从 Layout Engine 计算好的数据,在通过 GPU 将对应的视图渲染并展示。

Constraints Change

其过程为了两个步骤:更改表达式、Layout Engine 重新计算布局。

Constraints Change - WWDC 2015, Mysteries of Auto Layout, Part 2

更改约束表达式

约束,以线性表达式的形式存放于 Layout Engine 中,任何约束的更改,都属于 Constraints Change

这些更改包括:

  1. 约束对象的 activate / deactivate
  2. 设置 constant
  3. 设置 priority
  4. 视图的 add / remove(移除视图会自动移除相关的约束)。
Layout Engine 重新计算布局

约束表达式表达的是与视图位置、尺寸相关的变量。

当约束更新后,Layout Engine 重新计算布局,这些变量很可能被更新。

重新计算之后,由于约束更新导致位置、尺寸发生变化的视图的父视图会被标记为 needing layout ,也就是调用 superview.setNeedsLayout 来进行标记。

这些操作完成之后,视图的新的 frame 已经在 Layout Engine 中。但是视图还没有更新位置、尺寸。该 Deferred Layout Pass 表演了。

Deferred Layout Pass

Deferred Layout Pass - WWDC 2015, Mysteries of Auto Layout, Part 2

如图所示:Deferred Layout Pass 会在视图树上做两步操作:更新约束、重新赋值视图的 frame。

更新约束

Constraints Change 不同,这里的更新约束是指 “从下到上(子视图到父视图),依次遍历视图树,对所有被 needing layout 标记的视图调用 updateConstraints(UIViewController 对应方法为 updateViewConstraints)来更新视图的约束”

我们可以重写 updateCnstraints 方法来监听此过程。

为什么要自下而上? 因为子视图的约束会影响到父视图的约束。

我们可以调用 setNeedsUpdateConstraints 来手动触发这个过程。

Apple 建议在以下两种情况可以手动触发这个过程:

  1. 改变约束太慢的时候(批处理使在这里更新约束更快);
  2. 视图频繁改变的时候(避免多次);
重新赋值视图的 frame

Reassign view frames - WWDC 2015, Mysteries of Auto Layout, Part 2

  • 自上而下(从父视图到子视图) 遍历调用 layoutSubviews(UIViewController 对应方法为 layoutWillLayoutSubviews) ,以便让视图布局其子视图。

我们可以重写 layoutSubviews 来监听此过程,但是除非 Auto Layout 搞定不了才考虑重写。(重写时会发现,刚方法调用之前,视图本身已经有了新的 frame,而子视图的 frame 此时还是旧值)

为什么要自上而下? 因为只有先确定了父视图,才能确定子视图。

  • Layout Engine 中拷贝出子视图的位置、尺寸信息并设置到对应的视图上。

如下图所示,iOS 为 setCenter:setBounds;而 MacOS 为 setFrame:

Position the view's subviews - Reassign view frames - WWDC 2015, Mysteries of Auto Layout, Part 2

2.2 Render Loop

关于界面渲染,这里不做涉及,但是得说一下 Render Loop。我姑且将其翻译为 渲染循环。

Render Loop 是一个每秒执行 120 次的过程,用来确保所有的视图能为每一帧做好准备。其执行分三步:

  1. 更新/修改约束:从子视图向上逐层更新约束,一直到 window;

  2. 调整布局: 从父视图乡下逐层调整 layout;

  3. 渲染与显示: 从父视图乡下逐层渲染绘制。

也就是下面这张从 WWDC 2018:高性能 Auto Layout 偷来的图:

Layout EngineRender Loop

当某个视图发生 Constrain Change 时,Layout Engine 将这个视图上的多个约束方程式求解,其结果便是这个视图的 frame。(对应上边 2.2 Auto Layout 的布局周期 - Applicaiton Run Loop

方程求解完成之后,Layout Engine 通知对应的视图调用父视图的 setNeedsLayout 方法来更新约束。(对应上边 2.2 Auto Layout 的布局周期 - Constraints Change - Layout Engine 重新计算布局

当更新约束完成之后,进步布局阶段。每个视图都会从 Layout Engine 中读取器子视图的 frame,然后调用 layoutSubviews 来调整子视图的布局。(对应上边 **2.2 Auto Layout 的布局流程 - Deferred Layout Pass - 重新赋值视图的 frame)

Render Loop 的具体操作

Render Loop 每一步对应的方法如下:

干什么 由谁来干 谁可以让它干
更新约束 updateConstraints setNeedsUpdateConstraintsupdateConstraintsIfNeeded
调整布局 layoutSubviews setNeendsLayoutlayoutIfNeeded
渲染显示 drawRect: setNeedsDisplayInRect:
更新约束

- (void)updateConstraints

何时触发?

  1. initWithFrame: 时调用:

    但是需要重写属性 requiresConstraintBasedLayout 并返回 YES。

  2. 被标记为需要更新时,下次 layouty cycle 自动调用:

    调用 setNeedsUpdateConstraints

    当约束改变时,下次 render loop 还会自动调用 layoutsubviews 来布局。

  3. 有需要更新的标记,立即触发(手动触发):

    调用 updateConstraintsIfNeeded

    当约束改变时,下次 render loop 还会自动调用 layoutsubviews 来布局。

调整布局

- (void)layoutSubviews

何时触发?

  1. initWithFrame: 时调用:

    但是rect的值不能为CGRectZero。

  2. 标记为需要布局,下次 Layout cycle 自动调用:

    调用 setNeedsLayout

  3. 有需要布局的标记,立即触发(手动触发):

    调用 layoutIfNeeded

  4. 自身的 frame 发生改变时,约束会导致 frame 改变;

  5. 添加子视图、子视图 frame 发生改变,约束会导致 frame 改变;

  6. 视图被添加到 UIScrollView,滚动 UIScrollView

显示

- (void)drawRect:(CGRect)rect

何时触发?

  1. initWithFrame: 时触发:

    但 frame 值不能为 CGRectZero。

  2. 被标记为需要显示,下次 render loop 自动调用:

    调用 setNeedsDisplay

3、 Auto Layout 思维

在使用 Auto Layout 之前,我们需要先养成一种习惯:按照视图关系来思考,而不是原本的位置与尺寸。

  • 使用 布局约束 NSLayoutConstraint 来描述两个视图之间的关系,它本质上是一个方程式。不过这种方程式既可以是线性等式,也可以是不等式。

这个关系可以表示为 view1.attribute1 (relationship) view2.attribute2 * multiplier + constant,也就是 Apple 官方教程上的这张图

上图的意思就是:RedView 的头等于( 1 倍的 BlueView 的尾,再向右偏移 8)。再解释一下,就是 RedView 在 BlueView 右边,且两者之间的水平间隔是 8。

  • 只有同类型的 布局属性 NSLayoutAttribute 才能互相约束。

先列一个常用的布局属性表格:

属于同一分类的属性可以作用于上边的公式来互相约束(接下来我就写简称了)。

LeadingTrailing】对应着【LeftRight】,但是【LeftRight】不可跟 【LeadingTrailing】做约束关系。而且,在阅读习惯不同的语言中,其对应关系也不同:

从左向右 向右向左
Leading Left Right
Trailing Right Left

基于以上原则,推荐在开发中使用 LeadingTrailing,而不是 LeftRight,毕竟国际化也是个问题。

  • 约束方程式的等式关系

刚才说到,这个方程式既可以是等式,也可以是不等式。这也就意味着,这个方程式中间的 【等号 =】 可以是别的关系。准确来说,可以是 等于 =,也可以是 小于等于 ≤ 或者 大于等于 ≥

Apple 提供了这个了这样一个枚举:

typedef NS_ENUM(NSInteger, NSLayoutRelation) {
    NSLayoutRelationLessThanOrEqual = -1,       /// 小于等于 
    NSLayoutRelationEqual = 0,                  /// 等于
    NSLayoutRelationGreaterThanOrEqual = 1,     /// 大于等于
};
  • 约束的优先级

我们前边说到,存在一个 容错处理机制 Deffered Layout Pass。如果我们添加的所有约束关系中,存在缺失、或者冲突。程序笔记是程序,不是人,它无法主观决定哪个更重要。

于是,Apple 提供了布局约束的优先级,这个优先级本质上是一个 float 值(Swift 是一个由 Float 初始化的结构体)。

系统提供了以下几个默认的优先级:

/// 本质是一个 float 数值。
typedef float UILayoutPriority NS_TYPED_EXTENSIBLE_ENUM;



/// 必须约束,代表这个约束必须满足。
/// 最高优先级,也是默认优先级。
/// 默认优先级一旦冲突,直接崩溃。官方提示:不要指定超过这个值的优先级
UILayoutPriority UILayoutPriorityRequired = 1000;

/// 按钮 UIButton 内容扩张约束的优先级
/// 也就是向外扩张
UILayoutPriority UILayoutPriorityDefaultHigh = 750;

/// 按钮 UIButton 内容压缩约束的优先级
/// 也就是紧紧贴着其文字
UILayoutPriority UILayoutPriorityDefaultLow = 250;

/// 视图以参数 target size 为基准,通过计算尽量符合。
/// 不要用它来确定一个约束的优先级。使用 -[UIView systemLayoutSizeFittingSize:]。
/// 当我们调用 [view systemLayoutSizeFittingSize:CGSizeMake(50, 50)] 时,这个 view 会通过计算得到一个最符合 CGSizeMake(50, 50) 的尺寸。
UILayoutPriority UILayoutPriorityFittingSizeLevel = 50;





/// 拖动控件的推荐优先级,它可能会调整窗口尺寸
UILayoutPriority UILayoutPriorityDragThatCanResizeScene = 510;

/// 窗口希望保持相同大小的优先级。
/// 通常情况下,不要使用它来确定约束的优先级。
UILayoutPriority UILayoutPrioritySceneSizeStayPut = 500;

/// 拖动分割视图分隔符的优先级。不会调整窗口的尺寸
UILayoutPriority UILayoutPriorityDragThatCannotResizeScene = 490;

为了防止因为布局而崩溃,建议 将必须约束的优先级数值设置为 999。如此一来,既保证了其优先级,也避免了崩溃的风险。来自于 How do you set UILayoutPriority? - stack overflow

  • instrinsicContentSize / Compression Resistance Size Priority / Hugging Priority

某些视图具有 instrinsicContentSize 的属性,比如 UIlabel、UIButton、UITextField、UIImageView、选择控件、进度条、分段等。他们可以通过其内部 content 计算自己的大小,比如 UILabel 在设置 text 和 font 之后其大小可以通过计算得到。

基于这类控件的 content,Apple 提供了两个特定的约束:收缩约束 Content Hugging扩张约束 Content Compression Resistance。这两个约束简称为 CHCR。可以通过这张图来理解这两个约束。

此时,可以通过这两个函数来完成:

/*
 * @abstract 设置指定轴向上的压缩优先级
 *
 * @param priority
 * 优先级,值越大越抱紧视图里的内容。
 * 也就是,不会随着父视图变大而变大。
 *
 * @param axis
 * 轴向,分为水平与垂直。有 UILayoutConstraintAxisHorizontal 和 UILayoutConstraintAxisVertical
 */
- (void)setContentHuggingPriority:(UILayoutPriority)priority forAxis:(UILayoutConstraintAxis)axis;


/*
 * @abstract 设置指定轴向上的扩张优先级
 *
 * @param priority
 * 优先级,值越大越不容易被压缩
 * 当整体空间无法完全显示所有子视图的时候,Content Compression Resistance 越大的子视图内容显示越完整
 *
 * @param axis
 * 轴向,分为水平与垂直。有 UILayoutConstraintAxisHorizontal 和 UILayoutConstraintAxisVertical
 */
- (void)setContentCompressionResistancePriority:(UILayoutPriority)priority forAxis:(UILayoutConstraintAxis)axis;

这里有一个测试实例:

测试代码为:

[self.label1 setContentHuggingPriority:UILayoutPriorityDefaultHigh forAxis:UILayoutConstraintAxisHorizontal];
    
[self.label2 setContentCompressionResistancePriority:UILayoutPriorityDefaultHigh forAxis:UILayoutConstraintAxisHorizontal];
  • 约束的层级

最好使用有直接关系的两个视图来做约束关系,比如父子、兄弟。最好不要使用 view1.subview1 与 view2.subview2 来做。

这张图里,view1 与 view2 是兄弟。subview1 是 view1 的子视图,subview2 是 view2 的子视图。但是 subview1 与 subview2 毫无关系。

所以,按照上边说的。subview1 与 subview2 最好不要用来相互约束。

4、使用 Auto Layout

得益于广大开发者,现如今使用 Auto Layout 的方式有很多,我们一种一种来介绍。

无论使用任何一种方式来布局,只要是 Auto Layout,到最后都会转换成 NSLayoutConstraint 这个对象的实例。我们先来认识一下几个重要的属性与方法:

/// 当前需要约束的对象视图
@property (nullable, readonly, assign) id firstItem;

/// 作为关系的依赖视图。换句话说,用哪个视图来约束对象视图
@property (nullable, readonly, assign) id secondItem;

/// 当前对象视图需要约束的某个属性
@property (readonly) NSLayoutAttribute firstAttribute;

/// 使用依赖视图的哪个属性来约束对象视图的指定属性
@property (readonly) NSLayoutAttribute secondAttribute;

/// 方程式的 相等关系 或者是 不等关系
@property (readonly) NSLayoutRelation relation;

/// 方程式中的 倍数
@property (readonly) CGFloat multiplier;

/// 方程式中的 常数
@property CGFloat constant;

/// NSLayoutConstraint 对象是否启用
@property (getter=isActive) BOOL active;

/// NSLayoutConstraint 对象的标识
@property (nullable, copy) NSString *identifier;



/// 类方法:启用传入参数中的所有约束
+ (void)activateConstraints:(NSArray<NSLayoutConstraint *> *)constraints;

/// 类方法:停用传入参数中的所有约束
+ (void)deactivateConstraints:(NSArray<NSLayoutConstraint *> *)constraints

好了,我开始表演了。。。

4.1 原生 NSLayoutConstraint

如果非要我用这种方式来开发的话,可能我还是会选择用手动布局,或者是自己去封装一种简洁的使用方案了吧。。。。

话不多说,直接开始!!!

目的:

theView.x = 50,

theView.y = 100,

theView.width = superview.width * 0.5 + 50

theView.height = superview.height * 0.5 + 100

UIView *theView = [UIView new];
[self.view addSubview:theView];
theView.backgroundColor = [UIColor systemBlueColor];
theView.translatesAutoresizingMaskIntoConstraints = NO;


/// theView.leading = 1 * superview.centerX + 50
NSLayoutConstraint *leading = [NSLayoutConstraint constraintWithItem:theView
                                                           attribute:NSLayoutAttributeLeading
                                                           relatedBy:NSLayoutRelationEqual
                                                              toItem:self.view
                                                           attribute:NSLayoutAttributeLeading
                                                          multiplier:1
                                                            constant:50];
/// theView.top = 1 * superview.top + 100
NSLayoutConstraint *top = [NSLayoutConstraint constraintWithItem:theView
                                                           attribute:NSLayoutAttributeTop
                                                           relatedBy:NSLayoutRelationEqual
                                                              toItem:self.view
                                                           attribute:NSLayoutAttributeTop
                                                          multiplier:1
                                                            constant:100];
/// theView.width = 0.5 * superview.width + 50
NSLayoutConstraint *width = [NSLayoutConstraint constraintWithItem:theView
                                                         attribute:NSLayoutAttributeWidth
                                                         relatedBy:NSLayoutRelationEqual
                                                            toItem:self.view
                                                         attribute:NSLayoutAttributeWidth
                                                        multiplier:0.5
                                                          constant:50];
/// theView.height = 0.5 * superview.height + 100
NSLayoutConstraint *height = [NSLayoutConstraint constraintWithItem:theView
                                                          attribute:NSLayoutAttributeHeight
                                                          relatedBy:NSLayoutRelationEqual
                                                             toItem:self.view
                                                          attribute:NSLayoutAttributeHeight
                                                         multiplier:0.5
                                                           constant:100];

[self.view addConstraints:@[centerX, centerY, width, height]];
[NSLayoutConstraint activateConstraints:@[leading, top, width, height]];

在没怎么换行的情况下,这么长。。。

NSLayoutConstraint 的优缺点

优点:唯一的优点就是它确实是 Auto Layout

缺点:代码太过冗余,可读性也很差;约束的更新、删除很不方便,需要使用变量来记录约束对象、或者通过匹配来查找对应约束。

4.2 可视化语言 VFL

VFL,即 Visual Format Language,也就是可视化语言。

这种布局方式,同样创建 NSLayoutConstraint 对象来创建约束。刚说完的 一.4.2 原生 NSLayoutConstraint 每创建一个对象,就是一个约束关系。

VFL 使用字符串编码的方式来创建约束,它可以传入任意多个视图、任意多个布局关系。

因此可以采用 VFL 可以一次性创建多个约束关系,其方法返回一个 NSLayoutConstraint 对象的集合。

UIView *blueView = [UIView new];
[self.view addSubview:blueView];
blueView.backgroundColor = [UIColor systemBlueColor];
blueView.translatesAutoresizingMaskIntoConstraints = NO;

UIView *redView = [UIView new];
[self.view addSubview:redView];
redView.backgroundColor = [UIColor systemRedColor];
redView.translatesAutoresizingMaskIntoConstraints = NO;


/// VFL 需要获取对象的对应的字典 key,key 是字符串,在 VFL 中可以直接使用
//    NSDictionary *views = @{
//        @"blueView": blueView,
//        @"redView": redView,
//    };

/// Apple 提供这样一个快捷宏来创建这样的字典
NSDictionary *views = NSDictionaryOfVariableBindings(blueView, redView);

/// 可以创建这样一个字典 传入 metrics ,作为 VFL 语句中用到的具体数值
/// 如果将这个字典传入 metrics,那么我们可以将下边水平方向布局 VFL 语句中的 50 全部换成 space
//    NSDictionary *spaceMetrics = @{@"space": @50};



/*
 * 解释一下
 * @"H:|-50-[blueView(100)]-50-[redView(==blueView)]"
 * 边界 - 宽度为 50 的空白 - 宽度为 100 的 blueView - 宽度为 50 的空白 - 与 blueView 宽高都相同的 redView
 *
 * 如果语句最右边加上 -50-|,并且去掉 blueView 的宽度指明,也就是 H:|-50-[blueView]-50-[redView(==blueView)]-50-|
 * 边界 - 宽度为 50 的空白 - blueView - 宽度为 50 的空白 - 与 blueView 宽高都相同的 redView - 宽度为 50 的空白 - 边界
 * 并且此时,blueView 与 redView 的宽度将自适应为 (superView.width - 50 - 50 - 50) / 2。
 *
 *
 * NSLayoutFormatAlignAllTop | NSLayoutFormatAlignAllBottom
 * 使所有视图根据他们的顶部边缘和底部边缘对其,也就是垂直方向在同一位置
 *
 *
 * @"V:|-100-[blueView(200)]"
 * 边界 - 高度为 100 的空白 - 高度为 200 的 blueView
 *
 */
    
/// 水平方向
NSArray *horizontalConstraints = \
[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-50-[blueView(100)]-50-[redView(==blueView)]"
                                        options:NSLayoutFormatAlignAllTop | NSLayoutFormatAlignAllBottom
                                        metrics:nil
                                          views:views];

/// 垂直方向
NSArray *verticalConstraints = \
[NSLayoutConstraint constraintsWithVisualFormat:@"V:|-100-[blueView(200)]"
                                        options:kNilOptions
                                        metrics:nil
                                          views:views];

[NSLayoutConstraint activateConstraints:horizontalConstraints];
[NSLayoutConstraint activateConstraints:verticalConstraints];

我根本就不会这种方式,想要具体学习的朋友们请移步 IOS开发之自动布局--VFL语言iOS Auto Layout 中的对齐选项Apple 官方教程 VFL

VFL 的优缺点

优点:代码简洁,可一次性布局多个视图

缺点:

  1. 有些约束束手无策,比如 redView.height = 2 * blueView.height;
  2. 毕竟是字符串,无法在编译器检查;
  3. 需要一定的学习成本。

虽然我个人不推荐这种布局方式,但最好能看懂

4.3 Interface Builder

Apple 建议使用 Interface Builder 进行布局。如此开发速度确实很快。

个人平时都是纯代码开发,就不在这里演示了。而且就算演示了,也看不到演示过程,只是一个拖好的也没啥好看的!!!

优点:

  1. 可以直接看到布局效果,无需运行;
  2. storyBoard 自带布局检查,约束歧义的警告、约束关系错误都可以直接显示;
  3. 遇到负责界面可以结合代码进行开发。

缺点:

  1. 多人开发简直就是噩梦;
  2. 需求变更时修改起来会比纯代码麻烦许多;
  3. 产品经理会直接看着你拖界面,你慌不慌?

4.4 原生锚点 NSLayoutAnchor

iOS 9,Apple 提供了一种新的 Auto Layout 开发方式:锚点 NSLayoutAnchor

相比于 NSLayoutConstraint 来说,这种方案的开发速度提升了不少。

NSLayoutAnchor 存在多个对象直接作为 UIView 的属性,它们与 NSLayoutConstraint 的布局属性 NSLayoutAttribute 一一对应。

NSLayoutAnchor 有三个子类,别对应不同的布局属性:

子类 锚点类型 锚点属性
NSLayoutXAxisAnchor X 轴方向 leadingAnchortrailingAnchorleftAnchorrightAnchorcenterXAnchor
NSLayoutYAxisAnchor Y 轴方向 topAnchorbottomAnchorcenterYAnchorfirstBaselineAnchorlastBaselineAnchor
NSLayoutDimension 尺寸 widthAnchorheightAnchor

上代码!!!

UIView *blueView = [UIView new];
[self.view addSubview:blueView];
blueView.backgroundColor = [UIColor systemBlueColor];
blueView.translatesAutoresizingMaskIntoConstraints = NO;

UIView *redView = [UIView new];
[self.view addSubview:redView];
redView.backgroundColor = [UIColor systemRedColor];
redView.translatesAutoresizingMaskIntoConstraints = NO;


/// blueView.leading = 1 * superview.leading + 0
[blueView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor].active = YES;
/// blueView.top = 1 * superview.top + 100
[blueView.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:100].active = YES;
/// blueView.width = 0.3 * superview.width + 20
[blueView.widthAnchor constraintEqualToAnchor:self.view.widthAnchor multiplier:0.3 constant:20].active = YES;
/// blueView.height = 0 * nil + 50
[blueView.heightAnchor constraintEqualToConstant:50].active = YES;


/// redView.leading = 1 * blueView.trailing + 20
[redView.leadingAnchor constraintEqualToAnchor:blueView.trailingAnchor constant:20].active = YES;
/// redView.trailing = 1 * superview.trailing - 10
[redView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-10].active = YES;
/// redView.top = 1 * blueView.bottom + 30
[redView.topAnchor constraintEqualToAnchor:blueView.bottomAnchor constant:30].active = YES;
/// redView.bottom = 1 * superview.bottom - 50
[redView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor constant:-50].active = YES;

我这里直接将 返回的 NSLayoutConstraint 对象的 active 属性 设置为 YES,也就是激活了。当然也可以将返回的对象赋值给变量,然后使用 +[NSLayoutConstraint activateConstraints:] 来启用约束。

另外,与 NSLayoutAttribute 相同, leadingAnchor / trailingAnchorleftAnchor / rightAnchor 不可相互约束。

NSLayoutAnchor 的优缺点

优点:

  1. 原生;
  2. 易用,提供了多个“重载函数”,默认 multiplier 为 1,constant 为 0;
  3. 相对简洁,当然是相对 NSLayoutConstraint
  4. Xcode 会在编译器便能指出部分错误,比如不同类型属性不可相互约束。

缺点:

  1. 仅支持 iOS 9 以上,不过现在这个已经不算什么缺点了;
  2. 更新、删除与 NSLayoutConstraint 一样;
  3. 还不够简洁易用。

4.5 UIStackView

iOS 9,Apple 不止推出了 NSLayoutAnchor,还推出了这个。高产似??

说实话,我虽然知道这东西,但确实没用过。。。。

UIStackView 非常适合这种布局:

`UIStackView` 非常适合的布局

也就是,在某一个方向上平铺着一堆视图,一个接一个出现。但是,遇到这种的就必须要嵌套使用了:

最左边的两个视图,也就是深绿色与紫色的两块。只靠单独的一个 UIStackView 是无法实现的。

UIStackView 常用的属性与方法:

/// 布局轴向
/// 分为两种:水平 UILayoutConstraintAxisHorizontal 和垂直 UILayoutConstraintAxisVertical
@property(nonatomic) UILayoutConstraintAxis axis;

/// arrangeSubiviews 沿着布局轴向的布局方式
@property(nonatomic) UIStackViewDistribution distribution;

/// arrangedSubviews 沿着垂直于布局轴向的布局方式
@property(nonatomic) UIStackViewAlignment alignment;

/// arrangeSubiviews 之间的间隔 
@property(nonatomic) CGFloat spacing;

/// 向 arrangeSubiviews 添加 view,添加在其列表尾部
/// 同时将 view 添加为 UIStackView 的 subview
- (void)addArrangedSubview:(UIView *)view;

/// 从 arrangeSubiviews 移除 view
/// 但是 view 依然是 UIStackView 的 subview
- (void)removeArrangedSubview:(UIView *)view;

/// 向 arrangeSubiviews 指定位置插入 view
/// 注意 stackIndex 的越界问题,否则将造成崩溃
/// 若 stackIndex 为 [UIStackView subviews].count,则相当于 -addArrangedSubview:
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex;

关于 distributionalignment 这两个属性的具体作用,请查看 官网UIStackView学习分享, 纯代码实现

上代码!!!

- (void)didClickButton:(UIButton *)sender {
    switch (sender.tag) {
        case 100: {
            UILabel *theView = [UILabel new];
            theView.textAlignment = NSTextAlignmentCenter;
            theView.backgroundColor = [UIColor colorWithRed:arc4random() % 256 / 255.0
                                                    green:arc4random() % 256 / 255.0
                                                     blue:arc4random() % 256 / 255.0
                                                    alpha:1];
            NSMutableString *title = [NSMutableString stringWithString:@"Test"];
            int length = arc4random() % 3;
            for (int i = 0; i < length; ++i) {
                [title appendFormat:@"Test"];
            }
            
            theView.text = @"TestTest";
            [stackView addArrangedSubview:theView];
            [UIView animateWithDuration:1 animations:^{
                [self->stackView layoutIfNeeded];
            }];
            break;
        }
            
        case 101: {
            UIView *theView = [stackView subviews].lastObject;
            if (nil == theView) return;
            [stackView removeArrangedSubview:theView];
            [theView removeFromSuperview];
            [UIView animateWithDuration:0.5 animations:^{
                [self->stackView layoutIfNeeded];
            }];
            break;
        }
            
        default:
            break;
    }
}


- (void)display_UIStackView {
    
    stackView = [[UIStackView alloc] initWithFrame:CGRectMake(0, 100, self.view.bounds.size.width, 300)];
    [self.view addSubview:stackView];
    stackView.backgroundColor = [UIColor systemBlueColor];
    
    /// 布局方向
    stackView.axis = UILayoutConstraintAxisHorizontal;
    
    /// 子视图之间的间距
    stackView.spacing = 10;
    
    /// 子控件依据何种规则布局
    stackView.distribution = UIStackViewDistributionFill;
    
    UIButton *addButton = [[UIButton alloc] initWithFrame:CGRectMake(50, 500, 100, 40)];
    [self.view addSubview:addButton];
    addButton.tag = 100;
    [addButton setTitle:@"添加" forState:UIControlStateNormal];
    [addButton setTitleColor:[UIColor systemGreenColor] forState:UIControlStateNormal];
    [addButton addTarget:self action:@selector(didClickButton:) forControlEvents:UIControlEventTouchUpInside];
    
    UIButton *removeButton = [[UIButton alloc] initWithFrame:CGRectMake(200, 500, 100, 40)];
    [self.view addSubview:removeButton];
    removeButton.tag = 101;
    [removeButton setTitle:@"移除" forState:UIControlStateNormal];
    [removeButton setTitleColor:[UIColor systemRedColor] forState:UIControlStateNormal];
    [removeButton addTarget:self action:@selector(didClickButton:) forControlEvents:UIControlEventTouchUpInside];
}

UIStackView添加特效.gif

UIStackView 的特殊性

  1. 只有通过 addArrangedSubview:UIStackView 的视图才具有正确的布局;使用 addSubview: 添加的视图,确实在 UIStackView.subviews 中,但是其 frame 为 (0, 0, 0, 0)。

  2. UIStackView 只参与布局,不参与渲染。也就是说,给 UIStackView 设置 backgroundColor 没有任何效果,圆角等效果也是如此;

  3. 若将 arrangedSubviews 中某个视图的 isHidden 状态设置为 YES,这个视图依然存在于 arrangedSubviews,且还在正常布局的位置,但是不会展示出来,也不会影响其他视图的布局。而如果不是 UIStackView,就算 view.isHidden 为 YES,其约束依然存在,依然会影响布局。

  4. UIView.isHidden 是不可动画的属性,但是添加到 UIStackView.arrangedSubviews 中的视图,其 isHidden 已经是可动画的了。

  5. 使用 UIStackView 的布局,通常需要设置视图的 instrinsicContentSizeCHCR ,也就是 一、3、Auto Layout 思维 中提到的 instrinsicContentSize / Compression Resistance Size Priority / Hugging Priority

UIStackView 的优缺点

优点:

  1. 原生,且百度团队维护的 FDStackView,可以做到向下兼容到 iOS 6,代码无侵入,无学习成本,直接使用 UIStackView 就好;

  2. 添加、移除自带动画。

缺点:

  1. 稍微复杂一点的布局就需要嵌套(个人觉得这种简单的布局还比如直接用 VFL);

  2. 学习成本。

接下来要吹的 Masonry 也提供了 UIStackView 类似的功能!!!

4.6 Masonry

Auto Layout 第三方开源框架,代码地址:Masonry。Swift 版本为 SnapKit

不多吹了。就一句话:Auto Layout 首选方案!!!

先来搞一搞 UIStackView 的功能:

/// 将 五个 views 看成一个整体 redviews
NSMutableArray<UIView *> *redviews = [NSMutableArray array];

for (int i = 0; i < 5; ++i) {
    UIView *theView = [UIView new];
    theView.backgroundColor = [UIColor systemRedColor];
    
    [theBackView addSubview:theView];
    [redviews addObject:theView];
}

/// 确定最左边的间隔,最右边的间隔,每个 view 之间的间隔为 10,redView 的宽度自适应

/// 设置布局轴向为 水平轴向
/// redviews 中各个 view 之间的间隔为 10(可调整)
/// redviews.leading = 1 * superview.leading + 10
/// redviews.trailing = 1 * superview.trailing + 10
[redviews mas_distributeViewsAlongAxis:MASAxisTypeHorizontal withFixedSpacing:10 leadSpacing:10 tailSpacing:10];

/// 补齐缺失的约束
[redviews mas_makeConstraints:^(MASConstraintMaker *make) {
    /// redviews.top = 1 * superview.top + 80
    make.top.equalTo(theBackView).offset(20);
    /// redviews.height = 1 * redView.width + 0
    make.height.equalTo(@200);
}];

效果图:

升级一下,实现下边的绿色色块。要求:

  1. 三个绿色色块的宽高为 30 * index;
  2. 三个色块底部坐标相同;
  3. 五个间隔宽度一致。

当然,肯定可以通过不同的写法实现,我就负责抛砖引玉了~

NSMutableArray *greenviews = [NSMutableArray array];
    
CGFloat greenNum = 3;
CGFloat greenBaseWidth = 30;
/// (greenNum * (1 + greenNum) / 2) 等差数列前 N 项只和
CGFloat greenPadding = (theBackView.bounds.size.width - greenBaseWidth * (greenNum * (1 + greenNum) / 2) ) / (greenNum + 1);

for (int i = 0; i < greenNum; ++i) {
    UIView *theView = [UIView new];
    theView.backgroundColor = [UIColor systemGreenColor];
    
    [theBackView addSubview:theView];
    [greenviews addObject:theView];
    
    [theView mas_makeConstraints:^(MASConstraintMaker *make) {
        
        /// theView.leading = 1 * theBackView.mas_leading + ( i+1个greenPadding + 之前所有blueView的宽度之和)
        make.leading.equalTo(theBackView.mas_leading).offset(greenPadding * (i + 1) + greenBaseWidth * (i * (1 + i) / 2));
        /// theView.bottom = 1 * theBackView.bottom - 20
        make.bottom.equalTo(theBackView.mas_bottom).offset(-80);
        /// theView.width = greenBaseWidth * (i + 1)
        /// theView.height = greenBaseWidth * (i + 1)
        make.width.height.mas_equalTo(greenBaseWidth * (i + 1));
    }];
}

来一个很实用的。。。让子视图撑开父视图:

指定其部分属性:

  1. 头像:

    leading = 1 * grayview.leading + 20;

    top = 1 * grayview.top + 10;

    width = 60;

    height = 60。

  2. 昵称:

    leading = 1 * 头像.leading + 20;

    top = 1 * 头像.top

    height = 1 * 头像.height;

    width:自适应。

  3. 描述:

    leading = 1 * grayview.leading + 10;

    top = 1 * 头像.bottom + 10;

    trailing = 1 * grayview.trailing - 10

    bottom = 1 * grayview.bottom - 10

  4. grayview:

    top = 1 * superview.top + 70;

    centerX = 1 * superview.centerX;

    width = 1 * superview.width - 20;

    height = 1 * superview.height - 100;

从上边的图,和约束的说明也能看出来。能直接确定 frame 只有 头像一个。连 grayview 都无法确定其 frame。

UIView *grayview = [UIView new];
[self.view addSubview:grayview];
grayview.backgroundColor = [UIColor systemBlueColor];


CGFloat avatarWidth = 60;
self.avatarImageView = [UIImageView new];
self.avatarImageView.image = [UIImage imageNamed:@"theFox.jpg"];
self.avatarImageView.clipsToBounds = YES;
self.avatarImageView.layer.cornerRadius = avatarWidth / 2;

self.nameLabel = [UILabel new];
self.nameLabel.text = @"Title";

self.descLabel = [UILabel new];
self.descLabel.text = @"Desc";
self.descLabel.numberOfLines = 0;


for (UIView *theView in @[self.avatarImageView, self.nameLabel, self.descLabel]) {
    [grayview addSubview:theView];
}



[self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
    /// avatarImageView.leading = 1 * containerView.leading + 20
    make.leading.equalTo(grayview).offset(20);
    /// avatarImageView.top = 1 * containerView.top + 10
    make.top.equalTo(grayview).offset(10);
    /// avatarImageView.width = 1 * 0 + avatarWidth
    /// avatarImageView.height = 1 * 0 + avatarWidth
    make.width.height.mas_equalTo(avatarWidth);
}];

[self.nameLabel mas_makeConstraints:^(MASConstraintMaker *make) {
    /// nameLabel.leading = 1 * avatarImageView.trailing + 20
    make.leading.equalTo(self.avatarImageView.mas_trailing).offset(20);
    /// nameLabel.trailing = 1 * containerView.trailing - 20
    make.trailing.equalTo(grayview).offset(-20);
    /// nameLabel.top = 1 * avatarImageView.top + 0
    make.top.equalTo(self.avatarImageView);
    /// nameLabel.height = 1 * avatarImageView.height + 0
    make.height.equalTo(self.avatarImageView);
    
}];



[self.descLabel mas_makeConstraints:^(MASConstraintMaker *make) {
    /// descLabel.leading = 1 * containerView.leading + 10
    make.leading.equalTo(grayview).offset(10);
    /// descLabel.trailing = 1 * containerView.trailing - 10
    make.trailing.equalTo(grayview).offset(-10);
    /// descLabel.top = 1 * avatarImageView.bottom + 10
    make.top.equalTo(self.avatarImageView.mas_bottom).offset(10);
    /// descLabel.bottom = 1 * containerView.bottom - 10
    make.bottom.equalTo(grayview).offset(-10);
}];


[grayview mas_makeConstraints:^(MASConstraintMaker *make) {
    /// containerView.centerX = 1 * self.view.centerX + 0
    make.centerX.equalTo(self.view);
    /// containerView.top = 1 * self.view.top + 70
    make.top.equalTo(self.view).offset(70);
    /// containerView.width ≤ 1 * self.view.width -20;
    make.width.lessThanOrEqualTo(self.view).offset(-20);
    /// containerView.height ≤ 1 * self.view.height - 100
    make.height.lessThanOrEqualTo(self.view).offset(-100);
}];

现在长这个样子:

我在左下角加一个 UIButton,来随机改变 昵称描述

UIButton *theButton = [[UIButton alloc] initWithFrame:CGRectMake(0, self.view.bounds.size.height - 60, 100, 50)];
[self.view addSubview:theButton];
[theButton setTitle:@"Just Do It" forState:UIControlStateNormal];
[theButton addTarget:self action:@selector(justDoIt) forControlEvents:UIControlEventTouchUpInside];


- (void)justDoIt {
    int numTitle = arc4random() % 10;
    int numDesc = arc4random() % 300;
    NSMutableString *theTitle = [NSMutableString stringWithString:@"Title"];
    NSMutableString *theDesc = [NSMutableString stringWithString:@"Desc"];
    
    for (int i = 0; i < numTitle; ++i) {
        [theTitle appendString:@"Title"];
    }
    
    for (int i = 0; i < numDesc; ++i) {
        [theDesc appendString:@"Desc"];
    }
    
    dispatch_async(dispatch_get_main_queue(), ^{
        self.nameLabel.text = theTitle;
        self.descLabel.text = theDesc;
    });
}

子视图撑起父视图.gif

二、UIViewCALayer

1、UIView

本节内容绝大部分翻译自 UIView | Apple Developer Documentation

An object that manages the content for a rectangular area on the screen.

一个管理屏幕上矩形区域内容的对象。

UIView 是构建程序用户界面的基本单元。视图对象在其边界区域内呈现内容,并处理与内容相关的交互事件。

视图对象是应用中与用户交互的主要方式,其承担了诸多责任:

  • 绘制与动画

    视图使用 UIKitCore Graphics 来绘制内容。

    某些属性在被设置为新值时,可以呈现动画。

  • 布局与子视图管理

    视图可以包含零个或多个子视图。

    视图可以调整其子视图的尺寸与位置。

    使用 Auto Layout 来定义视图树的改变来调整视图大小和位置的规则。

  • 事件处理

    UIView 继承自 UIResponder,可以响应触摸和其他类型的事件。

    视图可以添加 UIGestureRecognizer 来处理常用手势。

视图可以被嵌套在其他视图中,以此创建视图树,这是一种组织有关内容的便捷方式。嵌套视图将创建子视图 subview 和父视图 superview 的关系。

默认情况下,子视图超出父视图的部分不会被裁减,可以使用 clipsToBounds 属性来改变这种行为。

每一个视图的几何形态由 framebounds 定义。frame 定义了视图在父视图坐标系中的位置与尺寸,bounds 则定义了其以自身为坐标系的尺寸。center 属性提供了一种无需修改 frame 和 bounds 便可重新摆放其位置的便捷方式。

1.1 视图绘制周期 The View Drawing Cycle

UIView 类使用按需绘制模式来来呈现内容。当视图第一次显示,或由于布局变化导致视图的全部或部分可见时,系统会要求视图绘制其内容。

系统会捕获 view 显示内容的快照,并把这个快照作为 view 的视觉显示。只要不更改 view 的内容,这个 view 的绘制代码就不会被再次调用。这份快照可以用于大多数与 view 有关的操作(比如拉伸,平移等)。

一旦 view 的内容改变了,我们不需要直接重新绘制这些改变。我们使用 setNeedsDisplaysetNeedsDisplayInRect: 方法来给 view 打上 dirty 的标记。这两个方法告诉系统 view 的内容已经改变,需要在下一个 drawing cycle 进行重绘。

系统在当前 runloop 的最后进行绘制操作。正是这个延迟机制,让我们可以一次性调整多个 view (包括但不限于重绘、添加 / 删除视图、隐藏视图、调整大小、调整位置等),这些具体调整会在同一帧画面呈现出来。

更改 view 的集合形状并不会自动引起系统对 view 的重绘操作。view 的 contentMode 决定了如何解释视图形状的改变。绝大多数 contentMode 都不会创建新的快照,而是在 view 的边界内拉伸或重新摆放现有的快照。

试试将 contentMode 设置为 UIViewContentModeRedraw。【记得子类化 UIView 并重写 drawRect:

1.2 动画

某些属性的改变时可动画的。通过改变动画来创建的动画以当前值开始,到指定值结束。

以下属性可动画:

使用 + (void)animateWithDuration:(NSTimeInterval)duration animations:(void (^)(void))animations 即可完成以上属性的动画:

- (void)doAnimation:(NSNumber *)flag {
    
    int theFlag = [flag intValue];
    
    switch (theFlag) {
        case 1: {
            [UIView animateWithDuration:0.25 animations:^{
                self.theView.frame = CGRectMake(50, 200, 200, 150);
            }];
            break;
        }
            
        case 2: {
            [UIView animateWithDuration:0.25 animations:^{
                self.theView.bounds = CGRectMake(0, 0, 300, 300);
            }];
            break;
        }
            
        case 3: {
            [UIView animateWithDuration:0.25 animations:^{
                self.theView.center = self.view.center;
            }];
            break;
        }
            
        case 4: {
            [UIView animateWithDuration:0.25 animations:^{
                self.theView.transform = CGAffineTransformMakeRotation(M_PI_4);
            }];
            break;
        }
            
        case 5: {
            self.theView.alpha = 0.1;
            [UIView animateWithDuration:0.25 animations:^{
                self.theView.alpha = 1;
            }];
            break;
        }
            
        case 6: {
            [UIView animateWithDuration:0.25 animations:^{
                self.theView.backgroundColor = [UIColor blackColor];
            }];
            break;
        }
            
        default: {
            break;
        }
    }
    
    if (theFlag < 6) {
        [self performSelector:@selector(doAnimation:) withObject:@(theFlag + 1) afterDelay:1];
    }
    
}

当然,Apple 在 iOS 10 提供了 UIViewPorpertyAnimator 这个类让我们在做交互式动画时更加方便了。这里就不做延伸了,感兴趣的朋友请谷歌。

1.3 与 Auto Layout 联动一下

  • 更新约束

若存在需要更新的约束,但不着急,调用 [self setNeedsUpdateConstraints] 来标记为在下次 layout cycle 更新;

若存在需要立马更新的约束,调用 [self updateConstraintsIfNeeded]

  • 调整布局

若存在需要调整的布局,但不着急,调用 [self setNeedsLayout] 来标记为在下次 layout cycle 调整布局;

若存在需要立马调整的布局,调用 [self layoutIfNeeded]

  • 渲染与显示

若需要重新绘制整个 layer,调用 [self setNeedsDisplay] 来在下一次 drawing cycle 绘制全部内容;

若只需要重新绘制部分内容,调用 [self setNeedsDisplayInRect:rect] 来在下一次 drawing cycle 绘制指定矩形框内的内容。

2、CALayer

本节内容绝大部分翻译自 CALayer | Apple Developer Documentation

An object that manages image-based content and allows you to perform animations on that content.

一个管理基于图形内容且允许在其内容上执行动画的对象。

CALayer 通常用来给 UIView 提供后备存储,但即便没有 UIViewCALayer 也可以正常展示内容。我们可以把 CALayer 称之为 “”。

layer 的主要工作是管理我们提供的视觉内容,但是 layer 本身也含有可以被设置的视觉属性,例如背景色、边框、阴影等。

除了管理视觉内容, layer 还维护其内容在屏幕上展示的几何形态(如位置、尺寸、变换)。改变 layer 属性也就是启动 layer 内容或几何形态动画的手段。 layer 通过实现协议 CAMediaTiming 的方式来管理其动画的时长和步调。

若 layer 对象由视图创建,则这个 view 就自动成为这个 layer 的代理,不要改变这种关系。如果 layer 是由我们自己创建的,我们可以为这个 layer 提供一个代理,并由这个代理来动态为 layer 提供内容以及执行任务。

layer 内部维护着三份 layer tree,分别是:presentationLayer tree(动画树)、modelLayer tree(模型树)、render tree(渲染树)。在做动画时,我们修改的动画属性是 presentationLayer tree 的。而最终显示在界面上的内容其实是 modelLayer 提供的。本段内容来自详解CALayer 和 UIView的区别和联系

2.1 layer.contents

我们都知道,如果要给一个 view 设置一张背景图片,既可以给这个 view 添加一个类型为 UIImageView 的 subview,也可以这样给 view.layer 添加一个 sublayer:

CALayer *theLayer = [CALayer layer];
[self.myView.layer addSublayer:theLayer];
theLayer.frame = self.myView.bounds;
theLayer.contents = (__bridge id)[UIImage imageNamed:@"theFox.jpg"].CGImage;

甚至可以一句代码搞定:

self.myView.layer.contents = (__bridge id)[UIImage imageNamed:@"theFox.jpg"].CGImage;

不过这是会拉伸的,拉伸选项来源于属性 contentsGravity

contents 是一个类型为 id 的属性。给此属性赋值一个 CGImage 就可以在 layer 上显示给定的图片。

其实 layer 的显示也是靠 contents 的。可是为什么这个属性时 id 类型呢?其实,这个属性在早期的 macOS 时代就已经存在,那时可是显示 CGImage 与 NSImage,但在 iOS 上是不适用 NSImage 的,于是就只剩下 CGImage。但这个属性的类型还是保留下来了,我认为是为了库原理的统一性。

与 contents 相关的属性

contentsGravity : 指定 contents 的对齐方式

contentsScale : 指定 contentes 的缩放比例

contentsRect : 指定 contents 的显示区域

contentsCenter : 指定 contents 的拉伸区域

  • contentsGravity

这是一个 NSString *!!!指定 layer 的 contents 如何映射到它的矩形区域,也就是对齐方式。对应 UIView.contentMode

kCAGravityCenter
kCAGravityTop
kCAGravityBottom
kCAGravityLeft
kCAGravityRight
kCAGravityTopLeft
kCAGravityTopRight
kCAGravityBottomLeft
kCAGravityBottomRight
kCAGravityResize
kCAGravityResizeAspect
kCAGravityResizeAspectFill

默认值为 kCAGravityResize

这跟 UIView.contentMode 基本是兄弟,没啥好说的,说了也 记不住

不过记住一点:有 Resize 的会重设尺寸,此时不会在乎屏幕的分辨率问题。

  • contentsScale

这个属性指定了 contents 的缩放比例。它决定了 layer 的实际绘图与物理屏幕显示的映射比例。对应 UIView.contentScaleFactor

如果物理尺寸为 (w, h),那么逻辑尺寸就是 (w / contentsScale, h / contentsScale)。它的值不但影响由 CGImage 提供的 contents,还会影响由 drawInContext: 绘制的 contents(如果值为 2,drawInContext: 在绘制时会绘制 layer.bounds 的两倍)。

默认值为 1.0。但是,如果这个 layer 是 UIView 直属的(创建 view 时创建的),这个值会自动被设置为 [UIScreen mainScreen].scale;但是对于我们自行创建的 layer ,值为 1.0。

NOTE 测试的时候记得把 contentsGravity 设置为不带 Resize 的。

  • contentsRect

这个值允许我们设置 layer 显示 contents 的区域。

默认值为 {0, 0, 1, 1},以为整个 contents。与 frame / bounds 一点计数不同,这个属性使用单位坐标,每个小参数取值为 [0, 1]。

这个属性非常适合使用拼接图。某些时候,会需要使用大量的小图标,例如功能列表(请按一下操作执行:微信 -> 我,功能列表的小图标)。这样做有许多好处:内存使用、加载时间、渲染性能等。

搞起来~~~

int hNum = 2;
int vNum = 3;

int totalCount = hNum * vNum;

CGFloat margin = 10;

CGFloat vStart = 80;
CGFloat totalHeight = self.view.bounds.size.height - vStart - 110;

CGFloat viewWidth = (self.view.bounds.size.width - margin * (hNum + 1)) / hNum;
CGFloat viewHeight = (totalHeight - margin * (vNum + 1)) / vNum;


for (int row = 0; row < vNum; ++row) { /// 一行一行来
    for (int col = 0; col < hNum; ++col) {  /// 一列一列来
        UIView *theView = [[UIView alloc] initWithFrame:
                           CGRectMake(margin * (col + 1) + viewWidth * col,
                                      vStart + margin * (row + 1) + viewHeight * row,
                                      viewWidth,
                                      viewHeight)];
        [self.view addSubview:theView];
        
        theView.layer.contents = (__bridge id)theImage;
        theView.layer.contentsRect = CGRectMake((hNum * row + col) * 1.0 / totalCount, 0, 1.0 / totalCount, 1);
    }
}

UIView *wholeImage = [[UIView alloc] initWithFrame:CGRectMake(0, self.view.bounds.size.height - 105, self.view.bounds.size.width, 100)];
[self.view addSubview:wholeImage];
wholeImage.layer.contents = (__bridge id)theImage;

layer.contentsRect 演示

  • contentsCenter

这个一个 CGRect ,CGRect, CGRect。用来确定被拉伸区域。跟 UIImage.resizableImageWithCapInsets: 相似。

先把网上流传的图片拿过来:

然后解释一下:

设置了 contentsCenter 这个 CGRect 之后,我们把这个 rect 的边界延伸出去,这个 contents 就被分成了 9 块。

首先是这个 rect 之内的区域,也就是绿色区域;

然后是 rect 上下的两块区域,也就是蓝色区域;

接下来是 rect 左右的两块区域,也就是红色区域;

最后是在 rect 四个角的四块区域,也就是黄色区域。

  1. 蓝色区域只在水平方向拉伸;

    垂直方向两端在垂直方向不拉伸

    蓝色区域在 rect 垂直方向,垂直方向不拉伸

  2. 红色区域只在垂直方向拉伸;

    水平方向两端在水平方向不拉伸

    红色区域在 rect 水平方向,水平方向不拉伸

  3. 绿色区域同时在水平方向与垂直方向拉伸;

    在 rect 内部,两个方向都拉伸。

  4. 黄色区域不拉伸;

    在四个角,不拉伸。

说实话,看了那么多网上的解释,我想。。。

想了很久,找了一张旋涡图,个人认为很适合演示这个属性的。

layer.contentsCenter 演示

NOTE 并不是非要 x * 2 + width = 1,有兴趣的朋友可以尝试一下其他的组合。

2.2 专用 Layer 类

用途
CAEmitterLayer 实现基于 Core Animation 的粒子发射系统。CAEmitterLayer 控制粒子的产生和位置。
CAGradientLayer 绘制填满 layer 的渐变色。
CAMetalLayer 建立和使用可绘制纹理以使用 Metal 渲染 layer 内容。
CAEAGLLayer / CAOpenGLLayer 建立后备存储和图形上下文,并使用 OpenGL ES 来渲染 layer 内容(后者为 macOS,使用 OpenGL)
CAReplicatorLayer 当需要制作一个或多个 sublalyer 的副本时使用。复制器制造副本,并使用我们指定的属性来更改副本的外观与属性。
CAScrollLayer 管理由多个 sublayer 组成大的滑动区域
CAShapeLayer 绘制立体的贝塞尔曲线。CAShapeLayer 在绘制基于路径的形状上非常优秀,因为它永远输出清晰的路径,这与我们绘制到 layer 后备存储相反,后者在缩放时效果并不好。然而,清晰的结果需要在主线程上绘制并缓存。
CATextLayer 渲染纯文本或属性字符串
CATiledLayer 管理可分离为小块的大图,支持放大和缩小内容,从而分别渲染
CATransformLayer 渲染真正的 3D 图层结构

2.3 与 Auto Layout 联动一下

若需要重新绘制整个 layer,调用 [self setNeedsDisplay] 来在下一次 drawing cycle 绘制全部内容;

若只需要重新绘制部分内容,调用 [self setNeedsDisplayInRect:rect] 来在下一次 drawing cycle 绘制指定矩形框内的内容。

3、 UIViewCALayer 的联系

UIView 负责响应事件,并管理 CALayer 的生命周期。CALayer 负责绘制内容。

UIViewLayer 的代理(CALayerDelegate)。

类比一下 PhotoShop。我们将 UIView 比喻成 PSD,而 CALayer 便是 图层了。一个 view 是由多个 layer 叠加而成。

  1. view 是 layer 的管理器,给 layer 提供了发挥的空间;

  2. 一个 view 至少有一个 layer;

  3. view 能绘制到屏幕上,这就要归功于 layer 了。layer 有一个 graphics context,view 其实就将这个 context 绘制到屏幕上,layer 会缓存这个绘制结果,view 可以通过 layer 来访问 context。当 view 需要被绘制到屏幕上时,会调用 layer 的绘制方法,当然这个过程也可以利用 drawRect: 来绘制自定义的内容,这个方法内绘制的内容就存在与 context 中。

  4. view 的层级决定 layer 的层级。view 的层级可以改变 layer 的层级,反之不行。(给 view 添加 subview ,其 layer 也会添加响应的 sublayer;但是直接给 layer 添加 sublayer 并不会影响 view 的 subviews);

接下来一段照抄。。。查看原文请前往 View-Layer Synergy

所有的 view 内部都存在一个 layer,并且 view 从这个 layer 直接获得了大多数数据。因此,对 layer 的修改也将反应到 view 中。这意味着,使用 Core Animation 或 UIKit 都可以达到修改界面的目的。

不过,也有一些单独存在的 layer,比如 AVCaptureVideoPreviewLayerCAShapeLayer,它们不需要附加到 view 就能在屏幕上显示内容。无论是哪种情况,都是 layer 在起决定性作用。然而,被附加到 view 的 layer 与独立的 layer 在行为上还是稍有不同的。

如果我们修改独立 layer 的任何属性(几乎是任何属性),都会看到一个从旧值过渡到新值的动画。然而,我们修改 view 中 layer 的同一个属性,它会直接从这一帧调到下一帧。尽管这两种情况主角都是 layer,但 layer 一旦被附加到 view 中,其默认隐式动画的行为就消失了。

layer 几乎所有的属性都是隐式可动画的。在文档中可以看到他们的简介以 animatable 结尾。

这适用于绝大多数数字属性,例如 position、size、color、opacity,甚至是 isHidden 和 doubleSided。

属性 paths 也是可动画的,但是不支持隐式动画。

在文章 Core Animation Programming Guide 第四章节 “Animating Layer Content” 的小节 “How to Animate Layer-Backed Views” 中,解释了为何 view 中的 layer 没有隐式动画的能力。

The UIView class disables layer animations by default but reenables them inside animation blocks.

默认情况下,UIView 类禁止了 layer 的动画,但是在 animation block 中又重新启用了。

这正是我们所看到的行为:在 animation block 外修改属性并没有动画;但是在 animation block 中修改属性时动画便出现了。

无论何时,一个可动画的属性改变时,layer 总是会总是寻找合适的 action 来实行这个改变。用 Core Animation 的专业术语来讲,这样的动画被称为 actionCAAction)。

从技术上来讲,CAAction 是一个可以,他可以用来做很多事情。但是在实际使用中,我们一般用来处理动画。

layer 以 文档 中所说的方式来查找 action,这包括五个步骤。当我们研究 view 与 layer 的交互时,第一步是最有趣的。

layer 向它的代理(如果是附加到 view 的 layer,代理就是这个 view;独立的 layer,其代理使我们自行制定的 )发送消息 actionForLayer:forKey: 来询问一个属性改变的 action。代理可以通过以下三种方式来响应:

  1. 返回一个 action 对象,这种情况下 layer 将使用这个 action;
  2. 返回 nil,此时 layer 将去其他地方继续寻找;
  3. 返回一个 NSNull 对象,此时 layer 将不执行 action,寻找工作也到此为止。

有趣的点在于:对于一个附加到 view 中的 layer,这个 view 就是这个 layer 的代理。

在 iOS 中,如果一个 layer 被附加到 UIView 对象中,layer 的 delegate 属性必须设置为这个 UIView 对象。

之前如此晦涩难懂的行为,现在已经很明了了:任何时候,layer 询问 action ,view 总是返回 NSNull 对象,除非在 animation block 中修改属性。但是,不要轻信,我们可以验证一下。只需要以一个可以动画的 layer 属性来询问 view 即可,比如 position:

NSLog(@"在 animtion block 之外:%@", [self.view actionForLayer:self.view.layer forKey:@"position"]);
    
[UIView animateWithDuration:0.25 animations:^{
    NSLog(@"在 animtion block 之中:%@", [self.view actionForLayer:self.view.layer forKey:@"position"]);
}];

从结果来看,在 animation block 之外返回的是一个 <null>,这正是 NSNull 对象,而在 animtion block 之中返回的是一个 _UIViewAdditiveAnimationAction 对象。

打印 nil 的 log 是 (null) ,NSNull 是 <null>

对于附加到 view 的 layer,对于 action 的寻找只会到第一步。对于独立的 layer,更多的四个步骤可以查看 actionForKey: - Apple Developer Documentation

4、 UIViewCALayer 的区别

  • UIView 负责响应事件,参与响应链,为 layer 提供内容。

  • CALayer 负责绘制内容,动画。

三、UI 绘制

在开发中,UI 界面是不可或缺的一部分,对于用户来说,这部分甚至高过于其他任何东西。iOS 提供了非常丰富且性能优越的 UI 工具库,直接使用 UIKit 和 Core Animation 已经可以满足绝大部分的工作学了。

但是,我们仍然会遇到显示的一些问题,其中以卡顿为首。想要解决问题,就必须得了解问题的本质。只要搞懂了 UI 是如何显示到屏幕上的,其中经过了哪些步骤,再集合一些辅助工具,就能从根源解决问题。

1、屏幕成像原理

1.1 VSync、HSync

CRT 显示器绘制原理

这是老式的 CRT 显示器的原理图。CRT 电子枪按照上图方式,从上到下一行一行扫描,扫描完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。

为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或其他硬件)会用硬件时钟产生一系列的定时信号。

当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizontal synchronization),简称 HSync;当一帧画面绘制完成之后,电子枪恢复到初始位置,准备绘制下一帧之前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。

通常显示器以固定的频率进行刷新,这个固定的刷新频率就是 VSync 信号产生的频率。

1.2 图像显示原理

屏幕上一帧画面的显示是由 CPU、GPU 和显示器按照上图的方式协同工作完成的。CPU 计算好显示内容提交到 GPU,GPU 渲染完成之后将渲染结果放入帧缓存区,随后视频控制器会按照 VSync 信号 逐行 读取帧缓冲区 frame buffer 的数据,经过数模转换传递给显示器显示。

在最简单的情况下,帧缓冲区只有一个,这种情况下帧缓冲区的读取与刷新都会存在比较大的效率问题。为了解决效率问题,显示系统会引入两个帧缓冲区,也就是 双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU 会直接把视频控制器的指针指向第二个缓冲区。

双缓冲这样的处理确实解决了效率问题,但是这也引入了新的问题。当视频控制器对当前缓冲区的读取还未完成时,即屏幕内容刚显示一部分时,GPU 将新的一帧提交到帧缓冲区并将两个缓冲区指针交换后,视频控制器就会从新一帧画面数据中来读取还未读取的部分,造成画面上下撕裂。就像这样:

画面撕裂

视频控制器是 逐行 读取帧缓冲区的数据的。

上半部分数据来自于交换之前的帧缓冲区,下半部分数据来自原交换之后的帧缓冲区。

为了解决这个问题,GPU 通常有一个叫 VSync 的机制。开启 VSync 之后,GPU 会等待显示器上一帧的 VSync 信号发出之后,才进行当前帧的缓冲区更新和下一帧的渲染。这就能解决上边的画面撕裂问题,也增加了画面流畅度。但是这需要更多的计算资源,也会带来部分延迟。

GPU 等待 VSync 信号是为了等待视频控制器读取完上一帧的数据然后 交换两个缓冲区的指针渲染下一帧。并不是为了渲染当前帧,当前帧已经在另一个缓冲区了。GPU 会在视频控制器刚刚读完数据的哪个缓冲区渲染下一帧。如果是渲染当前帧,那就失去了双缓冲区的意义了。

可能有点绕:等待第一帧画面显示完成的 VSync 信号,将视频控制器的指针指向已经渲染好的第二帧画面的缓冲区,然后开始渲染第三帧(在第一帧画面的缓冲区内)。

目前 iOS 设备有双缓冲机制,也有 三缓冲机制

总结来说,屏幕成像流程如下:

  1. CPU 计算好需要显示的内容,提交给 GPU;

  2. GPU 进行纹理合成,将渲染结果提交到帧缓冲区;

  3. 视频控制器总帧缓冲区逐行读取输入,进行数据转换之后传递给显示器;

  4. 显示器成像。

1.3 Core Animation 流水线

在 iOS 中,视图的渲染工作其实是由一个 Render Server 的独立进程来完成的。它在 iOS 5 及之前交 SpringBoard,之后则被叫做 BackBoard。

我们的所有视图、动画的都是由 Core Animation 的 CALayer 实现的,这是 Core Animation 的绘制管线图:

Core Animation 通过 Core Animation Pipeline 实现绘制,它以流水线的形式进行渲染工作:

  • Commit Transaction

    • Layout :构建 UI, 布局,文本计算等;

    • Display :视图绘制,主要就是 drawRect

    • Prepare :附加步骤、一般做图片解码;

    • Commit :将 layer 递归打包,提交给 Render Server。

      这一步发生在 Runloop 在 BeforeWaitingExit 之前的 drawing cycle 中。

  • Render Server

    • 将数据反序列化得到图层数;

    • 根据图层树的图层顺序、RGBA值、图层 frame 等过滤掉图层中被遮挡的部分;

    • 将图层树转为渲染树;

    • 将渲染树提交给 OpenGL / Metal

    • OpenGL / Metal 生成绘制命令,等待 VSync 信号到来,随后提交到命令缓冲区 Command Buffer 供 GPU 读取执行。

  • GPU

    等待 VSync 信号到来,随后从命令缓冲区读取指令并执行。

    • Vertext Shader :顶点着色

    • Shape Assembly :形状装配,又称图元装配

    • Geometry Shader : 几何着色

    • Rasterization :光栅化

    • Fragment Shader :片段着色

    • Tests and Blending : 测试与混合

  • Display

VSync 信号到来时,视频控制器从帧缓冲区中逐行读取数据,控制屏幕显示内容

1.4 卡顿、掉帧原因

众所周知,iOS 设备的屏幕刷新率为 60hz(iOS 14 之后可以开启高刷新率 120hz。。。)。也就是说,一秒钟需要更新 60 帧画面,这相当于一帧画面的渲染时间是 16.67ms。

VSync 信号每隔 16.67ms 产生一次。等 VSync 信号到来之后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容,这包括视图创建、布局计算、图片解码、文本绘制等。随后 CPU 将计算好的内容提交到 GPU ,GPU 进行变换、合成、渲染,然后将结果提交到帧缓冲区。等待下一次 VSync 信号到来时显示到屏幕上。

由于垂直同步的机制,如果在一个 VSync 的时间内,CPU 或 GPU 没有完成其负责的工作,这就导致渲染结果提交到帧缓冲区的时间晚于下一个 VSync 信号到达的时间,那这一帧就被丢弃,等待下一次机会再显示,此时显示器保持之前的内容不变。也就是说,本该存在于第一帧与第三帧之间的第二帧没有被现实,就造成了掉帧。这也就是界面卡顿的原因。

总结下来: **CPU 与 GPU 在下一次 VSync 信号到达时还未完成下一帧画面的渲染,就会造成掉帧卡顿。

专栏限制 20000 字, 续: 阿里、字节:一套高效的iOS面试题(九 - 视图&图像相关 - 下)