Runloop & KVO

130 阅读16分钟

app如何接收到触摸事件的

iOS app接收触摸事件的过程如下:

  1. 系统响应阶段:

    • 手指触碰屏幕后,屏幕会感受到触摸事件,并将其交由IOKit处理[1].
    • IOKit将触摸事件封装成IOHIDEvent对象,并通过mach port传递给SpringBoard进程[1].
    • SpringBoard是一个系统进程,可以理解为桌面系统,它统一管理和分发系统接收到的触摸事件[1].
    • SpringBoard接收到触摸事件后,会触发系统进程的主线程的runloop的source回调[1].
  2. APP响应触摸事件:

    • APP进程的mach port接收来自SpringBoard的触摸事件,主线程的runloop被唤醒,触发source1回调[1].
    • source1回调又触发一个source0回调,将接收到的IOHIDEvent对象封装成UIEvent对象,此时APP开始对触摸事件进行响应[1].
    • source0回调将触摸事件添加到UIApplication的事件队列中,当触摸事件出队后,UIApplication会为触摸事件寻找最佳响应者[1].
    • 寻找到最佳响应者后,事件会在响应链中传递和响应[1].
  3. 触摸事件的响应者:

    • 触摸对象是UITouch对象,每个手指触摸屏幕都会生成一个UITouch对象[1].
    • 触摸事件是UIEvent对象,对UITouch对象进行封装,如果有多个手指同时触摸,会生成多个UITouch对象[1].
    • 响应者是UIResponder对象,包括UIView、UIViewController、UIApplication和Appdelegate等[1].
    • 响应者通过以下四个方法来响应触摸事件:
      • touchesBegan:withEvent: - 手指触摸屏幕,触摸开始[1].
      • touchesMoved:withEvent: - 手指在屏幕上移动[1].
      • touchesEnded:withEvent: - 手指离开屏幕,触摸结束[1].
      • touchesCancelled:withEvent: - 触摸结束前,某个系统事件中断了触摸,例如电话呼入[1].
  4. 寻找最佳响应者(Hit-Testing):

    • 当APP接收到触摸事件后,需要确定哪个视图应该去响应该事件,这个过程称为寻找最佳响应者[2].
    • 寻找最佳响应者的流程如下:
      • UIApplication首先将事件传递给窗口对象(UIWindow),如果有多个UIWindow对象,则选择最后加入的UIWindow对象[2].
      • 如果UIWindow对象能够响应该事件,则继续向其子视图传递,传递顺序是最后加入的子视图先传递[2].
      • 如果子视图无法响应该事件,则返回父视图,再传递给倒数第二个加入该父视图的子视图[2].
      • 最终找到的最佳响应者就是事件响应链中的最后一个视图[2].

以上是iOS app接收触摸事件的基本流程和机制。


Learn more:

  1. iOS中触摸事件传递和响应原理 - 简书
  2. iOS触摸事件全家桶 - 简书
  3. IOS触摸事件是如何从屏幕转移到APP内的?_当我们按下屏幕后,事件是怎么传递到应用的?_马拉萨的春天的博客-CSDN博客

为什么只有主线程的runloop是开启的

主线程的runloop是开启的主要原因是为了让程序可以一直运行并接收事件。在iOS开发中,主线程负责处理UI相关的操作,包括用户交互、界面更新等。为了保证主线程能够一直运行并响应事件,需要在主线程中开启一个runloop。

以下是关于为什么只有主线程的runloop是开启的的一些解释:

  1. UIApplicationMain函数:在iOS应用程序的入口函数main()中,会调用UIApplicationMain函数来创建一个主线程,并且在主线程中开启一个runloop。这样可以保证主线程一直处于运行状态,能够接收和处理用户的触摸事件、定时器事件等[1]

  2. 线程安全:Objective-C不是一门线程安全的语言,多线程读写不同步可能会导致数据竞争和内存访问错误。为了避免这种情况,将UI操作限制在主线程中,通过主线程的runloop来处理UI事件,可以保证线程安全性[2]

  3. 屏幕刷新频率:iOS设备的屏幕刷新频率通常是60帧/秒(60Hz),也就是每秒钟刷新60次。为了保证界面的流畅性和响应性,主线程的runloop需要以相同的频率进行调用,以便及时更新界面。如果将UI操作放在其他线程中,很难保证与屏幕刷新的同步,可能会导致界面卡顿或不同步的情况发生[2]

总结起来,只有主线程的runloop是开启的是为了保证UI操作的线程安全性和界面的流畅性。通过在主线程中开启runloop,可以让主线程一直处于运行状态,能够接收和处理用户的触摸事件,并及时更新界面。


Learn more:

  1. iOS Runloop面试小笔记原创 - CSDN博客
  2. iOS面试题之Runloop - 掘金
  3. 阿里、字节:一套高效的iOS面试题之Runloop&KVO | 迈腾大队长

