全方位剖析iOS高级技术问题(一)之UI视图

本文主要内容

一.UITableView相关
二.事件传递&视图响应
三.UI图像显示原理
四.UI卡顿&掉帧
五.UIView的绘制原理&异步绘制
六.离屏渲染

全方位剖析iOS高级技术问题(一)之UI视图

一.UITableView相关

1、重用机制

cell = [tableView dequeueReusableCellWithIdentifier:identifier];

如图,虚线部分为屏幕所显示内容,其中A3-A5都完全显示在屏幕当中,A2和A6只有一部分显示在屏幕中,假如当前UITableView是将屏幕向上滑动的中间状态结果,此时A1会被加入到重用池中(因为A1已经被滚出到屏幕之外了),再继续向上滑动UITableView,A7就会从重用池中根据指定的identifier标识符取出一个可重用的cell。如果A1-A7都用同一个标识符,A7就可以复用A1所创建的cell的内存或者说是空间,从而达到cell的重用(复用)的目的。

全方位剖析iOS高级技术问题(一)之UI视图

实例

字母索引条
在工程中主要定义了类ViewReusePool代表视图的重用池,用来实现重用机制,Index edTableView是UITableView的带索引条的子类,最后在ViewController.m中使用带索引条的IndexedTableView

如下为ViewReusePool.h,关于重用池的实现方案。首先创建继承于NSObject的类V iewReusePool,用这个类来实现重用机制。其中定义了3个方法dequeueReusableView方法是从重用池当中取出一个可重用的view作为此方法的返回值,addUsingView:方法是向重用池当中添加一个视图,reset方法是重置方法,将当前使用中的视图全部移动到可重用队列当中。

#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN

// 实现重用机制的类
@interface ViewReusePool : NSObject

// 从重用池当中取出一个可重用的view
- (UIView *)dequeueReusableView;

// 向重用池当中添加一个视图
- (void)addUsingView:(UIView *)view;

// 重置方法,将当前使用中的视图移动到可重用队列当中
- (void)reset;

@end

ViewReusePool.m有对应方法的实现。在实现中创建了ViewReusePool类的扩展,并在其中添加了两个成员变量:waitUseQueue代表等待使用的队列,usingQueue代表使用中的队列,都是用集合来实现。在初始化过程中创建等待使用的队列和使用中的队列。接着对声明的3个方法进行实现。
dequeueReusableView是从重用池当中取出一个可重用的view。首先从集合_waitUse Queue中取出一个对象,如果取出的对象为nil,说明当前没有可重用的view,返回nil;如果取出的对象存在,则先要把等待重用队列中的这个视图移除,再将该视图添加到正在使用的队列当中,返回这个视图作为可重用的视图。
addUsingView:是向重用池当中添加一个视图。首先进行异常判断,如果添加的view为空,什么也不做,直接返回;如果有值,就把view添加到使用中的队列当中。
reset是重置方法,将当前使用中的视图移动到可重用队列当中。首先声明局部变量view,然后遍历使用队列中的视图,如果存在,将其从使用中队列移除,加入到等待使用的队列当中,即将使用中队列的视图全部移动到等待使用的队列当中

#import "ViewReusePool.h"
@interface ViewReusePool()

// 等待使用的队列
@property(nonatomic, strong) NSMutableSet *waitUseQueue;
// 使用中的队列
@property(nonatomic, strong) NSMutableSet *usingQueue;

@end

@implementation ViewReusePool

// 初始化
- (instancetype)init {

    self = [self init];
    if (self) {

        // 1.创建等待中的队列和使用中的队列
        _waitUseQueue = [NSMutableSet set];
        _usingQueue = [NSMutableSet set];
    }
    return self;
}

#pragma mark - 从重用池当中取出一个可重用的view
- (UIView *)dequeueReusableView {

    // 2.1 从集合_waitUseQueue中取出一个对象
    UIView *view = [_waitUseQueue anyObject];

    // 2.2-1 如果取出的对象为nil,说明当前没有可重用的view
    if (view == nil) {
        return nil;
    }else {
        // 2.2-2如果取出的对象存在,则先要把等待重用队列中的这个视图移除,再将该视图添加到正在使用的队列当中,返回这个视图作为可重用的视图
        // 进行队列移动
        [_waitUseQueue removeObject:view];
        [_usingQueue addObject:view];

        return view;
    }
}

#pragma mark - 向重用池当中添加一个视图
- (void)addUsingView:(UIView *)view {

    // 3.1如果添加的view为空,return
    if (view == nil) {
        return;
    }
    // 3.2如果添加的视图不为空,添加视图到使用中的队列
    [_usingQueue addObject:view];
}

#pragma mark - 重置方法,将当前使用中的视图移动到可重用队列当中
- (void)reset {

    // 4.1定义局部变量
    UIView *view = nil;

    // 4.2 将使用中队列中的视图对象全部移动到等待使用队列当中
    while ((view = [_usingQueue anyObject])) {

        // 4.2-1 从使用中的队列移除
        [_usingQueue removeObject:view];

        // 4.2-2 加入等待使用的队列
        [_waitUseQueue addObject:view];
    }
}

定义类IndexedTableView是UITableView的带索引条的子类。IndexedTableView .h中内容如下,定义了一个数据源协议,来获取一个tableView的字母索引条数据:

#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN

// 数据源协议
@protocol IndexedTableViewDataSource <NSObject>

// 获取一个tableView的字母索引条数据的方法
- (NSArray <NSString *> *)indexTitlesForIndexTableView:(UITableView *)tableView;

@end

@interface IndexedTableView : UITableView

@property (nonatomic, weak) id <IndexedTableViewDataSource>indexedDataSource;

@end

IndexedTableView.m中对应的实现,定义一个容器containerView用于装载所有的字母索引控件,定义一个ViewReusePool类型的重用池reusePool,实现重用机制。然后重写reloadData方法,懒加载字母索引容器和重用池,将重用池重置,即将所有视图标记为可重用状态。在加载字母索引条过程中,先判断当前数据源是否响应数据源方法index TitlesForIndexTableView:,如果响应则通过数据源方法向数据源提供方获取字母索引数组,获取字母索引条的显示内容,判断字母索引条是否为空,为空时,不显示索引条。接着从重用池当中取一个button出来,如果没有可重用的button重新创建一个,同时注册button到重用池中,如果有可重用的button则重用。然后将button添加到父视图中。

#import "IndexedTableView.h"
#import "ViewReusePool.h"

@interface IndexedTableView() {
    // 容器:装载所有的字母索引控件
    UIView *containerView;

    // 重用池
    ViewReusePool *reusePool;
}

@end

@implementation IndexedTableView

- (void)reloadData {
    [super reloadData];
    
    // 懒加载字母索引容器
    if (containerView == nil) {
        containerView = [[UIView alloc] initWithFrame:CGRectZero];
        containerView.backgroundColor = [UIColor whiteColor];

        //避免索引条随着table滚动
        [self.superview insertSubview:containerView aboveSubview:self];
    }

    // 懒加载重用池
    if (reusePool == nil) {
        reusePool = [[ViewReusePool alloc] init];
    }

    // 标记所有视图为可重用状态
    [reusePool reset];

    // reload字母索引条
    [self reloadIndexedBar];
}

