javanli / blog

blog
0 stars 0 forks source link

iOS知识梳理 - UI(一)渲染&触摸 #33

Open javanli opened 4 years ago

javanli commented 4 years ago

梳理一下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。

这两个过程中我们可以做的主要是:

  1. 自上而下的第一响应者寻找过程,可以重写HitTest/pointInside方法,使得一个View改变响应区域。
  2. 自下而上的事件传递中,可以在必要时阻断事件传递或转发给其它响应者进行处理。

UIControl

UIControl继承自UIView,UIControl的子类们如UIButton可以添加点击等事件:

button.addTarget(self, action: #selector(onClickButton), for: UIControl.Event.touchUpInside);

UIControl主要有两个特点:

  1. UIControl对所有Touch事件都会阻断
  2. UIControl只有自己是第一响应者的时候才会处理UIControl.Event

手势

手势是对touch事件的更高层封装,可以对应一个或一系列的touch事件。

因此手势的识别会有个状态机进行维护:

img

如图所示,对于离散的手势,状态比较简单,只有Possible、Failed、Recognized三种状态。

而对于连续的手势,在第一次识别到touch时会变为Began,然后变为Changed,然后一直Changed -> Changed,直到用户手指离开视图,状态为Recognized。

默认地,多个手势识别器不会同时识别,且有默认顺序。可以通过gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:控制多个手势同时识别;可以通过requireGestureRecognizerToFail:控制手势识别的顺序。

手势和Touch

img

默认地,touchesBegan和touchesMoved事件是同时传递给手势识别器和View的,而touchesEnd事件则会先传递给手势识别器,手势识别器如果识别成功,会传递touchsCanceled给View,如果识别失败,则把touchedEnd传给View。

手势识别器有几个属性会影响这一过程: