回答-阿里、字节:一套高效的iOS面试题⑦(视图&图像相关)

394 阅读17分钟

1、AutoLayout的原理,性能如何

原理:

Auto Layout 只关注视图之间的关系,通过布局引擎和已有的约束计算出各个视图的frame,每当约束改变时会重新计算各个视图的frame,获得frame的过程,就是根据各个视图已有的约束条件解方程式的过程。

性能:

性能会随着视图数量的增加呈指数级增加,达到一定数量的视图时,布局所需要的时间就会大于16.67ms,超过屏幕的刷新频率时会出现卡顿。

2、UIView & CALayer的区别

@interface UIView : UIResponder 
@property(nonatomic,readonly,strong)                 CALayer  *layer;              // returns view's layer. Will always return a non-nil value. view is layer's delegate
@end

联系:

view持有layer,view是layer的delegate。

CALayer有一个可选的 delegate 属性,实现了 CALayerDelegate 协议,当 CALayer需要一个内容特定的信息时,就会从协议中请求。

CALayerDelegate是一 个非正式协议,其实就是说没有CALayerDelegate @protocol可以让你在类里面引用啦。你只需要调用你想调用的方法,CALayer会帮你做剩下的。( delegate 属 性被声明为id类型,所有的代理方法都是可选的)。

当需要被重绘时,CALayer会请求它的代理给他一个寄宿图来显示。它通过调用下面这个方法做到的:

- (void)displayLayer:(CALayerCALayer *)layer;

区别:

view 负责事件的响应和UI的交互,为layer 提供内容。

UIView 继承自 UIResponder,可以响应触摸和其他类型的事件,视图可以添加 UIGestureRecognizer 来处理常用手势。

layer 负责内容、动画的显示,但即便没有 UIViewCALayer 也可以正常展示内容。我们可以把 CALayer 称之为 “”。layer 的主要工作是管理我们提供的视觉内容,但是 layer 本身也含有可以被设置的视觉属性,例如背景色、边框、阴影等。

3、事件响应链

一个事件有多个响应者,就存在寻找最佳响应者的过程。事件传递的目的是为了寻找事件的最佳响应者,是自下而上的传递。

寻找最佳响应者流程:

1、UIApplication首先将事件传递给窗口对象(UIWindow)。

2、窗口不响应传递给其他窗口,能响应传递给自己的子视图。

3、根据视图层级由后向前将事件传递给上一个同级子视图;若能响应,则从后往前询问当前视图的子视图。

4、若没有能够响应的子视图时,自己就是最佳响应者。

hitTest:withEvent:

每个UIView对象都有一个 hitTest:withEvent: 方法,这个方法是Hit-Testing过程中最核心的存在,其作用是询问事件在当前视图中的响应者,同时又是作为事件传递的桥梁。

UIApplication将事件通过调用UIWindow对象的 hitTest:withEvent: 传递给UIWindow对象,如果UIWindow能够响应事件,则调用UIWindow的子视图的hitTest:withEvent: ,一直往最上层的子视图传递找到最佳响应者,最终UIWindow返回一个视图层次中的响应者视图给UIApplication,这个视图就是hit-testing的最佳响应者。

默认实现:
  • 若当前视图无法响应事件,则返回nil
  • 若当前视图可以响应事件,但无子视图可以响应事件,则返回自身作为当前视图层次中的事件响应者
  • 若当前视图可以响应事件,同时有子视图可以响应,则返回子视图层次中的事件响应者

事件传递的目的是为了寻找事件的最佳响应者,是自下而上的传递。响应者做出对事件的响应,这个过程是自上而下的。

响应者对于事件的操作方式:

响应者对于事件的拦截以及传递都是通过 touchesBegan:withEvent: 方法控制的。

响应者对于接收到的事件有3种操作:

1、不拦截,默认操作,事件会自动沿着默认的响应链往下传递。

2、拦截,不再往下分发事件,重写 touchesBegan:withEvent: 进行事件处理,不调用父类的touchesBegan:withEvent:。