- (void)reloadIndexedBar {

    // 获取字母索引条的显示内容
    NSArray <NSString *> *arrayTitles = nil;

    // 判断当前数据源是否响应数据源方法
    if ([self.indexedDataSource respondsToSelector:@selector(indexTitlesForIndexTableView:)]) {

        // 通过数据源方法向数据源提供方获取字母索引数组
        arrayTitles = [self.indexedDataSource indexTitlesForIndexTableView:self];
    }

    // 判断字母索引条是否为空
    if (!arrayTitles || arrayTitles.count <= 0) { // 没有字母索引条
        [containerView setHidden:YES];
        return;
    }

    NSUInteger count = arrayTitles.count;
    CGFloat buttonWidth = 60;
    CGFloat buttonHeight = self.frame.size.height / count;

    for (int i = 0; i < [arrayTitles count]; i++) {
        NSString *title = [arrayTitles objectAtIndex:i];

        // 从重用池当中取一个Button出来
        UIButton *button = (UIButton *)[reusePool dequeueReusableView];
        
        // 如果没有可重用的Button重新创建一个
        if (button == nil) {
            button = [[UIButton alloc] initWithFrame:CGRectZero];
            button.backgroundColor = [UIColor blueColor];

            // 注册button到重用池中
            [reusePool addUsingView:button];
            
            NSLog(@"新创建了一个button");
        } else {
            NSLog(@"button重用了");
        }

        // 添加button到父视图控件
        [containerView addSubview:button];
        [button setTitle:title forState:UIControlStateNormal];
        [button setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
        // 设置button的坐标
        [button setFrame:CGRectMake(0, i * buttonHeight, buttonWidth, buttonHeight)];
    }

    [containerView setHidden:NO];
    containerView.frame = CGRectMake(self.frame.origin.x + self.frame.size.width - buttonWidth, self.frame.origin.y, buttonWidth, self.frame.size.height);
}

@end

ViewController.m中使用带索引条的IndexedTableView。定义带有索引条的tabl eView、刷新索引条的按钮button、数据源dataSource。接着创建IndexedTableVi ew类型的tableView,设置tableView的索引数据源,创建刷新按钮button,初始化数据源dataSource。然后遵守数据源协议返回索引条数据,实现UITableView数据源和代理协议。

#import "ViewController.h"
#import "IndexedTableView.h"

@interface ViewController ()<UITableViewDataSource,UITableViewDelegate,IndexedTableViewDataSource> {

    // 带有索引条的tableView
    IndexedTableView *tableView;

    // 刷新button
    UIButton *button;

    // 数据源
    NSMutableArray *dataSource;
}
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    // 创建一个TableView
    tableView = [[IndexedTableView alloc] initWithFrame:CGRectMake(0, 60, self.view.frame.size.width, self.view.frame.size.height - 60) style:UITableViewStylePlain];
    tableView.delegate = self;
    tableView.dataSource = self;

    // 设置tableView的索引数据源
    tableView.indexedDataSource = self;
    [self.view addSubview:tableView];

    // 刷新按钮
    button = [[UIButton alloc] initWithFrame:CGRectMake(0, 20, self.view.frame.size.width, 40)];
    button.backgroundColor = [UIColor redColor];
    [button setTitle:@"readTable" forState:UIControlStateNormal];
    [button addTarget:self action:@selector(doAction:) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:button];

    // 数据源
    dataSource = [NSMutableArray array];
    for (int i = 0; i < 100; i++) {
        [dataSource addObject:@(i+1)];
    }
}

#pragma mark **- IndexedTableViewDataSource**
- (NSArray<NSString *> *)indexTitlesForIndexTableView:(UITableView *)tableView {

    // 奇数次调用返回6个字母,偶数次调用返回11个字母
    static BOOL change = NO;

    if (change) {
        change = NO;
        return @[@"A",@"B",@"C",@"D",@"E",@"F",@"G",@"H",@"I",@"J",@"K"];
    } else {
        change = YES;
        return @[@"A",@"B",@"C",@"D",@"E",@"F"];
    }
}

#pragma mark **- UITableViewDataSource**
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return [dataSource count];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

    static NSString *identifier = @"reuseID";
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];

    // 如果重用池当中没有可重用的cell,则创建一个cell
    if (cell == nil) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:identifier];
    }

    // 文案设置
    cell.textLabel.text = [[dataSource objectAtIndex:indexPath.row] stringValue];
    return cell;
}

#pragma mark **- UITableViewDelegate**
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return 40;
}