为什么只在主线程刷新UI

在iOS开发中,只能在主线程刷新UI的原因有以下几点:

  1. UIKit线程不安全:UIKit是一个线程不安全的类,涉及到UI操作时,可能会存在读写问题。如果在后台线程中对UI进行操作,可能会导致数据竞争和不可预测的结果。为了避免这种情况,iOS要求在主线程上刷新UI [1][2].

  2. 主线程是UI事件的传递线程:整个iOS应用程序的起点是UIApplication,在主线程上进行初始化。所有的用户事件,如点击、拖动等,都是在主线程上进行传递。因此,只有在主线程上才能对这些事件进行响应。如果在其他线程上刷新UI,可能会导致用户交互无法正常响应 [2].

  3. 渲染流程需要同步更新:iOS的渲染流程要求以60帧的刷新率在屏幕上同时更新。如果在非主线程异步刷新UI,无法确定这个处理过程能够实现同步更新,可能会导致画面刷新完,但UI还没有被渲染的情况发生。为了保证画面的同步更新,iOS要求在主线程上刷新UI [2].

综上所述,iOS要求在主线程上刷新UI是为了保证UI框架的线程安全,避免可能会导致UI问题的不必要的麻烦和风险 [1].


Learn more:

  1. iOS为什么要在主线程刷新UI - 稀土掘金
  2. iOS - 为什么要在主线程中操作UI - 简书
  3. iOS开发-为什么所有UI操作必须放在主线程_ios 归档 需要在主线程吗-CSDN博客

PerformSelector和runloop的关系

PerformSelector和runloop之间有一定的关系。下面是关于这个关系的一些要点:

  1. PerformSelector是NSObject类提供的方法,用于在指定的线程中执行某个方法。这些方法会被添加到runloop中,以便在适当的时候执行。

  2. Runloop是一个事件循环,用于处理各种事件(例如用户输入、定时器触发等)。每个线程都有一个与之关联的runloop,用于处理该线程的事件。

  3. PerformSelector方法会将要执行的方法添加到当前线程的runloop中。当runloop运行时,会根据设定的条件(例如延迟时间、运行模式等)来触发执行相应的方法。

  4. 如果在子线程中使用PerformSelector方法,需要手动启动该子线程的runloop,否则方法可能不会被执行。可以通过调用runloop的run方法来启动runloop。

  5. PerformSelector方法添加的方法会在runloop的特定阶段被执行,例如在timers阶段或者sources阶段。具体执行的阶段取决于方法添加的时机和所设置的延迟时间。

  6. 如果runloop在执行完添加的方法后没有其他任务需要处理,它可能会退出并结束运行。因此,如果希望runloop持续运行,可以添加其他事件源或定时器来保持其活跃状态。

综上所述,PerformSelector方法通过将方法添加到runloop中,实现了在指定线程中延迟执行某个方法的功能。


Learn more:

  1. Runloop与performSelector - 掘金
  2. 【runloop】performSelector方法探究_runloop perform-CSDN博客
  3. RunLoop与PerformSelector - 简书

如何使线程保活

线程保活就是不让线程退出,所以往简单说就是搞个 “while(1)” 自己实现一套处理流程,事件派发就可以了;但 iOS 中有 runloop,所以我们就无须大费周章。 TODO: C 语言嵌入式如何实现线程池和保活。

runloop 线程保活前提就是有事情要处理,这里指 timer,source0,source1 事件。

所以有如下几种方式,方式一:

NSTimer *timer = [NSTimer timerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
   NSLog(@"timer 定时任务");
}];
NSRunLoop *runloop = [NSRunLoop currentRunLoop];
[runloop addTimer:timer forMode:NSDefaultRunLoopMode];
[runloop run];

方式二:

NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop run];

方式三:

- (IBAction)testRunLoopKeepAlive:(id)sender {
    self.myThread = [[NSThread alloc] initWithTarget:self selector:@selector(start) object:nil];
    [self.myThread start];
}

