Threading Programming Guide 3 Run Loops

iOS线程编程手册-3-Run Loops

Run Loop是和线程关系密切的基础架构,它配合线程实现可循环事件处理机制。Run Loop的设计目的是让线程由事件驱动而运行,没有接收到事件就休眠。

只有主线程才是自带Run Loop光环的,设置主线程的Run Loop是应用程序启动的一部分。其他你自己创建的线程都需要自己写代码来实现Run Loop。Cocoa和Core Foundation都提供了run loop object让你可以方便的创建和管理run loop。

下文所提及的所有内容均可以在 NSRunLoop Class ReferenceCFRunLoop Reference 中找到。

Anatomy of a Run Loop //剖析Run Loop

Run Loop中的Loop需要自己实现。

两种events:

  • 异步的Input source events //来源于其他线程或其他应用程序的事件
  • 同步的Timer sources events //自己设定的定时或循环发生的事件

如下图,input sources传递异步事件到对应的handler,同时触发NSRunLoop object的runUntilDate:方法退出当前runloop object。Timer sources传递事件给对应的handler但是不会促使runloop object 退出。

runloop

为了管理input sources,run loop还生成run loop通知(notification)。你可以注册(registered) run-loop observers 来接受这些消息然后进行其他处理(需使用Core Foundation)。

Run Loop Modes //Run Loop 模式

一个run loop mode是run loop需要处理的input sources和timers的集合。当你设置好run loop object的run loop mode之后,run loop object只会对run loop mode中包含的event做处理。

Cocoa和Core Foundation都预定义了一些常用的run loop mode。当然你也可以自定义,但是必须包含所有你需要监听的event sources。

区分Modes不是根据event类型而是更具event source。例如你不能使用modes来匹配鼠标点击或者键盘点击事件,而是直接监听对应的端口。我也看不太明白……原文:Modes discriminate based on the source of the event,not the type of the event.Forexample, you would not use modes to match only mouse-down events or only keyboard events. You could use modes to listen to a different set of ports, suspend timers temporarily, or otherwise change the sources and run loop observers currently being monitored.

预定义modes:

Mode 模式 Name 名 Description 描述
Default 默认模式 NSDefaultRunLoopMode(Cocoa)、kCFRunLoopDefaultMode(Core Foundation) Cocoa下创建run loop时默认的模式
Connection 连接模式 NSConnectionReplyMode(Cocoa) Cocoa框架下用该模式配合NSConnection object来监听replies。少用。
Modal NSModalPanelRunLoopMode(Cocoa) Cocoa下使用该mode区分出modal panel event。
Event tracking NSEventTrackingRunLoopMode(Cocoa) Cocoa下使用该模式来约束用户操作图形界面时到达的event。
Common modes 普通模式 NSRunLoopCommonModes(Cocoa)、kCFRunLoopCommonModes(Core Foundation) Core Foundation下的默认模式,可以使用CFRunLoopAddCommonMode方法追加其他modes。Cocoa下是default, modal, and event tracking modes的集合。

Input Sources

异步传递事件给线程。两种类型:Port-based input sources、Custom input sources。两种类型signal方式不一样,Port-based sources是由kernel signal的,而custom-sources是由另一线线程手动signal的。

创建一个input source之后把它添加到你的run loop mode里面去。如果当前的run loop mode不支持该input source,所有该source的event都会被挂起无法被接收。

Port-Based Sources

Cocoa中直接使用NSPort即可创建Sources并添加到run loop mode,而在Core Foundation中需要手动创建(使用CFMachPortRefCFMessagePortRef或者CFSocketRef

详见后面的 Configuring a Port-Based Input Source

Custom Input Sources

使用Core Foundation中的CFRunLoopSourceRef

定义callback函数来设置一个custom input source,然后让Core Foundation调用来处理对应的event。

详见后面的 Defining a Custom Input Source

Cocoa Perform Selector Sources

这个也是Port-based sources,只是运行完之后就就自动从run loop中删除。

目标thread必须有run loop,也就是说如果这个thread是你自己创建的,必须先设置并start对应的run loop。

因为main thread的run loop的自动设置并运行的,你可以直接在applicationDidFinishLaunching:方法里面使用。

run loop会在一个循环内处理完所有selector source,而不是一个循环处理一个。

可用函数:

Methods 方法名 Description 描述
performSelectorOnMainThread:withObject:waitUntilDone: performSelectorOnMainThread:withObject:waitUntilDone:modes: 直接在main thread的下一个runloop中执行。其中waitUntilDone:可以让你设置是否在selector执行完之前block当前线程。
performSelector:onThread:withObject:waitUntilDone: performSelector:onThread:withObject:waitUntilDone:modes: 在制定的线程中执行。其中waitUntilDone:可以让你设置是否在selector执行完之前block当前线程。
performSelector:withObejct:afterDelay: performSelector:withObject:afterDelay:inModes: 在下一个runloop中执行selector,并可以制定delay option,让它们可以按照指定循序串行执行。
cancelPreviousPerformRequestsWithTarget: cancelPreviousPerformRequestsWithTarget:selector:object: 用来取消上面performSelector:withObejct:afterDelay:performaSelector:withObject:afterDelay:inModes:的动作

这些方法的详细介绍在 NSObject Class Refernce

Timer Sources

同步传递事件给线程的run loop。timer让thread知道自己要定时“吃药”(执行任务)。

timer并不是实时的机制,虽然它产生定时任务。例如说,thread正在忙于执行一个任务的时候,一个timer到点了,timers会等到run loop下一次检查timer event再传递定时任务通知。如果线程的run loop根本没有在运行,timer也不会运行。

timer可以是一次性,也可以是循环触发的。循环触发的timer是根据指定的时间间隔触发的。例如一个循环timer的循环触发时间间隔为5s,则每间隔5触发一次。如果被触发的一个或多个timer被推迟了很久才运行,被推迟的timer将会被合并当做触发了一次处理,运行后间隔5s再触发。

详见后面的 Configuring Timer SourcesReferenceNSTimer Class ReferenceCFRunLoopTimer Reference

Run Loop Observers

区别于事件驱动的run loop sources,run loop observers是在run loop的特定位置触发的。你可以使用run loop observers来预配置(prepare)线程来处理event、或者在线程休眠之前做一些处理。

你可以在这些地方添加observers:

  • The entrance to the run loop.\\run loop入口
  • When the run loop is about to process a timer.\r\un loop准备处理一个timer的时候
  • When the run loop is about to process an input source.\\run loop准备处理一个input source的时候
  • When the run loop is about to go to sleep.\\run loop准备休眠的时候
  • When the run loop has woken up, but before is has processed the event that woke it up.\\run loop被唤醒,执行任务之前
  • The exit from the run loop.\\run loop退出之前

使用Core Foundation中的CFRunLoopObserverRef不透明类型opaque type创建。

obervers和timer一样可以是一次性,也可以是循环触发的,在创建的时候指定。

详见下方 Configuring the Run Loop 。Reference见 CFRunLoopObserver Reference

The Run Loop Sequence of Events

run loop流程:

原文:

  1. Notify observers that the run loop has been entered.
  2. Notify observers that any ready timers are about to fire.
  3. Notify observers that any input sources that are not port based are about to fire.
  4. Fire any non-port-based input sources that are ready to fire.
  5. If a port-based input source is ready and waiting to fire, process the event immediately. Go to step 9.
  6. Nofity observers that the thread about to sleep.
  7. Put the thread to sleep until one of the following events occurs:
    • An event arrives for a port-based input source.
    • A timer fires.
    • The timeout value set for the run loop expires.
    • The run loop is explicity woken up.
  8. Notify observers that the thread just woke up.
  9. Process the pending event.
    • If a user-defined timer fired, process the timer event and restart the loop. Go to step 2.
    • If an input source fired, deliver the event.
    • If the run loop was explicitly woken up but has not yet timed out, restart the loop. Go to step2.
  10. Notify the observers that the run loop has exited.

译文:

  1. 通知observers已经进入run loop。
  2. 通知observers所有准备触发(fire)的timer。
  3. 通知observers所有准备触发(fire)的non-port-based sources。
  4. 触发(fire)所有可以触发的non-port-based sources。
  5. 如果有port-based input source可以触发,马上处理。跳到步骤9。
  6. 通知observers当前线程准备休眠。
  7. 让thread sleep,知道下面情况发生:
    • port-based input source事件到达。
    • 一个Timer触发。
    • timeout到了。
    • run loop被指定唤醒。
  8. 通知observers线程被唤醒。
  9. 处理待处理的事件:
    • 如果一个用户定义的timer触发了,处理timer事件然后重启run loop,跳到步骤2。
    • 如果一个input source触发,传递事件给线程。
    • 如果线程被指定唤醒但是还没有到timeout时间,重启run loop,跳到步骤2。
  10. 通知observers当前run loop正在退出。

When Would You Use a Run Loop?

UIApplication中的run方法包含了主线程中run loop的启动,所以app启动的时候是会自动创建主线程的run loop的。

只有在你需要和线程有更加多得互交的时候才使用run loop:

  • 使用ports或者custom input sources来进行线程通信
  • 使用timer
  • 使用performSlector等方法
  • 让线程执行周期性任务

下面章节开始说具体的使用了:

Using Run Loop Objects

NSRunLoop、CFRunLoopRef

Getting a Run Loop Object

Cocoa下使用 NSRunLoop的 currentRunLoop方法

Core Foundation下使用CFRunLoopGetCurrent方法

虽然NSRunLoop和CFRunLoopRef不是互通的,可以用过NSRunLoop的getCFRunLoop方法取得一个CFRunLoopRef类型的指针。因为二者指向同一个Run Loop,所以可以交替使用。

Configuring the Run Loop

启动run loop的时候必须包含至少一个input source,否则run loop会马上退出。关于如何add sources到一个run loop见 Configuring Run Loop Sources

创建run loop observer需要创建一个CFRunLoopObserverRef然后使用CFRunLoopAddObserver函数添加到指定的run loop。即使在Cocoa环境,也只能用Core Foundation来创建run loop observer。

栗子:

- (void)threadMain
  {
      // The application uses garbage collection, so no autorelease pool is needed.
      NSRunLoop* myRunLoop = [NSRunLoop currentRunLoop];


      // Create a run loop observer and attach it to the run loop.
        CFRunLoopObserverContext context = {0, self, NULL, NULL, NULL}; CFRunLoopObserverRef observer =             CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities, YES, 0, &myRunLoopObserver, &context);


      if (observer)
      {
          CFRunLoopRef    cfLoop = [myRunLoop getCFRunLoop];
          CFRunLoopAddObserver(cfLoop, observer, kCFRunLoopDefaultMode);
      }


      // Create and schedule the timer.
      [NSTimer scheduledTimerWithTimeInterval:0.1 target:self
                  selector:@selector(doFireTimer:) userInfo:nil repeats:YES];
      NSInteger    loopCount = 10;


      do
      {
          // Run the run loop 10 times to let the timer fire.
          [myRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]];
          loopCount--;
        }
      while (loopCount);
  }

对于long-lived线程,最好添加至少一个input source来接受消息。

Starting the Run Loop

启动方式:

  • Unconditionally ////无条件的运行,不推荐,,虽然可以添加删除timer和input sources,但是关闭只能kill掉
  • With a set time limit ////限时运行,到时间后自动退出,但是可以重新restart
  • In a particular mode ////指定mode运行,可以和限时运行一起用,指定timer和input sources,详见前面的 Run Loop Modes
- (void)skeletonThreadMain
  {
    // Set up an autorelease pool here if not using garbage collection.
    BOOL done = NO;
    // Add your sources or timers to the run loop and do any other setup.
    do {    
        // Start the run loop but return after each source is handled.
        SInt32    result = CFRunLoopRunInMode(kCFRunLoopDefaultMode, 10, YES);

        // If a source explicitly stopped the run loop, or if there are no
        // sources or timers, go ahead and exit.
        if ((result == kCFRunLoopRunStopped) || (result == kCFRunLoopRunFinished))
        done = YES;

        // Check for any other exit conditions here and set the
        // done variable as needed.
    }while (!done);
      // Clean up code here. Be sure to release any allocated autorelease pools.
  }

run loop可递归。你可以在NSRunLoop中处理input sources或timer的方法里面调用CFRunLoopRunCFRunLoopRunInMode等。

Exiting the Run Loop

在处理event前,有两种方法可以用:

  • 设置runloop的timeout
  • 使用CFRunLoopStop方法

run loop会发送所有剩下的notification然后退出。

虽然把run loop里面所有input sources和timer删除也可以让run loop马上退出,但是最好不要这么做,如果漏了会导致run loop不能退出。

Thread Safety and Run Loop Objects

core Foundation下地run loop是线程安全的。

cocoa框架下地NSRunLoop并不继承core Foundation的线程安全,如果你把一个原本属于别的runloop的input source/timer add到当前runloop,app会crash。

Configuring Run Loop Sources、

下面是在Cocoa和core foundation框架下设置各种input sources的例子

Defining a Custom Input Source

先确定如下几点:

  • input sources需要处理的信息
  • 一个调度程序让client知道怎样和这个input source通信
  • 一个用来处理input source的handler
  • 一个取消input source的方法

The scheduler, handler, and cancellation routines are the key routines。

如下图,主线程Main Thread持有Run Loop、Input Srouce、Command data的指针。主线程发送command和相关数据到comman buffer(需要做线程同步访问控制),然后signal input source来唤醒worker线程的run loop。run loop接收command后就调用input source的handler,交给worker线程处理。

custominputsources

接下来的章节介绍custom input source的具体实现代码。

Defining the Input Source

定义custom input source必须使用core foundation来设置run loop source,但是你还是可以使用objective-c或者c++来作进一步封装的。

上图的custom input source的具体实现样例如下:

@interface RunLoopSource : NSObject
  {
      CFRunLoopSourceRef runLoopSource;
      NSMutableArray* commands;
  }

    - (id)init;
    - (void)addToCurrentRunLoop;
    - (void)invalidate;

    // Handler method
    - (void)sourceFired;

    // Client interface for registering commands to process
    - (void)addCommand:(NSInteger)command withData:(id)data;
    - (void)fireAllCommandsOnRunLoop:(CFRunLoopRef)runloop;
@end


  // These are the CFRunLoopSourceRef callback functions.
  void RunLoopSourceScheduleRoutine (void *info, CFRunLoopRef rl, CFStringRef mode); 
  void RunLoopSourcePerformRoutine (void *info);
  void RunLoopSourceCancelRoutine (void *info, CFRunLoopRef rl, CFStringRef mode);

  // RunLoopContext is a container object used during registration of the input source.
@interface RunLoopContext : NSObject
  {
      CFRunLoopRef        runLoop;
      RunLoopSource*        source;
  }


  @property (readonly) CFRunLoopRef runLoop;
  @property (readonly) RunLoopSource* source;


  - (id)initWithSource:(RunLoopSource*)src andLoop:(CFRunLoopRef)loop;
@end

把input source attach到run loop还是需要用c函数。其中三个需要实现的callback函数:

  • void RunLoopSourceScheduleRoutine (void *info, CFRunLoopRef rl, CFStringRef mode); ////attach run loop source 到 run loop 的函数
  • void RunLoopSourcePerformRoutine (void *info); ////
  • void RunLoopSourceCancelRoutine (void *info, CFRunLoopRef rl, CFStringRef mode); ////

实现例子依次如下:

说明:因为这个input source只有main thread一个client,所以直接使用了sharedAppDelegate。然后使用RunLoopContext的registerSource:方法注册input source。如果这个delegate要和input source通信,使用那个RunLoopContext就可以。

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];
}

处理signal的handler,直接调用runloopsource的sourcefire方法。

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

cancel。调用removeSource:方法

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];
}

应用程序delegate的registerSource:和removeSource:方法在 Coordinating with Clients of the Input Source

Installing the Input Source on the Run Loop

这是上面例子里面的init、addToCurrenyRunLoop方法的实现

init方法创建了opaque type CFRunLoopSourceRef,用来attach到runloop。

- (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;
}

RunLoopSourceScheduleRoutinecallback function被调用之后,下面方法会将source添加到run loop中。

- (void)addToCurrentRunLoop
{
      CFRunLoopRef runLoop = CFRunLoopGetCurrent();
      CFRunLoopAddSource(runLoop, runLoopSource, kCFRunLoopDefaultMode);
}

Coordinating with Clients of the Input Source

register(对应callback函数:RunLoopSourceScheduleRoutine)和remove(对应callback函数:RunLoopSourceCancelRoutine)方法

- (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];
    }

Signaling the Input Source

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

不明白:不要通过signal一个custom input source来处理 SIGHUP 或进程相关的signal。

Note: You should never try to handle a SIGHUP or other type of process-level signal by messaging a custom input source. The Core Foundation functions for waking up the run loop are not signal safe and should not be used inside your application’s signal handler routines. For more information about signal handler routines, see the sigaction man page.

Configuring Timer Sources

创建一个timer object然后schedule到run loop就可以。

Cocoa下:
使用NSTimer来创建timer object。

使用如下方法创建和schedule一个timer:

  • scheduledTimerWithTimeInterval:target:selector:useerInfo:repeats:
  • scheduledTimerWithTimeInterval:invocation:repeats:

这两个方法在创建timer之后将其添加到当前线程的run loop(使用default mode NSDefaultRunLoopMode).

也可以手动创建NSTimer再加入到run loop,使用NSRunLoop的方法addTimer:forMode:。一样的,后者可以定义mode。

以下代码分别使用了两种方法创建timer并添加到runloop:

NSRunLoop* myRunLoop = [NSRunLoop currentRunLoop];

  // Create and schedule the first timer.
  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];

Core Foundation下:
使用CFRunLoopTimerRef opaque类型创建。

NSTimer是CFRunLoopTimerRef的扩展。

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);

详见 CFRunLoopTimer Reference

Configuring a Port-Based Sources

Cocoa\Core Foundation均有提供port-based object来实现线程/进程间通信。

Configuring an NSMachPort Object

对于仅用于应用程序内部通信的port-based source,创建NSMachPort object后add到主线程的run loop,然后创建其他线程之后把同一个object pass过去就可以了。其他的线程用同一个NSMachPort object和主线程通信。

Implementing the main Thread Code

Cocoa的代码比较简单,和Core Foundation比起来的不同就是发送localport给worker线程的时候使用NSPort object而不是localport的名字:

- (void)launchThread
{
    NSPort* myPort = [NSMachPort port];
    if (myPort)
    {
        // This class handles incoming port messages.
        [myPort setDelegate:self];


        // Install the port as an input source on the current run loop.
        [[NSRunLoop currentRunLoop] addPort:myPort forMode:NSDefaultRunLoopMode];


        // Detach the thread. Let the worker release the port.
        [NSThread detachNewThreadSelector:@selector(LaunchThreadWithPort:) toTarget:[MyWorkerClass class] withObject:myPort];
    } 
}

为了建立起一个线程间的双向通信通道,需在worker线程通过check-in信息发送它自己的local port给主线程:

  #define kCheckinMessage 100

  // Handle responses from the worker thread.
  - (void)handlePortMessage:(NSPortMessage *)portMessage
  {
      unsigned int message = [portMessage msgid];
      NSPort* distantPort = nil;


      if (message == kCheckinMessage)
      {
          // Get the worker thread’s communications port.
          distantPort = [portMessage sendPort];


          // Retain and save the worker port for later use.
          [self storeDistantPort:distantPort];
      }else {
          // Handle other messages.
      }
   }

Implementing the Secondary Thread Code

其他线程的实现代码:

 +(void)LaunchThreadWithPort:(id)inData
  {
      NSAutoreleasePool*  pool = [[NSAutoreleasePool alloc] init];

      // Set up the connection between this thread and the main thread.
      NSPort* distantPort = (NSPort*)inData;


      MyWorkerClass*  workerObj = [[self alloc] init];
      [workerObj sendCheckinMessage:distantPort];
      [distantPort release];


      // Let the run loop process things.
      do
      {
          [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
      }while (![workerObj shouldExit]);


      [workerObj release];
      [pool release];
  }

local和remote可以使用一个NSMachPort实现单向的通信。

check-in逻辑代码:

使用上面LaunchThreadWithPort:方法获得的port作为发送目标。

// Worker thread check-in method
  - (void)sendCheckinMessage:(NSPort*)outPort
  {
      // Retain and save the remote port for future use.
      [self setRemotePort:outPort];


      // Create and configure the worker thread port.
      NSPort* myPort = [NSMachPort port];

      [myPort setDelegate:self];
      [[NSRunLoop currentRunLoop] addPort:myPort forMode:NSDefaultRunLoopMode];


      // Create the check-in message.
      NSPortMessage* messageObj = [[NSPortMessage alloc] initWithSendPort:outPort receivePort:myPort components:nil];


      if (messageObj)
      {
          // Finish configuring the message and send it immediately.
          [messageObj setMsgId:setMsgid:kCheckinMessage];
          [messageObj sendBeforeDate:[NSDate date]];
        } 
}

Configuring an NSMessagePort Object

remote message ports只能通过port名获取,所以你需用个nsstring注册你的local port,然后再传给remote线程。

NSPort* localPort = [[NSMessagePort alloc] init];

// Configure the object and add it to the current run loop.
[localPort setDelegate:self];
[[NSRunLoop currentRunLoop] addPort:localPort forMode:NSDefaultRunLoopMode];


// Register the port using a specific name. The name must be unique.
NSString* localPortName = [NSString stringWithFormat:@"MyPortName"];
[[NSMessagePortNameServer sharedInstance] registerPort:localPort name:localPortName];

Configuring a Port-Based Input Sources in Core Foundation

Core Foundation版本没有看就不写了:)

标签:ios, object-c, iosrunloops