- (void)doAction:(UIButton *)button {

    NSLog(@"reloadData");
    [tableView reloadData];
}

@end

2、数据源同步问题

当我们开启子线程处理数据源的时候,主线程的操作并没有记录在子线程中。这样就会导致子线程处理完数据返回主线程刷新UI后数据错乱。
例如,在tableView中显示新闻数据和广告,子线程进行网络请求,数据解析等操作的同时,在主线程中删除了广告,并更新UI。然后子线程处理完数据,最终也在主线程中更新UI,子线程没有记录主线程的删除操作,导致数据源没有同步的问题。数据源同步解决方案有两种:

(1)并发访问、数据拷贝
比如当前存在主线程和子线程,在主线程中进行数据拷贝,给子线程使用。在子线程中进行新数据的网络请求、数据解析、预排版等,与此同时,在主线程中删除一行数据,reload UI后此数据UI消失,接着进行其他的工作。接着,子线程返回请求的结果,并再进行一次reload UI。整个过程中为了保证数据源的同步,需要注意两步操作:一是在主线程删除数据时记录此删除操作;二是在子线程返回请求数据之前同步进行删除操作,从而保证子线程在返回数据时的数据源和主线程数据源同步

时序图: 全方位剖析iOS高级技术问题(一)之UI视图

(2)串行访问
串行访问的原理是通过GCD开辟一条串行队列,把数据操作的任务放到串行队列上面操作,这样可以同步主线程和子线程对数据源的操作。 时序图: 全方位剖析iOS高级技术问题(一)之UI视图

  1. 并行访问数据拷贝:顾名思义是要对操作进行记录并拷贝到子线程中,这样需要开辟内存空间,对内存消耗较大。
  2. 串行访问:当线程有耗时操作时,就会导致对数据源的增删改查操作有延时。

二.事件传递&响应

问题一:UIView和CALayer相关问题
UIView包含layer、backgroundColor属性,layer为CALayer类型,backgroundColor实际是对CALayer中同名属性的包装,UIView的显示部分由CALayer中contents属性决定的,contents中的backing store实际是一个bitmap位图。

UIView为CALayer提供内容,以及负责处理触摸等事件,参与事件响应链; CALayer负责显示内容contents; 如上符合单一职责的设计原则。

全方位剖析iOS高级技术问题(一)之UI视图

问题二:事件传递机制
如图,View A中包含View B1、View B2,View B2中包含View C1,View C2既包含View C1的一部分,由包含View B2的一部分,View C1中包含View D。当点击View C2的空白区域时,系统如何找到事件响应者为View C2?

全方位剖析iOS高级技术问题(一)之UI视图

事件传递和如下2个方法相关

// 哪个视图响应事件返回哪个  
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;     
// 点击位置是否在当前视图范围  
-(BOOL)pointInside(CGPoint)point withEvent:(UIEvent *)event; 

(1)事件传递流程

当用户点击屏幕的某个位置,该事件会被传递给UIApplicationUIApplication又传递给当前的UIWindow,UIWindow会通过hitTest:WithEvent方法返回响应的视图。hi tTest:WithEvent:方法内部通过pointInside:withEvent:方法判断点击point是否在当前UIWindow范围内,如果在,则会遍历其中的所有子视图SubViews来查找最终响应此事件的视图,遍历方式为倒序遍历,即最后添加到UIWindow的视图最优先被遍历到,依次遍历,可以看作是递归调用。每个UIView中又都会调用其对应hitTest:WithEvent:方法,最终返回响应视图hit,如果hit有值,则hit视图就作为该事件的响应视图被返回,如果hit没有值,但在当前UIWindow范围内,则当前UIWindow作为事件的响应视图。

全方位剖析iOS高级技术问题(一)之UI视图

(2)hitTest:WithEvent:系统内部实现

