线程编程指南翻译第三篇(运行循环)

文档地址

案例代码下载

运行循环

运行循环是与线程相关的基础架构的一部分。一个运行循环是指用于安排工作,并协调接收传入事件的事件处理循环。运行循环的目的是在有任务时保持线程忙,并在没有任务时让线程进入休眠状态。

运行循环管理不是完全自动的。仍然必须设计线程的代码以在适当的时间启动运行循环并响应传入的事件。Cocoa和Core Foundation都提供了运行循环对象来帮助配置和管理线程的运行循环。应用程序不需要显式创建这些对象; 每个线程(包括应用程序的主线程)都有一个关联的运行循环对象。但是,只有辅助线程需要显式运行其运行循环。作为应用程序启动过程的一部分,应用程序框架会自动在主线程上设置并运行运行循环。

以下部分提供有关运行循环以及如何为应用程序配置它们的更多信息。有关运行循环对象的其他信息,请参阅NSRunLoop类参考CFRunLoop参考

运行循环剖析

运行循环非常像它的名字。它是线程进入并用于运行事件处理程序以响应传入事件的循环。代码提供了用于实现运行循环的实际循环部分的控制语句 - 换句话说,代码提供了驱动运行循环的while或者for循环。在循环中,使用运行循环对象来“运行”事件处理代码来接收事件并调用已安装的处理程序。

运行循环从两种不同类型的源接收事件。输入源提供异步事件,通常是来自另一个线程或来自不同应用程序的消息。定时器源提供同步事件,发生在预定时间或重复间隔。两种类型的源都使用特定于应用程序的处理程序例程来处理事件。

图3-1显示了运行循环和各种源的概念结构。输入源将异步事件传递给相应的处理程序,并导致该runUntilDate:方法(在线程的关联NSRunLoop对象上调用)退出。计时器源将事件传递给其处理程序例程,但不会导致运行循环退出。

图3-1 运行循环的结构及其来源

image

除了处理输入源之外,运行循环还会生成有关运行循环行为的通知。已注册的运行循环观察器可以接收这些通知并使用它们在线程上执行其他处理。可以使用Core Foundation在线程上安装运行循环观察器。

以下部分提供有关运行循环的组件及其运行模式的更多信息。还描述了在处理事件期间的不同时间生成的通知。

运行循环模式

一个运行循环模式是监控输入源和定时器源的集合,也是运行循环被通知的观察者的集合。每次运行运行循环时,都指定(显式或隐式)运行的特定“模式”。在运行循环的过程中,仅监视与该模式关联的源并允许其传递其事件。(类似地,只有与该模式相关联的观察者被通知运行循环的进度。)与其他模式相关联任何新事件都会挂起,直到后续以适当模式通过循环。

代码中,可以按名称识别模式。Cocoa和Core Foundation都定义了默认模式和几种常用模式,以及用于在代码中指定这些模式的字符串。只需为模式名称指定自定义字符串即可定义自定义模式。虽然为自定义模式指定的名称是任意的,但这些模式的内容不是。必须确保将一个或多个输入源,计时器或运行循环观察器添加到创建的模式中才有用。

可以使用模式在特定的运行循环期间过滤掉不需要的来源中的事件。大多数情况下,需要在系统定义的“默认”模式下运行运行循环。但是,模态面板可能以“模态”模式运行。在此模式下,只有与模态面板相关的源才会向线程传递事件。对于辅助线程,可以使用自定义模式来防止低优先级源在时间关键操作期间传递事件。

注意: 模式根据事件的源而不是事件的类型进行区分。例如,不会使用模式仅匹配鼠标按下事件或仅匹配键盘事件。您可以使用模式来侦听不同的端口集,暂时挂起计时器,或源和当前正被监视的运行循环观察者的其他变化。

表3-1列出了Cocoa和Core Foundation定义的标准模式以及何时使用该模式的说明。name列列出了用于在代码中指定模式的实际常量。

表3-1 预定义的运行循环模式

模式 名称 描述
默认 NSDefaultRunLoopMode (Cocoa) kCFRunLoopDefaultMode (Core Foundation) 默认模式是用于大多数操作。大多数情况下,应该使用此模式启动运行循环并配置输入源。
连接 NSConnectionReplyMode (Cocoa) Cocoa将此模式与NSConnection对象结合使用以监视回复。应该很少需要使用此模式。
模态 NSModalPanelRunLoopMode (Cocoa) Cocoa使用此模式来识别用于模态面板的事件。
事件跟踪 NSEventTrackingRunLoopMode (Cocoa) Cocoa使用此模式在鼠标拖动循环和其他种类的用户界面跟踪循环期间限制传入事件。
常用模式 NSRunLoopCommonModes (Cocoa) kCFRunLoopCommonModes (Core Foundation) 这是一组可配置的常用模式。将输入源与此模式相关联也会将其与组中的每个模式相关联。对于Cocoa应用程序,此集合默认包括默认,模态和事件跟踪模式。Core Foundation最初只包含默认模式。可以使用该CFRunLoopAddCommonMode功能将自定义模式添加到集合中。

输入源

输入源以异步方式向线程传递事件。事件的来源取决于输入源的类型,通常是两个类别中的一个。基于端口的输入源监视应用程序的Mach端口。自定义输入源监视自定义事件源。就运行循环而言,输入源是基于端口还是自定义无关紧要。系统只是实现可以使用的两种类型的输入源。两个来源之间的唯一区别是它们如何发出信号。基于端口的源由内核自动发出信号,而自定义源必须从另一个线程手动发信号。

创建输入源时,将其分配给运行循环的一个或多个模式。模式会影响在任何给定时刻输入源是否被监听。大多数情况下,在默认模式下运行运行循环,但也可以指定自定义模式。如果输入源未处于当前监视模式,则会生成的任何事件都会挂起,直到运行循环以正确模式运行。

以下部分描述了一些输入源。

基于端口的源

Cocoa和Core Foundation提供内置支持,使用与端口相关的对象和函数创建基于端口的输入源。例如,在Cocoa中,根本不必直接创建输入源。只需创建一个端口对象,并使用NSPort方法将该端口添加到运行循环中。port对象处理所需输入源的创建和配置。

在Core Foundation中,您必须手动创建端口及其运行循环源。在这两种情况下,使用函数关联的端口不透明类型(CFMachPortRef,CFMessagePortRef或CFSocketRef)创建合适的对象。

有关如何设置和配置基于端口的自定义源的示例,请参阅配置基于端口的输入源

自定义输入源

要创建自定义输入源,在Core Foundation中必须使用与CFRunLoopSourceRef的opaque类型关联的函数。可以使用多个回调函数配置自定义输入源。Core Foundation在不同的点调用这些函数来配置源,处理传入事件,并在从运行循环中删除源时销毁源。

除了在事件到达时定义自定义源的行为外,还必须定义事件传递机制。源的这一部分在一个单独的线程上运行,负责为输入源提供其数据,并在数据准备好进行处理时发出信号。事件传递机制取决于您,但不必过于复杂。

有关如何创建自定义输入源的示例,请参阅定义自定义输入源。有关自定义输入源的参考信息,另请参阅CFRunLoopSource参考

Cocoa执行选择器源

除了基于端口的源,Cocoa还定义了一个自定义输入源,允许在任何线程上执行选择器。与基于端口的源类似,执行选择器请求在目标线程上被序列化,从而减轻了在一个线程上运行多个方法时可能发生的许多同步问题。与基于端口的源不同,执行选择器源在执行其选择器后将其自身从运行循环中移除。

注意: 在OS X v10.5之前,执行选择器源主要用于向主线程发送消息,但在OS X v10.5及更高版本和iOS中,您可以使用它们将消息发送到任何线程。

在另一个线程上执行选择器时,目标线程必须具有活动的运行循环。对于您创建的线程,这意味着要等到代码显式启动运行循环。但是,因为主线程启动了自己的运行循环,所以只要应用程序调用applicationDidFinishLaunching:应用程序委托的方法,就可以开始在该线程上发出调用 。运行循环每次通过循环处理所有排队的执行选择器调用,而不是在每次循环迭代期间处理一个。

表3-2列出了定义在NSObject可用于在其他线程上执行选择器的方法。因为NSObject声明了这些方法,所以您可以在任何可以访问Objective-C对象的线程中使用它们,包括POSIX线程。这些方法实际上并不创建新线程来执行选择器。

表3-2 在其他线程上执行选择器

方法 描述
performSelectorOnMainThread:withObject:waitUntilDone:与performSelectorOnMainThread:withObject:waitUntilDone:modes: 在该线程的下一个运行循环周期中,在应用程序的主线程上执行指定的选择器。这些方法为您提供了阻止当前线程直到执行选择器的选择。
performSelector:onThread:withObject:waitUntilDone:与performSelector:onThread:withObject:waitUntilDone:modes: 在您拥有NSThread对象的任何线程上执行指定的选择器。这些方法为您提供了阻止当前线程直到执行选择器的选择。
performSelector:withObject:afterDelay:与performSelector:withObject:afterDelay:inModes: 在下一个运行循环周期的可选延迟时间之后,在当前线程上执行指定的选择器。因为它等待直到下一个运行循环周期来执行选择器,所以这些方法提供了来自当前执行代码的自动延迟。多个排队选择器按排队顺序依次执行。
cancelPreviousPerformRequestsWithTarget:与cancelPreviousPerformRequestsWithTarget:selector:object: 允许您取消使用performSelector:withObject:afterDelay:or performSelector:withObject:afterDelay:inModes:方法发送到当前线程的消息。

有关每种方法的详细信息,请参阅NSObject类参考

定时器源

定时器源在将来的预设时间将事件同步传递给您的线程。定时器是线程通知自己做某事的一种方式。例如,一旦在来自用户的连续击键之间经过了一定量的时间,搜索框就可以使用计时器来启动自动搜索。使用此延迟时间使用户有机会在开始搜索之前输入尽可能多的所需搜索字符串。

虽然它生成基于时间的通知,但计时器不是实时机制。与输入源类似,定时器与运行循环的特定模式相关联。如果计时器未处于运行循环当前正在监视的模式,则在您以其中一个计时器支持的模式运行运行循环之前,它不会触发。类似地,如果计时器在运行循环处于执行处理程序例程的过程中触发,则计时器将等待直到下一次通过运行循环来调用其处理程序例程。如果运行循环根本没有运行,则计时器永远不会触发。

您可以将计时器配置为仅生成一次或重复生成事件。重复计时器根据计划的触发时间自动重新计划,而不是实际的触发时间。例如,如果计划在特定时间和之后每5秒计触发一次计时器,则即使实际发射时间延迟,计划发射时间也将始终落在原始的5秒时间间隔上。如果发射时间延迟太多以至于错过了一个或多个预定发射时间,则计时器仅在错过的时间段内发射一次。在错过的时间内触发后,计时器被重新安排用于下一个预定的触发时间。

有关配置定时器源的更多信息,请参阅配置定时器源。有关参考信息,请参阅NSTimer类参考CFRunLoopTimer参考

运行循环观察器

与在发生适当的异步或同步事件时触发的源相反,运行循环观察器在执行运行循环期间在特殊位置触发。您可以使用运行循环观察器来准备线程以处理给定事件或在线程进入休眠状态之前准备线程。您可以将运行循环观察器与运行循环中的以下事件相关联:

运行循环的入口。
当运行循环即将处理计时器时。
当运行循环即将处理输入源时。
当运行循环即将进入睡眠状态时。
当运行循环唤醒时,但在它处理唤醒它的事件之前。
从运行循环退出。
您可以使用Core Foundation将运行循环观察器添加到应用程序。要创建运行循环观察器,请创建CFRunLoopObserverRefopaque类型的新实例。此类型会跟踪您的自定义回调函数及其感兴趣的活动。

