大厂常问iOS面试题–Runloop篇

大厂常问iOS面试题--Runloop篇

一.RunLoop概念

  • RunLoop顾名思义就是可以一直循环(loop)运行(run)的机制。这种机制通常称为"消息循环机制 "

    NSRunLoop和CFRunLoopRef就是实现"消息循环机制"的对象。其实NSRunLoop本质是由CFRunLoopRef封装 的,提供了面向对象的API,而CFRunLoopRef是一些面向过程的C函数API。两者最主要的区别在于:NSRunLoop是非线程安全的,意味着你不能用非当前线程去调用当前线程的NSRunLoop ,否则会出现意想不到的错误(You should never try to call the methods of an NSRunLoop object running in a different thread)。而CFRunLoopRef是线程安全的

  • RunLoop是通过内部维护的事件循环来对事件/消息进行管理的一个对象

    它的关键点在于:如何通过事件循环来管理事件/消息,从而让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒

基本作用

  • 保持程序的持续运行
  • 处理APP中的各种事件(触摸,定时器,GCD异步回到主线程,runloop中block回调的处理等)
  • 节省CPU资源,提供性能,该做事的时候做事,该休息的时候休息

Runloop 和线程的关系

  • 一个线程对应一个 Runloop
  • 主线程默认就有了 Runloop,而且默认是启动的
  • 子线程的 Runloop 以懒加载的形式创建;默认是没有启动的,如果你需要更多的线程交互则可以手动配置和启动,如果线程只是去执行一个长时间的已确定的任务则不需要
  • Runloop存储在一个全局的可变字典里,线程是 key ,Runloop 是 value。

二.RunLoop的运行模式

RunLoop的运行模式共有5种,RunLoop只会运行在一个模式下,要切换模式,就要暂停当前模式,重写启动一个运行模式。model 主要作用是用来指定事件在运行循环中的优先级的。

  • kCFRunLoopDefaultMode-默认,空闲状态: App的默认运行模式,通常主线程是在这个运行模式下运行
  • UITrackingRunLoopMode-ScrollView滑动时:, 跟踪用户交互事件(用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他Mode影响)
  • kCFRunLoopCommonModes-Mode集合, 伪模式,不是一种真正的运行模式
  • UIInitializationRunLoopMode-启动时:在刚启动App时第进入的第一个Mode,启动完成后就不再使用
  • GSEventReceiveRunLoopMode-:接受系统内部事件,通常用不到

三.Runloop内部逻辑

  • 实际上 RunLoop 就是这样一个函数,其内部是一个 do-while 循环。当你调用 CFRunLoopRun() 时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。
  • 内部逻辑:
    1.通知观察者RunLoop已经启动
    2.通知观察者即将要开始的定时器
    3.通知观察者即将即将触发 Source0 (非port) 回调
    4.触发 Source0 (非port) 回调。
    5.如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1
    6.通知观察者线程进入休眠状态
    7.将线程置于休眠直到任一下面的事件发生:
    某一事件到达 Source1 (基于port)
    定时器启动
    RunLoop设置的时间已经超时
    RunLoop被显示唤醒(例如手动调用CFRunLoopWakeUp)
    8.通知观察者线程将被唤醒
    9.处理未处理的事件,之后再跳回2
    10.通知观察者RunLoop结束。

四.Runloop的使用场景

1.main.m中的autoreleasePool

iOS的应用程序里面,程序启动后main.m会有一个如下的main()函数,为什么执行了main函数后,app没有自动退出,反而能一直响应用户消息呢?

复制代码
int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

这是因为UIApplicationMain()函数,这个方法会为main thread设置一个NSRunLoop对象,这样就可以达到:我们的应用在无人操作的时候休息,需要让它干活的时候立马响应。

代码逻辑通常是这样的:

复制代码
function loop() {
    initialize();
    do {
        var message = get_next_message();
        process_message(message);
    } while (message != quit);
}

或使用伪代码来展示下:
int main(int argc, char * argv[]) {
 //程序一直运行状态
 while (AppIsRunning) {
      //睡眠状态,等待唤醒事件
      id whoWakesMe = SleepForWakingUp();
      //得到唤醒事件
      id event = GetEvent(whoWakesMe);
      //开始处理事件
      HandleEvent(event);
 }
 return 0;
}

这种模型通常被称作Event Loop。Event Loop在很多系统和框架里都有实现,比如 Node.js 的事件处理,比如Windows程序的消息循环,再比如iOS里的 RunLoop

关于Runloop的事件循环

  • 维护的事件循环,可以用来不断的处理消息或者事件,对他们进行管理
  • 当没有消息进行处理时,会从用户态发生到内核态 的切换,由此可以用来当前线程的休眠,避免资源占用
  • 当有消息需要处理时,会发生从内核态到用户态 的切换,当前的用户线程会被唤醒