首先在hitTest:WithEvent:方法内部先判断当前视图的hidden属性、是否可交互、透明度是否大于0.01。如果该视图不满足上述3个条件,则返回nil,当前视图不作为事件的响应视图,当前视图的父视图继续遍历其他的子视图;如果该视图没有隐藏、用户可交互、透明度大于0.01,则会通过pointInside:WithEvent:方法判断点击的点是否在当前视图范围内,如果不在,则同样返回nil,当前视图仍不作为事件的响应者;如果在,则会通过倒序遍历当前视图的子视图,调用其子视图对应的hitTest:WithEvent:方法,如果某个视图返回了事件响应视图,则该返回的视图被作为事件的响应者,反之则继续遍历判断。如果遍历完后没有任何视图响应此事件,因为此事件点击的范围在当前视图范围内,则将当前视图作为事件响应者返回。

全方位剖析iOS高级技术问题(一)之UI视图

(3)视图事件响应流程

上述讲述了视图事件的传递流程,当视图事件传递后,最终事件由谁来响应呢,这就涉及视图的响应链、响应链的机制和流程。 如图,页面存在一个UILabel一个UITextField、一个UIButton,实线箭头表示下一个响应者。

全方位剖析iOS高级技术问题(一)之UI视图

视图事件响应链相关的方法有:

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

例如,当点击View C2的空白处时,事件由谁来响应呢?首先由View C2接收事件,如果它不处理,就会把事件传递给View B2,如果View B2还不响应这个事件,View B2会通过响应链将事件传递给它的父视图View A,如果还不响应,则会沿着响应链一直向上传递,直到传递到UIApplicationDelegate仍然不对事件进行处理,则会忽略此事件全方位剖析iOS高级技术问题(一)之UI视图

三.UI图像显示原理

CPUGPU通过总线连接起来,在CPU中输出的结果往往是一个位图,经由总线在合适的时间点传给GPUGPU拿到位图会进行位图图层的渲染包括纹理的合成,然后将结果放到帧缓存区域frame Buffer中,由视图控制器根据VSync信号在指定时间之内去提取对应帧缓存区域中屏幕显示内容,最终显示在手机屏幕上。

全方位剖析iOS高级技术问题(一)之UI视图

1、图像显示原理

首先创建一个UIView控件后,它的显示部分是由CALayer负责的,CALayer中有一个con tents属性,就是要绘制到屏幕的位图。比如要用UILabel显示”Hello world”,则contents的内容就是关于”Hello world”的文字位图,系统会在合适的时候回调drawRect方法,在此基础上还可以绘制一些自定义的内容。绘制好的位图会通过Core Animation框架提交给GPU部分的OpenGL渲染管线,进行位图的渲染包括纹理的合成,最终显示在屏幕之上。 全方位剖析iOS高级技术问题(一)之UI视图

2、CPU和GPU的工作

CPU工作

Layout: UI布局和文本计算。如对应每一个控件frame的设置、文字、size等的计算。
Display: 绘制过程。如drawRect方法.
Prepare:图片编解码。如UIImageView,图片无法直接显示,需要对图片解码。
Commit: 提交位图。

GPU渲染管线

顶点着色:对位图的处理。
图元装配
光栅化
片段着色
片段处理
将处理好的位图提交到帧缓冲区FrameBuffer中

四.UI卡顿&掉帧

1、UI卡顿、掉帧的原因

一般说页面滑动的流畅性是60FPS,指每一秒中有60帧画面更新,人眼看到的就是流畅的效果。基于此,相当于每隔六十分之一秒,即16.7ms就要产生一帧画面,在这16.7ms的时间内需要CPU和GPU共同协作产生这一帧数据,比如CPU文本布局、UI计算、视图绘制、图片解码等,把产生的位图提交给GPU,GPU再进行图层合成、纹理渲染等,准备好下一帧画面,在下一帧的VSync信号到来时显示画面。假如CPU完成工作使用的时长较长时,留给GPU的时间就会较少,完成工作所用的总时间就可能会超过16.7ms,所以在下一帧VSync到来时没有准备好当下画面,由此产生掉帧,看到的效果就是滑动卡顿。总结来说就是:在规定的16.7ms之内,在下一帧VSync信号到来之前,CPU和GPU没有完成下一帧画面的合成,导致卡顿/掉帧全方位剖析iOS高级技术问题(一)之UI视图

2、滑动优化方案

  • CPU

    1.对象创建、调整、销毁放到子线程中,节省时间;
    2.预排版,将布局计算、文本计算放到子线程中,让主线程有更多时间响应用户的交互;
    3.预渲染,对文本等进行异步绘制,对图片等进行编解码。

  • GPU

    1.纹理渲染,尽量避免离屏渲染,减少纹理渲染用时;
    2.视图混合,减少视图层级的复杂度,异步绘制等。

五.UIView的绘制原理&异步绘制

1、UIView的绘制原理

当UIView调用setNeedsDisplay后并没有立刻开始当前视图绘制工作,而是在之后的某一时机才进行绘制。当UIView调用setNeedsDisplay时,系统会立即调用view的layer同名setNeeds Display,相当于在当前视图layer上打了一个脏标记,在当前runloop将要结束时才会调用CALayerdisplay方法,然后进入到当前视图真正的绘制工作流程中。CALayer的display方法内部会先判断layer的delegate是否响应displayLayer:方法,如果不响应就进入到系统绘制流程,如果响应,就相当于提供了异步绘制入口。

全方位剖析iOS高级技术问题(一)之UI视图

系统的绘制流程
CALayer内部会创建一个backing store,可以理解为CGConetextRef,然后判断layer是否有delegate,如果没有代理,CALayer调用drawInContext方法,如果有代理,就调用代理的drawLayer:inContext:方法,做视图的绘制工作,此过程存在于系统内部,再在合适的时机提供drawRect:回调方法做其他绘制工作。最后通过CALayer上传backing store(位图)到GPU,结束系统默认的绘制流程。 全方位剖析iOS高级技术问题(一)之UI视图

2、异步绘制

如果layer的delegate响应displayLayer方法,就可以进入异步绘制的流程中,代理负责生成对应的bitmap,设置该bitmap作为layer.contents属性的值

[layer.delegate displayLayer]

如图,存在主队列和全局并发队列,在某个时机,View调用了setNeedsDispla y方法,在当前runloop将要结束时系统调用视图对应CALayerdisplay方法,如果View.layer的代理实现了displayLayer方法,再通过线程的切换,在子线程中CGBitmapContextCreate创建绘图的上下文,通过CoreGraphic API做当前UI控件的绘制工作CGBitmapContextCreatelmage根据当前所绘制的上下文绘制一张image图片。然后回到主队列中提交这个位图,设置给CALayerContents。这就是异步绘制的整个过程。

全方位剖析iOS高级技术问题(一)之UI视图

六.离屏渲染

1、On-Screen Rendering

意为当前屏幕渲染,指的是GPU的渲染操作是在当前用于显示的屏幕缓冲区中进行;

2、Off-Screen Rendering

意为离屏渲染,指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。

3、什么情况会触发离屏渲染?

当我们指定了UI视图的某些属性,标记为它在未预合成之前不能用于当前屏幕上直接显示时,就会触发离屏渲染。有如下情况:

  • 圆角(当和maskToBounds一起使用时)
  • 图层蒙版
  • 阴影
  • 光栅化

4、为何要避免离屏渲染?

  • 在触发离屏渲染时,会增加GPU的工作量,这样很有可能导致CPU和GPU工作加起来的时间超过16.7ms,就会导致UI的卡顿和掉帧,所以要尽量避免离屏渲染。
  • 离屏渲染会创建新的渲染缓冲区,还要进行上下文切换。

本文总结

1.系统的UI事件传递机制是怎样的(hitTestWithEvent、pointInside);
2.使UITableView滚动更流畅得方案或思路都有哪些(CPU、GPU方面);
3.什么是离屏渲染(GPU),为什么要避免离屏渲染;
4.UIView和CALayer之间的关系是怎样的;