3、拦截,继续往下分发事件,重写 touchesBegan:withEvent: 进行事件处理,同时调用父类的 touchesBegan:withEvent: 将事件往下传递。

响应链中的事件传递规则:

每一个响应者对象(UIResponder对象)都有一个 nextResponder 方法,用于获取响应链中当前对象的下一个响应者。因此,一旦事件的最佳响应者确定了,这个事件所处的响应链就确定了。
打印响应链:
- (void)printResponderChain
{
    UIResponder *responder = self;
    printf("%s",[NSStringFromClass([responder class]) UTF8String]);
    while (responder.nextResponder) {
        responder = responder.nextResponder;
        printf(" --> %s",[NSStringFromClass([responder class]) UTF8String]);
    }
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [self printResponderChain];
    [super touchesBegan:touches withEvent:event];
}
UIView

若视图是控制器的根视图,则其nextResponder为控制器对象;否则,其nextResponder为父视图。

UIViewController

若控制器的视图是window的根视图,则其nextResponder为窗口对象;若控制器是从别的控制器present出来的,则其nextResponder为presenting view controller。

UIWindow

nextResponder为UIApplication对象。

UIApplication

若当前应用的app delegate是一个UIResponder对象,且不是UIView、UIViewController或app本身,则UIApplication的nextResponder为app delegate。

Reference:

mp.weixin.qq.com/s/9rvSRt4kf…

4、drawrect & layoutsubviews调用时机

drawrect调用时机:
  1. drawrect:是在UIViewController的loadView:ViewDidLoad:方法之后调用.

  2. 当我们调用[UIView sizeToFit]后,会触发系统自动调用drawRect:

  3. 当设置UIView的contentMode或者Frame后会立即触发触发系统调用drawRect:

  4. 直接调用setNeedsDisplay设置标记 或setNeedsDisplayInRect:的时候会触发drawRect:,我们使用 setNeedsDisplaysetNeedsDisplayInRect: 方法来给 view 打上 dirty 的标记,这两个方法告诉系统 view 的内容已经改变,需要在下一个 drawing cycle 进行重绘。

当一个view第一次显示,或者发生让事件可见部分无效的事件时,将会调用这个方法。

This method is called when a view is first displayed or when an event occurs that invalidates a visible part of the view. You should never call this method directly yourself. To invalidate part of your view, and thus cause that portion to be redrawn, call the setNeedsDisplay or setNeedsDisplayInRect: method instead.

layoutsubviews调用时机:
  1. addSubview会触发layoutSubviews。
  2. 设置view的Frame会触发layoutSubviews (frame发生变化触发)。
  3. 滚动一个UIScrollView会触发layoutSubviews。
  4. 旋转Screen会触发父UIView上的layoutSubviews事件。
  5. 改变一个UIView大小的时候也会触发父UIView上的layoutSubviews事件。
  6. 直接调用setLayoutSubviews。

5、UI的刷新原理

苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
    QuartzCore:CA::Transaction::observer_callback:
        CA::Transaction::commit();
            CA::Context::commit_transaction();
                CA::Layer::layout_and_display_if_needed();
                    CA::Layer::layout_if_needed();
                        [CALayer layoutSublayers];
                            [UIView layoutSubviews];
                    CA::Layer::display_if_needed();
                        [CALayer display];
                            [UIView drawRect]; //只有初始化frame的时候才会触发,更新界面并不会再次触发。如果想触发,可手动调setNeedsDisplay方法。

[UIView setNeedsDisplay] 并没有发生当前视图立即绘制工作,打上需要重绘的脏标记,最后是在某个时机完成。

[UIView setLayoutIfNeed] 立即重新布局视图(下一个Runloop)。

[view layouIfNeeded] 当前RunLoop休眠前更新。

