视图-①Runloop

视图-①Runloop

[toc]

一、RunLoop的理解

让线程永不休眠。

背景:负责持续性的处理各种任务(比如Source,Timer,Observer),让线程能一直运行,且在没有任务的时候能够进入休眠,减少 CPU 的使用率,从而节省电量和资源。

1、正常一个线程一次只能执行一个任务,执行完成后线程就退出了。为了让线程能随时处理事件但并不退出,使用do-while循环实现。

1
2
3
4
5
6
7
function loop() {
initialize();
do {
var message = get_next_message();
process_message(message);
} while (message != quit);
}

2、do-while的检查是一直主动检查?线程使用RunLoop时,不需要一直主动检查输入源是否有事件到来。而是靠事件驱动。即

事件驱动(Event-Driven)

在事件驱动的编程模型中,应用程序的执行流程是由事件(如用户操作、消息、定时器超时等)来驱动的。应用程序不需要不断地检查某个条件,而是注册事件处理函数,并让操作系统在相应的事件发生时通知应用程序。这样,应用程序就可以在没有事件发生时执行其他任务或者进入休眠状态,从而节省资源。

3、do-while性能消耗?

当 RunLoop 检测到没有待处理的事件时,它会将线程置于”休眠等待”状态,而不是忙等。这时,线程不会消耗 CPU 资源。当事件到来时,如用户输入、定时器超时或 I/O 完成等,RunLoop 会被唤醒,处理相应的事件,然后再次进入休眠状态。

Runloop是绑定到线程上的(每个线程可以有一个对应的 RunLoop 对象。这些 RunLoop 对象被保存在一个全局的 Dictionary 中,其中线程作为 Key,RunLoop 作为 Value。)。每个线程可以有自己的 RunLoop,这样每个线程可以独立地处理与自己任务相关的事件,提高了事件处理的效率和局部性。

每个Runloop有多种Model。不同的 Mode 可以包含不同的事件源(Sources)和定时器(Timers)。通过切换 Mode,RunLoop 可以过滤掉一些不想要的事件,只处理当前 Mode 下相关的事件。这样可以避免在处理特定任务时被不相关的事件打扰,提高程序的响应性和效率。例如当用户滚动列表时,iOS 应用程序的 RunLoop 通常会切换到 UITrackingRunLoopMode 模式。这个模式会降低非滚动相关的事件(如未将定时器添加到 NSRunLoopCommonModes 模式则timer会暂停)处理优先级,从而确保滚动操作的流畅性 。

主线程是如何切换runloop?

当系统检测到有scrollerview滑动时,系统就会将当前进程的主线程切换到UITrackingRunLoopMode,直到滑动结束,又会切换到NSDefaultRunLoopMode。

模拟主线程runloop的mode切换。在touchbegan的时候切换到UITrackingRunLoopMode,touchend的时候又切换回NSDefaultRunLoopMode。从模拟中可以看出如果所切到的mode是timer未添加的,则timer会暂停。这也就是为什么NSTimer需要设置在NSRunLoopCommonModes模式下运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
  dispatch_async(dispatch_get_global_queue(0, 0), ^{
self.rl = CFRunLoopGetCurrent();

//timer1 运行在 default mode
NSTimer *timer1 = [NSTimer timerWithTimeInterval:1.f repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"timer1 fired");
}];
[[NSRunLoop currentRunLoop] addTimer:timer1 forMode:NSDefaultRunLoopMode]; // NSDefaultRunLoopMode

//timer2 运行在 track Mode
NSTimer *timer2 = [NSTimer timerWithTimeInterval:1.f repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"timer2 fired");
}];
[[NSRunLoop currentRunLoop] addTimer:timer2 forMode:UITrackingRunLoopMode]; // UITrackingRunLoopMode

//指定当前运行mode
self.currentMode = NSDefaultRunLoopMode;
while (1) {
[[NSRunLoop currentRunLoop] runMode:self.currentMode beforeDate:[NSDate distantFuture]];
}
});


- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touch began")
//touchbegan 切换成track mode
self.currentMode = UITrackingRunLoopMode;
CFRunLoopStop(self.rl);
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touch end");
//touchend 切换成kCFRunLoopDefaultMode
self.currentMode = kCFRunLoopDefaultMode;
CFRunLoopStop(self.rl);
}

runloop与卡顿的关系

在iOS开发中,卡顿通常是由于主线程被长时间占用导致的。CFRunLoop的状态变化可以反映主线程的运行情况,因此通过监听CFRunLoop的状态,我们可以检测到应用的卡顿现象。

正常情况下,CFRunLoop会经历以下几个状态:

  1. kCFRunLoopEntry:即将进入RunLoop

  2. kCFRunLoopBeforeTimers:即将处理定时器。

  3. kCFRunLoopBeforeSources:即将处理输入源。

  4. kCFRunLoopBeforeWaiting:即将进入休眠。

  5. kCFRunLoopAfterWaiting:刚从休眠中唤醒。

  6. kCFRunLoopExit:即将退出RunLoop

为什么通常选择kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting这两个状态来作为卡顿的判断依据,而不使用kCFRunLoopBeforeWaitingkCFRunLoopBeforeTimers呢?

因为①卡顿通常发生在事件处理阶段,而kCFRunLoopBeforeWaiting状态标志着RunLoop即将进入休眠,而不是正在处理事件。当应用处于静止状态,即用户没有进行任何操作时,RunLoop通常处于kCFRunLoopBeforeWaiting状态,等待新的事件到来。在这种状态下,线程会进入休眠模式,以节省CPU资源。

②至于kCFRunLoopBeforeTimers状态,虽然它也是RunLoop状态之一,但它主要表示RunLoop即将处理定时器事件。如果定时器回调执行时间过长,确实可能导致卡顿,但是在实际应用中,定时器回调通常执行时间较短,且定时器回调的执行时间可以通过调整定时器的触发频率来控制,因此kCFRunLoopBeforeTimers状态不是卡顿判断的主要依据。

在没有卡顿的情况下,CFRunLoopkCFRunLoopBeforeSourceskCFRunLoopAfterWaiting这两个状态的停留时间通常是非常短的。kCFRunLoopBeforeSources状态表示RunLoop即将处理输入源,而kCFRunLoopAfterWaiting状态表示RunLoop从休眠中被唤醒。如果主线程在这两个状态之间花费的时间过长,说明线程可能被阻塞,导致应用无法响应用户操作,从而出现卡顿。

这两个状态是RunLoop循环中的关键点,它们分别代表了事件处理前后的状态。如果主线程在这两个状态之间花费的时间过长,说明线程可能被阻塞,导致应用无法响应用户操作,从而出现卡顿。因此,通过监控这两个状态,我们可以有效地检测和优化应用的性能,提高用户体验。

runloop与崩溃的关系

在 iOS 开发中,由于 RunLoop 导致的崩溃通常不是直接由 RunLoop 本身引起的,而是由于 RunLoop 中的事件处理代码存在问题。

《起死回生/回光返照》见《异常与崩溃.md

runtime

运行时(Runtime)是 Objective-C 语言的核心特性之一,它提供了一组丰富的 API,允许程序在运行时查询和修改程序的行为。这种动态性使得 Objective-C 语言具有很高的灵活性。

1、RunLoop概念

Run loop,正如其名,loop表示某种循环,和run放在一起就表示一直在运行着的循环。一个run loop就是一个事件处理的循环,用来不停的调度工作以及处理输入事件。其内部就是do-while循环,这个循环内部不断地处理各种任务(比如Source,Timer,Observer)。使用run loop的目的是让你的线程在有工作的时候忙于工作,而没工作的时候处于休眠状态。

2、RunLoop和线程的关系?

run loop和线程是紧密相连的,可以这样说run loop是为了线程而生,没有线程,它就没有存在的必要。Run loops是线程相关的基础框架的一部分,Cocoa和CoreFundation都提供了run loop对象方便配置和管理线程的run loop(以下都已Cocoa为例)。

每个线程,包括程序的主线程(main thread)都有与之相应的run loop对象。

①、主线程的run loop默认是启动的。

1
2
3
4
5
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

重点是UIApplicationMain()函数,这个方法会为main thread设置一个NSRunLoop对象,这就解释了本文开始说的为什么我们的应用可以在无人操作的时候休息,需要让它干活的时候又能立马响应。

②、对其它线程来说,run loop默认是没有启动的。

③、在任何一个Cocoa程序的线程中,都可以通过:NSRunLoop *runloop = [NSRunLoop currentRunLoop];来获取到当前线程的run loop。

3、RunLoop相关各类关系

在 CoreFoundation 里面关于 RunLoop 有5个类:

1
2
3
4
5
CFRunLoopRef
CFRunLoopModeRef
CFRunLoopSourceRef
CFRunLoopTimerRef
CFRunLoopObserverRef

其中 CFRunLoopModeRef 类并没有对外暴露,只是通过 CFRunLoopRef 的接口进行了封装。他们的关系/RunLoop相关各类关系,如下图所示:

RunLoop相关各类关系
一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer。每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个Mode被称作 CurrentMode。如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。

RunLoop的内部逻辑
RunLoop的内部逻辑

3.1 CFRunLoopSourceRef

Source有两个版本:Source0 和 Source1。

  • Source0 只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。
  • Source1 包含了一个 mach_port 和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程,其原理在下面会讲到。

苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()。

在iOS 中,除了source1可以自己唤醒run loop之外,其他的事件都需要用户手动唤醒run loop才可以。

3.1.附上题中button点击后,关于RunLoop的过程(此处略过对响应链的描述)

大概为:当一个硬件事件(触摸/锁屏/摇晃等)发生后,
①、首先由 IOKit.framework 生成一个 IOHIDEvent 事件,Source1 接收到系统事件,RunLoop被唤醒
②、RunLoop通知Observer,处理Timer和Source 0
③、RunLoop处理Source 1,Source1 触发回调,并调用_UIApplicationHandleEventQueue() 进行应用内部的分发
_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。
④、Springboard接受touch event,并用source1 的 之后mach port 转发给App进程。
⑤、RunLoop处理完毕进入睡眠,此前会释放旧的autorelease pool并新建一个autorelease pool。

3.2 CFRunLoopTimerRef

CFRunLoopTimerRef 是基于时间的触发器,它和 NSTimer 是toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调(NSTimer 其实就是 CFRunLoopTimerRef)。

3.3 CFRunLoopObserverRef

CFRunLoopObserverRef 是观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。

上面的 Source/Timer/Observer 被统称为 mode item,一个 item 可以被同时加入多个 mode。但一个 item 被重复加入同一个 mode 时是不会有效果的。如果一个 mode 中一个 item 都没有,则 RunLoop 会直接退出,不进入循环。

4、RunLoop的应用

最常见的为定时器 NSTimer

NSTimer 其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。

如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。

讲到RunLoop,我们需明确一点。runloop会对timer有强引用,timer会对目标对象进行强引用

其他详细参考以下文章:

4.1、autoreleasepool 自动释放池

既然说到runloop,简单说下autoreleasepool自动释放池。runloop会默认创建autoreleasepool,在runloop睡眠前或者退出前会执行pop操作。线程池详情查看下面的内存管理中的介绍。

@autoreleasepool是自动释放池,让我们更自由的管理内存;所以我们下面说说内存管理。

4.2、runloop、autorelease pool以及线程之间的关系

END

其他参考文档:runloop

视图-②布局

视图-②布局

[toc]

一、Intrinsic Content Size,Content Hugging Priority和Content Compression Resistance Priority

看一下下面的例子,看给出的例子约束是否完整?

1
2
3
4
5
6
7
8
UILabel *label = [[UILabel alloc] init];
label.font = [UIFont systemFontOfSize:15];
label.text = @"Hello";
[self.view addSubview:label];
[label mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.view.mas_left).offset(16);
make.top.equalTo(self.view.mas_top).offset(16);
}];

这里只定义了两个约束,left 和 top,只够计算出frame的originX和orginY,没有width和height。那么是不是属于不完整的约束呢?其实在这里给出的约束已经是完整的了。因为对于UILabel这个控件而言 ,只要通过其font和text系统就可以计算出Label该有的长度和宽度。这里的长度和宽度就是UILabel的intrinsic content size(固有属性)。

Intrinsic Content Size, 通俗来讲,就是控件(UIButton,UILabel,UIImageView)能根据它们的内容(content)计算自己的大小(Size)

开发中用到的一些控件或视图,本身就自带大小,比如UIButton控件,设置完title后就能知道这个UIButton是文字的大小再加上两个固定的button margin。
像这种控件或视图本身就带有的高度、宽度,就叫做intrinsic content size(固定内容尺寸)。

2、浅谈 iOS AutoLayout 中 Label 的抗拉伸和抗压缩

在 Autolayout 优先级的范围是 1 ~ 1000,创建一个约束,默认的优先级是最高的 1000。

Content Hugging Priority:
该优先级表示一个控件抗被拉伸的优先级。优先级越高,越不容易被拉伸(即越容易保持原状),默认是251。

Content Compression Resistance Priority:
该优先级表示一个控件抗压缩的优先级。优先级越高,越不容易被压缩(即越容易保持原状),默认是750。

使用场景:

当一个视图上有多个 intrinsic content size 的子控件,并且子控件可能会超出父视图的区域时,此属性可控制哪些视图被内容被优先压缩,使其不超出父视图区域。

场景举例:

1
2
3
4
5
[[yellowLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.view);
make.left.equalTo(self.view).offset(100);
make.right.equalTo(self.view).offset(-100);
}];

当yellowLable的宽度最多为screenWidth-200。

则我们想让lable对左右两边的约束性没那么高,可以设置

1
2
3
4
5
[yellowLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.view);
make.left.equalTo(self.view).offset(100).priority(250);
make.right.equalTo(self.view).offset(-100).priority(250);
}];

给出一个比较常见的需求:

在同一行中显示标题和时间,时间必须显示完全,标题如果太长就截取可显示的部分,剩余的用…表示。

intrinsic content size

目标:我们想让绿色的时间显示全,则应该要压缩前面的titleLabel。也就是要降低titleLabel的抗压缩。

1
2
3
4
5
if (b) {
[timeLabel setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
// 或
//[titleLabel setContentHuggingPriority:UILayoutPriorityFittingSizeLevel forAxis:UILayoutConstraintAxisHorizontal];
}

UILayoutPriorityRequired:1000

UILayoutPriorityDefaultHigh:750

UILayoutPriorityDefaultLow:250

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//在同一行中显示标题和时间,时间必须显示完全,标题如果太长就截取可显示的部分,剩余的用…表示。
- (UIView *)contentViewWith:(BOOL)b {
UIView *contentView = [[UIView alloc] init];
contentView.backgroundColor = [UIColor lightGrayColor];

UILabel *titleLabel = [[UILabel alloc] init];
titleLabel.backgroundColor = [UIColor redColor];
titleLabel.text = @"Each of these constraints can have its own priority. By default, ";
titleLabel.font = [UIFont systemFontOfSize:17];
[contentView addSubview:titleLabel];
[titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(contentView.mas_top);
make.left.equalTo(contentView.mas_left).offset(16);
}];

UILabel *timeLabel = [[UILabel alloc] init];
timeLabel.backgroundColor = [UIColor greenColor];
timeLabel.text = @"2017/03/12 18:20:22";
timeLabel.font = [UIFont systemFontOfSize:17];
[contentView addSubview:timeLabel];
[timeLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(titleLabel.mas_top);
make.left.equalTo(titleLabel.mas_right).offset(8);
make.right.lessThanOrEqualTo(contentView.mas_right).offset(-8);
}];

if (b) {
[timeLabel setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
// 或
//[titleLabel setContentHuggingPriority:UILayoutPriorityFittingSizeLevel forAxis:UILayoutConstraintAxisHorizontal];
}

return contentView;
}

二、iOS使用topLayoutGuide和bottomLayoutGuide

参考文章:iOS使用topLayoutGuide和bottomLayoutGuide

在iOS中,可以使用topLayoutGuide和bottomLayoutGuide来适配屏幕内容,它们是属于UIViewController的属性,配合masonry和SnapKit等约束工具,效果更好。

1
2
3
4
5
6
7
8
UIView *bottomPayView = [[UIView alloc] init];
bottomPayView.backgroundColor = [UIColor grayColor];
[self.view addSubview:bottomPayView];
[bottomPayView mas_makeConstraints:^(MASConstraintMaker *x) {
x.height.equalTo(@45);
x.left.right.equalTo(self.view);
x.bottom.equalTo(self.mas_bottomLayoutGuide);
}];

三、UITableView自动计算cell高度并缓存,再也不用管高度啦

UITableView自动计算cell高度并缓存,再也不用管高度啦

用xib加约束和用masonry加代码约束都是可以的。注意约束一定要自上而下加好,让系统知道怎么去计算高度。

加好约束后,然后告诉tableView自己去适应高度就可以了。有两种写法:

1
2
self.tableView.rowHeight = UITableViewAutomaticDimension;
self.tableView.estimatedRowHeight = 100;

或者直接写这个代理方法就可以了

1
2
3
4
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath
{
return 100;
}

这个的意思就是告诉tableView,你需要自己适应高度,我不给你算啦哈哈哈。但是我们需要告诉它一个大概高度,例如上面的100,理论上这个是可以随便写的,并不影响显示结果,但是越接近真实高度越好。

可能遇到的问题和解决办法

1.高度不对
有时候有可能运行出来后看到cell的高度显示的不对。这个问题是因为约束没有满足自上而下,从而系统不知道怎么去计算。解决办法就是去修改约束,直到满足为止。一定要好好理解约束啊!

2.点击状态栏无法滚动到顶部
我们知道,如果界面中有UIScrollView的话,点击状态栏会让其滚动到顶部,就像这样:

但是如果我们用了自动计算高度的方法,又调用了tableView的reloadData方法(例如我们的数据有分页的时候,加载完下一页的数据后会去刷新tableView)。这时候就会出现问题,点击状态栏就有几率不能精确滚动到顶部了:

解决这个问题的办法是去缓存cell的高度,代码如下:

1
@property (nonatomic, strong) NSMutableDictionary *heightAtIndexPath;//缓存高度所用字典
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#pragma mark - UITableViewDelegate
-(CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath
{
NSNumber *height = [self.heightAtIndexPath objectForKey:indexPath];
if(height)
{
return height.floatValue;
}
else
{
return 100;
}
}

- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath
{
NSNumber *height = @(cell.frame.size.height);
[self.heightAtIndexPath setObject:height forKey:indexPath];
}

四、问题

问题1:使用Masonry的时候进行updateConstraints没有效果

原因:使用updateConstraints更新的时候必须是makeConstraints里面设置过的约束。但如果只是这样还不行,还需要约束对象匹配才能成功。

问题详见:使用Masonry的时候进行updateConstraints没有效果

iOS11适配-Safe Area

iOS11适配-Safe Area

在iOS 11,UIViewController中的UIView的topLayoutGuide和bottomLayoutGuide被替换成了新的安全区属性。

1
2
3
4
5
6
> @available(iOS 11.0, *)
> open var safeAreaInsets: UIEdgeInsets { get }
>
> @available(iOS 11.0, *)
> open var safeAreaLayoutGuide: UILayoutGuide { get }12345
>

safeAreaInsets属性意味着屏幕可以被任何方向遮挡,并不只是上下,当iPhone X出现时,我们就明白了为什么我们需要对左右两边也进行缩进。

Masonry动画

视图-①本质

视图-①本质

[toc]

目录

1
2
3
4
5
6
7
8
9
10
附:整个响应链及事件链
1、完善响应链查找知识点
2、基础概念等详解
2.1 响应者对象(UIResponder)
2.2、UITouch(点击对象)
2.2.1、UITouch的几个主要属性和方法
2.2.2、UITouch的生成场景
2.3、UIEvent(事件对象)
3、响应链的应用
>

一、在一个app中间有一个button,在你手触摸屏幕点击后,到这个button收到点击事件,中间发生了什么

这其实是一个事件传递和响应链的问题。(其实,按钮点击后,这里还包括runloop的唤醒等知识,不过这点我们放在下一大点讲)。

答:在我们点击按钮的时候,会产生了UITouch(点击对象)和UIEvent(事件对象),这两个对象组合成一个点击事件。而发生触摸事件后,

①消息循环(runloop)/系统就会接收到这个触摸事件,并将它放到一个由UIApplication管理的消息队列(先进先出)里。

②UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理。首先UIApplication将事件传递给的是UIWindow对象(即一般为应用程序的主窗口keyWindow)。

③然后,UIWindow(继承自UIView)对象会继续向它的子View对象传递,直到传递到最上层。(或者说UIWindow使用hitTest:withEvent:方法查找touch操作的所在的视图view)

其中的应用程序逐级寻找能够响应这个事件的对象,直到没有响应者响应。这一寻找的过程,被称作事件的响应链

二、事件的响应链

事件的响应链大概过程如下图所示:

事件的响应链

  • 1、在传递的过程中,下一响应者的查找是通过UIView里的- pointInside: withEvent:- hitTest: withEvent:两个方法来确定的。当从最初的只有一个响应者通过这样的方式不断的找到下一响应者后,这些响应者就组成了一个响应者链。

  • 2、当通过- hitTest: withEvent:找到第一响应者后,若第一响应者没有处理事件,则沿着响应者链向上追溯寻找响应者(即灰色箭头方向)执行touches方法。这个过程就是事件的传递过程。从这可以看出它的方向是跟响应链方向相反的。这里我们可以用UITableViewCell中点击上面的label来想象。

附:整个响应链及事件链

整个响应链(向下)及事件链(向上),大概如图所示:

响应链(向下)及事件链(向上)及事件链(向上).png)
在上图,当- hitTest: withEvent:方法沿着红色箭头方向寻找第一响应者后,若第一响应者没有处理事件,则沿着响应者链向上追溯寻找响应者(即灰色箭头方向)执行touches方法。
所以响应链为红色部分,事件链的顺序可以理解为图上的灰色箭头部分(个人理解)。

1、完善响应链查找知识点

我们已经知道响应者链是由多个响应者组合起来的链条。那么怎么找到这些相应者呢?

响应者的查找为通过UIView内部的下面两个方法来查找的

1
2
3
4
5
//根据点击坐标返回事件是否发生在本视图以内
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event; // default returns YES if point is in bounds

// 返回响应点击事件的对象(当点击区域在分为内时候,如果有子视图则返回子视图里最终的响应者,如果没有子视图则返回自身)
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system

对于一个视图

①、若子视图中的- pointInside: withEvent:方法返回为NO,即判断用户点击的区域不在该子视图范围内的话,则停止对这个子视图里的子视图继续查找,- hitTest: withEvent:返回nil。

②、若子视图中的- pointInside: withEvent:方法返回为YES,即判断用户点击的区域在该子视图范围内的话,则继续往该子视图里的子视图查找,直到没有子视图,然后- hitTest: withEvent:返回这个子视图,而后之前的视图的- hitTest: withEvent:也返回这个子视图。

  • 1
     

hitTest-withEvent-查找过程举例,如下图

hitTest-withEvent-查找过程举例
图片中view等级

1
2
3
4
[ViewA addSubview:ViewB];
[ViewA addSubview:ViewC];
[ViewB addSubview:ViewD];
[ViewB addSubview:ViewE];

那么点击viewE后,发生的过程是怎样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1.A 是UIWindow的根视图,首先对A进行hitTest:withEvent:
2.判断A的userInteractionEnabled,如果为NO,A的hitTest:withEvent返回nil;
3.pointInside:withEvent:方法判断用户点击是否在A的范围内,显然返回YES
4.遍历A的子视图B和C。由于从后向前遍历,因此先查看C,再查看B。
>
4.1 查看C:
调用C的hitTest:withEvent方法:pointInside:withEvent:方法判断用户点击是否在C的范围内,不在返回NO,C对应的hitTest:withEvent: 方法return nil;
>
4.2 再查看B
调用B的hitTest:withEvent方法:pointInside:withEvent:判断用户点击是否在B的返回内,在返回YES。
>遍历B的子视图D和E,从后向前遍历,所以先查看E,再查看D。
4.2.1先查看E,调用E的hitTest:withEvent方法:pointInside:withEvent:方法 判断用户点击是否在E的范围内,在返回YES,E没有子视图,因此E对应的hitTest:withEvent方法返回E,再往前回溯,就是B的hitTest:withEvent方法返回E,因此A的hitTest:withEvent方法返回E。
4.2.2查看D,略
>
至此,点击事件的第一响应者就找到了。

2、基础概念等详解

iOS中的事件可以分为3大类型:

  1. 触摸事件
  2. 加速计事件
  3. 远程控制事件

在iOS中不是任何对象都能处理事件,能接受并这些处理事件的对象只有直接或间接继承自UIResponder的对象,我们称之为“响应者对象”。

2.1 响应者对象(UIResponder)

①、为什么只有继承自UIResponder的类才能够接收并处理事件呢?因为处理这些事件的方法是卸载UIResponder中的啊。详细的UIResponder中提供的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
4个处理触摸事件的对象方法
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;

和3个处理加速计事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event;

以及1个处理远程控制事件的方法
- (void)remoteControlReceivedWithEvent:(UIEvent *)event;

附:如何实现UIView的拖拽呢?即让UIView随着手指的移动而移动。

答: 重写touchsMoved:withEvent:方法

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event{
// 想让控件随着手指移动而移动,监听手指移动
// 获取UITouch对象
UITouch *touch = [touches anyObject];
// 获取当前点的位置
CGPoint curP = [touch locationInView:self];
// 获取上一个点的位置
CGPoint preP = [touch previousLocationInView:self];
// 获取它们x轴的偏移量,每次都是相对上一次
CGFloat offsetX = curP.x - preP.x;
// 获取y轴的偏移量
CGFloat offsetY = curP.y - preP.y;
// 修改控件的形变或者frame,center,就可以控制控件的位置
// 形变也是相对上一次形变(平移)
// CGAffineTransformMakeTranslation:会把之前形变给清空,重新开始设置形变参数
// make:相对于最原始的位置形变
// CGAffineTransform t:相对这个t的形变的基础上再去形变
// 如果相对哪个形变再次形变,就传入它的形变
self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY);
}

②、那么iOS中能接收并处理事件或者说继承自UIResponder的类有哪些呢?

1
2
3
> iOS中能接收并处理事件或者说继承自UIResponder的类有:
UIApplication、UIWindow、UIViewController和所有继承UIView的UIKit类都直接或间接的继承自UIResponder。
>

从UIResponder内部提供的方法可以看出,触摸方法接收两个参数,一个UITouch对象的集合,还有一个UIEvent对象。这两个参数分别代表的是点击对象和事件对象。

2.2、UITouch(点击对象)

UITouch表示单个点击,其类文件中存在枚举类型UITouchPhase的属性,用来表示当前点击的状态。这些状态包括点击开始、移动、停止不动、结束和取消五个状态。每次点击发生的时候,点击对象都放在一个集合中传入UIResponder的回调方法中。

2.2.1、UITouch的几个主要属性和方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@property(nonatomic,readonly) NSTimeInterval      timestamp;    // 记录了触摸事件产生或变化时的时间,单位是秒
@property(nonatomic,readonly) UITouchPhase phase; // 当前触摸事件所处的状态
@property(nonatomic,readonly) NSUInteger tapCount; // 短时间内点按屏幕的次数,可以根据tapCount判断单击、双击或更多的点击
@property(nonatomic,readonly) UITouchType type NS_AVAILABLE_IOS(9_0);


@property(nullable,nonatomic,readonly,strong) UIWindow *window; //触摸产生时所处的窗口
@property(nullable,nonatomic,readonly,strong) UIView *view; //触摸产生时所处的视图
@property(nullable,nonatomic,readonly,copy) NSArray <UIGestureRecognizer *> *gestureRecognizers NS_AVAILABLE_IOS(3_2);

/**
* 获取当前点击位置的坐标点
*
* @param view 调用时传入的view参数为nil的话,返回的是触摸点在UIWindow的位置
*
* @return 返回值表示触摸在view上的位置点(这里返回的位置是针对view的坐标系的(以view的左上角为原点(0, 0)))
*/
- (CGPoint)locationInView:(nullable UIView *)view;

/// 获取前一个触摸点位置的坐标点
- (CGPoint)previousLocationInView:(nullable UIView *)view;
2.2.2、UITouch的生成场景:

前言:每根手指触摸屏幕时都会创建一个与该手指相关的UITouch对象。一根手指对应一个UITouch对象。每个UITouch对象保存着跟手指相关的信息,比如触摸的位置、时间、阶段。
当手指移动时,系统会更新同一个UITouch对象,使之能够一直保存该手指在的触摸位置。
当手指离开屏幕时,系统会销毁相应的UITouch对象

实际调用现象举例:

①、当用户用一根手指触摸屏幕时,view会调用1次touchesBegan:withEvent:方法。touches参数中装着1个UITouch对象。

②、如果这两根手指一前一后分开触摸同一个view,那么view会分别调用2次touchesBegan:withEvent:方法,并且每次调用时的touches参数中只包含一个UITouch对象。

③、如果两根手指同时触摸一个view,那么view只会调用一次touchesBegan:withEvent:方法,touches参数中装着2个UITouch对象

2.3、UIEvent(事件对象)

iOS使用UIEvent表示用户交互的事件对象,在UIEvent.h文件中,我们可以看到有一个UIEventType类型的属性,这个属性表示了当前的响应事件类型。分别有多点触控、摇一摇以及远程操作(在iOS之后新增了3DTouch事件类型)。在一个用户点击事件处理过程中,UIEvent对象是唯一的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@property(nonatomic,readonly) UIEventType     type NS_AVAILABLE_IOS(3_0);
@property(nonatomic,readonly) UIEventSubtype subtype NS_AVAILABLE_IOS(3_0);


typedef NS_ENUM(NSInteger, UIEventType) {
UIEventTypeTouches,
UIEventTypeMotion,
UIEventTypeRemoteControl,
UIEventTypePresses NS_ENUM_AVAILABLE_IOS(9_0),
};

typedef NS_ENUM(NSInteger, UIEventSubtype) {
// available in iPhone OS 3.0
UIEventSubtypeNone = 0,

// for UIEventTypeMotion, available in iPhone OS 3.0
UIEventSubtypeMotionShake = 1,

// for UIEventTypeRemoteControl, available in iOS 4.0
UIEventSubtypeRemoteControlPlay = 100,
UIEventSubtypeRemoteControlPause = 101,
UIEventSubtypeRemoteControlStop = 102,
UIEventSubtypeRemoteControlTogglePlayPause = 103,
UIEventSubtypeRemoteControlNextTrack = 104,
UIEventSubtypeRemoteControlPreviousTrack = 105,
UIEventSubtypeRemoteControlBeginSeekingBackward = 106,
UIEventSubtypeRemoteControlEndSeekingBackward = 107,
UIEventSubtypeRemoteControlBeginSeekingForward = 108,
UIEventSubtypeRemoteControlEndSeekingForward = 109,
};

介绍了以上响应者对象(UIResponder)及其相关的UITouch(点击对象)和UIEvent(事件对象)相关概念后,我们就知道了用户点击后,会产生了UITouch(点击对象)和UIEvent(事件对象)并打包发送,最后由响应者对象(UIResponder)来处理这些事件。

现在的问题是你知道它是怎么通过用户的点击位置找到处理该点击事件的响应者对象吗?

3、响应链的应用

既然已经知道了系统是怎么获取响应视图的流程了,那么我们可以通过重写查找事件处理者的方法来实现不规则形状点击。

最常见的不规则视图就是圆形视图,在demo中我设置view的宽高为200,那么重写方法事件如下:

1
2
3
4
5
6
7
8
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
const CGFloat halfWidth = 100;
CGFloat xOffset = point.x - 100;
CGFloat yOffset = point.y - 100;
CGFloat radius = sqrt(xOffset * xOffset + yOffset * yOffset);
return radius <= halfWidth;
}

最终的效果图如下:
响应链的应用1_点击不规则图形

前面说过按钮点击后,这里还包括一些runloop相关的知识,如唤醒等,所以下面我们就专门开讲一件Runloop。

三、CALayer和UIView

UIView与CALayer是什么关系。

<单一职责原则>
UIView为CALayer提供内容,以及负责处理触摸等事件,参与响应链
CALayer负责显示内容contents

UIViewCALayerdelegate(CALayerDelegate)

UIView继承自UIResponder类,可以响应事件

CALayer直接继承自NSObject类,不可以响应事件

UIView主要处理事件,CALayer负责绘制

每个UIView内部都有一个CALayer在背后提供内容的绘制和显示,并且UIView的尺寸样式都由内部的Layer所提供。两者都有树状层级结构,Layer内部有SubLayersView内部有SubViews,但是LayerView多了个AnchorPoint

CALayer的结构图如下:

CALayer的结构图

在 iOS 系统中所有显示的视图都是从基类UIView继承而来的,同时UIView负责接收用户交互。 但是实际上你所看到的视图内容,包括图形等,都是由UIView的一个实例图层属性来绘制和渲染的,那就是CALayer。

CALayer类的概念与UIView非常类似,它也具有树形的层级关系,并且可以包含图片文本、背景色等。它与UIView最大的不同在于它不能响应用户交互,可以说它根本就不知道响应链的存在。

在每一个UIView实例当中,都有一个默认的支持图层,UIView负责创建并且管理这个图层。实际上这个CALayer图层才是真正用来在屏幕上显示的,UIView仅仅是对它的一层封装,实现了CALayer的delegate,提供了处理事件交互的具体功能,还有动画底层方法的高级 API。

以上摘要来自:内存恶鬼drawRect - 谈画图功能的内存优化中的CALayer和UIView介绍部分。

CALayer有三个视觉元素:背景色、内容和边框,其中,内容的本质是一个CGImage

CALayer和UIView

简述CALayer和UIView的关系

答:UIView和CALayer是相互依赖的关系。UIView依赖与calayer提供的内容,CALayer依赖uivew提供的容器来显示绘制的内容。归根到底CALayer是这一切的基础,如果没有CALayer,UIView自身也不会存在,UIView是一个特殊的CALayer实现,添加了响应事件的能力

结论:
UIView来自CALayer,高于CALayer,是CALayer高层实现与封装。UIView的所有特性来源于CALayer支持。

常见笔试/面试题

< 返回目录

问:UIButton从子类到父类依次继承自什么?

答:UIControl-> UIView-> UIResponder。

哪些视图的设置能禁止其相应事件

1、userInterface = NO;
2、hidden = YES;
3、当UIBUTTON透明度为0就不响应事件了,当UIBUTTON透明度为0就不响应事件了。

更多参考:iOS开发经验:button不能响应的原因

离屏渲染

在使用圆角、阴影和遮罩等视图功能的时候,图层属性的混合体被指定为在未预合成之前不能直接在屏幕中绘制,所有就需要在屏幕外的上下文中渲染,即离屏渲染。

更多参考:iOS离屏渲染之优化分析该文非常重要。

END

< 返回目录

视图-③跳转

视图-③跳转

目录

一、视图的跳转

< 返回目录

1、获取当前显示的视图控制器ViewController

2、如何在多次presentViewController后直接返回到指定层

场景:如果多个控制器都通过 present 的方式跳转呢?比如从A跳转到B,从B跳转到C,从C跳转到D,如何由D直接返回到A呢?

答:可以通过 presentingViewController 一直找到A控制器,然后调用A控制器的 dismissViewControllerAnimated 方法。方法如下:

1
2
3
4
5
UIViewController *controller = self;
while(controller.presentingViewController != nil){
controller = controller.presentingViewController;
}
[controller dismissViewControllerAnimated:YES completion:nil];

PS:如果不是想直接返回到A控制器,比如想回到B控制器,while循环的终止条件可以通过控制器的类来判断。

3、presentedViewController 与 presentingViewController

假设从A控制器通过present的方式跳转到了B控制器,那么 A.presentedViewController 就是B控制器;
B.presentingViewController 就是A控制器。

4、如何通过视图(view)获取该视图所在的控制器(viewController)

1
2
3
4
5
6
7
8
+ (nullable UIViewController *)findBelongViewControllerForView:(UIView *)view {
UIResponder *responder = view;
while ((responder = [responder nextResponder]))
if ([responder isKindOfClass: [UIViewController class]]) {
return (UIViewController *)responder;
}
return nil;
}

常见笔试/面试题

< 返回目录

END

< 返回目录

视图-②生命周期

目录

五、控制器View的生命周期

< 返回目录

更详细的生命周期请查看:iOS程序执行顺序和UIViewController 的生命周期(整理)

题目1:控制器View的生命周期及相关函数是什么?你在开发中是如何用的?
1
2
3
4
1.在视图显示之前调用viewWillAppear;该函数可以调用多次; 
2.视图显示完毕,调用viewDidAppear;
3.在视图消失之前调用viewWillDisAppear;该函数可以调用多次(如需要);
4.在布局变化前后,调用viewWill/DidLayoutSubviews处理相关信息;

viewWillAppear——-》viewWillLayoutSubviews—–》viewDidLayoutSubviews———–》

viewDidAppear

题目2:loadView, viewDidLoad, viewDidUnLoad,分别是在什么时候被调用的.

loadView, viewDidLoad, viewDidUnLoad,分别是在什么时候被调用的.

3、layoutSubviews布局与drawRect重绘

(1)、layoutSubviews布局

layoutSubviews是对subviews重新布局;
比如,我们想更新子视图的位置的时候,可以通过调用layoutSubviews方法,即可以实现对子视图重新布局。但实际上一般我们都是不要直接手动调用layoutSubviews方法。因为有操作时候,系统会自动调用layoutSubviews。

那我们进行哪些操作会触发layoutSubviews方法呢?答如下:

1
2
3
4
5
6
7
8
9
首先注意:
①init初始化不会触发layoutSubviews,
但是使用initWithFrame进行初始化时,当rect的值不为CGRectZero时,会触发layoutSubviews。
②、直接调用setLayoutSubviews。
③、addSubview的时候一般都会触发layoutSubviews。(最常见) 注:但当本View的frame为0时,addSubView也不会调用layoutSubViews。
④、当view的frame发生改变的时候触发layoutSubviews。
⑤、滑动UIScrollView的时候触发layoutSubviews。
⑥、旋转Screen会触发父UIView上的layoutSubviews事件。
⑦、改变一个UIView大小的时候也会触发父UIView上的layoutSubviews事件。

所以我们可以看出当视图约束/frame变化时候,会触发layoutSubviews,进行重新布局。

1
2
3
4
5
6
7
8
9
10
附:
如果你还是想强制更新布局,你可以调用setNeedsLayout方法;
如果你想立即显示你的views,你需要调用layoutIfNeed方法。

①、- (void)layoutSubviews;
这个方法,默认没有做任何事情,需要子类进行重写;
②、- (void)setNeedsLayout;
标记为需要重新布局,异步调用layoutIfNeeded刷新布局,不立即刷新,但layoutSubviews一定会被调用;
③、- (void)layoutIfNeeded;
如果,有需要刷新的标记,立即调用layoutSubviews进行布局(如果没有标记,不会调用layoutSubviews)。

其他参考文章:iOS layoutSubview的方法总结/重绘drawRect

什么时候用layoutSubviews?

答:仅仅在以下情况下:自动布局达不到想要效果时你才有必要重写这个方法.可以直接设置subviews的尺寸.

(2)、drawRect重绘

重绘作用:重写该方法以实现自定义的绘制内容

1
2
3
-drawRect:(CGRect)rect方法:重写此方法,执行重绘任务
-setNeedsDisplay方法:标记为需要重绘,异步调用drawRect
-setNeedsDisplayInRect:(CGRect)invalidRect方法:标记为需要局部重绘
(3)、updateConstraints更新约束、layoutSubviews重新布局与drawRect重绘的调用顺序
1
2
3
layoutSubviews是对subviews重新布局;
drawRect重绘;
layoutSubviews方法调用先于drawRect,也就是先布局子视图,在重绘。

所以,在调用updateConstraintsIfNeeded可能会立即执行updateConstraints,然后调用layoutSubviews。因为按照Autolayout布局的步骤,应该是先更新约束然后更新布局的。

常见笔试/面试题

< 返回目录

END

< 返回目录

视图-④图片

视图-④图片

视图-更新机制

[toc]

视图-更新机制

系统有基本稳定的刷新频率,在layer内容改变的时候,把这个layer做个需要刷新的标记,即是setNeedsDisplay

然后每次刷新时,把上次刷新之后被标记的layer一次性全部提交给图形系统,所以这里还有一个东西,就是事务(CATransaction)

其他类似举例:

1
2
3
4
tableView.beginUpdates()
tableView.insertRows(at: [IndexPath(row: tableData.count-2, section: 0)], with: UITableViewRowAnimation.automatic)
tableView.insertRows(at: [IndexPath(row: tableData.count-1, section: 0)], with: UITableViewRowAnimation.automatic)
tableView.endUpdates()

beginUpdatesendUpdates 方法的作用是,让这两条语句之间的对 tableView 的操作( insert/delete)不立即执行,而是先聚合起来,然后同时更新 UI

1、刷新频率

1.1、显示器的刷新率 VS 显卡渲染的帧率

1、显示器的刷新率为 50Hz,显卡渲染的帧率是 200FPS

即显示器20ms显示一帧,显卡5ms渲染一帧。

则20ms里,显卡渲染了4帧数据,但显示器只能显示了一次,该画面由4帧数据组成,造成撕裂。

所以需要压制显卡的渲染速率,使显卡的帧缓冲区切换行为与显示器的帧绘制保持同步。垂直同步(Vertical Synchronization, VSync)即是为了处理此。

好文推荐: 扫描,撕裂和垂直同步 - VSync 技术实现

1.1.1、显示数据的提供来源:帧缓冲区

通常,显示器是一台独立工作的设备,状态与显卡无关,会以恒定不变的频率从某个「池」里面读取画面,以保证稳定的图像输出。与此同时,显卡也会往这个「池」里写画面,以供显示器读取。这个「池」叫「Framebuffer(帧缓冲区)」。

一张显卡通常有 2 个帧缓冲区:主/副(Primary/Secondary)缓冲区(也称前/后缓冲区(Front/Back)),由数据选择器(Multiplexer)选择连接到显示器的缓冲区。连接到显示器的缓冲区总是主缓冲区,显示器从中读取图像内容;未连接到显示器的缓冲区总是副缓冲区,显卡向其中写入渲染好的内容。

显卡总是会尝试以最快的速度渲染内容,每完成一帧渲染即切换主/副缓冲区,与显示器的工作状态完全无关。

image-20240905163331025

当渲染完成后,副缓冲区的内容会与主缓冲区的内容进行交换,这个过程是双缓冲(Double Buffering)技术的一部分,用于避免屏幕撕裂和闪烁,同时提高图像渲染的效率。下面详细解释这个过程:

  1. 主缓冲区(Front Buffer):这是当前正在屏幕上显示的图像所在的缓冲区。用户看到的所有内容都存储在这里,显示器会不断读取这个缓冲区的内容来显示图像。
  2. 副缓冲区(Back Buffer):这是显卡用来准备下一帧图像的缓冲区。当一帧图像显示在屏幕上时,显卡可以在副缓冲区中渲染下一帧图像,而不会影响当前显示的内容。
  3. 渲染过程:显卡开始在副缓冲区中渲染新的一帧图像。这个过程可能包括执行复杂的图形计算,如光照计算、纹理映射、深度测试等。
  4. 缓冲区交换(Buffer Swap):一旦副缓冲区中的新帧渲染完成,显卡会执行一个缓冲区交换操作。这个操作会将副缓冲区的内容复制到主缓冲区,这样显示器就可以开始显示新的一帧图像,而副缓冲区则准备好接受下一帧的渲染数据。
  5. 避免屏幕撕裂:由于显示器是逐行刷新的,如果在显示器刷新过程中显卡正在渲染新的帧,就可能出现屏幕撕裂现象。双缓冲技术通过在渲染完成后才进行缓冲区交换,确保了显示器在任何时候都不会读取到半成品的帧,从而避免了屏幕撕裂。
  6. 提高效率:在双缓冲机制下,显卡可以在一帧显示的同时准备下一帧,这样可以更有效地利用显卡资源,提高渲染效率。
  7. 垂直同步(V-Sync):为了进一步提高图像质量和减少撕裂,有时会使用垂直同步技术。垂直同步会同步显卡的渲染速度和显示器的刷新率,确保缓冲区交换发生在显示器刷新周期的合适时刻。

通过这种方式,双缓冲技术能够在不牺牲渲染效率的情况下,提供流畅且无撕裂的图像显示。

1.2、垂直同步(Vertical synchronization)

V-Sync 的主要作用就是保证只有在帧缓冲区中的图像被渲染之后,后备缓冲区中的内容才可以被拷贝到帧缓冲区中

理想情况下每次 V-Sync 发生时,CPU 以及 GPU 都已经完成了对图像的处理以及绘制,显示器可以直接拿到缓冲区中的帧。即 V-Sync 会按这种方式工作:

image-20240905184838586

但是,如果 CPU 或者 GPU 的处理需要的时间较长,就会发生掉帧的问题,即在 V-Sync 信号发出时,CPU 和 GPU 并没有准备好需要渲染的帧,显示器就会继续使用当前帧。

image-20240905184900129

其实到这里关于屏幕渲染的内容就已经差不多结束了,根据 V-Sync 的原理,优化应用性能、提高 App 的 FPS 就可以从两个方面来入手,优化 CPU 以及 GPU 的处理时间。

iOS 的显示系统是由 VSync 信号驱动的,VSync 信号由硬件时钟生成,每秒钟发出 60 次(这个值取决设备硬件,比如 iPhone 真机上通常是 59.97)。

RunLoop是一个事件循环对象,用于管理线程的事件处理。每个线程都有一个与之对应的RunLoop对象,主线程的RunLoop在应用程序启动时自动创建并运行,而子线程的RunLoop需要手动创建和维护。RunLoop的作用是让线程能够随时处理事件而不退出,它通过循环检查输入源(如Timer、Source等)和定时源,等待接收事件,当没有事件时让线程休眠以节省资源。

Core Animation(CA)是 iOS 中的动画和图形渲染引擎,它在 RunLoop 中注册了 Observer 来监听特定的事件,以便在适当的时机进行渲染操作。这些 Observer 可以监听多种 RunLoop 的事件,包括:

  1. BeforeWaiting:RunLoop 即将进入休眠状态之前,此时没有待处理的事件,系统可能在等待新的输入事件或定时器事件。Core Animation 会在这个时候进行渲染操作,准备下一帧的显示内容。
  2. Exit:RunLoop 即将退出时,这通常意味着线程将结束运行。在这个阶段,Core Animation 可能会进行一些清理工作。

手机硬件时钟生成Vsync信号

=> 图形服务接收到 VSync 信号后,会通过 IPC 通知到 App 内。

=> app收到当VSync信号到达时,应用程序的主线程开始处理显示内容,如视图创建、布局计算等,然后将内容提交给GPU进行渲染。渲染完成后,GPU将结果提交到帧缓冲区,等待下一个VSync信号到来时显示到屏幕上。

=> Runloop接收到时钟信号(App 的 Runloop 在启动后会注册对应的 CFRunLoopSource 通过 mach_port 接收传过来的时钟信号通知,随后 Source 的回调会驱动整个 App 的动画与显示。

当VSync信号到来时,系统图形服务会通过CADisplayLink等机制通知应用程序,

离屏渲染

在iOS设备上,GPU渲染通常发生在帧缓冲区中,这是GPU用来临时存储即将显示到屏幕上的像素数据的区域。帧缓冲区通常采用双缓冲机制,即存在一个前台缓冲区和一个后台缓冲区。前台缓冲区是当前显示在屏幕上的帧,而后台缓冲区是GPU正在渲染的下一帧。

离屏渲染(Off-Screen Rendering)是当GPU无法直接在帧缓冲区中渲染某些效果时所采用的一种技术。以下是一些可以在帧缓冲区处理的效果,以及一些需要离屏渲染的效果:

可以在帧缓冲区处理的效果:

  1. 简单的颜色填充和边框绘制:这些可以直接在GPU的帧缓冲区中进行,因为它们不涉及复杂的像素操作。
  2. 使用contents属性设置的图像:如果图层的内容是一个简单的图像,且没有复杂的变换或混合,这些可以直接在帧缓冲区中渲染。

需要离屏渲染的效果:

  1. 圆角(Rounded Corners):当为UIView或其子类设置cornerRadius属性时,如果视图同时具有不透明背景色或复杂的背景图像,可能会触发离屏渲染。
  2. 阴影(Shadows):设置layer的shadow属性(如shadowColor、shadowOffset、shadowRadius等)会产生阴影效果,这些效果通常需要离屏渲染。
  3. 透明度(Opacity):当视图的alpha值小于1或使用了CALayer的opacity属性时,如果有复杂混合层级,可能触发离屏渲染。
  4. 遮罩(Masking):使用CALayer的mask属性或UIView的maskView时,遮罩效果通常需要离屏渲染。
  5. 非默认混合模式:当视图或图层使用非默认的混合模式(如multiply、screen、overlay等)时,系统可能需要在离屏缓冲区中进行混合操作。
  6. 多重渲染目标(Multiple Render Passes):需要多次渲染才能完成的效果,如复杂动画、多重叠加效果等,可能需要离屏缓冲区进行中间结果的存储和合并。

为什么某些效果不行:

某些效果需要在渲染过程中进行多次像素级的处理,这在帧缓冲区的单次渲染流程中难以实现。例如,阴影效果需要在原始图层渲染后,再在其周围绘制额外的阴影像素,这涉及到对已经渲染的像素进行二次处理,因此需要在离屏缓冲区中先进行渲染,然后再与主帧缓冲区的内容合并。

优化建议:

  • 避免不必要的离屏渲染:例如,对于圆角效果,可以考虑使用系统提供的圆角属性,而不是通过离屏渲染实现。
  • 合理利用视图层级关系:在iOS中,视图层级关系会影响渲染的优先级,可以通过调整视图的层级来优化渲染性能。
  • 使用offscreen rendering进行调试:通过打开offscreen rendering的调试选项,可以观察到应用在进行离屏渲染时的具体情况,帮助定位性能瓶颈。

通过深入理解离屏渲染的原理并采取有效的优化措施,可以提升应用的性能和用户体验。在实际开发中,应尽量避免不必要的离屏渲染操作,合理利用视图层级关系和Metal API进行自定义渲染,从而打造出流畅、高效的iOS应用。

性能优化-①列表优化

列表优化

[toc]

其他参考文档:列表无限滚动时,数据如何预加载,从而达到无缝加载的效果 Demo

一、请求时,列表网络数据的预加载

1、第一页的预加载

提前创建 vm 或者 manager 管理请求数据。 (平常用的数据携带、默认数据等本地数据暂不在此讨论)

2、下一页的预加载

预加载是指在Cell还没有出现在屏幕上时,就提前加载它所需的数据和资源。这可以减少Cell出现时的加载时间,提升用户体验。

举例:在用户阅读了最新页码数据的70%(contentSize:UIScrollView所有内容的尺⼨)时(根据实际情况调节),提前进行下一页数据的加载。这样用户可以省去本来在阅读完已加载的时候需要做一次上拉加载等待数据的过程。

可以看到第一页阈值是70%,即代表进入后即使没滑动也会自动加载第二页。

image-20240815012027334

图片来源:列表的预加载.graffle

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let currentOffset = scrollView.contentOffset.y + scrollView.frame.size.height
let totalHeight = scrollView.contentSize.height
let ratio = currentOffset / totalHeight // 当前滚动内容占总内容的比例

//let threshold = 0.7 // 优化前
var threshold = (curPageIndex+0.7)/(curPageIndex+1.0) // 优化后,可以看到第一页还是70% 即使进入后没滑动也会自动加载第二页
// 超过阈值 threshold 则进行预加载下一页数据。可以看到第一页阈值是70%,即代表进入后即使没滑动也会自动加载第二页
if ratio >= threshold {
fetchNextPageData(page: currentPage)
}
}

var currentPage = 0
func fetchNextPageData() {
currentPage += 1
loadPage(pageIndex: currentPage)
}


func loadPage(_ pageIndex: int) {

}

参考文章:预加载与智能预加载(iOS)

其他参考文章:

二、请求后,数据渲染时的按需加载

滑动时,按需加载:UITableView禁止或者减速滑动结束的时候,进行异步加载图片,快滑动过程中,只加载目标范围内的Cell。

问:从第1个cell滑动到第100个cell。请问在快速滑动情况下如果在tableView(_:cellForRowAt:) 中打印indexPath,能够打印到1到100的indexPath吗

答:在快速滑动 UITableView 从第一个单元格到第100个单元格时,tableView(_:cellForRowAt:) 方法可能会被多次调用,但并不意味着它会为每个索引路径(从0到99)都打印出对应的值。如果用户滑动得非常快,UITableView 为了保持流畅的滚动性能,可能会跳过一些单元格的 tableView(_:cellForRowAt:) 调用,尤其是那些在屏幕外的单元格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class TableViewController: UITableViewController {
// 标记是否应该加载图片
var shouldLoadImages = false

override func viewDidLoad() {
super.viewDidLoad()
self.tableView.estimatedRowHeight = 100 // 设置预估行高
self.tableView.rowHeight = UITableView.automaticDimension
}

// ... 其他代码
}


func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 计算滚动速度,并根据滚动速度设置是否加载图片,有时候快停止的时候就可以加载了,不用完全停止。
let currentVelocity = scrollView.panGestureRecognizer.velocity(in: scrollView)
shouldLoadImages = abs(currentVelocity.y) > 1.0 ? false : true
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier", for: indexPath)
// 检查是否应该加载图片(快速滑动过程中,可能有多次调用到该方法)
if shouldLoadImages {
loadImageAsync(for: cell) // 异步加载图片
} else {
cell.imageView?.image = UIImage(named: "placeholder") // 快速滑动时,只加载占位图或者不加载图片
}

// 配置cell的其他内容
// ...

return cell
}

写法二:不太推荐,性质一样,不过写法有点别扭,相当于cell内容的处理位置变了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 计算滚动速度,并根据滚动速度设置是否加载图片,有时候快停止的时候就可以加载了,不用完全停止。
let currentVelocity = scrollView.panGestureRecognizer.velocity(in: scrollView)
shouldLoadImages = abs(currentVelocity.y) > 1.0 ? false : true

// 1. 获取当前可见的单元格索引路径数组,再根据索引路径获取对应的单元格
if let visiblePaths = self.tableView.indexPathsForVisibleRows {
for indexPath in visiblePaths {
if let cell = self.tableView.cellForRow(at: indexPath) {
// 检查是否应该加载图片
if shouldLoadImages {
loadImageAsync(for: cell) // 异步加载图片
} else {
cell.imageView?.image = UIImage(named: "placeholder") // 快速滑动时,只加载占位图或者不加载图片
}
}
}
}
}

优化加强:如果滚动方向改变,快速下滑后又上滑,取消可见区域下面的部分(可见区域上面的部分)。类似于 PrefetchDataSource 的 prefetchRowsAtIndexPaths 和 cancelPrefetchingForRowsAtIndexPaths

延伸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
var shouldLoadImages: Bool = true										// 假设有一个标记来记录是否应该加载图片
var loadingOperations: [IndexPath: Operation] = [:] // 用于存储正在加载的图片的单元格的IndexPath

func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 计算滚动速度,并根据滚动速度设置是否加载图片,有时候快停止的时候就可以加载了,不用完全停止。
let currentVelocity = scrollView.panGestureRecognizer.velocity(in: scrollView)
shouldLoadImages = abs(currentVelocity.y) < 1.0

if shouldLoadImages {
if let visiblePaths = self.tableView.indexPathsForVisibleRows {
prefetchRows(at: visiblePaths)
}
} else {
// 如果不需要加载图片,取消预加载
cancelPrefetchingForRows(in: scrollView)
}
}

func prefetchRows(at indexPaths: [IndexPath]) {
// 调用预加载方法
self.tableView.prefetchRows(at: indexPaths)
}

func cancelPrefetchingForRows(in scrollView: UIScrollView) {
// 检查当前滚动速度和方向
let currentVelocity = scrollView.panGestureRecognizer.velocity(in: scrollView)
let isScrollingUp = currentVelocity.y < 0

// 找出所有不在可视区域内的单元格indexPaths
guard let visiblePaths = self.tableView.indexPathsForVisibleRows else { return }
let indexPathsToCancel = loadingOperations.keys.filter { !visiblePaths.contains($0) }

// 如果滚动方向改变,取消不在可视区域内的单元格的预加载
if isScrollingUp || !visiblePaths.contains(where: { $0 >= indexPathsToCancel.first! }) {
cancelPrefetchingForRows(at: indexPathsToCancel)
}
}

// 实现 UITableViewDataSourcePrefetching 协议的方法
extension YourTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAtIndexPaths indexPaths: [IndexPath]) {
// 在这里执行图片的预加载操作
for indexPath in indexPaths {
if shouldLoadImages, let cell = tableView.cellForRow(at: indexPath) as? YourTableViewCell {
loadImageAsync(for: cell, at: indexPath)
}
}
}

func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAtIndexPaths indexPaths: [IndexPath]) {
// 在这里取消图片的预加载操作
for indexPath in indexPaths {
if let operation = loadingOperations[indexPath], !operation.isFinished {
operation.cancel()
}
}
}
}

// 异步加载图片的示例方法
func loadImageAsync(for cell: YourTableViewCell, at indexPath: IndexPath) {
// 执行异步图片加载操作,例如从网络下载
// 这里应该使用你的图片加载逻辑
let operation = BlockOperation {
// 模拟异步图片加载
guard let downloadedImage = UIImage() else { return }

// 将下载的图片缓存到内存中
DispatchQueue.main.async {
cell.imageView.image = downloadedImage
}
}

// 将操作添加到后台队列
operationQueue.addOperation(operation)

// 记录这个操作
loadingOperations[indexPath] = operation
}

func cancelPrefetchingForRows(at indexPaths: [IndexPath]) {
// 调用tableView的取消预加载方法
self.tableView.cancelPrefetchingForRows(at: indexPaths)
}

其他参考文档:

三、渲染时候的优化

1、ASDK

Texture 拥有自己的一套成熟布局方案,虽然学习成本略高,但至少比原生的 AutoLayout 写起来舒服,重点是性能远好于 AutoLayout

参考文档:

iOS 开发一定要尝试的 Texture(ASDK)

iOS原生开发视角下的复杂列表开发与性能优化

四、UITableView的性能优化

< 返回目录

参考资料:UITableView性能优化,超实用

①Cell重用

1
2
3
4
5
6
7
> // 返回Cell的代理方法会调用很多次,为防止重复创建,我们使用static 保证只创建一次reuseID,提高性能
> static NSString *reuseID = “reuseCellID”;
>
> // 从缓存池中取相应identifier的Cell并更新数据。
> // 如果没有,才开始alloc新的Cell,并用identifier标识Cell。每个Cell都会注册一个identifier(重用标识符)放入缓存池,当需要调用的时候就直接从缓存池里找对应的id,当不需要时就放入缓存池等待调用。(移出屏幕的Cell才会放入缓存池中,并不会被release)
> UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseID];
>

