1、UI相关
1.1 说一下事件的传递和响应
1.1.1 事件的产生过程
用户在使用手机的的过程中会产生很多"事件",例如触摸手机屏幕、摇晃手机、利用耳机上的按键控制手机等。这些事件大体上可以分为三类:"Touch Events
"、"Motion Events
"、"Remote Events
"。 在iOS开发当中,我们能接触到的有关该过程的相关类有三个:UIEvent
、UITouch
、UIResponder
。
UIEvent
描述了用户与手机的一次交互,包括类型、时间等,还有很多UITouch。
UITouch
表示了触摸在屏幕上的位置、移动、大小、压力。
UIResponder
抽象了响应和处理事件的接口。
当iPhone
接受到一个触摸事件时,处理过程如下:
- 通过动作产生触摸事件唤醒处于睡眠状态中的app;
- 使用
IOKit.framework
将事件封装为IOHIDEvent
对象;- 系统通过
mach port
将IOHIDEvent
对象转发给SpringBoard.app
处理。SpringBorad
是iPhone手机的桌面管理程序,SpringBoard
可以找到能够处理该事件的app,然后将IOHIDEvent
对象通过mach port转发给对应的App;- App的主线程
Runloop
接收到SpringBoard
转发的消息,触发对应mach port
的source1回调__IOHIDeventSystemClientQueueCallback()
;source1
回调内部触发source0回调,__UIApplicationHandleEventQueue()
;source0
内部将IOHIDEvent对象包装为UIEvent对象;source0
内部回调UIApplication
的sendEvnet:
方法,将UIEvent
传递给UIWindow
;UIWindow
接收到UIEvent
后,就开始寻找合适的UIResponsder
处理。
1.1.2 事件的传递过程
UIWindow
开始使用逆序深度优先遍历算法,查找到最合适的 Responsder
。响应过程是顺着事件传递过程的路径反向进行的。view的 nextResponder
可能是控制器,也可能是view。
1.2 离屏渲染
1.2.1 什么是离屏渲染?
离屏渲染是指GPU在当前屏幕缓冲区(Frame Buffer
)以外开辟一块新的缓冲区(Off- Screen Buffer
)进行渲染工作。在当前屏幕缓冲区之外的渲染称之为离屏渲染。创建新的缓冲区是会消耗CPU和GPU资源的,而且创建和删除缓冲区都需要CPU和GPU同步,这会造成GPU渲染流水线停顿。离屏渲染需要两次渲染工作,一次在当前屏幕缓冲区另一次在离屏缓冲区,这意味着GPU需要做更多的工作。触发离屏渲染时会从Frame Buffer
切换到Off- Screen Buffer
,渲染完毕后再切换回Frame Buffer,这一过程需要来回切换上下文,因此对性能有一定的影响。
以下方式会造成离屏渲染:
- 同时使用
cornerRadius
和masksToBounds
,且contents
有内容情况下,比如UIButton设置了背景图,UIImageVIew设置了图片后再同时设置cornerRadius和masksToBounds; - layer.shouldRasterize;
- layer.mask;
- layer.opacity 和 layer.allowsGroupOpacity = true;
- 使用了高斯模糊;
实际上可以理解为该视图需要多个图层合并的情况下。
1.2.2 如何避免离屏渲染?
- 避免同时设置
layer.cornerRadius
和layer.masksToBounds = YES
,即设置圆角的同时又允许切割圆角; - 需要使用圆角图片时,预先用
CoreGraphics
切好; - 阴影使用
shadowPath
; - 需要mask的情况下,可以使用自定义
UIView
; - 需要进行模糊处理的时候尽量不用
UIVisualEffectView
,使用CoreImage
提供的方法或者是Accelerate.framework
。
离屏渲染并不一定是不好的,合理使用离屏渲染也是提高app性能的一种方式。比如在视图包含图片切比较复杂的情况下,开启光栅化虽然会造成离屏渲染,但是系统会将这一次渲染结果进行保存,下次需要渲染的时候之间就可以拿过来使用了,从而在一定程度上提高了性能。需要注意的是,该缓存只有100ms,且大小不得超过屏幕像素数据的2.5倍
。
1.3 UITableView如何优化?
UITableView
常用的优化思路如下:
- 减少
cell
图层的数量,如果子视图太多的话在drawRect:
方法里使用CoreGraphics
绘制; UITab了View
在滚动的时候不渲染,可以在tableView.dragging == NO && tableView.decelerating == NO
的时候显示图片;- 使用
YYWebImage
等框架时,可以将其返回的图片预先切好圆角,直接替换内存缓存和磁盘缓存中的数据; - 如果高度不固定的情况下,预先计算好高度并缓存;
- 重用特殊类型的cell;
- 不要在
cellForRowAtIndexPath:
方法中绑定数据,因为此时cell还没有显示。在cellWillDisplay
方法中绑定数据; - 尽量不要给视图设置透明背景;
- 使用异步渲染框架或者思路
AsyncDisplayKit
。
1.4 说一下UIViewController生命周期
可以从进入UIViewController
和退出两个方面阐述。
当进入一个视图控制器时:
+(instancetype)initialize
;-(instancetype)init
;-(void)loadView
;-(void)viewDidload
;-(void)viewWillAppear
;-(void)viewWillLayoutSubviews
-(void)viewDidLayoutSubviews
-(void)viewDidAppear
;
当退出一个视图时:
-(void)viewWillDisappear
;-(void)viewDidDisappear
;-(void)dealloc
;
1.5 UIView和CALayer
- UIView 继承自 UIResponder,是 UIKit框架里面的,CALayer是继承自 NSObject 的,是 QuartzCore框架中的一个类;
- CALayer 是 UIView 里面的一个属性;
- CALayer 负责渲染,UIView 能够处理响应事件;
- UIView 的 layer 树形在系统内部维护了三份拷贝:第一棵树(逻辑树)是我们可以操作的,比如设置圆角阴影等,第二棵树(动画树)是系统操作的,系统在这一层进行操作更改属性,第三棵树(显示树)就是当前屏幕显示的。
2、Objective-C语言特性
2.1 实例对象的本质
实例对象的底层实际上是一个结构体,比如一个 NSObject 的实例对象结构如下:
objc
struct NSObject_IMP {
Class isa;
};
2.2 类对象的本质
类对象的本质也是一个结构体。结构体里面保存了 isa、superclass 指针、方法缓存、类信息等。 实例对象的 isa 指向类对象。类对象的 isa 指向元类对象。类对象和元类对象的结构是一样的。 isa 从64 位以后是以共用体形式存在的。下面是简化版的结构:
objc
struct objc_class {
Class isa;
Class superclass;
cache_t cache; // 方法缓存
class_data_bits_t bits; // 类信息。通过进行&FAST_DATA_MASK运算可以得到
...
}
struct class_rw_t { // 运行时可变
uint32_t flags;
uint32_t version;
const class_ro_t *ro;
method_list_t *methods; // 方法列表
property_list_t *properties; // 属性列表
const protocol_list_t *protocols; // 协议列表
Class firstSubclass;
Class nextSiblingClass;
char *demangleName;
...
}
struct class_ro_t { // 运行时不可变。
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize;
uint32_t reversed;
const uint8_t *ivarLayout;
const char *name;
method_list_t *baseMethods; // 方法列表
property_list_t *baseProperties; // 属性列表
protocol_list_t *baseProtocols; // 协议列表
const ivar_list_t *ivars; // 成员变量信息列表
const uint8_t *weakIvarsLayout;
...
}
2.3 分类和扩展有什么区别
- 分类在运行时决议,扩展在编译时决议。分类实现同名的方法会"覆盖"原来的方法;
- 分类不可以添加属性,扩展可以添加属性;
- 二者写法不一样;
OC 的分类在编译后实际上是一个结构体,里面包含着与类相似的信息;
objc
struct category_t {
const char *name;
classref_t cls;
struct method_list_t *instanceMethods;
struct method_list_t *classMethods;
struct protocol_list_t *protocols;
struct property_list_t *instanceProperties;
struct property_list_t *classProperties;
method_list_t *methodsForMeta(bool isMeta) {
if (isMeta) return classMethods;
else return instanceMethods;
}
property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi);
}
后编译的分类方法会被插入到类的方法列表前面,因此会先调用分类方法。
2.3 load
方法、initialize
方法的调用顺序
load
方法调用顺序:
- 先调用类的
load
方法再调用分类的load
方法; - 先编译先调用;
- 子类的
load
方法调用之前先调用父类的。
initialize
的方法调用顺序:
- 类第一次调用方法时会先初始化,即调用
initialize
方法; - 先调用父类的再调用子类的,即先初始化父类再初始化子类。
两者的区别是 load
方法的调用是直接通过函数地址调用,而 initialize
方法是消息发送。initialize
方法如果子类没有实现,那么父类的方法会被调用多次。
2.5 KVO
KVO是 OC 里面的对类的属性变化监听的技术。其原理用到了 OC 的 runtime 底层技术,体现了OC 动态语言的强大之处。具体实现是这样的: 当我们给一个实例对象XXX添加观察者调用addObserver:forKeyPath:
方法后,该方法内部会进行一系列的处理。其内部会生成一个XXX的子类 NSKVONotifing_XXX
, 并且 XXX 的 isa 会指向新生成的子类,因此在调用方看来还是跟之前一样。这个新派生的类重写了基类 NSObject
的 class
、_isKVOA
、dealloc
和父类被观察属性的 setter
方法。
被观察属性的 setter
方法被重写后,里面会调用 _NSSetXValueAndNotifiy()
, 其内部会调用 [super setXX:xx]
且之前之后分别插入[self willChangeValueForKey]
和 [self didChangeValueForKey]
。后者会调用观察者实现的observerValueForKeyPath:ofObject:change:context:
方法; 使用KVO 的时候应该注意,在合适的时机移除观察者,否则会触发 NSRangeException
异常; KVC 会触发 KVO,但是直接给成员变量赋值是不会触发的;
2.6 KVC
KVC
,说的官方一点就是"键值编码",通过 KVC 这种技术可以给一个类的私有属性进行赋值,比如 UITextField
的 placholderLabel
修改文字颜色等等。使用方法是 setValue:forKey:
或者是 setValue:ForKeyPath:
。其中 value
可以传nil
。可以提一下的是,字典的方法 setObject:forKey:
中的两个参数都不可以为空。
下面谈谈 KVC 的实现原理。 先去看有没有实现上面提到的两个赋值方法,如果有就直接赋值,如果没有就调用accessInstanceVariablesDirectly
方法,如果返回了 NO,就调用 valueForUndefineKey:
方法,并抛出异常。如果是返回了 YES,说明可以直接访问成员变量,按照 _key
, _isKey
, key
, isKey
的顺序找成员变量,如果找到了就赋值,反之就调用 valueForUndefineKey:
并抛出异常。
KVC 的取值跟上面的类似,查找顺序是 getKey, key, isKey, _Key ,如果找到了就返回,没有找到也是调用 accessInstanceVariableDirectly 方法判断是否能够直接访问成员变量,如果可以的话,就按照 _key, _isKey, key, isKey这个顺序查找。
2.7 通知
2.7.1 实现原理
通知中心维护了一个 table,table 里面包含了 named表、nameless表、wildcard链表这三个数据结构;当我们调用 addObserver:selector:name:object:
方法时,其内部大概是这样实现的: 1.构造一个 Observation对象,该对象里面保存着 object 和 selector,可以看做是一个链表的节点。 2. 判断传入的 name 是否为空。如果 name 不为空,以 name为 key 从 named 的字典中取出一个 n 字典,然后从 n 字典里面以 object 为 key 取出 observation,再然后把 observation 对象存入链表。 3. 判断传入的 object 是否为空。如果 object 不为空,以 object 为 key 从 namedless 字典中取出 observation 链表,将 observation 对象存入; 4. 如果name 和 object 都为空,则将Observertion 对象存入 wildcard 链表中。
发送通知的过程是先判断object,再判断 name。name 的优先级高于 object。
2.7.2 通知的发送是同步的还是异步的?
同步的,会调用performSelector:withObject。但是有种情况可以不实时发送通知,而是在合适的时机发送,并没有开启线程,这种说法是指使用 NSOperationQueue,指定发送时机,可以依赖 Runloop 等到下一次循环开始时发送。
2.7.3 NSNotificationCenter 接收消息和发送消息是在同一个线程吗,如何异步发送通知?
是的,发送消息在哪个线程,接收消息就在哪个线程。
2.7.4 NSNotificaionQueue 是异步还是同步?在哪个线程响应?
没有异步发送一说,只是利用了 Runloop 可以选择触发时机。
2.7.5 NSNotificationQueue 和 Runloop 的关系?
前者依赖后者。比如指定 postStyle 的时候 NSPostWhenIdle 表示在 Runloop 空闲的时候发送。 此外还有 NSPostASAP,尽可能快 发送,NSPostNow多个相同的通知合并后马上发送。
2.7.6 如何保证通知接收的线程在主线程?
使用 block 方式注册通知,在主队列响应。或者是在主线程注册 machPort,这是负责线程通信的,当异步线程收到通知后,给 machport 发送消息。 还可以在通知的回调方法里面,使用 GCD 主队列调度方法。
2.7.8 页面销毁时,不移除通知会崩溃吗?
iOS9 之后不会了,通知中心对 Observer是弱引用的。
2.7.9 多次添加同一个通知和多次移除同一个通知会是什么结果?
多次添加会多次响应。移除没事儿。
2.8 消息转发流程
当给某个实例发送一个消息找不到方法时,就会进入所谓的消息转发流程。消息转发流程是这样的:
- 触发resoveInstanceMethod:,这个时候可以解决方法找不到的问题,俗称"动态方法解析"。该方法返回 YES 后,会继续走消息发送流程,从方法缓存开始查找。我们可以在这里使用 Runtime 的 api 动态添加一个方法实现; 2 动态方法解析过后,会来到"消息转发"阶段。该阶段有三个方法可以供我们实现,在这三个方法里操作一番也可以解决方法找不到的问题。按照调用顺序,首先是forwardingTargetForSelector:,该方法可以返回一个能够处理消息的对象,在这里我们可以创建一个能够处理消息的实例对象,让这个对象去处理消息。当这里没有实现或者返回 nil 时,会调用 methodSignaturaForSelector:和forwardInvocation:方法。实现这两个方法可以有最后一次机会处理找不到方法的问题。
2.9 super 调用方法的本质
虽然是通过 super 关键字调用方法,本质上还是给当前对象发送消息,只不过是方法查找的起点是从父类开始:objcMsgSendSuper2:这么一个函数调用。该函数接收两个参数,第一个是一个结构体:
objc
struct objc_super2 {
id receiver; // 消息接收者,也就是 self
Class current_class; // 方法查找起点,也就是父类
}
第二个参数是 SEL。 像下面这种调用方式肯定会死循环的:
objc
@implementation** B
- (void)a {
[super performSelector: @selector(a)];
}
@end
3. Block的本质
block 本质也是一种 OC 对象,其内部也有 isa,是封装了函数调用和函数调用环境的 OC 对象。有三种类型 block,即 NSGlobalBlock 、NSStackBlock 、NSMallocBlock,这三种 block 都继承自 NSBlock。没有访问 auto 变量的 block 属于 Global 类型的,保存在数据区。在 ARC 环境下,block 作为返回值、usingBlock:方法传入的 block、block被__strong指针指向、GCD里面的 block,这些情况下编译器会自动识别并且会调用 copy方法拷贝到堆上。
block 的变量捕获:auto 类型的变量是值捕获,static 修饰的变量是指针捕获,全局变量不捕获。
4. Runtime
Runtime 是 OC的基石,没有 Runtime 支持就没有 OC 、也没有 OC 的动态特性。通过 Runtime 的 api 可以实现很多功能,比如方法交换、动态生成类(KVO)、获取类的成员变量方法列表等信息、字典转模型、关联对象等等。 常用 api:
- objc_allocateClass // 创建一个类
- objc_registerClassPare // 注册一个类
- object_getClass // 获取类对象
- class_getSuperClass // 获取父类对象
- class_getInstanceVariable // 获取一个实例变量
- class_copyIvarList // 获取成员变量列表
- class_addIvar // 添加成员变量(已经初始化过后的类是不能添加的)
- class_copyMethodList // 获取方法列表
- method_exchangeImplementation // 交换方法实现
- class_replaceMethod // 替换方法实现
- imp_implementationWithBlock // 使用 block 作为方法实现
5. Runloop
Runloop 是 APP 运行的保证,事件处理、NStimer、autoreleasePoll内存管理、GCD 回到主队列的执行、网络请求、perforSelector、屏幕刷新都是基于 Runloop 的,Runloop 的底层实际上就是一个做了很多事情的 while 循环,前面说的那些事情就是在循环内部实现的。Runloop 和线程是一一对应关系,每个线程都可以获取一个 Runloop。
OC 里面有可以通过 NSRunloop 来使用 Runloop,也可以使用 CFRunloopRef 这套 C语言的 api 使用。
Runloop 和线程是以一一对应关系,保存在全局字典里面,线程对象作为 key,Runloop 为值。Runloop 会在第一次获取的时候创建。子线程的 Runloop 默认是不开启的。Runloop 会在线程结束后销毁。
跟 Runloop 相关的五个类:CFRunloopRef、CFRunloopModeRef、CFRunloopSourceRef、CFRunloopTimerRef、CFRunloopObserverRef。CFRunloopModeRef 代表 Runloop 的运行模式。常见的运行模式有 defaultMode 和 UITrackingMode。
CFRunloopRef 是个结构体指针,结构体里面有三个集合分别是_commonModes、_commonModeItems、_modes,还有_currentMode和pthread。
每个 mode 里面包含source0、source1、timers、observers,如果没有这些东西,Runloop 会退出。observer 可以监听 Runloop 的六种状态,进入、退出、处理 source、处理 timer、开始等等、结束等待,枚举值分别为kCFRunloopEntry、kCFRunloopExit、kCFRunloopBeforeSources、kCFRunloopBeforeTimers、kCFRunloopBeforeWating、kCFRunlooopAfterWating。
Runloop 的运行逻辑:
- 通知 observers,进入 Runloop;
- 通知 observers,处理 timers;
- 通知 observers,处理 sources;
- 处理 block;
- 处理 sources0;
- 如果存在 source1 就处理source1 ,跳转第8 步;
- 通知 observers 开始等待;
- 通知 observers 结束等待(被唤醒)-> 处理 timers、处理GCD调度主队列、处理 source1;
- 处理 block;
- 根据之前的处理结果判断是否从第二部进入循环,或者退出;
- 通知 observers 退出 Runloop;
6. 内存管理
自从 iOS5 开始,OC 的内存管理由 MRC 升级到了 ARC,即自动引用计数。ARC是编译器、Runtime 共同作用下完成的。OC 的内存管理是通过引用计数来实现的,当一个对象的引用计数为 0 时,该对象就会被释放。引用计数是被存储在 isa 指针或者是 SideTable 里面,当 isa 指针提供的19 位不够使用时就会存到 SideTable。
SideTable 是内存管理的方案之一,包含了引用计数表和弱引用表。__weak
修饰的对象,就会被添加到弱引用表里面。一个 iOS 项目会全局维护一个 SideTables,SideTables 里面又有多个 SideTable(真机情况下是 8 个)。SideTable 里面有线程锁、RefcountMap、weak_table_t三个核心属性。
6.1 autorelease
- AutoreleasePool 并没有独立的结构,而是由若干个 AutoreleasePoolPage 以双向链表的形式组合而成。
- AutoreleasePooPage 是按线程一一对应的。
- AutoreleasePoolPage 每个对象会开辟 4096 个字节(虚拟内存一页的大小),除了上面的实例变量所占空间,剩下的空间全部用来存储 Autorelease 对象的地址。
- 一个 AutoreleasePoolPage 的空间被占满时,会新建一个 AutoreleasePoolPage 对象,追加到链表尾部,后来的 Autorelease 对象加入到新建的 AutoreleasePoolPage 里面,是以栈的形式加入。里面有个 next 指针指向栈顶的下一个位置。
6.2 Autorelease 对象什么时候释放?
没有手动添加 AutoreleasePool 的情况下,Autorelease 对象是在当前的 Runloop 循环结束时释放的,而它能够释放的原因是系统在每个 Runloop 迭代中都加入了自动释放池 push 和 pop。这些都是跟 Runloop 相关的:iOS 在主线程的 Runloop 中注册了两个 Observer。第一个 Observer 监听了 kCFRunloopEntry 事件,这个监听会调用objc_autoreleasePoolPush()。第二个 Observer 监听了 kCFRunloopBeforeExit 事件,会调用objc_autoreleasePoolPop()。
AutoreleasePool实际用途:
- 开发命令行工具,没有 UI框架给我们创建 Runloop 的时候,可以开启一个;
- 短时间内创建大量对象内存暴增的时候;
7. 多线程
iOS 里面常用的线程方案有 NSThread、NSOperation、GCD。NSThread 是基于 p_thread的高层次封装,NSOperation是基于 GCD 的封装。NSOpeartion 不能够直接使用,通常情况下我们会使用NSBlockOperation 和 NSInvocationOperation,前者的回调是 block 形式的,后者是方法。 我们还可以使用 NSOperationQueue 控制并发数、添加依赖、取消任务等等多种操作。GCD 是面向 C语言的接口,通过 block 的形式执行任务,使用起来代码更加聚合。
使用 GCD 的时候需要注意死锁。使用 sync 函数向当前串行队列中添加任务时就会产生死锁。
8. 网络
8.1 了解 TCP 吗,请你说一下 TCP 建立连接和释放连接的过程?
TCP建立连接需要经过所谓的"三次握手"。
- 客户端发送请求报文,标志位 SYN=1,序列号 seq=x,告知服务端"我想跟你建立连接";
- 服务端收到客户端发送的请求,服务端回应客户端,标志位 ACK=1,SYN=1,seq=y,ack=x+1,该过程表示服务端愿意跟客户端建立连接,"我收到你的请求了,可以建立连接";
- 客户端收到服务端的回应,告知服务端"我收到你的消息了",其中标志位 ACK=1,seq=x+1,ack=y+1;
之所以是三次握手而不是两次,是因为双方都需要确定对方有发送消息和接收消息的能力,如果只有两次的话服务端是不知道客户端是否有接收消息的能力的。
TCP 释放连接需要经历"四次握手"。
- 客户端发送标志位 FIN=1 的报文给服务端,告知服务端"我要跟你断开连接了";
- 服务端返回 ACK=1 的报文,告知客户端"知道了,但是等我会儿,我可能还有消息没给你发完";
- 过一会服务端的消息也发送完了,给客户端发送FIN=1的报文,告诉客户端"我的消息发完了,我这边下线了";
- 客户端收到服务端的断开请求后,回应一个 ACK=1,告诉服务端"好的我也下线了"。
客户端经过 MSL 的时间后释放端口。为什么要等这么一段时间呢?因为如果服务端没有收到客户端最后一次的 ACK 确认报文,可能会重发,如果不等待这段时间,那么新的进程开启后占用之前的端口,新建的连接就收到了服务端重发的 FIN 报文,刚建立的连接就被断开了。
10. 数据持久化
iOS 开发中常见的数据持久化方案有这么几种:
- UserDefaults。实际上是对 plist 文件的操作,使用起来非常方便,经常用于记录一些用户数据。
- 归档。使用归档方式可以将 OC 对象存储到磁盘当中。
- 文件。直接将数据转为 Data 以文件的形式存入磁盘。
- Bundle。使用 XCode 像添加文件一样添加一个 Bundle,用户可以在设置中看到需要存储的内容。
- Plist,可以将集合中的数据保存为 Plist 格式的,比如字典或者数组。
- Sqlite3,轻量级的数据库。
- CoreData,苹果面向对象的持久化框架,一般情况下很少用。
10. 设计模式
10.1 SOLID原则
- S 单一功能,或者单一职责。对象各司其职,具有单一功能。
- O 开闭原则,软件应该是对于扩展开发的,但是修改关闭的。没问题的代码就最好不要修改。
- L 里氏替换,凡是之前用Person 类可以实现的功能,那么换成 Person 的子类也应该可以支持。
- I 接口隔离,客户端不应该依赖那些它用不到的接口,应该仅仅依赖它实际使用的方法。如果一个接口具备了多个方法,那么实现类就会实现所有的方法,代码会臃肿。
- D 依赖倒置,高层不依赖底层,应该依赖抽象。抽象不依赖细节,细节依赖抽象。对应的设计模式有工厂模式、模板方法、策略。