iOS 进阶知识总结(二)
3~5年开发经验 的 iOS工程师 应该知道的知识点~本篇总结了以下内容
- KVO
- KVC
- 多线程
- 锁
- runloop
- 计时器
导航
- 对象
- 类对象
- 分类
- runtime
- 消息与消息转发
- KVO
- KVC
- 多线程
- 锁
- runloop
- 计时器
- 视图渲染和离屏渲染
- 事件传递和响应链
- crash处理和性能优化
- 编译流程和启动流程
- 内存管理
- 野指针处理
- autoreleasePool
- weak
- 单例、通知、block、继承和集合
- 网络基础
- AFNetWorking
- SDWebImage
KVO & KVC
KVO
用法和底层原理
- 使用方法:添加观察者并实现监听的代理方法
KVO
底层使用了isa-swizling
技术OC
中每个对象/类都有isa
指针,isa
表示这个对象是哪个类的对象- 当给对象的某个属性注册了一个
observer
,系统会创建一个新的中间类(intermediate class
)继承自原来的class
,把该对象的isa
指针指向中间类。 - 然后中间类会重写
setter
方法,赋值前调用willChangeValueForKey
, 赋值后调用didChangeValueForKey
,通知所有观察者值发生了更改 - 重写了
-class
方法伪装类没有发生改变
KVO的优缺点
- 优点
- 1、可以方便快捷的实现两个对象的关联同步,例如
view & model
- 2、能够观察到新值和旧值的变化
- 3、可以方便的观察到嵌套类型的数据变化
- 1、可以方便快捷的实现两个对象的关联同步,例如
- 缺点
- 1、观察对象通过
string
类型设置,如果写错或者变量名改变,编译时可以通过但是运行时会发生crash
- 2、观察多个值需要在代理方法中多个
if
判断 - 3、多次注册会多次触发代理方法
- 4、添加和移除观察者必须成对出现,次数不匹配会
crash
- 1、观察对象通过
怎么手动触发KVO
- 在观察对象变化前调用
willChangeValueForKey:
- 在观察对象变化后调用
didChangeValueForKey:
给KVO
添加筛选条件
- 重写
automaticallyNotifiesObserversForKey
,需要筛选的key
返回NO
。 - 手动触发
KVO
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
if ([key isEqualToString:@"age"]) {
return NO;
}
return [super automaticallyNotifiesObserversForKey:key];
}
- (void)setAge:(NSInteger)age {
if (age >= 18) {
[self willChangeValueForKey:@"age"];
_age = age;
[self didChangeValueForKey:@"age"];
}else {
_age = age;
}
}
使用KVC
会触发KVO
吗?
- 会,只要
accessInstanceVariablesDirectly
返回YES
,通过KVC
修改成员变量的值会触发KVO
。 - 这说明
KVC
内部调用了willChangeValueForKey:
方法和didChangeValueForKey:
方法
直接修改成员变量会触发KVO
吗?
- 不会
KVO
的崩溃与防护
- 崩溃原因:
- KVO 添加和移除次数不相等,大部分是移除多于注册。
- 被观察者
dealloc
时仍然注册着KVO
,导致崩溃。 - 添加了观察者,但未实现
observeValueForKeyPath:ofObject:change:context:
。
- 防护方案1:
- 直接使用facebook开源框架
KVOController
- 直接使用facebook开源框架
- 防护方案2:
- 自定义一个哈希表,记录观察者和观察对象的关系。
- 使用
fishhook
替换addObserver:forKeyPath:options:context:
,在添加前先判断是否已经存在相同观察者,不存在才添加,避免重复触发造成bug。 - 使用
fishhook
替换removeObserver:forKeyPath:
和removeObserver:forKeyPath:context
,移除之前判断是否存在对应关系,如果存在才释放。 - 使用
fishhook
替换dealloc
,执行dealloc
前判断是否存在未移除的观察者,存在的话先移除。
KVC
底层原理
setValue:forKey:
的实现
- 查找
setKey:
方法和_setKey:
方法,只要找到就直接传递参数,调用方法; - 如果没有找到
setKey:
和_setKey:
方法,查看accessInstanceVariablesDirectly
方法的返回值,如果返回NO
(不允许直接访问成员变量),调用setValue:forUndefineKey:
并抛出异常NSUnknownKeyException
; - 如果
accessInstanceVariablesDirectly
方法返回YES
(可以访问其成员变量),就按照顺序依次查找_key、_isKey、key、isKey
这四个成员变量,如果查找到了就直接赋值;如果没有查到,调用setValue:forUndefineKey:
并抛出异常NSUnknownKeyException
。
valueForKey:
的实现
- 按照
getKey,key,isKey
的顺序查找方法,只要找到就直接调用; - 如果没有找到,
accessInstanceVariablesDirectly
返回YES
(可以访问其成员变量),按照顺序依次查找_key、_isKey、key、isKey
这四个成员变量,找到就取值;如果没有找到成员变量,调用valueforUndefineKey
并抛出异常NSUnknownKeyException
。 accessInstanceVariablesDirectly
返回NO
(不允许直接访问成员变量),那么会调用valueforUndefineKey:
方法,并抛出异常NSUnknownKeyException
;
多线程
进程和线程的区别
- 进程:进程是指在系统中正在运行的一个应用程序,一个进程拥有多个线程。
- 线程:线程是进程中的一个单位,一个进程想要执行任务, 必须至少有一条线程。应程序启动默认开启主线程。
进程都有什么状态
Not Running
:未运行。Inactive
:前台非活动状态。处于前台,不能接受事件处理。Active
:前台活动状态。处于前台,能接受事件处理。Background
:后台状态。处于后台,如果有后台任务会继续执行代码,执行完后挂起程序。Suspended
:挂起状态。处于后台,不能执行代码,如果内存不足程序会被杀死。
什么是线程安全?
- 多条线程同时访问一段代码,不会造成数据混乱的情况
怎样保证线程安全?
- 通过线程加锁
pthread_mutex
互斥锁(C语言)@synchronized
NSLock
对象锁NSRecursiveLock
递归锁NSCondition & NSConditionLock
条件锁dispatch_semaphore
GCD信号量实现加锁OSSpinLock
自旋锁(不建议使用)os_unfair_lock
自旋锁(IOS10以后替代OSSpinLock
)
你接触到的项目,哪些场景运用到了线程安全?
- 在线列表的增员和减员,需要加锁保持其线程安全。
iOS
开发中有多少类型的线程?分别说说
- 1、
pthread
- C语言实现的跨平台通用的多线程API
- 使用难度大,没有用过
- 2、
NSThread
OC
面向对象的多线程API
- 简单易用,可以直接操作线程对象。
- 需要手动管理生命周期
- 3、
GCD
- C语言实现的多核并行CPU方案,能更合理的运行多核
CPU
- 可以自动管理生命周期
- C语言实现的多核并行CPU方案,能更合理的运行多核
- 4、
NSOperation
OC
基于GCD
的封装- 完全面向对象的多线程方案
- 可以自动管理生命周期
GCD
有什么队列,默认提供了哪些队列
- 串行同步队列,任务按顺序(串行),在当前线程执行(同步)
- 串行异步队列,任务按顺序(串行),开辟新的线程执行(异步)
- 并行同步队列,任务按顺序(无法体现并行),在当前线程执行(同步)
- 并行异步队列,任务同时执行(并行),开辟新的线程执行(异步)
- 默认提供了主队列和全局队列
GCD
主线程 & 主队列的关系
- 主队列任务只在主线程中被执行的
- 主线程运行的是一个
runloop
,除了主队列的任务,还有UI处理
和响应处理
。
描述一下线程同步与异步的区别?
- 线程同步是指当前有多个线程的话,必须等一个线程执行完了才能执行下一个线程。
- 线程异步指一个线程去执行,他的下一个线程不用等待他执行完就开始执行。
线程同步的方式
- 嵌套调用
- 线程加锁
GCD
- 使用串行队列,任务都一个个按顺序执行
- 使用
dispatch_semaphore
信号量阻塞线程,直到任务完成再放行 dispatch_group
也可以阻塞到所有任务完成才放行
NSOperationQueue
- 设置
maxConcurrentOperationCount = 1
,同一时刻只有1个NSOperation
被执
- 设置
死锁的四个条件
- 互斥条件,一个资源每次只能被一个线程持有
- 请求与保持条件,需要非持有的资源完成任务,对已获得的资源保持不放
- 不剥夺条件,不能强行剥夺其他线程正在使用的资源
- 循环等待条件,线程间形成循环等待资源关系
你遇到哪些死锁的情况
- 串行队列,正在进行的任务A向串行队列添加一个同步任务B,会造成任务互相等待,形成死锁。
- 优先级反转,
OSSpinlock
dispatch_once
实现原理
dispatch_once
需要传入dispatch_once_t
类型的参数,其实是个长整形- 处理
block
前会判断传入的dispatch_once_t
是否为0,为0表示block
未执行 - 执行后把
token
的值改为1,下次判断非0则不处理
performSelector
和runloop
的关系
- 调用
performSelecter:afterDelay:
,内部会创建一个Timer
并添加到当前线程的RunLoop
。 - 如果当前线程
Runloop
没有跑起来,这个方法会失效。 - 其他的
performSelector
系列方法是类似的
子线程执行 [p performSelector:@selector(func) withObject:nil afterDelay:4]
会发生什么?
- 上面这个方法放在子线程,其实内部会创建一个
NSTimer
定时器。 - 子线程不会默认开启
runloop
,如果需要执行func
函数得手动开启runloop
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); dispatch_async(queue, ^{ // [[NSRunLoop currentRunLoop] run]; 放在上面无效 // 只开启runloop但是里面没有任何事件,开启失败 [self performSelector:@selector(test) withObject:nil afterDelay:2]; [[NSRunLoop currentRunLoop] run]; });
为什么只在主线程刷新UI
UIKit
是线程不安全的,用户操作涉及到渲染和访问View
的属性,异步操作会存在读写问题,加锁会耗费大量资源并拖慢运行速度。UIApplication
在主线程初始化,用户交互都在主线程传递,所以view
在主线程上响应事件最好。
一个队列负责插入数据操作,一个队列负责读取操作,同时操作一个存储的队列,如何保证顺利进行
- 使用
GCD
栅栏函数实现多度单写 - 读取的时候使用
dispatch_sync
立刻返回数据 - 写入的时候使用
dispatch_barrier_async
阻塞其他操作后写入 - 注意尽量不要使用全局队列,因为全局队列里还有其他操作
锁
为什么需要锁?
- 多线程编程会出现线程相互干扰的情况,如多个线程访问一个资源。
- 需要一些同步工具,确保线程交互是安全的。
什么是互斥锁
- 如果共享数据已经有了其他线程加锁了,线程会进行休眠状态等待锁
- 一旦被访问的资源被解锁,则等待资源的线程会被唤醒。
- 任务复杂的时间长的情况建议使用互斥锁
- 优点
- 获取不到资源时线程休眠,cpu可以调度其他的线程工作
- 缺点
- 存在线程调度的开销
- 如果任务时间很短,线程调度降低了cpu的效率
什么是自旋锁
- 如果共享数据已经有其他线程加锁了,线程会以死循环的方式等待锁
- 一旦被访问的资源被解锁,则等待资源的线程会立即执行
- 适用于持有锁较短时间
- 优点:
- 自旋锁不会引起线程休眠,不会进行线程调度和CPU时间片轮转等耗时操作。
- 如果能在很短的时间内获得锁,自旋锁效率远高于互斥锁。
- 缺点:
- 自旋锁一直占用CPU,未获得锁的情况下处于忙等状态。
- 如果不能在很短的时间内获得锁,使CPU效率降低。
- 自旋锁不能实现递归调用。
读写锁
- 读写锁又被称为
rw锁
或者readwrite锁
- 不是最常用的,一般是数据库操作才会用到。
- 具体操作为多读单写,写入操作只能串行执行,且写入时不能读取;读取需支持多线程操作,且读取时不能写入
说说你知道的锁有哪些
pthread_mutex
互斥锁(C语言)@synchronized
NSLock
对象锁NSRecursiveLock
递归锁NSCondition & NSConditionLock
条件锁dispatch_semaphore
GCD信号量实现加锁OSSpinLock
自旋锁(暂不建议使用)os_unfair_lock
自旋锁(IOS10以后替代OSSpinLock
)
说说@synchronized
- 原理
- 底层是链表,存储节点
SyncData
,内部包含下列数据
typedef struct SyncData { id object; // 传进来的obj recursive_mutex_t mutex; // 可重入锁 struct SyncData* nextData; // 链表指向下一个Data int threadCount; // 记录访问资源的线程数量 }
- 使用
obj
的地址作为hash
传参通过id2data
方法查找`SyncData - 通过
objc_sync_enter(obj),objc_sync_exit(obj)
,加锁解锁。 - 传入的
obj
被释放或为nil
,会执行锁的释放
- 底层是链表,存储节点
- 优点
- 不需要创建锁对象也能实现锁的功能
- 使用简单方便,代码可读性强
- 缺点
- 加锁的代码尽量少
- 性能没有那么好
- 注意锁的对象必须是同一个
OC
对象
说说NSLock
- 遵循
NSLocking
协议 - 注意点
- 同一线程
lock
和unlock
需要成对出现 - 同一线程连续
lock
两次会造成死锁
- 同一线程
说说NSRecursiveLock
NSRecursiveLock
是递归锁- 注意点
- 同一个程
lock
多次而不造成死锁 - 同一线程当
lock & unlock
数量一致的时候才会释放锁,其他线程才能上锁
- 同一个程
说说NSCondition & NSConditionLock
- 条件锁:满足条件执行锁住的代码;不满足条件就阻塞线程,直到另一个线程发出解锁信号。
NSCondition
对象实际上是一个锁和一个线程检查器- 锁用于保护数据源。
- 线程检查器根据条件判断是否阻塞线程。
- 需要手动开启等待和手动发送信号解除等待
- 一个
wait
必须对应一个signal
,一次唤醒全部需要使用broadcast
NSConditionLock
是NSCondition
的封装- 通过
condition
自动判断阻塞线程还是唤醒线程 - 通过不同的
condition
值触发不同的操作 - 解锁时通过
unlockWithCondition
修改condition
实现任务依赖
- 通过
说说GCD
信号量实现锁
dispatch_semaphore_creat(0)
生成一个信号量semaphore = 0
( 传入的值可以控制并行任务的数量)dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
使semaphore - 1
,当值小于0进入等待dispatch_semaphore_signal(semaphore)
发出信号,使semaphore + 1
,当值大于等于0放行
说说OSSpinLock
OSSpinLock
是自旋锁,忙等锁- 自旋锁存在优先级反转的问题,线程有优先级的时候可能导致下列情况。
- 一个优先级低的线程先访问某个数据,此时使用自旋锁进行了加锁。
- 一个优先级高的线程又去访问这个数据,优先级高的线程会一直占着CPU资源忙等访问
- 结果导致优先级低的线程没有CPU资源完成任务,也无法释放锁。
- 由于自旋锁本身存在的问题,所以苹果已经废弃了
OSSpinLock
。
说说 os_unfair_lock
- iOS10以后替代
OSSpinLock
的锁,不再忙等 - 获取不到资源时休眠,获取到资源时由内核唤醒线程
- 没有加强公平性和顺序,释放锁的线程可能立即再次加锁,之前等待锁的线程唤醒后可能也没能加锁成功。
- 虽然解决了优先级反转,但也造成了饥饿(
starvation
) starvation
指贪婪线程占用资源事件太长,其他线程无法访问共享资源。
5个线程读一个文件,如何实现最多只有2个线程同时读这个文件
dispatch_semaphore
信号量控制
Objective-C
中的原子和非原子属性
- OC在定义属性时有
nonatomic
和atomic
两种选择 atomic
:原子属性,为setter/getter
方法都加锁(默认就是atomic
),线程安全,需要消耗大量的资源nonatomic
:非原子属性,不加锁,非线程安全
atomic加锁原理:
property (assign, atomic) int age;
- (void)setAge:(int)age
{
@synchronized(self) {
_age = age;
}
}
- (int)age {
int age1 = 0;
@synchronized(self) {
age1 = _age;
}
}
atomic
修饰的属性 int a
,在不同的线程执行 self.a = self.a + 1
执行一万次,这个属性的值会是一万吗?
- 不会,左边的点语法调用的是
setter
,右边调用的是getter
,这行语句并不是原子性的。
atomic
就一定能保证线程安全么?
- 不能,只能保证
setter
和getter
在当前线程的安全 - 一个线程在连续多次读取某条属性值的时候,别的线程同时在改值,最终无法得出期望值
- 一个线程在获取当前属性的值, 另外一个线程把这个属性释放调了,有可能造成崩溃
nonatomic
是非原子操作符,为什么用nonatomic
不用atomic
?
- 如果该对象无需考虑多线程的情况,请加入这个属性修饰,这样会让编译器少生成一些互斥加锁代码,可以提高效率。
- 使用
atomic
,编译器会在setter
和getter
方法里面自动生成互斥锁代码,避免该变量读写不同步。
有人说能atomic
耗内存,你觉得呢?
- 因为会自动加锁,所以性能比
nonatomic
差。
atomic
为什么会失效
atomic
修饰的属性靠编译器自动生成的get/set
方法实现原子操作,如果重写了任意一个,atomic
关键字的特性将失效
nonatomic
实现
- (NSString *)userName {
return _userName;
}
- (void)setUserName:(NSString *)userName {
_userName = userName;
}
atomic
的实现
- (NSString *)userName {
NSString *name;
@synchronized (self) {
name = _userName;
}
return name;
}
- (void)setUserName:(NSString *)userName {
@synchronized (self) {
_userName = userName;
}
}
runloop
runloop
是什么?
- 系统内部存在管理事件的循环机制
runloop
是利用这个循环,管理消息和事件的对象。
runloop
是否等于 while(1) { do something ... }
?
- 不是
while(1)
是一个忙等的状态,需要一直占用资源。runloop
没有消息需要处理时进入休眠状态,消息来了,需要处理时才被唤醒。
runloop
的基本模式
- iOS中有五种
runLoop
模式 UIInitializationRunLoopMode
(启动后进入的第一个Mode
,启动完成后就不再使用,切换到kCFRunLoopDefaultMode
)kCFRunLoopDefaultMode
(App的默认Mode
,通常主线程是在这个Mode
下运行)UITrackingRunLoopMode
(界面跟踪Mode
,用于ScrollView
追踪触摸滑动,保证界面滑动时不受其他Mode
影响)NSRunLoopCommonModes
(这是一个伪Mode
,等效于NSDefaultRunLoopMode
和NSEventTrackingRunLoopMode
的结合 )GSEventReceiveRunLoopMode
(接受系统事件的内部Mode
,通常用不到)
runLoop
的基本原理
- 系统中的主线程会默认开启
runloop
检测事件,没有事件需要处理的时候runloop
会处于休眠状态。 - 一旦有事件触发,例如用户点击屏幕,就会唤醒
runloop
使进入监听状态,然后处理事件。 - 事件处理完成后又会重新进入休眠,等待下一次事件唤醒
runloop
和线程的关系
runloop
和线程一一对应。- 主线程的创建的时候默认开启
runloop
,为了保证程序一直在跑。 - 支线程的
runloop
是懒加载的,需要手动开启。
runloop
事件处理流程
- 事件会触发
runloop
的入口函数CFRunLoopRunSpecific
,函数内部首先会通知observer
把状态切换成kCFRunLoopEntry
,然后通过__CFRunLoopRun
启动runloop
处理事件 __CFRunLoopRun
的核心是是一个do - while
循环,循环内容如下
runloop
是怎么被唤醒的
- 没有消息需要处理时,休眠线程以避免资源占用。从用户态切换到内核态,等待消息;
- 有消息需要处理时,立刻唤醒线程,回到用户态处理消息;
source0
通过屏幕触发直接唤醒source0
通过调用mach_msg()
函数来转移当前线程的控制权给内核态/用户态。
什么是用户态、核心态
- 内核态:运行操作系统程序 ,表示一个应用进程执行系统调用后,或I/O 中断,时钟中断后,进程便处于内核执行
- 用户态:运行用户程序 ,表示进程正处于用户状态中执行
runloop
的状态
CFRunLoopObserverRef observerRef = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
switch (activity) {
case kCFRunLoopEntry: NSLog(@"runloop启动"); break;
case kCFRunLoopBeforeTimers: NSLog(@"runloop即将处理timer事件"); break;
case kCFRunLoopBeforeSources: NSLog(@"runloop即将处理sources事件"); break;
case kCFRunLoopBeforeWaiting: NSLog(@"runloop即将进入休眠"); break;
case kCFRunLoopAfterWaiting: NSLog(@"runloop被唤醒"); break;
case kCFRunLoopExit: NSLog(@"runloop退出"); break;
default: break;
}
});
CFRunLoopAddObserver(CFRunLoopGetCurrent(), observerRef, kCFRunLoopDefaultMode);
}
runLoop
卡顿检测的方法
NSRunLoop
处理耗时主要下面两种情况kCFRunLoopBeforeSources
和kCFRunLoopBeforeWaiting
之间kCFRunLoopAfterWaiting
之后
- 上述两个时间太长,可以判定此时主线程卡顿
- 生成全局可访问的信号量
- 添加
Observer
到主线程Runloop
中,监听Runloop
状态切换 - 子线程添加
do-while
循环访问dispatch_semaphore_wait
返回值, 非0 表示卡顿 - 获取卡顿的堆栈传至后端,再分析
怎么启动一个常驻线程
// 创建线程
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(play) object:nil];
[thread start];
// runloop保活
[[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
[[NSRunLoop currentRunLoop] run];
// 处理事件
[self performSelector:@selector(test) onThread:thread withObject:nil waitUntilDone:NO];
计时器
NSTimer、CADisplayLink、dispatch_source_t
的优劣
-
NSTimer
- 优点在于使用的是
target-action
模式,简单好用 - 缺点是容易不小心造成循环引用。需要依赖
runloop
,runloop
如果被阻塞就要延迟到下一次runloop
周期才执行,所以时间精度上也略为不足
- 优点在于使用的是
-
CADisplayLink
- 优点是精度高,每次刷新结束后都调用,适合不停重绘的计时,例如视频
- 缺点容易不小心造成循环引用。
selector
循环间隔大于重绘每帧的间隔时间,会导致跳过若干次调用机会。不可以设置单次执行。
-
dispatch_source_t
- 基于
GCD
,精度高,不依赖runloop
,简单好使,最喜欢的计时器 - 需要注意的点是使用的时候必须持有计时器,不然就会提前释放。
- 基于
NSTimer
在子线程执行会怎么样?
NSTimer
在子线程调用需要手动开启子线程的runloop
[[NSRunLoop currentRunLoop] run];
NSTimer
为什么不准?
- 如果
runloop
正处在阻塞状态的时候NSTimer
到达触发时间,NSTimer
的触发会被推迟到下一个runloop
周期
NSTimer
的循环引用?
timer
和target
互相强引用导致了循环引用。可以通过中间件持有timer & target
解决
GCD计时器
NSTimeInterval interval = 1.0;
_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
dispatch_source_set_timer(_timer, dispatch_walltime(NULL, 0), interval * NSEC_PER_SEC, 0);
dispatch_source_set_event_handler(_timer, ^{
NSLog(@"GCD timer test");
});
dispatch_resume(_timer);
转载自:https://juejin.cn/post/7075729120914571301