在iOS中是双缓冲机制,有前帧缓存、后帧缓存,即GPU会预先渲染好一帧放入一个缓冲区内(前帧缓存),让视频控制器读取,当下一帧渲染好后,GPU会直接把视频控制器的指针指向第二个缓冲器(后帧缓存)。当你视频控制器已经读完一帧,准备读下一帧的时候,GPU会等待显示器的VSync信号发出后,前帧缓存和后帧缓存会瞬间切换,后帧缓存会变成新的前帧缓存,同时旧的前帧缓存会变成新的后帧缓存。

Reference:

roadmap.isylar.com/iOS/UIKit/U…

6、隐式动画 & 显示动画区别

1、显式动画

是指用户自己通过beginAnimations:context:和commitAnimations创建的动画,显示动画需要手动创建。

2、隐式动画

是指通过UIView的animateWithDuration:animations:方法创建的动画,隐式动画是系统框架自动完成的。

Core Animation在每个runloop周期中自动开始一次新的事务,即使你不显式的用[CATransaction begin]开始一次事务,任何在一次runloop循环中属性的改变都会被集中起来,然后做一次0.25秒的动画。

7、什么是离屏渲染

如果你无法仅仅使用frame buffer来画出最终结果,那就只能另开一块内存空间来储存中间结果。

离屏渲染的定义:

如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的frame buffer,作为像素数据存储区域,而这也是GPU存储渲染结果的地方。如果有时因为面临一些限制,无法把渲染结果直接写入frame buffer,而是先暂存在另外的内存区域,之后再写入frame buffer,那么这个过程被称之为离屏渲染。

为什么会触发离屏渲染:

通常对于每一层layer,Render Server会遵循“画家算法”,按次序输出到frame buffer,后一层覆盖前一层,就能得到最终的显示结果,作为“画家”的GPU虽然可以一层一层往画布上进行输出,但是无法在某一层渲染完成之后,再回过头来擦除/改变其中的某个部分——因为在这一层之前的若干层layer像素数据,已经在渲染中被永久覆盖了。这就意味着,对于每一层layer,要么能找到一种通过单次遍历就能完成渲染的算法,要么就不得不另开一块内存,借助这个临时中转区域来完成一些更复杂的、多次的修改/剪裁操作

GPU离屏渲染的例子:

例子1:父视图上多个子视图被裁减圆角

1、将一个layer的内容裁剪成圆角,可能不存在一次遍历就能完成的方法。

2、容器的子layer因为父容器有圆角,那么也会需要被裁剪,而这时它们还在渲染队列中排队,尚未被组合到一块画布上,自然也无法统一裁剪。

3、此时我们就不得不开辟一块独立于frame buffer的空白内存,先把容器以及其所有子layer依次画好,然后把四个角“剪”成圆形,再把结果画到frame buffer中。这就是GPU的离屏渲染。

例子2:shadow

1、虽然layer本身是一块矩形区域,但是阴影默认是作用在其中”非透明区域“的,而且需要显示在所有layer内容的下方,因此根据画家算法必须被渲染在先。

2、但矛盾在于此时阴影的本体(layer和其子layer)都还没有被组合到一起,怎么可能在第一步就画出只有完成最后一步之后才能知道的形状呢

3、这样一来又只能另外申请一块内存,把本体内容都先画好,再根据渲染结果的形状,添加阴影到frame buffer,最后把内容画上去。

避免阴影发生离屏渲染的方法:

我们能够预先告诉CoreAnimation(通过shadowPath属性)阴影的几何形状,那么阴影当然可以先被独立渲染出来,不需要依赖layer本体,也就不再需要离屏渲染了。

例子3:group opacity

alpha并不是分别应用在每一层之上,而是只有到整个layer树画完之后,再统一加上alpha,最后和底下其他layer的像素进行组合。显然也无法通过一次遍历就得到最终结果。

例子 4:mask

mask是应用在layer和其所有子layer的组合之上的,而且可能带有透明度,那么其实和group opacity的原理类似,不得不在离屏渲染中完成。

离屏渲染的优化:

1、既然已经花了不少精力把图片裁出了圆角,如果我能把结果缓存下来,那么下一帧渲染就可以复用这个成果,不需要再重新画一遍了。

2、CALayer为这个方案提供了对应的解法:shouldRasterize。一旦被设置为true,Render Server就会强制把layer的渲染结果(包括其子layer,以及圆角、阴影、group opacity等等)保存在一块内存中,这样一来在下一帧仍然可以被复用,而不会再次触发离屏渲染。

什么时候需要CPU渲染?

1、如文字(CoreText使用CoreGraphics渲染)和图片(ImageIO)渲染,由于GPU并不擅长做这些工作,不得不先由CPU来处理好以后,再把结果作为texture传给GPU。

2、使用CoreGraphics给图片加上圆角(将图片中圆角以外的部分渲染成透明)。

整个过程全部是由CPU完成的。这样一来既然我们已经得到了想要的效果,就不需要再另外给图片容器设置cornerRadius。另一个好处是,我们可以灵活地控制裁剪和缓存的时机,巧妙避开CPU和GPU最繁忙的时段,达到平滑性能波动的目的。

一些优化?

1、对于图片的圆角不经由容器来做剪切,而是预先使用CoreGraphics为图片裁剪圆角。

2、对于视频的圆角,由于实时剪切非常消耗性能,我们会创建四个白色弧形的layer盖住四个角,从视觉上制造圆角的效果。

3、对于view的圆形边框,如果没有backgroundColor,可以放心使用cornerRadius来做

4、对于所有的阴影,使用shadowPath来规避离屏渲染

5、对于特殊形状的view,使用layer mask并打开shouldRasterize来对渲染结果进行缓存

6、对于模糊效果,不采用系统提供的UIVisualEffect,而是另外实现模糊效果(CIGaussianBlur),并手动管理渲染结果。

Reference:

zhuanlan.zhihu.com/p/72653360

8、imageName & imageWithContentsOfFile区别

1、imageName:

1、imageNamed 会将使用过的图片缓存到内存中。

2、即使生成的对象被 AutoReleasepool 释放了,这份缓存依然存在。

3、imageNamed 会先尝试从缓存中读取,效率更高,但是会额外增加开销 CPU 的时间。

2、imageWithContentsOfFile:

1、imageWithContentsOfFile 直接从文件中加载图片,图片不会缓存,加载速度较慢,但是不会浪费内存。

2、除非某个图片经常使用,否则使用 imageWithContentsOfFile 这种经济的方式。

9、多个相同的图片,会重复加载吗

使用 imageName: 加载图片

  • 加载到内存当中后,占据内存空间较大
  • 相同的图片,图片不会重复加载
  • 加载内存当中之后,会一直停留在内存当中,不会随着对象销毁而销毁
  • 加载进去图片之后,占用的内存归系统管理,我们无法管理

使用 imageWithContentsOfFile: 加载图片

  • 加载到内存当中后,占据内存空间较小
  • 相同的图片会被重复加载内存当中
  • 对象销毁的时候,加载到内存中图片会随着一起销毁

结论:

  1. 图片较小,并且使用频繁,使用 imageName: 来加载(按钮图标/主页里面图片)
  2. 图片较大,并且使用较少,使用 imageWithContentsOfFile: 来加载(版本新特性/相册)

图片在沙盒中的存在形式

  1. 部署版本在>=iOS8的时候,打包的资源包中的图片会被放到Assets.car。图片有被压缩; 部署版本在<iOS8的时候,打包的资源包中的图片会被放在MainBudnle里面。图片没有被压缩
  2. 放在Images.xcassets里面的所有图片会直接暴露在沙盒的资源包(main Bundle), 会压缩到Assets.car文件,被放到MainBudnle里面图片没有被压缩。
结论:
  • 小图片\使用频率比较高的图片放在Images.xcassets里面
  • 大图片\使用频率比较低的图片(一次性的图片, 比如版本新特性的图片)不要放在Images.xcassets里面