用户态和内核态

  • 用户态: 应用程序一般都是运行在用户态上,用户进程,包括我们开发所使用的绝大多数API,都是针对用户层面的
  • 内核态:在内核态往往有些陷阱指令,中断,以及一些开机关机的操作,并且内核态里面的一些内容,可以对用户态中的一些线程进行调度和管理,包括进程间的通信
    在用户态调用 mach_msg_trap() 时会触发陷阱机制,切换到内核态;内核态中内核实现的 mach_msg() 函数会完成实际的工作。RunLoop 的核心就是一个 mach_msg() ,RunLoop 调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。例如你在模拟器里跑起一个 iOS 的 App,然后在 App 静止时点击暂停,你会看到主线程调用栈是停留在 mach_msg_trap() 这个地方。

    main函数为何能保持不退出?
  • 在UIApplicationMain函数内部会启动主线程的Runloop,可以不断的接收消息,比如点击屏幕事件,滑动列表以及处理网络请求的返回等
  • 接收消息后对事件进行处理,处理完之后,就会继续等待、休眠
  • Runloop是对事件循环的一种维护机制,可以做到在有事做的时候做事,没有事情的时候会通过用户态发生到内核态的切换,避免资源占用,当前线程处于休眠的状态。

autoreleasePool 在何时被释放?

  • App启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是 _wrapRunLoopWithAutoreleasePoolHandler()。
  • 第一个 Observer 监视的事件是 Entry(即将进入Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。其 order 是 -2147483647,优先级最高,保证创建释放池发生在其他所有回调之前
  • 第二个 Observer 监视了两个事件: BeforeWaiting(准备进入休眠) 时调用_objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池 ;Exit(即将退出Loop) 时调用 _objc_autoreleasePoolPop() 来释放自动释放池 。这个 Observer 的 order 是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后
  • 在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 Pool 了。

2.Runloop使用场景-GCD

  • Runloop的超时时间就是通过GCD timer来控制的。GCD启动子线程,内部其实用到了runloop。GCD从子线程返回到主线程,会触发runloop的Source1事件

    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    /// GCD往子线程丢一个延时操作,能够执行,说明GCD内部其实用到了runloop。
    NSLog(@"global after %@", [NSThread currentThread]);
    });

dispatch_async(main_queue, block)时,libdispatch会向主线程的runloop发送消息唤醒runloop,runloop被唤醒后会从消息中获取block,在callout函数 CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE 中执行该block任务。仅限主线程,子线程的dispatch block操作全部由libdispatch完成的。

runloopMode

dispatch_async(dispatch_get_main_queue(), ...)会将block放到commonModes中执行,而CFRunLoopPerformBlock允许指定runloopMode来执行block。

能否唤醒runloop

dispatch_async(dispatch_get_main_queue(), ...)会唤醒主线程的runloop,而CFRunLoopPerformBlock不会主动唤醒runloop。如runloop休眠,则CFRunLoopPerformBlock的block不能执行。可以使用CFRunLoopWakeUp来唤醒runloop。

GCD的main queue是一个串形队列

GCD的main queue是一个串形队列,这样的结果就是dispatch_async(dispatch_get_main_queue(), ...)传入的block会作为一个整体,在runloop的下一次循环时执行。

请看如下代码,输出 1,3,2 是我们再也熟悉不过的代码了,而后半部分为什么会 输出 4,5,6呢?且等待的1s间隔时机也不一样,分别为1...32和45...6。这里的...表示间隔

复制代码
- (void)testGCDMainQueue {
    dispatch_async(dispatch_get_main_queue(), ^{
        NSLog(@"main queue task 1");
        dispatch_async(dispatch_get_main_queue(), ^{
            NSLog(@"main queue task 2");
        });
        [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]];
        NSLog(@"main queue task 3");
    });
    /// 输出 1,3,2
    
    CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopCommonModes, ^{
        NSLog(@"main queue task 4");
        CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopCommonModes, ^{
            NSLog(@"main queue task 5");
        });
        [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]];
        NSLog(@"main queue task 6");
    });
    /// 输出 4,5,6
}