- (void)start {
    self.finished = NO;
    do {
      	// Runs the loop until the specified date, during which time it processes data from all attached input sources.
        [NSRunLoop.currentRunLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
    } while (!self.finished);
}
- (IBAction)closeRunloop:(id)sender {
    self.finished = YES;
}

- (IBAction)executeTask:(id)sender {
    [self performSelector:@selector(doTask) onThread:self.myThread withObject:nil waitUntilDone:NO];
}

- (void)doTask {
    NSLog(@"执行任务在线程:%@",[NSThread currentThread]);
}

If no input sources or timers are attached to the run loop, this method exits immediately and returns NO; otherwise, it returns after either the first input source is processed or limitDate is reached. Manually removing all known input sources and timers from the run loop does not guarantee that the run loop will exit immediately. macOS may install and remove additional input sources as needed to process requests targeted at the receiver’s thread. Those sources could therefore prevent the run loop from exiting.

所以上面的方式并非完美,只要没有源,runloop 直接就被退出了,但是因为包了一个while (!self.finished),所以相当于退出->起->退出-> 起。

Note: 如果runloop中没有处理事件,这里一直会退出然后起runloop,就算设置了 [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; 也没用,但是执行过一次 [self performSelector:@selector(doTask) onThread:self.myThread withObject:nil waitUntilDone:NO],那么程序就卡在 [NSRunLoop.currentRunLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]] 这一行了。


Learn more:

  1. iOS 线程保活 - 掘金
  2. iOS Runloop 线程保活及坑 - 简书
  3. iOS 线程保活_ios如何使线程保活-CSDN博客

KVO 实现原理

KVO(Key-Value Observing)是一种iOS中的观察者模式的实现方式,用于监听对象属性值的改变。下面将详细介绍KVO的实现原理。

  1. KVO基础知识

    • KVO全称为Key-Value Observing,也称为“键值监听”,主要用于监听某个对象的属性值的改变[1]
    • KVO的基本使用是通过为需要监听的对象属性设置观察者,让观察者接收到属性值改变的消息通知[2]
  2. KVO的实现原理

    • KVO的实现原理是在运行时通过isa-swizzling技术动态地创建一个继承自当前类的派生类,并且动态地修改当前实例对象的isa指针,使其指向派生类。派生类重写了父类的setter方法,并在setter方法中调用了Foundation中的_NSSetXXXValueAndNotify函数,该函数会先调用willChangeValueForKey:方法,然后调用父类原来的setter方法,最后调用didChangeValueForKey:方法,触发监听器的监听回调函数[1]
    • 为了隐藏动态生成的派生类,苹果重写了派生类的class方法,使其返回当前类的Class,屏蔽了内部实现[1]
  3. 派生类中重写的方法

    • 派生类重写了父类的setter方法、class方法、dealloc方法、_isKVOA方法[1]
  4. 验证派生类中重写的方法

    • 可以通过打印派生类的方法列表来验证派生类中重写的方法。使用class_copyMethodList函数获取方法列表,然后遍历方法列表获取方法名[1].

Learn more:

  1. iOS开发之KVO底层实现原理篇 | 平凡的世界
  2. KVO 从基本使用到原理剖析_kvo基础知识讲解_VeggieOrz的博客-CSDN博客
  3. KVO 原理详解 | 楚权的世界

如何手动关闭kvo

在iOS中,可以通过以下方法手动关闭KVO(键值观察):

  1. 重写被观察对象的automaticallyNotifiesObserversForKey方法,返回NO。这会告诉系统不要自动发送KVO通知[1]
  2. 重写被观察对象的automaticallyNotifiesObserversOf<Key>方法,返回NO。这里的<Key>是被观察属性的名称,例如automaticallyNotifiesObserversOfName[1]

需要注意的是,关闭KVO后,需要手动在赋值前后添加willChangeValueForKeydidChangeValueForKey,才能确保能够收到观察通知[1]

以下是一个示例代码:

class MyObject: NSObject {
    var myProperty: String = "" {
        willSet {
            self.willChangeValue(forKey: "myProperty")
        }
        didSet {
            self.didChangeValue(forKey: "myProperty")
        }
    }
    
    override class func automaticallyNotifiesObservers(forKey key: String) -> Bool {
        return false
    }
    
    override class func automaticallyNotifiesObserversOfMyProperty() -> Bool {
        return false
    }
}

在上面的示例中,MyObject类重写了automaticallyNotifiesObservers(forKey:)方法和automaticallyNotifiesObserversOfMyProperty()方法,将返回值设置为false,从而手动关闭了KVO。在myProperty属性的willSetdidSet中,手动调用了willChangeValue(forKey:)didChangeValue(forKey:)方法,以确保能够收到观察通知。


Learn more:

  1. 如何手动关闭kvo-问答-阿里云开发者社区-阿里云
  2. iOS面试题:如何关闭默认的KVO的默认实现,KVO的实现原理? - 简书
  3. iOS面试题之KVO - 掘金

通过KVC修改属性会触发KVO么

通过KVC修改属性会触发KVO。KVC(KeyValueCoding)是一种间接访问属性方法或成员变量的机制,可以通过字符串来访问对应的属性方法或成员变量。KVO(Key-Value Observing)是一种观察者模式,用于监听对象属性值的改变。

KVC的赋值过程如下:

  1. 首先会按照setKey、_setKey的顺序查找方法,若找到方法,则直接调用方法并赋值。
  2. 如果未找到方法,则调用+ (BOOL)accessInstanceVariablesDirectly方法。
  3. 如果accessInstanceVariablesDirectly方法返回YES,则按照_key、_isKey、key、isKey的顺序查找成员变量,找到直接赋值,找不到则抛出异常。
  4. 如果accessInstanceVariablesDirectly方法返回NO,则直接抛出异常。

KVO的实现原理是通过Runtime API动态生成一个子类,并将实例对象的isa指向这个子类。当修改实例对象的属性时,会调用Foundation的_NSSetXXXValueAndNotify函数,该函数会依次调用willChangeValueForKey方法、父类原来的setter方法和didChangeValueForKey方法,最终触发监听器的监听方法[1]

所以,通过KVC修改属性会触发KVO,因为KVC在修改属性时会调用willChangeValueForKey方法和didChangeValueForKey方法,从而触发KVO的监听方法。


Learn more:

  1. ios 通过kvc修改属性会触发kvo_底层原理 - KVO/KVC-CSDN博客
  2. iOS KVC和KVO-腾讯云开发者社区-腾讯云
  3. Objective-C基础之二(深入理解KVO、KVC) - 掘金

哪些情况下使用kvo会崩溃,怎么防护崩溃

在使用KVO(Key-Value Observing)时,可能会出现以下情况导致崩溃:

  1. KVO添加次数和移除次数不匹配:

    • 移除了未注册的观察者,导致崩溃。
    • 重复移除多次,移除次数多于添加次数,导致崩溃。
    • 重复添加多次,虽然不会崩溃,但是发生改变时,也同时会被观察多次。
    • 添加和删除的顺序不一致导致崩溃 [1].
  2. 被观察者提前被释放,被观察者在dealloc时仍然注册着KVO,导致崩溃。

    • 例如,被观察者是局部变量的情况(iOS 10及之前会崩溃) [1].
  3. 添加了观察者,但未实现observeValueForKeyPath:ofObject:change:context:方法,导致崩溃 [1].

  4. 添加或者移除时keypath为nil,导致崩溃 [1].

为了防止这些崩溃,可以采取以下防护方法:

  1. 对KVO的添加和移除方法进行swizzle(方法交换):

    • 在添加KVO时,将KVO的相关信息存储到KVO缓存容器中。
    • 在移除KVO时,先检查KVO缓存容器中是否有匹配的KVO信息,如果有,则调用系统的方法进行删除,如果没有,则不处理。
    • 这样可以保证KVO缓存和系统KVO列表的一致性,避免多次删除导致的崩溃问题 [1].
  2. 在observeValueForKeyPath:ofObject:change:context:方法中使用try-catch语句:

    • 在调用原方法之前加上try-catch语句,捕获异常,避免因为未实现该方法而导致的崩溃 [1].
  3. 在添加和移除KVO时,对keypath进行判空:

    • 在addObserver:forKeyPath:options:context:、removeObserver:forKeyPath:和removeObserver:forKeyPath:context:方法中,对keypath进行判空处理,避免因为keypath为nil而导致的崩溃 [1].
  4. FBKVOController 对 KVO 机制进行了额外的一层封装,框架不但可以自动帮我们移除观察者,还提供了 block 或者 selector 的方式供我们进行观察处理。不可否认的是,FBKVOController 为我们的开发提供了很大的便利性。但是相对而言,这种方式对项目代码的侵入性比较大,必须依靠编码规范来强制约束团队人员使用这种方式。

综上所述,通过对KVO的添加和移除方法进行swizzle,使用try-catch语句处理observeValueForKeyPath:ofObject:change:context:方法,以及对keypath进行判空处理,可以有效防止KVO使用中可能导致的崩溃问题。


Learn more:

  1. iOS KVO 崩溃防护笔记 - 掘金
  2. iOS 开发:『Crash 防护系统』(二)KVO 防护 - 掘金
  3. iOS 开发:『Crash 防护系统』(二)KVO 防护-CSDN博客

kvo的优缺点

优点:

  1. 运用了设计模式:观察者模式
  2. 支持多个观察者观察同一属性,或者一个观察者监听不同属性
  3. 开发人员不需要实现属性值变化了发送通知的方案,系统已经封装好了,大大减少开发工作量;
  4. 能够对非我们创建的对象,即内部对象的状态改变作出响应,而且不需要改变内部对象(SDK对象)的实现;
  5. 能够提供观察的属性的最新值以及先前值;
  6. 用key paths来观察属性,因此也可以观察嵌套对象;
  7. 完成了对观察对象的抽象,因为不需要额外的代码来允许观察值能够被观察

缺点:

  1. 观察的属性键值硬编码(字符串),编译器不会出现警告以及检查;
  2. 由于允许对一个对象进行不同属性观察,所以在唯一回调方法中,会出现地狱式 if-else if - else 分支处理情况;

References: