全方位剖析iOS高级技术问题(一)之UI视图
本文主要内容
一.UITableView相关
二.事件传递&视图响应
三.UI图像显示原理
四.UI卡顿&掉帧
五.UIView的绘制原理&异步绘制
六.离屏渲染
一.UITableView相关
1、重用机制
cell = [tableView dequeueReusableCellWithIdentifier:identifier];
如图,虚线部分为屏幕所显示内容,其中A3-A5都完全显示在屏幕当中,A2和A6只有一部分显示在屏幕中,假如当前UITableView是将屏幕向上滑动的中间状态结果,此时A1会被加入到重用池中(因为A1已经被滚出到屏幕之外了),再继续向上滑动UITableView,A7就会从重用池中根据指定的identifier标识符取出一个可重用的cell。如果A1-A7都用同一个标识符,A7就可以复用A1所创建的cell的内存或者说是空间,从而达到cell的重用(复用)的目的。
实例
字母索引条
在工程中主要定义了类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。整个过程中为了保证数据源的同步,需要注意两步操作:一是在主线程删除数据时记录此删除操作;二是在子线程返回请求数据之前同步进行删除操作,从而保证子线程在返回数据时的数据源和主线程数据源同步
。
时序图:
(2)串行访问
串行访问的原理是通过GCD开辟一条串行队列,把数据操作的任务放到串行队列上面操作,这样可以同步主线程和子线程对数据源的操作。 时序图:
- 并行访问数据拷贝:顾名思义是要对操作进行记录并拷贝到子线程中,这样需要开辟内存空间,对内存消耗较大。
- 串行访问:当线程有耗时操作时,就会导致对数据源的增删改查操作有延时。
二.事件传递&响应
问题一:UIView和CALayer相关问题
UIView包含layer、backgroundColor属性,layer为CALayer类型,backgroundColor实际是对CALayer中同名属性的包装,UIView的显示部分由CALayer中contents属性决定的,contents中的backing store实际是一个bitmap位图。
UIView为CALayer提供内容,以及负责处理触摸等事件,参与事件响应链; CALayer负责显示内容contents; 如上符合单一职责的设计原则。
问题二:事件传递机制
如图,View A中包含View B1、View B2,View B2中包含View C1,View C2既包含View C1的一部分,由包含View B2的一部分,View C1中包含View D。当点击View C2的空白区域时,系统如何找到事件响应者为View C2?
事件传递和如下2个方法相关
:
// 哪个视图响应事件返回哪个
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
// 点击位置是否在当前视图范围
-(BOOL)pointInside(CGPoint)point withEvent:(UIEvent *)event;
(1)事件传递流程
当用户点击屏幕
的某个位置,该事件会被传递给UIApplication
,UIApplication
又传递给当前的UIWindow
,UIWindow
会通过hitTest:WithEvent
方法返回响应的视图。hi tTest:WithEvent:
方法内部通过pointInside:withEvent:
方法判断点击point
是否在当前UIWindow
范围内,如果在,则会遍历其中的所有子视图SubViews
来查找最终响应此事件的视图,遍历方式为倒序遍历,即最后添加到UIWindow
的视图最优先被遍历到,依次遍历,可以看作是递归调用。每个UIView
中又都会调用其对应hitTest:WithEvent:
方法,最终返回响应视图hit
,如果hit
有值,则hit
视图就作为该事件的响应视图被返回,如果hit
没有值,但在当前UIWindow
范围内,则当前UIWindow
作为事件的响应视图。
(2)hitTest:WithEvent:系统内部实现
首先在hitTest:WithEvent:
方法内部先判断当前视图的hidden属性、是否可交互、透明度是否大于0.01。如果该视图不满足上述3个条件,则返回nil,当前视图不作为事件的响应视图,当前视图的父视图继续遍历其他的子视图;如果该视图没有隐藏、用户可交互、透明度大于0.01,则会通过pointInside:WithEvent:
方法判断点击的点是否在当前视图范围内,如果不在,则同样返回nil,当前视图仍不作为事件的响应者;如果在,则会通过倒序遍历当前视图的子视图,调用其子视图对应的hitTest:WithEvent:
方法,如果某个视图返回了事件响应视图,则该返回的视图被作为事件的响应者,反之则继续遍历判断。如果遍历完后没有任何视图响应此事件,因为此事件点击的范围在当前视图范围内,则将当前视图作为事件响应者返回。
(3)视图事件响应流程
上述讲述了视图事件的传递流程,当视图事件传递后,最终事件由谁来响应呢,这就涉及视图的响应链、响应链的机制和流程
。 如图,页面存在一个UILabel
、一个UITextField
、一个UIButton
,实线箭头表示下一个响应者。
视图事件响应链相关的方法有:
- (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仍然不对事件进行处理,则会忽略此事件
。
三.UI图像显示原理
CPU
和GPU
通过总线连接起来,在CPU
中输出的结果往往是一个位图,经由总线在合适的时间点传给GPU
,GPU
拿到位图会进行位图图层的渲染包括纹理的合成,然后将结果放到帧缓存区域frame Buffer
中,由视图控制器根据VSync信号
在指定时间之内去提取对应帧缓存区域中屏幕显示内容,最终显示在手机屏幕上。
1、图像显示原理
首先创建一个UIView控件后,它的显示部分是由CALayer
负责的,CALayer
中有一个con tents
属性,就是要绘制到屏幕的位图。比如要用UILabel显示”Hello world”,则contents
的内容就是关于”Hello world”的文字位图,系统会在合适的时候回调drawRect
方法,在此基础上还可以绘制一些自定义的内容。绘制好的位图会通过Core Animation
框架提交给GPU
部分的OpenGL
渲染管线,进行位图的渲染包括纹理的合成,最终显示在屏幕之上。
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没有完成下一帧画面的合成,导致卡顿/掉帧
。
2、滑动优化方案
- CPU
1.对象创建、调整、销毁放到子线程中,节省时间;
2.预排版,将布局计算、文本计算放到子线程中,让主线程有更多时间响应用户的交互;
3.预渲染,对文本等进行异步绘制,对图片等进行编解码。 - GPU
1.纹理渲染,尽量避免离屏渲染,减少纹理渲染用时;
2.视图混合,减少视图层级的复杂度,异步绘制等。
五.UIView的绘制原理&异步绘制
1、UIView的绘制原理
当UIView调用setNeedsDisplay后并没有立刻开始当前视图绘制工作,而是在之后的某一时机才进行绘制
。当UIView调用setNeedsDisplay
时,系统会立即调用view的layer同名setNeeds Display
,相当于在当前视图layer
上打了一个脏标记,在当前runloop将要结束时才会调用CALayer
的display
方法,然后进入到当前视图真正的绘制工作流程中。CALayer的display
方法内部会先判断layer的delegate
是否响应displayLayer:
方法,如果不响应就进入到系统绘制流程,如果响应,就相当于提供了异步绘制入口。
系统的绘制流程
CALayer内部会创建一个backing store
,可以理解为CGConetextRef
,然后判断layer是否有delegate,如果没有代理,CALayer调用drawInContext方法,如果有代理,就调用代理的drawLayer:inContext:方法,做视图的绘制工作,此过程存在于系统内部,再在合适的时机提供drawRect:
回调方法做其他绘制工作。最后通过CALayer上传backing store
(位图)到GPU
,结束系统默认的绘制流程。
2、异步绘制
如果layer的delegate响应displayLayer方法,就可以进入异步绘制的流程中,代理负责生成对应的bitmap,设置该bitmap作为layer.contents属性的值
。
[layer.delegate displayLayer]
如图,存在主队列和全局并发队列,在某个时机,View
调用了setNeedsDispla y
方法,在当前runloop
将要结束时系统调用视图对应CALayer
的display
方法,如果View.layer
的代理实现了displayLayer
方法,再通过线程的切换,在子线程中CGBitmapContextCreate
创建绘图的上下文,通过CoreGraphic API
做当前UI控件的绘制工作CGBitmapContextCreatelmage
根据当前所绘制的上下文绘制一张image图片。然后回到主队列中提交这个位图,设置给CALayer
的Contents
。这就是异步绘制的整个过程。
六.离屏渲染
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之间的关系是怎样的;