与计时器类似,运行循环观察器可以使用一次或重复使用。一次性观察者在发射后将其自身从运行循环中移除,而重复的观察者仍然附着。您可以指定观察者在创建时运行一次还是重复运行。

有关如何创建运行循环观察器的示例,请参阅配置运行循环。有关参考信息,请参阅CFRunLoopObserver参考

运行循环事件序列

每次运行它时,线程的运行循环都会处理挂起的事件,并为任何附加的观察者生成通知。它执行此操作的顺序非常具体,如下所示:

  1. 通知观察者已经输入了运行循环。
  2. 通知观察者准备好的计时器即将触发。
  3. 通知观察者任何非基于端口的输入源即将触发。
  4. 触发任何准备触发的基于非端口的输入源。
  5. 如果基于端口的输入源准备就绪并等待触发,请立即处理该事件。转到第9步。
  6. 通知观察者线程即将睡眠。
  7. 将线程置于睡眠状态,直到发生以下事件之一:
  • 基于端口的输入源事件到达。
  • 计时器触发。
  • 为运行循环设置的超时值到期。
  • 运行循环被明确唤醒。
  1. 通知观察者线程刚刚醒来。
  2. 处理待处理事件。
  • 如果触发了用户定义的计时器,则处理计时器事件并重新启动循环。转到第2步。
  • 如果输入源被触发,则传递事件。
  • 如果运行循环被明确唤醒但尚未超时,请重新启动循环。转到第2步。
  1. 通知观察者运行循环已退出。

由于计时器和输入源的观察者通知是在这些事件实际发生之前传递的,因此通知时间与实际事件的时间之间可能存在差距。如果这些事件之间的时间关系很重要,您可以使用睡眠和唤醒睡眠通知来帮助您关联实际事件之间的时间。

因为在运行运行循环时会传递计时器和其他定期事件,所以绕过该循环会中断这些事件的传递。每当您通过输入循环并重复从应用程序请求事件来实现鼠标跟踪例程时,就会出现此行为的典型示例。因为您的代码直接抓取事件,而不是让应用程序正常调度这些事件,所以直到在鼠标跟踪例程退出并将控制权返回给应用程序,活动计时器将无法触发。

可以使用运行循环对象显式唤醒运行循环。其他事件也可能导致运行循环被唤醒。例如,添加另一个非基于端口的输入源会唤醒运行循环,以便可以立即处理输入源,而不是等到其他事件发生。

什么时候使用运行循环?

唯一需要显式运行运行循环的是为应用程序创建辅助线程。应用程序主线程的运行循环是一个至关重要的基础架构。因此,应用程序框架提供了运行主应用程序循环的代码并自动启动该循环。所述在IOS中UIApplication(或在OS X中NSApplication)的run方法启动应用程序的主循环作为正常启动序列的一部分。如果您使用Xcode模板项目来创建应用程序,则永远不必显式调用这些例程。

对于辅助线程,您需要确定是否需要运行循环,如果是,则自行配置并启动它。在有的情况下,不需要启动线程的运行循环。例如,如果使用线程执行某些长时间运行且预定义的任务,则可以避免启动运行循环。运行循环适用于您希望与线程进行更多交互的情况。例如,如果您计划执行以下任何操作,则需要启动运行循环:

  • 使用端口或自定义输入源与其他线程通信。
  • 在线程上使用计时器。
  • 应用程序中的任何使用performSelectorCocoa...方法。
  • 保持线程以执行定期任务。

如果您确实选择使用运行循环,则配置和设置非常简单。与所有线程编程一样,您应该有一个在适当情况下退出辅助线程的计划。最好通过让退出而不是强制终止来干净地结束一个线程。有关如何配置和退出运行循环的信息,请参阅使用运行循环对象。

使用运行循环对象

运行循环对象提供了用于将输入源,计时器和运行循环观察器添加到运行循环然后运行它的主界面。每个线程都有一个与之关联的运行循环对象。在Cocoa中,此对象是NSRunLoop类的实例。在低级应用程序中,它是指向CFRunLoopRefopaque类型的指针。

获取运行循环对象

要获取当前线程的运行循环,请使用以下方法之一:

  • 在Cocoa应用程序中,使用NSRunLoop的currentRunLoop类方法来检索NSRunLoop对象。
  • 使用该CFRunLoopGetCurrent功能。
    虽然它们不是免费的桥接类型,但您可以在需要CFRunLoopRef不透明类型时从NSRunLoop对象获取。NSRunLoop类定义了一个getCFRunLoop返回CFRunLoopRef类型的方法,你可以传递给Core Foundation的例程。因为两个对象都引用相同的运行循环,所以可以根据需要混合NSRunLoop对象和CFRunLoopRefopaque类型的调用。

配置运行循环

在辅助线程上运行运行循环之前,必须至少为其添加一个输入源或计时器。如果运行循环没有要监视的任何源,则在您尝试运行它时会立即退出。有关如何将源添加到运行循环的示例,请参阅配置运行循环源。

除了安装源之外,您还可以安装运行循环观察器并使用它们来检测运行循环的不同执行阶段。要安装运行循环观察器,您需要创建一个CFRunLoopObserverRef opaque类型并使用CFRunLoopAddObserver函数将其添加到运行循环中。必须使用Core Foundation创建运行循环观察器,即使对于Cocoa应用程序也是如此。

清单3-1显示了一个将运行循环观察器附加到其运行循环的线程的主例程。该示例的目的是向您展示如何创建一个运行循环观察器,因此代码只是设置一个运行循环观察器来监视所有运行循环活动。基本处理程序例程(未显示)仅在处理计时器请求时记录运行循环活动。

清单3-1 创建一个运行循环观察器

- (void)mainThread {
    //应用程序使用垃圾收集,因此不需要自动释放池。
    NSRunLoop *myRunLoop = [NSRunLoop currentRunLoop];
    
    //创建一个运行循环观察器并将其附加到运行循环。
    CFRunLoopObserverContext context = {0, (__bridge void *)(self), NULL, NULL, NULL};
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &myRunLoopObserver, &context);
    
    if (observer) {
        CFRunLoopRef cfLoop = [myRunLoop getCFRunLoop];
        CFRunLoopAddObserver(cfLoop, observer, kCFRunLoopDefaultMode);
    }
    
    //创建并安排计时器。
    [NSTimer scheduledTimerWithTimeInterval:0.1 target:self selector:@selector(doFireTimer:) userInfo:nil repeats:YES];
    
    NSInteger loopCount = 10;
    do {
        //运行10次运行循环让计时器触发。
        [myRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]];
        loopCount--;
    } while (loopCount);
}

为长期存在的线程配置运行循环时,最好添加至少一个输入源来接收消息。虽然您只能连接一个定时器进入运行循环,但一旦定时器触发,它通常会失效,这会导致运行循环退出。附加重复计时器可以使运行循环运行更长的时间,但是会涉及定期触发计时器以唤醒您的线程,这实际上是另一种形式的轮询。相比之下,输入源会等待事件发生,让线程保持睡眠状态。

启动运行循环

只有应用程序中的辅助线程才需要启动运行循环。运行循环必须至少有一个输入源或计时器才能进行监视。如果未连接,则运行循环立即退出。

有几种方法可以启动运行循环,包括以下内容:

  • 无条件
  • 设定时限
  • 在特定模式下

无条件地进入运行循环是最简单的选择,但它也是最不可取的选择。无条件地运行您的运行循环会将线程置于永久循环中,这使您几乎无法控制运行循环本身。您可以添加和删除输入源和计时器,但停止运行循环的唯一方法是终止它。也无法在自定义模式下运行运行循环。

不要无条件地运行运行循环,最好使用超时值运行运行循环。使用超时值时,运行循环将一直运行,直到事件到达或分配的时间到期。如果事件到达,则将该事件分派给处理程序进行处理,然后退出运行循环。然后,您的代码可以重新启动运行循环以处理下一个事件。如果指定的时间到期,您只需重新启动运行循环或使用时间进行任何所需的任务处理。

除了超时值,您还可以使用特定模式运行运行循环。模式和超时值不是互斥的,可以在启动运行循环时使用。模式限制将事件传递到运行循环的源类型,并在运行循环模式中进行了更详细的描述。

清单3-2显示了线程主入口例程的框架版本。此示例的关键部分显示了运行循环的基本结构。实质上,您将输入源和计时器添加到运行循环中,然后重复调用其中一个例程以启动运行循环。每次运行循环例程返回时,您都会检查是否出现了可能需要退出该线程的任何条件。该示例使用Core Foundation运行循环例程,以便它可以检查返回结果并确定运行循环退出的原因。NSRunLoop如果使用Cocoa并且不需要检查返回值,也可以使用类方法以类似的方式运行运行循环。(有关调用NSRunLoop类方法的运行循环的示例,请参阅清单3-14。)

清单3-2 运行一个运行循环

- (void)skeletonThreadMain {
    //如果不使用垃圾收集,在此处设置自动释放池
    bool down = NO;
    
    //将输入源或计时器添加到运行循环并执行任何其他设置
    
    do {
        //启动运行循环,但在处理完每个源后返回。
        SInt32 result = CFRunLoopRunInMode(kCFRunLoopDefaultMode, 10, YES);
        
        //如果源显式停止了运行循环
        if ((result == kCFRunLoopRunStopped) || (result == kCFRunLoopRunFinished))
            down = YES;
        
        //在这里检查任何其他退出条件并设置
        //根据需要完成变量
    } while (!down);
}

退出运行循环

在处理事件之前,有两种方法可以使运行循环退出:

  • 使用超时值运行配置运行循环。
  • 告诉运行循环停止。

如果您可以管理它,那么使用超时值肯定是首选。指定超时值可让运行循环完成所有正常处理,包括在退出之前向运行循环观察器发送通知。

使用该CFRunLoopStop函数显式停止运行循环会产生类似于超时的结果。运行循环发出任何剩余的运行循环通知,然后退出。不同之处在于,您可以在无条件启动的运行循环中使用此技术。

虽然删除运行循环的输入源和定时器也可能导致运行循环退出,但这不是停止运行循环的可靠方法。某些系统例程将输入源添加到运行循环以处理所需的事件。因为您的代码可能不知道这些输入源,所以它将无法删除它们,这将阻止运行循环退出。

配置运行循环源

以下部分显示了如何在Cocoa和Core Foundation中设置不同类型的输入源的示例。

定义自定义输入源

创建自定义输入源涉及定义以下内容:

  • 希望输入源处理的信息。
  • 一个调度程序例程,让感兴趣的客户端知道如何联系这个输入源。
  • 执行任何客户端发送的请求的处理程序例程。
  • 取消例程以使输入源无效。

由于您创建了一个自定义输入源来处理自定义信息,因此实际配置的设计非常灵活。调度程序,处理程序和取消例程是您自定义输入源几乎总是需要的关键例程。但是,大多数输入源行为都发生在那些处理程序例程之外。例如,您可以定义将数据传递到输入源以及将存在的输入源传递给其他线程的机制。

图3-2显示自定义输入源的示例配置。在此示例中,应用程序的主线程维护对输入源的引用,该输入源的自定义命令缓冲区以及安装输入源的运行循环。当主线程有一个要传递给工作线程的任务时,它会向命令缓冲区发布一个命令以及工作线程启动任务所需的任何信息。(因为主线程和输入源工作的线程都可以访问命令缓冲区,所以必须同步该访问。)一旦发布命令,主线程就会发出信号输入源并唤醒工作线程的运行循环。收到唤醒命令后,运行循环调用输入源的处理程序,该处理程序处理命令缓冲区中的命令。

图3-2 操作自定义输入源

image

以下部分介绍了上图中自定义输入源的实现,并显示了您需要实现的关键代码。

定义输入源

定义自定义输入源需要使用Core Foundation框架来配置运行循环源并将其附加到运行循环。虽然基本处理程序是基于C的函数,但这并不妨碍您为这些函数编写包装器并使用Objective-C或C ++来实现代码体。

图3-2中引入的输入源使用Objective-C对象来管理命令缓冲区并与运行循环协调。清单3-3显示了该对象的定义。该RunLoopSource对象管理命令缓冲区并使用该缓冲区从其他线程接收消息。此列表还显示了RunLoopContext对象的定义,它实际上只是用于将RunLoopSource对象和运行循环引用传递给应用程序主线程的容器对象。

清单3-3 自定义输入源对象定义

@interface RunLoopSource : NSObject
{
    CFRunLoopSourceRef runLoopSource;
    NSMutableArray* commands;
}
 
- (id)init;
- (void)addToCurrentRunLoop;
- (void)invalidate;
 
// 处理方法
- (void)sourceFired;
 
// Client interface for registering commands to process
- (void)addCommand:(NSInteger)command withData:(id)data;
- (void)fireAllCommandsOnRunLoop:(CFRunLoopRef)runloop;
 
@end
 
// 回调函数
void RunLoopSourceScheduleRoutine (void *info, CFRunLoopRef rl, CFStringRef mode);
void RunLoopSourcePerformRoutine (void *info);
void RunLoopSourceCancelRoutine (void *info, CFRunLoopRef rl, CFStringRef mode);
 
// RunLoopContext是输入源注册的容器
@interface RunLoopContext : NSObject
{
    CFRunLoopRef        runLoop;
    RunLoopSource*        source;
}
@property (readonly) CFRunLoopRef runLoop;
@property (readonly) RunLoopSource* source;
 
- (id)initWithSource:(RunLoopSource*)src andLoop:(CFRunLoopRef)loop;
@end

尽管Objective-C代码管理输入源的自定义数据,但将输入源附加到运行循环需要基于C的回调函数。当您将运行循环源实际附加到运行循环时,将调用这些函数中的第一个,如清单3-4所示。因为此输入源只有一个客户端(主线程),所以它使用调度程序函数发送消息以使用该线程上的应用程序委托注册自身。当委托想要与输入源通信时,它使用RunLoopContext对象中的信息来执行此操作。

清单3-4 调度运行循环源

void RunLoopSourceScheduleRoutine(void * info,CFRunLoopRef rl,CFStringRef mode)
{
    RunLoopSource * obj =(RunLoopSource *)info;
    AppDelegate * del = [AppDelegate sharedAppDelegate];
    RunLoopContext * theContext = [[RunLoopContext alloc] initWithSource:obj andLoop:rl];
 
    [del performSelectorOnMainThread:@selector(registerSource :)
                                withObject:theContext waitUntilDone:NO];
}

最重要的回调例程之一是用于在输入源发出信号时处理自定义数据的例程。清单3-5显示了与该RunLoopSource对象关联的执行回调例程。此函数只是将请求转发给sourceFired方法,然后处理命令缓冲区中存在的任何命令。

清单3-5 在输入源中执行工作

void RunLoopSourcePerformRoutine(void * info)
{
    RunLoopSource * obj =(RunLoopSource *)info;
    [obj sourceFired];
}

如果使用该CFRunLoopSourceInvalidate函数从运行循环中删除输入源,系统将调用输入源的取消例程。您可以使用此例程通知客户端您的输入源不再有效,并且应删除对它的任何引用。 清单3-6显示了向RunLoopSource对象注册的取消回调例程。此函数将另一个RunLoopContext对象发送到应用程序委托,但这次要求委托删除对运行循环源的引用。

清单3-6 使输入源无效

void RunLoopSourceCancelRoutine(void * info,CFRunLoopRef rl,CFStringRef mode)
{
    RunLoopSource * obj =(RunLoopSource *)info;
    AppDelegate * del = [AppDelegate sharedAppDelegate];
    RunLoopContext * theContext = [[RunLoopContext alloc] initWithSource:obj andLoop:rl];
 
    [del performSelectorOnMainThread:@selector(removeSource :)
                                withObject:theContext waitUntilDone:YES];
}

注意: 应用程序委托registerSource:和removeSource:方法的代码显示在与输入源的客户端调度中。

在运行循环上安装输入源

清单3-7显示了RunLoopSource类的init方法和addToCurrentRunLoop方法。该init方法创建CFRunLoopSourceRef必须实际附加到运行循环的opaque类型。它将RunLoopSource对象本身作为上下文信息传递,以便回调例程具有指向对象的指针。在工作线程调用该addToCurrentRunLoop方法之前不会安装输入源,此时将RunLoopSourceScheduleRoutine调用回调函数。一旦输入源被添加到运行循环中,线程就可以运行其运行循环来等待它。

清单3-7 安装运行循环源

- (id)init
{
    CFRunLoopSourceContext    context = {0, self, NULL, NULL, NULL, NULL, NULL,
                                        &RunLoopSourceScheduleRoutine,
                                        RunLoopSourceCancelRoutine,
                                        RunLoopSourcePerformRoutine};
 
    runLoopSource = CFRunLoopSourceCreate(NULL, 0, &context);
    commands = [[NSMutableArray alloc] init];
 
    return self;
}
 
- (void)addToCurrentRunLoop
{
    CFRunLoopRef runLoop = CFRunLoopGetCurrent();
    CFRunLoopAddSource(runLoop, runLoopSource, kCFRunLoopDefaultMode);
}

与输入源的客户协调

为了使输入源有用,需要对其进行操作并从另一个线程发出信号。输入源的重点是将其关联的线程置于睡眠状态,直到有事情要做。这个事实需要让应用程序中的其他线程知道输入源并有办法与之通信。

通知客户端输入源的一种方法是在输入源首次安装在其运行循环时发出注册请求。可以根据需要向任意数量的客户注册输入源,或者只需将其注册到某个中央机构,然后将您的输入源发送给感兴趣的客户。清单3-8显示了应用程序委托定义的注册方法,并在调用RunLoopSource对象的调度程序函数时调用。此方法接收RunLoopContext对象提供的RunLoopSource对象,并将其添加到其源列表中。此列表还显示了从运行循环中删除输入源时用于取消注册的例程。

清单3-8 使用应用程序委托注册和删除输入源

- (void)registerSource:(RunLoopContext*)sourceInfo;
{
    [sourcesToPing addObject:sourceInfo];
}
 
- (void)removeSource:(RunLoopContext*)sourceInfo
{
    id    objToRemove = nil;
 
    for (RunLoopContext* context in sourcesToPing)
    {
        if ([context isEqual:sourceInfo])
        {
            objToRemove = context;
            break;
        }
    }
 
    if (objToRemove)
        [sourcesToPing removeObject:objToRemove];
}

注意: 调用前面列表中的方法的回调函数如清单3-4和清单3-6所示。

给输入源发信号

在将数据移交给输入源之后,客户端必须向源发送信号并唤醒其运行循环。信号源使运行循环知道源已准备好进行处理。并且因为线程可能在信号发生时处于睡眠状态,所以应该总是明确地唤醒运行循环。如果不这样做可能会导致处理输入源的延迟。

清单3-9显示了RunLoopSource对象的fireCommandsOnRunLoop方法。当客户端准备好处理他们添加到缓冲区的命令时,客户端会调用此方法。

清单3-9 唤醒运行循环

- (void)fireCommandsOnRunLoop:(CFRunLoopRef)runloop
{
    CFRunLoopSourceSignal(runLoopSource);
    CFRunLoopWakeUp(runloop);
}

注意: 不应该通过发送自定义输入源来尝处理SIGHUP和其他类型的进程级信号。用于唤醒运行循环的Core Foundation函数不是信号安全的,不应在应用程序的信号处理程序例程中使用。有关信号处理程序例程的更多信息,请参见sigaction手册页

配置定时器源

要创建计时器源,要做的就是创建一个计时器对象并在运行循环上调度。在Cocoa中,您使用NSTimer类创建新的计时器对象,在Core Foundation中使用CFRunLoopTimerRef opaque类型。事实上,NSTimer类只是在Core Foundation上做一些简单的扩展,它提供了一些便利功能,例如使用相同方法创建和调度计时器。

在Cocoa中,可以使用以下任一类方法一次创建和调度计时器:

  • scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:
  • scheduledTimerWithTimeInterval:invocation:repeats:

这些方法创建计时器并在默认模式(NSDefaultRunLoopMode)中将其添加到当前线程的运行循环中。如果需要,还可以手动调度计时器,方法是创建NSTimer对象,然后使用addTimer:forMode:方法将其添加到运行循环中NSRunLoop。这两种技术基本上都是一样的,但是可以对计时器的配置进行不同程度的控制。例如,如果创建计时器并手动将其添加到运行循环,则可以使用默认模式以外的模式执行此操作。清单3-10显示了如何使用这两种技术创建计时器。第一个计时器的初始延迟为1秒,但之后每0.1秒定时触发一次。第二个计时器在最初的0.2秒延迟后开始射击,然后每0.2秒触发一次。

清单3-10 使用NSTimer创建和调度计时器

NSRunLoop* myRunLoop = [NSRunLoop currentRunLoop];
 
// 创建并调度第一个计时器
NSDate* futureDate = [NSDate dateWithTimeIntervalSinceNow:1.0];
NSTimer* myTimer = [[NSTimer alloc] initWithFireDate:futureDate
                        interval:0.1
                        target:self
                        selector:@selector(myDoFireTimer1:)
                        userInfo:nil
                        repeats:YES];
[myRunLoop addTimer:myTimer forMode:NSDefaultRunLoopMode];
 
// 创建并调度第二个计时器
[NSTimer scheduledTimerWithTimeInterval:0.2
                        target:self
                        selector:@selector(myDoFireTimer2:)
                        userInfo:nil
                        repeats:YES];

清单3-11显示了使用Core Foundation函数配置计时器所需的代码。虽然此示例未在上下文结构中传递任何用户定义的信息,但可以使用此结构传递计时器所需的任何自定义数据。有关此结构内容的更多信息,请参阅CFRunLoopTimer参考中的说明。

清单3-11 使用Core Foundation创建和调度计时器

CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFRunLoopTimerContext context = {0, NULL, NULL, NULL, NULL};
CFRunLoopTimerRef timer = CFRunLoopTimerCreate(kCFAllocatorDefault, 0.1, 0.3, 0, 0,
                                        &myCFTimerCallback, &context);
 
CFRunLoopAddTimer(runLoop, timer, kCFRunLoopCommonModes);

配置基于端口的输入源

Cocoa和Core Foundation都提供了基于端口的对象,用于线程之间或进程之间的通信。以下部分介绍如何使用多种不同类型的端口设置端口通信。

配置NSMachPort对象

要与NSMachPort对象建立本地连接,请创建端口对象并将其添加到主线程的运行循环中。启动辅助线程时,将同一对象传递给线程的入口点函数。辅助线程可以使用相同的对象将消息发送回主线程。

实现主线程代码

清单3-12显示了启动辅助工作线程的主要线程代码。因为Cocoa框架执行许多配置端口和运行循环的中间步骤,所以该launchThread方法明显短于其Core Foundation等效代码(清单3-17); 然而,两者的行为几乎完全相同。一个区别是,该方法不是直接向工作线程发送本地端口的名称,而是直接发送NSPort对象。

清单3-12 主线程启动方法

- (void)luanchThread {
    NSPort *myPort = [NSMachPort port];
    if (myPort) {
        //此类处理传入的端口消息
        [myPort setDelegate:self];
        
        //在当前运行循环中将端口安装为输入源
        [[NSRunLoop currentRunLoop] addPort:myPort forMode:NSDefaultRunLoopMode];
        
        //分离线程,让工作线程释放端口
        [NSThread detachNewThreadSelector:@selector(luanchThreadWithPort:) toTarget:self withObject:myPort];
    }
}

为了在线程之间建立双向通信通道,您可能希望让工作线程在签入消息中将其自己的本地端口发送到主线程。接收到签入消息后,主线程就会知道在启动第二个线程时一切顺利,并且还为您提供了向该线程发送更多消息的方法。

清单3-13显示了主线程的handlePortMessage:方法。当数据到达线程自己的本地端口时,将调用此方法。当签入消息到达时,该方法直接从端口消息中检索辅助线程的端口并保存以供以后使用。

清单3-13 处理Mach端口消息

#define kCheckinMessage 100
 
// 处理来自工作线程的响应
- (void)handlePortMessage:(NSPortMessage *)portMessage
{
    unsigned int message = [portMessage msgid];
    NSPort* distantPort = nil;
 
    if (message == kCheckinMessage)
    {
        // 获取工作线程的通信端口
        distantPort = [portMessage sendPort];
 
        // 保留并保存工作端口以供以后使用
        [self storeDistantPort:distantPort];
    }
    else
    {
        // 处理其他消息
    }
}
实现辅助线程代码

对于辅助工作线程,您必须配置线程并使用指定的端口将信息传递回主线程。

清单3-14显示了设置工作线程的代码。在为线程创建自动释放池之后,该方法创建一个工作对象来驱动线程执行。worker对象的sendCheckinMessage:方法(如清单3-15所示)为工作线程创建一个本地端口,并将一个签入消息发送回主线程。

清单3-14 使用Mach端口启动工作线程

+(void)LaunchThreadWithPort:(id)inData
{
    NSAutoreleasePool*  pool = [[NSAutoreleasePool alloc] init];
 
    // 设置与主线程的连接
    NSPort* distantPort = (NSPort*)inData;
 
    MyWorkerClass*  workerObj = [[self alloc] init];
    [workerObj sendCheckinMessage:distantPort];
    [distantPort release];
 
    // 让RunLoop处理事情
    do
    {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
                            beforeDate:[NSDate distantFuture]];
    }
    while (![workerObj shouldExit]);
 
    [workerObj release];
    [pool release];
}

使用NSMachPort时,本地和远程线程可以使用相同的端口对象进行线程之间的单向通信。换句话说,由一个线程创建的本地端口对象成为另一个线程的远程端口对象。

清单3-15显示了辅助线程的签入例程。此方法为将来的通信设置自己的本地端口,然后将签入消息发送回主线程。该方法使用方法中接收的端口对象LaunchThreadWithPort:作为消息的目标。

清单3-15 使用Mach端口发送签入消息

// 工作线程签入方法
- (void)sendCheckinMessage:(NSPort*)outPort
{
    // 保留并保存远程端口以备将来使用
    [self setRemotePort:outPort];
 
    // 创建并配置工作线程端口
    NSPort* myPort = [NSMachPort port];
    [myPort setDelegate:self];
    [[NSRunLoop currentRunLoop] addPort:myPort forMode:NSDefaultRunLoopMode];
 
    // 创建签入消息
    NSPortMessage* messageObj = [[NSPortMessage alloc] initWithSendPort:outPort
                                         receivePort:myPort components:nil];
 
    if (messageObj)
    {
        // 完成消息配置并立即发送 immediately.
        [messageObj setMsgId:setMsgid:kCheckinMessage];
        [messageObj sendBeforeDate:[NSDate date]];
    }
}

配置NSMessagePort对象

要与NSMessagePort对象建立本地连接,不能简单地在线程之间传递端口对象。必须按名称获取远程消息端口。在Cocoa中实现这一点需要使用特定名称注册本地端口,然后将该名称传递给远程线程,以便它可以获取适当的端口对象进行通信。清单3-16显示了在您要使用消息端口的情况下的端口创建和注册过程。

清单3-16 注册消息端口

NSPort* localPort = [[NSMessagePort alloc] init];
 
// 配置对象并将其添加到当前运行循环中
[localPort setDelegate:self];
[[NSRunLoop currentRunLoop] addPort:localPort forMode:NSDefaultRunLoopMode];
 
// 使用特定名称注册端口,名称必须是唯一的
NSString* localPortName = [NSString stringWithFormat:@"MyPortName"];
[[NSMessagePortNameServer sharedInstance] registerPort:localPort
                     name:localPortName];

在Core Foundation中配置基于端口的输入源

本节介绍如何使用Core Foundation在应用程序的主线程和工作线程之间建立双向通信通道。

清单3-17显示了应用程序主线程调用以启动工作线程的代码。代码所做的第一件事是建立一个CFMessagePortRefopaque类型,用于侦听来自工作线程的消息。工作线程需要端口的名称来建立连接,因此在工作线程的入口点函数传递字符串值。端口名称在当前用户上下文中通常应该是唯一的; 否则,你可能会遇到冲突。

清单3-17 将Core Foundation消息端添附加到新线程

#define kThreadStackSize        (8 *4096)
 
