共计 4016 个字符,预计需要花费 11 分钟才能阅读完成。
梳理一下 iOS 的 UI。
iOS 的 UI 相关的重要概念就几个:Window,ViewController,View,Layer。
首先我们要知道,在这些概念中,UI 展示的核心的 layer,所有决定最终展示的信息都在 layer 上。
View 是对 layer 的直接封装,提供了更简洁的接口,并对部分外部输入(touch 事件等)作出处理。每个 View 都关联了一个 Layer,对这个 View 的 UI 相关修改基本上都会被同步到 Layer 上;当 View 在 addSubview 时,subview 的 layer 也会被添加到 layer 上。
ViewController 是 MVC 中的 Controller 层,负责对 View 的组织管理,以及 VC 间的跳转等处理。可以简单理解为页面级的管理器。跟 View 和 Layer 的关系类似,通过 VC 的方法改变 UI 时,是通过其 View 实现的。push 一个 VC 时,实质上是在移动 VC 关联的 View。
UIWindow 其实是 UIView 的子类,作为一种特殊的 View,它代表了 iOS UI 中最顶层的层级划分。在 iOS 的应用框架下,所有 UI 是必须挂在 Window 下才能够展示的,Window 可以理解为 UI 的入口。另外 Window 也是承载用户交互的核心。
下面梳理几个值得关注的点。
渲染流程
iOS 的渲染流程跟 Core Animation 这个框架关系比较大,Core Animation 其实不只是做动画,它管理着图层相关的一切。我们的 UI 会以图层的方式存储在图层树中,Core Animation 的职责就是将这些图层尽可能快地组合并渲染到屏幕上。
渲染流程没有开源,相关资料不是很多,
程序内的有:
- 布局 – 这是准备你的视图 / 图层的层级关系,以及设置图层属性(位置,背景色,边框等等)的阶段。
- 显示 – 这是图层的寄宿图片被绘制的阶段。绘制有可能涉及你的
-drawRect:
和-drawLayer:inContext:
方法的调用路径。- 准备 – 这是 Core Animation 准备发送动画数据到渲染服务的阶段。这同时也是 Core Animation 将要执行一些别的事务例如解码动画过程中将要显示的图片的时间点。
- 提交 – 这是最后的阶段,Core Animation 打包所有图层和动画属性,然后通过 IPC(内部处理通信)发送到渲染服务进行显示。
程序外的(系统渲染服务)有:
- 对所有的图层属性计算中间值,设置 OpenGL 几何形状(纹理化的三角形)来执行渲染
- 在屏幕上渲染可见的三角形
我们直接接触的 Layer 构成的树结构称为模型树,它记录了所有属性。如果有动画应用到 Layer 上,当前的模型树的数据其实是动画结束后的数据,而 Layer 当前应当展示的数据,对应着 Presentation Layer,构成了呈现树。渲染时呈现树被打包发送给渲染服务,渲染服务将其反序列化为一颗渲染树来执行最终渲染。
在一个通用的渲染流程中,拿到图层后,通常需要进行光栅化和合成。光栅化是指,在一个原始的 Layer 中,通常只是保存了绘制指令或相关属性等原始数据,通过这些原始数据生成每个像素的颜色,也就是内存中的图形数据的过程,称为光栅化;而合成是指,一个界面有很多个 Layer 构成,如果每个 layer 独立生成了自己的图形数据,需要将其合成在一起。
光栅化的过程,也可以是每个 Layer 光栅化到对应屏幕的目标缓冲区中,而非自己单独的缓冲区,这种称为直接光栅化。如果所有 Layer 都直接光栅化,那么合成这个步骤就没有必要存在了。但很多时候还是需要给一些 Layer 分配自己的缓冲区的,也就是间接光栅化。一方面是性能优化,有些 Layer 内容没变,每次都去重绘代价比较高,可以分配一个独立的缓冲区,只在需要的时候重绘,每次屏幕刷新只是从这个缓冲区 copy 到目标缓冲区,性能消耗大大降低了;另一方面,根据内容的不同,有的 Layer 需要 CPU 绘制,有的需要 GPU,两个绘制通常是不在一个串行的流水线上的,并且 CPU 绘制性能一般会差一些,因此往往给 CPU 绘制的内容分配独立的缓冲区。
CoreGraphics 是个主要依赖 CPU 渲染的框架,如果我们在 drawrect 或 drawLayer 方法中使用了 CoreGraphics 或直接把一个 CGImage 赋值给 Layer 的 contents,那么在渲染前,Layer 就在内存中分配了一个缓冲区存放了图形数据,其实是在发送给渲染服务前就进行了软件光栅化;而一般的 Layer,本身其实是绘制指令 / 属性的集合,需要生成 OpenGL/Mental 的绘制指令,是发送给系统渲染服务后,通过 GPU 进行光栅化。这一部分,可能大多数时候是直接光栅化。
但是也有些时候因为能力的问题,必须进行间接光栅化。比如 parent layer 设置了 cornerRadius+clipsToBounds,就只能先将这个 Layer 和它的所有 sublayer 先合成后再做裁剪,最终再 copy 到目标缓冲区。这在 iOS 目前的渲染流程下是不可避免的,被称为离屏渲染,需要我们在开发过程中酌情避免。常见的引起离屏渲染的属性,除了 cornerRadius+clipsToBounds 外,还有 shadow、group opacity、mask、UIBlurEffect 等。
响应链
这里主要关注一下 touch 事件。
touch 事件的根源是屏幕(硬件),能拿到的只是屏幕坐标,然后通过系统分发到应用然后按 UIKit 这套逻辑来处理。
那么显然,应用首先感知到 touch 事件的应当是 UIApplication。然后通过视图位置与层级关系寻找到真正点击的 View。
事件响应的完整流程是:
从 KeyWindow 开始通过 hitTest 方法层层遍历找到目标 View,这里主要是通过位置关系进行寻找;
找到目标 View(First Responser)后,再按视图层级向上传递。
从上下向下的查找逻辑主要是依赖视图的 frame 的,简单写个伪代码如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// 不在自己内,直接 return
if(![self pointInside:point withEvent:event]){return nil;}
// 倒着遍历,从外到内第一个符合条件的 subview
for(int i = self.subViews.length - 1;i>=0;i--){UIView *subView = self.subViews[i];
UIView *targetView = [subView hitTest:point withEvent:event];
if(targetView){return targetView;}
}
// subview 都不满足条件,返回 self
return self;
}
从下向上的响应链传递主要是依赖视图的层级关系:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {self.next.touchesBegan(touches, with: event);
}
next 是 UIResponder 的属性,
如上图所示,如果一个 View 是 UIViewController 的根 View,那么它的 Next responder 是 ViewController,否则是它的父 View,直到传给 Window 再到 UIApplication。
这两个过程中我们可以做的主要是:
- 自上而下的第一响应者寻找过程,可以重写 HitTest/pointInside 方法,使得一个 View 改变响应区域。
- 自下而上的事件传递中,可以在必要时阻断事件传递或转发给其它响应者进行处理。
UIControl
UIControl 继承自 UIView,UIControl 的子类们如 UIButton 可以添加点击等事件:
button.addTarget(self, action: #selector(onClickButton), for: UIControl.Event.touchUpInside);
UIControl 主要有两个特点:
- UIControl 对所有 Touch 事件都会阻断
- UIControl 只有自己是第一响应者的时候才会处理 UIControl.Event
手势
手势是对 touch 事件的更高层封装,可以对应一个或一系列的 touch 事件。
因此手势的识别会有个状态机进行维护:
如图所示,对于离散的手势,状态比较简单,只有 Possible、Failed、Recognized 三种状态。
而对于连续的手势,在第一次识别到 touch 时会变为 Began,然后变为 Changed,然后一直 Changed -> Changed,直到用户手指离开视图,状态为 Recognized。
默认地,多个手势识别器不会同时识别,且有默认顺序。可以通过 gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:
控制多个手势同时识别;可以通过 requireGestureRecognizerToFail:
控制手势识别的顺序。
手势和 Touch
默认地,touchesBegan 和 touchesMoved 事件是同时传递给手势识别器和 View 的,而 touchesEnd 事件则会先传递给手势识别器,手势识别器如果识别成功,会传递 touchsCanceled 给 View,如果识别失败,则把 touchedEnd 传给 View。
手势识别器有几个属性会影响这一过程:
- delaysTouchesBegan(默认 NO): touchesBegan/Move 会先发给手势识别器,保证当一个手势识别器在识别手势时,不会有 touch 事件传递给 View。
- delaysTouchesEnded(默认为 YES):即上面所说的,会等到手势识别出结果再发送 Canceled/Ended 给视图。