3.AFNetworking 中如何运用 Runloop

  • AFURLConnectionOperation 这个类是基于 NSURLConnection 构建的,其希望能在后台线程接收 Delegate 回调。为此 AFNetworking 单独创建了一个线程,并在这个线程中启动了一个 RunLoop:

    • (void)networkRequestThreadEntryPoint:(id)__unused object {
      @autoreleasepool {
      [[NSThread currentThread] setName:@"AFNetworking"];
      NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
      [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
      [runLoop run];
      }
      }

    • (NSThread *)networkRequestThread {
      static NSThread *_networkRequestThread = nil;
      static dispatch_once_t oncePredicate;
      dispatch_once(&oncePredicate, ^{
      _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
      [_networkRequestThread start];
      });
      return _networkRequestThread;
      }

  • RunLoop 启动前内部必须要有至少一个 Timer/Observer/Source,所以 AFNetworking 在 [runLoop run] 之前先创建了一个新的 NSMachPort 添加进去了。通常情况下,调用者需要持有这个 NSMachPort (mach_port) 并在外部线程通过这个 port 发送消息到 loop 内;但此处添加 port 只是为了让 RunLoop 不至于退出,并没有用于实际的发送消息。

    • (void)start {
      [self.lock lock];
      if ([self isCancelled]) {
      [self performSelector:@selector(cancelConnection) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
      } else if ([self isReady]) {
      self.state = AFOperationExecutingState;
      [self performSelector:@selector(operationDidStart) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
      }
      [self.lock unlock];
      }
  • 当需要这个后台线程执行任务时,AFNetworking 通过调用 [NSObject performSelector:onThread:...] 将这个任务扔到了后台线程的 RunLoop 中。

4.PerformSelector 的实现原理?

  • 当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。
  • 当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。

5.PerformSelector:afterDelay:这个方法在子线程中是否起作用?

  • 不起作用,子线程默认没有 Runloop,也就没有 Timer。可以使用 GCD的dispatch_after来实现

6.以+ scheduledTimerWithTimeInterval...的方式触发的timer,在滑动页面上的列表时,timer会暂停回调,为什么?如何解决?

RunLoop只能运行在一种mode下,如果要换mode,当前的loop也需要停下重启成新的。利用这个机制,ScrollView滚动过程中NSDefaultRunLoopMode(kCFRunLoopDefaultMode)的mode会切换到UITrackingRunLoopMode来保证ScrollView的流畅滑动:在NSDefaultRunLoopMode模式下处理的事件会影响ScrollView的滑动。

如果我们把一个NSTimer对象以NSDefaultRunLoopMode(kCFRunLoopDefaultMode)添加到主运行循环中的时候, ScrollView滚动过程中会因为mode的切换,而导致NSTimer将不再被调度。

复制代码
//1.创建Timer
//2.自动添加到当前Runloop中,默认mode为kCFRunLoopDefaultMode
//mode 为kCFRunLoopDefaultMode导致的问题:当Scrollview滑动时,会停止计时,滑动结束后继续计时。
//原因:当滑动Scrollview时。mode改变成为UITrackingRunLoopMode,而timer添加到了默认模式下 NSDefaultRunLoopMode
_timer1 = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(handle:) userInfo:_timer1 repeats:YES];

同时因为mode还是可定制的,所以:

Timer计时会被scrollView的滑动影响的问题可以通过将timer添加到NSRunLoopCommonModes(kCFRunLoopCommonModes)来解决。代码如下:

复制代码
//将timer添加到NSDefaultRunLoopMode中
[NSTimer scheduledTimerWithTimeInterval:1.0
     target:self
     selector:@selector(timerTick:)
     userInfo:nil
     repeats:YES];
//然后再添加到NSRunLoopCommonModes里
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0
     target:self
     selector:@selector(timerTick:)
     userInfo:nil
     repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

6.事件响应的过程?

  • 苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()。
  • 当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。这个过程的详细情况可以参考这里。SpringBoard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 Event,随后用 mach port 转发给需要的 App 进程。随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的分发。
  • _UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。

7.手势识别的过程?

  • 当 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。
  • 苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个 Observer 的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行GestureRecognizer 的回调。
  • 当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

8.CADispalyTimer和Timer哪个更精确

CADisplayLink 更精确

  • iOS设备的屏幕刷新频率是固定的,CADisplayLink在正常情况下会在每次刷新结束都被调用,精确度相当高。
  • NSTimer的精确度就显得低了点,比如NSTimer的触发时间到的时候,runloop如果在阻塞状态,触发时间就会推迟到下一个runloop周期。并且 NSTimer新增了tolerance属性,让用户可以设置可以容忍的触发的时间的延迟范围。
  • CADisplayLink使用场合相对专一,适合做UI的不停重绘,比如自定义动画引擎或者视频播放的渲染。NSTimer的使用范围要广泛的多,各种需要单次或者循环定时处理的任务都可以使用。在UI相关的动画或者显示内容使用 CADisplayLink比起用NSTimer的好处就是我们不需要在格外关心屏幕的刷新频率了,因为它本身就是跟屏幕刷新同步的。
相关推荐
玫瑰花开一片一片2 小时前
Flutter IOS 真机 Widget 错误。Widget 安装后系统中没有
flutter·ios·widget·ios widget
烎就是我4 小时前
100行代码swift从零实现一个iOS日历
ios·swift
鸿蒙布道师19 小时前
鸿蒙NEXT开发通知工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
鸿蒙布道师19 小时前
鸿蒙NEXT开发网络相关工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
cauyyl1 天前
xcode 16 遇到contains bitcode
react native·xcode
余生大大1 天前
关于Safari浏览器在ios<16.3版本不支持正则表达式零宽断言的解决办法
ios·正则表达式·safari
A_ugust__1 天前
Vue3集成浏览器API实时语音识别
人工智能·语音识别·xcode
爱分享的程序员1 天前
前端跨端框架的开发以及IOS和安卓的开发流程和打包上架的详细流程
android·前端·ios
Macle_Chen1 天前
ios开发中xxx.debug.dylib not found
ios·bug·debug.dylib