附:比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> 缓存池获取可重用Cell两个方法的区别
>
> -(nullable __kindof UITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier;
> // 方法1:这个方法会查询可重用Cell,如果注册了原型Cell,能够查询到,否则,返回nil;而且需要判断if(cell == nil),才会创建Cell,不推荐
>
> -(__kindof UITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath NS_AVAILABLE_IOS(6_0);
> // 方法2:使用这个方法之前,必须通过xib(storyboard)或是Class(纯代码)注册可重用Cell,而且这个方法一定会返回一个Cell
>
> // 附:方法2需要的注册Cell的方法
> - (void)registerNib:(nullable UINib *)nib forCellReuseIdentifier:(NSString *)identifier NS_AVAILABLE_IOS(5_0);
> - (void)registerClass:(nullable Class)cellClass forCellReuseIdentifier:(NSString *)identifier NS_AVAILABLE_IOS(6_0);
>
> // 好处:如果缓冲区 Cell 不存在,会使用原型 Cell 实例化一个新的 Cell,不需要再判断,同时代码结构更清晰。
>

②定义一种(尽量少)类型的Cell及善用hidden隐藏(显示)subviews。即可以初始化时就添加,然后通过hide来控制是否显示。(比如朋友圈),而不要用addView给Cell动态添加View,

③提前计算并缓存Cell的高度;(Model去缓存,或者使用SDAutoLayout工具)
④网络数据的异步加载(如cell中的图片加载),不要阻塞主线程;
⑤滑动时,按需加载,常见于大量图片时候。即当UITableView静止或者减速滑动结束之后才去进行异步加载图片。
⑥渲染优化:减少subviews的个数和层级;对于不透明的View,设置opaque为YES;阴影绘制及性能优化。

更新时候:使用局部更新,如果只是更新某组的话,使用reloadSection进行局部更新

1、Cell的重用

基于Cell的重用,真正运行时铺满屏幕所需的Cell数量大致是固定的,设为N个。所以
①如果如果只有一种Cell,那就是只有N个Cell的实例;
②但是如果有M种Cell,那么运行时最多可能会是“M x N = MN”个Cell的实例;
虽然可能并不会占用太多内存,但是能少点不是更好吗。

四、列表加载图片的优化

1、缩略图的使用

图片划分两个地址.一个地址获取缩略图,一个地址获取原图>> 这样你就可以在TableViewCell使用缩略图(展示用),点击图片查看(使用原图). 这样就大大减少了内存的使用.

2、UITableView优化

更轻量的 View Controllers 把 Data Source 和其他 Protocols 分离出来

各页面的预加载

以上内容为同一页面内的预加载处理。那如果是页面间的呢?

在iOS中,使用UITabBarController作为应用程序的主视图控制器时,通常会有几个子视图控制器与之关联。

问:那么这些子视图控制器的初始化和内容渲染分别是在什么时候?或者说刚启动app时候让app在默认的第一个tab,此时第二tab的视图控制器加载了哪些方法,其其他方法又是什么时候触发的。

答:①第二个tab的初始化即init ,在UITabBarController被初始化时,并被设置为窗口(UIWindow)的根视图控制器之后执行。附其他tab的init也一样。

②第二个tab的viewDidload,在 UITabBarController 切换到该tab 时候才会调用。

以上验证代码可在 https://github.com/dvlproad/001-UIKit-CQDemo-iOS 中验证

问1:UITabBarController下,如何预加载指定的视图控制器?

答:数据通过 vm 或者 manager 提前在UITabBarController初始化时进行获取。

问2:UITabBarController下的子视图控制器中如果还有多tab(在顶部),则又如何进行预加载。

答:同1一样。数据通过 vm 或者 manager 提前在该子视图控制器初始化时进行获取。

性能监控-②其他

性能优化

[toc]

目录

帧率优化?卡顿优化?

Flutter DevTools 的视频使用教程:轻松调试和提高 APP 性能

一、耗电量、耗流量优化

< 返回目录

通过

1
2
3
4
5
6
①优化位置服务(尽量降低定位精度)、
②网络操作(减少传输、压缩数据、缓存数据)、
③任务处理(减少任务处理量、按需处理,常见于一些后台任务的处理,比如不需要计算里程时候,鹰眼服务可以先关闭)、
④内容更新(减少app使用的视图数量、去除不必要的内容更新)、
⑤定时器(降低触发频率、及时关闭不再需要的重复性定时器)
>

优化参考文章:iOS进阶–App功耗优化看这篇就够了

定位服务:按需取用,定位频率该降低降低,该关闭关闭

根据位置特性对静止不动的点、位置变化小的点、位置变化大的点,结合不同业务决定位置上报情况,减少不必要的上报,降低耗电量与节省流量。

相似问题参考:解决iOS地图持续定位耗电问题

网络请求优化的

这个需要和后台API一起优化,尽量减少不必要的请求,比如一次API请求尽量把客户端要用到的数据都返回过来,而不是要通过多个请求去返回,同时最好注意数据分页,不要几万条数据都扔给客户端了。

CPU:

使用 Instruments 中的 Time Profile 时间分析工具用来检测应用CPU的使用情况。 定位 app 使用过程中占用高CPU、耗时长的地方。

使用方法参考:Instrument 的 Time Profiler总结

Timer:合理使用Timer数和Timer时间间隔,不宜太短,满足需求即可

设置上报检测计时器(一般设为1秒,但合理的是取不同业务上报频率的最大公约数,比如报班状态下需要5秒上传一次位置,未报班状态只需要20上传一次位置,则取5秒)

合理使用线程,线程适量,不宜过多,不要阻塞主线程

太多线程会导致消耗大量内存(在iOS中,如果把需要消耗大量时间的操作放在主线程上面,会妨碍主线程中被称为RunLoop的主循环的执行,从而导致不能更新用户界面、应用程序的画面长时间停滞等问题。)。

优化算法,减少循环次数

还有关键的就是图片尺寸了,最好客户端需要啥尺寸,服务端就直接给啥尺寸,而不是到客户端上再缩放。

二、内存优化、内存泄露处理

< 返回目录

1、正确的地方使用 reuseIdentifier

2、内存泄漏

1、使用Product-Analyze分析内存泄
利用Product-Analyze分析内存泄露,并不能把所有的内存泄露查出来,因为有的内存泄露是在运行时,用户操作时才产生的。那就需要用到Instruments了。

2、使用Instruments检测定位并解决iOS内存泄露

三、其他优化

< 返回目录

问题:优化多线程处理,改善多线程嵌套严重,请求耗时的问题

解决:优化多线程处理,改善多线程嵌套严重,请求耗时的问题。

详细:原本项目,采用多线程嵌套的同步方式处理多个线程请求到数据后,再执行最后操作。经优化多线程处理为异步执行时,改善了多线程嵌套严重,请求耗时的问题。

定时器使用的优化

问题:定时器多,其在主线程

为什么要在非主线程创建NSTimer

将 timer 添加到主线程的Runloop里面本身会增加线程负荷
如果主线程因为某些原因阻塞卡顿了,timer 定时任务触发的时间精度肯定也会受到影响
有些定时任务不是UI相关的,本来就没必要在主线程执行,给主线程增加不必要的负担。当然也可以在定时任务执行时,手动将任务指派到非主线程上,但这也是有额外开销的。

iOS 应用性能调优其他参考:

耗时(instruments的Time Profiler)、卡顿(主线程)

iOS app性能优化的那些事

iOS应用性能调优的25个建议和技巧

四、渲染

参考文章:iOS 渲染原理解析

1、渲染原理CPU 与 GPU

  • CPU(Central Processing Unit):现代计算机整个系统的运算核心、控制核心。
  • GPU(Graphics Processing Unit):可进行绘图运算工作的专用微处理器,是连接计算机和显示终端的纽带。

GPU 的渲染流程图

GPU 的渲染流程图

1、Application 应用处理阶段:得到图元

这个阶段具体指的就是图像在应用中被处理的阶段,此时还处于 CPU 负责的时期。在这个阶段应用可能会对图像进行一系列的操作或者改变,最终将新的图像信息传给下一阶段。这部分信息被叫做图元(primitives),通常是三角形、线段、顶点等。

2、Geometry 几何处理阶段:处理图元

3、Rasterization 光栅化阶段:图元转换为像素

光栅化的主要目的是将几何渲染之后的图元信息,转换为一系列的像素,以便后续显示在屏幕上。这个阶段中会根据图元信息,计算出每个图元所覆盖的像素信息等,从而将像素划分成不同的部分。

img

一种简单的划分就是根据中心点,如果像素的中心点在图元内部,那么这个像素就属于这个图元。如上图所示,深蓝色的线就是图元信息所构建出的三角形;而通过是否覆盖中心点,可以遍历出所有属于该图元的所有像素,即浅蓝色部分。

4、Pixel 像素处理阶段:处理像素,得到位图

经过上述光栅化阶段,我们得到了图元所对应的像素,此时,我们需要给这些像素填充颜色和效果。所以最后这个阶段就是给像素填充正确的内容,最终显示在屏幕上。这些经过处理、蕴含大量信息的像素点集合,被称作位图(bitmap)。

2. 屏幕成像与卡顿

屏幕撕裂 Screen Tearing

CPU+GPU 的渲染流程是一个非常耗时的过程。如果在电子束开始扫描新的一帧时,位图还没有渲染好,而是在扫描到屏幕中间时才渲染完成,那么已扫描的部分和未扫描的部分就不是同一帧图像,这就造成屏幕撕裂。

解决屏幕撕裂、提高显示效率的一个策略就是使用垂直同步信号 Vsync 与双缓冲机制 Double Buffering。

屏幕卡顿的本质

手机使用卡顿的直接原因,就是掉帧。前文也说过,屏幕刷新频率必须要足够高才能流畅。对于 iPhone 手机来说,屏幕最大的刷新频率是 60 FPS,一般只要保证 50 FPS 就已经是较好的体验了。但是如果掉帧过多,导致刷新频率过低,就会造成不流畅的使用体验。

CALayer 与 UIView 的关系

当我们创建一个 UIView 的时候,UIView 会自动创建一个 CALayer,为自身提供存储 bitmap 的地方(也就是前文说的 backing store),并将自身固定设置为 CALayer 的代理。

核心关系

  1. CALayer 是 UIView 的属性之一,负责渲染和动画,提供可视内容的呈现。
  2. UIView 提供了对 CALayer 部分功能的封装,同时也另外负责了交互事件的处理。

有了这两个最关键的根本关系,那么下面这些经常出现在面试答案里的显性的异同就很好解释了。举几个例子:

  • 相同的层级结构:我们对 UIView 的层级结构非常熟悉,由于每个 UIView 都对应 CALayer 负责页面的绘制,所以 CALayer 也具有相应的层级结构。
  • 部分效果的设置:因为 UIView 只对 CALayer 的部分功能进行了封装,而另一部分如圆角、阴影、边框等特效都需要通过调用 layer 属性来设置。
  • 是否响应点击事件:CALayer 不负责点击事件,所以不响应点击事件,而 UIView 会响应。
  • 不同继承关系:CALayer 继承自 NSObject,UIView 由于要负责交互事件,所以继承自 UIResponder。

当然还剩最后一个问题,为什么要将 CALayer 独立出来,直接使用 UIView 统一管理不行吗?为什么不用一个统一的对象来处理所有事情呢?

这样设计的主要原因就是为了职责分离,拆分功能,方便代码的复用。通过 Core Animation 框架来负责可视内容的呈现,这样在 iOS 和 OS X 上都可以使用 Core Animation 进行渲染。与此同时,两个系统还可以根据交互规则的不同来进一步封装统一的控件,比如 iOS 有 UIKit 和 UIView,OS X 则是AppKit 和 NSView。