OSStatus MySpawnThread()
{
    // 创建用于接收响应的本地端口
    CFStringRef myPortName;
    CFMessagePortRef myPort;
    CFRunLoopSourceRef rlSource;
    CFMessagePortContext context = {0, NULL, NULL, NULL, NULL};
    Boolean shouldFreeInfo;
 
    // 创建一个包含端口名称的字符串
    myPortName = CFStringCreateWithFormat(NULL, NULL, CFSTR("com.myapp.MainThread"));
 
    // 创建端口
    myPort = CFMessagePortCreateLocal(NULL,
                myPortName,
                &MainThreadResponseHandler,
                &context,
                &shouldFreeInfo);
 
    if (myPort != NULL)
    {
        // 端口已成功创建
        // 现在为它创建一个运行循环源
        rlSource = CFMessagePortCreateRunLoopSource(NULL, myPort, 0);
 
        if (rlSource)
        {
            // 将源添加到当前运行循环
            CFRunLoopAddSource(CFRunLoopGetCurrent(), rlSource, kCFRunLoopDefaultMode);
 
            // 安装完成后,可以释放这些内容
            CFRelease(myPort);
            CFRelease(rlSource);
        }
    }
 
    // 创建线程并继续处理
    MPTaskID        taskID;
    return(MPCreateTask(&ServerThreadEntryPoint,
                    (void*)myPortName,
                    kThreadStackSize,
                    NULL,
                    NULL,
                    NULL,
                    0,
                    &taskID));
}

安装端口并启动线程后,主线程可以在等待线程签入时继续其常规执行。当签入消息到达时,它将被分派到主线程的MainThreadResponseHandler函数,如清单3-18所示。。此函数提取工作线程的端口名称,并为将来的通信创建管道。

清单3-18 接收签入消息

#define kCheckinMessage 100
 
// 主线程端口消息处理程序
CFDataRef MainThreadResponseHandler(CFMessagePortRef local,
                    SInt32 msgid,
                    CFDataRef data,
                    void* info)
{
    if (msgid == kCheckinMessage)
    {
        CFMessagePortRef messagePort;
        CFStringRef threadPortName;
        CFIndex bufferLength = CFDataGetLength(data);
        UInt8* buffer = CFAllocatorAllocate(NULL, bufferLength, 0);
 
        CFDataGetBytes(data, CFRangeMake(0, bufferLength), buffer);
        threadPortName = CFStringCreateWithBytes (NULL, buffer, bufferLength, kCFStringEncodingASCII, FALSE);
 
        // 必须按名称获取远程消息端口
        messagePort = CFMessagePortCreateRemote(NULL, (CFStringRef)threadPortName);
 
        if (messagePort)
        {
            // 保留并保存线程的通信端口以供将来参考
            AddPortToListOfActiveThreads(messagePort);
 
            // 由于前一个函数保留了端口,因此请释放
            CFRelease(messagePort);
        }
 
        // Clean up.
        CFRelease(threadPortName);
        CFAllocatorDeallocate(NULL, buffer);
    }
    else
    {
        // 处理其他消息
    }
 
    return NULL;
}

配置主线程后,剩下的唯一事情就是新创建的工作线程创建自己的端口并签入。清单3-19显示了工作线程的入口点函数。该函数提取主线程的端口名称,并使用它创建一个返回主线程的远程连接。然后,该函数为自己创建一个本地端口,在线程的运行循环上安装该端口,并向包含本地端口名称的主线程发送签入消息。

清单3-19 设置线程结构

OSStatus ServerThreadEntryPoint(void* param)
{
    // Create the remote port to the main thread.
    CFMessagePortRef mainThreadPort;
    CFStringRef portName = (CFStringRef)param;
 
    mainThreadPort = CFMessagePortCreateRemote(NULL, portName);
 
    // Free the string that was passed in param.
    CFRelease(portName);
 
    // Create a port for the worker thread.
    CFStringRef myPortName = CFStringCreateWithFormat(NULL, NULL, CFSTR("com.MyApp.Thread-%d"), MPCurrentTaskID());
 
    // Store the port in this thread’s context info for later reference.
    CFMessagePortContext context = {0, mainThreadPort, NULL, NULL, NULL};
    Boolean shouldFreeInfo;
    Boolean shouldAbort = TRUE;
 
    CFMessagePortRef myPort = CFMessagePortCreateLocal(NULL,
                myPortName,
                &ProcessClientRequest,
                &context,
                &shouldFreeInfo);
 
    if (shouldFreeInfo)
    {
        // Couldn't create a local port, so kill the thread.
        MPExit(0);
    }
 
    CFRunLoopSourceRef rlSource = CFMessagePortCreateRunLoopSource(NULL, myPort, 0);
    if (!rlSource)
    {
        // Couldn't create a local port, so kill the thread.
        MPExit(0);
    }
 
    // Add the source to the current run loop.
    CFRunLoopAddSource(CFRunLoopGetCurrent(), rlSource, kCFRunLoopDefaultMode);
 
    // Once installed, these can be freed.
    CFRelease(myPort);
    CFRelease(rlSource);
 
    // Package up the port name and send the check-in message.
    CFDataRef returnData = nil;
    CFDataRef outData;
    CFIndex stringLength = CFStringGetLength(myPortName);
    UInt8* buffer = CFAllocatorAllocate(NULL, stringLength, 0);
 
    CFStringGetBytes(myPortName,
                CFRangeMake(0,stringLength),
                kCFStringEncodingASCII,
                0,
                FALSE,
                buffer,
                stringLength,
                NULL);
 
    outData = CFDataCreate(NULL, buffer, stringLength);
 
    CFMessagePortSendRequest(mainThreadPort, kCheckinMessage, outData, 0.1, 0.0, NULL, NULL);
 
    // Clean up thread data structures.
    CFRelease(outData);
    CFAllocatorDeallocate(NULL, buffer);
 
    // Enter the run loop.
    CFRunLoopRun();
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,565评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,021评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,003评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,015评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,020评论 5 370
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,856评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,178评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,824评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,264评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,788评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,913评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,535评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,130评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,102评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,334评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,298评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,622评论 2 343

推荐阅读更多精彩内容