Reference:

juejin.cn/post/684490…

10、图片是什么时候解码的,如何优化

首先要搞清楚一个图片显示的流程是怎样的?

  1. 从磁盘拷贝数据到内核缓冲区(系统调用);
  2. 从内核缓冲区拷贝数据到用户控件(UIImageView)(进程所在)
  3. 生成 UIImageView,把图像数据赋值给 UIImageView;
  4. 如图像未解码,解码为位图数据;
  5. GPU 处理位图数据,进行渲染。

为什么要解码?

常用的图片格式 PNG、JPEG 等都是压缩格式,而屏幕显示的是位图 bitmap。

怎么解码?

Data Buffer :存储在内存中的原始数据。图像可以以不同格式存储,如 JPEG、PNG。

Image Buffer :图像在内存中的存储方式,每一个元素描述一个像素点。其存储方式与位图相同,存储在内存中。

Frame Buffer :帧缓存,用于显示到显示器上的。存储在 vRAM(video RAM)中。

将 Data Buffer 转换为 Image Buffer 的过程,就可以称为解码。或者说,将未解码的 CGImage 转换为位图。

何时解码?

图片在被设置到 UIImageView.image 或 layer.contents 中之后,在 layer 被提交到 GPU 之前,CGImage 数据才会被解码。这一步发生在主线程中,无可避免。

如何优化?

提前强制解码。

画布重绘解码,将图片用 CGContextDrawImage() 绘制到画布上,然后把画布的数据取出来当作图片。

Reference:

juejin.cn/post/684790…

11、图片渲染怎么优化

优化无非从两个方向着手:空间、时间。

空间:

1、限制图片的大小

2、限制图片的缓存,除非使用频繁,否则不要缓存

3、使用 Image Asset Catalogs,压缩率较高(缺点是只能通过 imageNamed: 来加载);

时间:

1、提前强制解码

2、使用符合尺寸的图片:字节对齐

为什么图片要字节对齐?

对齐是为了提高读取的性能。因为处理器读取内存中的数据不是一个一个字节读取的,而是一块一块读取的。

不对齐需要访问两个块然后进行数据处理,会影响读取的性能。

在iOS中,如果这个图像的数据没有字节对齐,那么Core Animation会自动拷贝一份数据做对齐处理。

为了实现高性能的滚动,Core Animation能够使用一个图像而不需要首先创建一个副本,这一点至关重要。Core Animation会创建一个图像的副本的原因之一是图像的底层CGImageRef的字节不对齐。正确对齐的每行字节值必须是 8个像素 × 每个像素的字节 。对于一个典型的ARGB图像,每行对齐的字节值是64的倍数。每个 image table 都是这样配置的,每个图像从一开始就对Core Animation进行正确的字节对齐。因此,当图像从图像表检索时,它们已经处于Core Animation可以直接使用的形式,而不需要创建一个副本。

12、如果GPU的刷新率超过了iOS屏幕60Hz刷新率是什么现象,怎么解决

1、准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。显示器通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。

2、在最简单的情况下,帧缓冲区只有一个,这时帧缓冲区的读取和刷新都都会有比较大的效率问题。为了解决效率问题,显示系统通常会引入两个缓冲区,即双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU 会直接把视频控制器的指针指向第二个缓冲器。如此一来效率会有很大的提升。

3、当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象。

如果GPU的刷新率超过了iOS屏幕60Hz刷新率是什么现象?

如果GPU的刷新频率高于iOS屏幕的60HZ时,就意味着GPU缓冲区存在还没有显示完成,GPU 将新的一帧内容提交到帧缓冲区,并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象。

怎么解决?

为了解决这个问题,GPU 通常有一个机制叫做垂直同步(简写也是 V-Sync),当开启垂直同步后,GPU 会等待显示器的 VSync 信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要消费更多的计算资源,也会带来部分延迟。

Reference:

blog.ibireme.com/2015/11/12/…