本文首发CSDN,如需转载请与CSDN联系。

记得第一次读这个文档还是3年前,那时也只是泛读。如今关于iOS多线程的文章层出不穷,但我觉得若想更好的领会各个实践者的文章,应该先仔细读读官方的相关文档,打好基础,定会有更好的效果。文章中有对官方文档的翻译,也有自己的理解,官方文档中代码片段的示例在这篇文章中都进行了完整的重写,还有一些文档中没有的代码示例,并且都使用Swift完成,给大家一些Objc与Swift转换的参考。
官方文档地址:Threading Programming Guide

何时使用Run Loop

前文中多次提到过,在主线程中Run Loop是随着应用程序一起启动的,也就是说当我们打开一个应用时,主线程中的Run Loop就已经启动了,尤其现在我们都使用Xcode中的项目模版创建项目,更是不用考虑主线程中Run Loop的状体。所以只有在二级线程中,也就是我们自己创建的线程中才有机会手动的创建的Run Loop,并对其进行配置的操作。

在前文中还提到过,Run Loop在线程中的主要作用就是帮助线程常驻在进程中,并且不会过多消耗资源。所以说Run Loop在二级线程中也不是必须需要的,要根据该线程执行的任务类型以及在整个应用中担任何作用而决定是否需要使用Run Loop。比如说,如果你创建一个二级线程只是为了执行一个不会频繁执行的一次性任务,或者需要执行很长时间的任务,那么可能就不需要使用Run Loop了。如果你需要一个线程执行周期性的定时任务,或者需要较为频繁的与主线程之间进行交互,那么就需要使用Run Loop。归纳一下需要使用Run Loop的情况大概有以下四点:

  • 通过基于端口或自定义的数据源与其他线程进行交互。
  • 在线程中执行定时事件源的任务。
  • 使用Cocoa框架提供的performSelector…系列方法。
  • 在线程中执行较为频繁的,具有周期性的任务。

光说不练假把式,下面就让我们来看看如何具体创建、配置、操作Run Loop。

Run Loop对象

要想操作配置Run Loop,那自然需要通过Run Loop对象来完成,它提供了一系列接口,可帮助我们便捷的添加Input sources、timers以及观察者。较高级别的Cocoa框架提供了NSRunLoop类,较底层级别的Core Foundation框架提供了指向CFRunloopRef的指针。

获取Run Loop对象

前文中提到过,在Cocoa和Core Foundation框架中都没有提供创建Run Loop的方法,只有从当前线程获取Run Loop的方法:

  • 在Cocoa框架中,NSRunLoop类提供了类方法currentRunLoop()获取NSRunLoop对象。
    > 该方法是获取当前线程中已存在的Run Loop,如果不存在,那其实还是会创建一个Run Loop对象返回,只是Cocoa框架没有向我们暴露该接口。
  • 在Core Foundation框架中提供了CFRunLoopGetCurrent()函数获取CFRunLoop对象。

虽然这两个Run Loop对象并不完全等价,它们之间还是可以转换的,我们可以通过NSRunLoop对象提供的getCFRunLoop()方法获取CFRunLoop对象。因为NSRunLoopCFRunLoop指向的都是当前线程中同一个Run Loop,所以在使用时它们可以混用,比如说要给Run Loop添加观察者时就必须得用CFRunLoop了。

配置Run Loop观察者

前文中提到过,可以向Run Loop中添加各种事件源和观察者,这里事件源是必填项,也就是说Run Loop中至少要有一种事件源,不论是Input source还是timer,如果Run Loop中没有事件源的话,那么在启动Run Loop后就会立即退出。而观察者是可选项,如果没有监控Run Loop各运行状态的需求,可以不配置观察者,这一节先看看如何向Run Loop中添加观察者。

在Cocoa框架中,并没有提供创建配置Run Loop观察者的相关接口,所以我们只能通过Core Foundation框架中提供的对象和方法创建并配置Run Loop观察者,下面我们看看示例代码:

import Foundation

class TestThread: NSObject {

func launch() {

print("First event in Main Thread.")

NSThread.detachNewThreadSelector("createAndConfigObserverInSecondaryThread", toTarget: self, withObject: nil)

print(NSThread.isMultiThreaded())

sleep(3)

print("Second event in Main Thread.")

}

func createAndConfigObserverInSecondaryThread() {

autoreleasepool{

// 1
let runloop = NSRunLoop.currentRunLoop()

// 2
var _self = self

// 3
var observerContext = CFRunLoopObserverContext(version: 0, info: &_self, retain: nil, release: nil, copyDescription: nil)

// 4
let observer = CFRunLoopObserverCreate(kCFAllocatorDefault, CFRunLoopActivity.AllActivities.rawValue, true, 0, self.observerCallbackFunc(), &observerContext)

if(observer != nil) {

// 5
let cfRunloop = runloop.getCFRunLoop()

// 6
CFRunLoopAddObserver(cfRunloop, observer, kCFRunLoopDefaultMode)

}

// 7
NSTimer.scheduledTimerWithTimeInterval(0.5, target: self, selector: "fireTimer", userInfo: nil, repeats: true)

var loopCount = 10

repeat {

// 8
runloop.runUntilDate(NSDate(timeIntervalSinceNow: 1))

loopCount--

} while(loopCount > 0)

}

}

func observerCallbackFunc() -> CFRunLoopObserverCallBack {

return {(observer, activity, context) -> Void in

switch(activity) {

case CFRunLoopActivity.Entry:
print("Run Loop已经启动")
break
case CFRunLoopActivity.BeforeTimers:
print("Run Loop分配定时任务前")
break
case CFRunLoopActivity.BeforeSources:
print("Run Loop分配输入事件源前")
break
case CFRunLoopActivity.BeforeWaiting:
print("Run Loop休眠前")
break
case CFRunLoopActivity.AfterWaiting:
print("Run Loop休眠后")
break
case CFRunLoopActivity.Exit:
print("Run Loop退出后")
break
default:
break

}

}

}

func fireTimer() {

}

}

let testThread = TestThread()
testThread.launch()

下面解读一下上述代码示例,launch()方法在主线程中,通过NSThread类的类方法detachNewThreadSelector:toTarget:withObject:创建并启动一个二级线程,将createAndConfigObserverInSecondaryThread()方法作为事件消息传入该二级线程,这个方法的主要作用就是在二级线程中创建配置Run Loop观察者并启动Run Loop,然后让主线程持续3秒,以便二级线程有足够的时间执行任务。

createAndConfigObserverInSecondaryThread()中共有8个关键步骤,下面一一进行说明:

  • 第一步:通过NSRunLoop类的类方法currentRunLoop()获取当前线程的Run Loop,这里获取到的Run Loop对象是NSRunLoop对象。
  • 第二步:申明当前对象的变量,至于为什么要这么做,在下一步中会有说明。
  • 第三步:通过Core Foundation框架的CFRunLoopObserverContext结构体构造Run Loop观察者上下文,大家需要注意前两个参数,我们先看看这个结构体:
public struct CFRunLoopObserverContext {
public var version: CFIndex
public var info: UnsafeMutablePointer<Void>
public var retain: (@convention(c) (UnsafePointer<Void>) -> UnsafePointer<Void>)!
public var release: (@convention(c) (UnsafePointer<Void>) -> Void)!
public var copyDescription: (@convention(c) (UnsafePointer<Void>) -> Unmanaged<CFString>!)!
public init()
public init(version: CFIndex, info: UnsafeMutablePointer<Void>, retain: (@convention(c) (UnsafePointer<Void>) -> UnsafePointer<Void>)!, release: (@convention(c) (UnsafePointer<Void>) -> Void)!, copyDescription: (@convention(c) (UnsafePointer<Void>) -> Unmanaged<CFString>!)!)
}
  1. version:结构体版本号,必须设置为0。
  2. info:上下文中retainreleasecopyDescription三个回调函数以及Run Loop观察者的回调函数所有者对象的指针。在Swift中,UnsafePointer结构体代表C系语言中申明为常量的指针,UnsafeMutablePoinger结构体代表C系语言中申明为非常量的指针,比如说:
C:
void functionWithConstArg(const int *constIntPointer);

Swift:
func functionWithConstArg(constIntPointer: UnsafePointer<Int32>)

C:
void functionWithNotConstArg(unsigned int *unsignedIntPointer);

Swift:
func functionWithNotConstArg(unsignedIntPointer: UnsafeMutablePointer<UInt32>)

C:
void functionWithNoReturnArg(void *voidPointer);

Swift:
func functionWithNoReturnArg(voidPointer: UnsafeMutablePointer<Void>)
  • 第四步:通过Core Foundation框架的CFRunLoopObserverCreate函数创建CFRunLoopObserver对象:
public func CFRunLoopObserverCreate(allocator: CFAllocator!, _ activities: CFOptionFlags, _ repeats: Bool, _ order: CFIndex, _ callout: CFRunLoopObserverCallBack!, _ context: UnsafeMutablePointer<CFRunLoopObserverContext>) -> CFRunLoopObserver!
  1. allocator:该参数为对象内存分配器,一般使用默认的分配器kCFAllocatorDefault
  2. activities:该参数配置观察者监听Run Loop的哪种运行状态。在示例中,我们让观察者监听Run Loop的所有运行状态。
  3. repeats:该参数标识观察者只监听一次还是每次Run Loop运行时都监听。
  4. order:观察者优先级,当Run Loop中有多个观察者监听同一个运行状态时,那么就根据该优先级判断,0为最高优先级别。
  5. callout:观察者的回调函数,在Core Foundation框架中用CFRunLoopObserverCallBack重定义了回调函数的闭包。
  6. context:观察者的上下文。
  • 第五步:因为NSRunLoop没有提供操作观察者的接口,所以我们需要getCFRunLoop()方法获取到CFRunLoop对象。
  • 第六步:通过CFRunLoopAddObserver函数向当前线程的Run Loop中添加创建好的观察者:
func CFRunLoopAddObserver(_ rl: CFRunLoop!, _ observer: CFRunLoopObserver!, _ mode: CFString!)
  1. rl:当前线程的CFRunLoop对象。
  2. observer:创建好的观察者。
  3. mode:设置将观察者添加到哪个Run Loop模式中。

这里需要注意的是,一个观察者只能被添加到一个Run Loop中,但是可以被添加到Run Loop中的多个模式中。

  • 第七步:通过Timer事件源向当前线程发送重复执行的定时任务,时间间隔为0.5秒,因为只是为了测试观察者,所以fireTimer()是一个空任务。另外前文中提到过,如果Run Loop中没有任何数据源,那么Run Loop启动后会立即退出,所以大家可以把这行注释了运行看看会有什么效果。
  • 第八步:通过NSRunLoop对象的runUntilDate(limitDate: NSDate)方法启动Run Loop,设置Run Loop的运行时长为1秒。这里将其放在一个循环里,最大循环次数为10次,也就是说,如果不考虑主线程的运行时间,该二级线程的Run Loop可运行10次。

再来看看观察者的回调方法observerCallbackFunc(),上面在介绍CFRunLoopObserverCreate函数时提到观察者的回调函数是CFRunLoopObserverCallBack重定义的一个闭包,我们来看看这个闭包:

typealias CFRunLoopObserverCallBack = (CFRunLoopObserver!, CFRunLoopActivity, UnsafeMutablePointer<Void>) -> Void

这个闭包没有返回值,第一个参数是触发监听的观察者,第二个参数是观察者监听的Run Loop运行状态,第三个参数是观察者的运行上下文环境。所以在回调方法中,我们只需要根据第二个参数的值即可判断观察者监听到的Run Loop状态。大家可以拷贝上面的代码,建一个Command Application运行看看结果。

启动Run Loop

在启动Run Loop前务必要保证已添加一种类型的事件源,原因在前文中已提到多次。在Cocoa框架和Core Foundation框架中启动Run Loop大体有三种形式,分别是无条件启动、设置时间限制启动、指定特定模式启动。

无条件启动

NSRunLoop对象的run()方法和Core Foundation框架中的CFRunLoopRun()函数都是无条件启动Run Loop的方式。这种方式虽然是最简单的启动方式,但也是最不推荐使用的一个方式,因为这种方式将Run Loop置于一个永久运行并且不可控的状态,它使Run Loop只能在默认模式下运行,无法给Run Loop设置特定的或自定义的模式,而且以这种模式启动的Run Loop只能通过CFRunLoopStop(_ rl: CFRunLoop!)函数强制停止。

设置时间限制启动

该方式对应的方法是NSRunLoop对象的runUntilDate(_ limitDate: NSDate)方法,在启动Run Loop时设置超时时间,一旦超时那么Run Loop则自动退出。该方法的好处是可以在循环中反复启动Run Loop处理相关任务,而且可控制运行时长。

指定特定模式启动

该方式对应的方法是NSRunLoop对象的runMode(_ mode: String, beforeDate limitDate: NSDate)方法和Core Foundation框架的CFRunLoopRunInMode(_ mode: CFString!, _ seconds: CFTimeInterval, _ returnAfterSourceHandled: Bool)函数。前者有两个参数,第一个参数是Run Loop模式,第二个参数仍然是超时时间,该方法使Run Loop只处理指定模式中的事件源事件,当处理完事件或超时Run Loop会退出,该方法的返回值类型是Bool,如果返回true则表示Run Loop启动成功,并分派执行了任务或者达到超时时间,若返回false则表示Run Loop启动失败。后者有三个参数,前两个参数的作用一样,第三个参数的意思是Run Loop是否在执行完任务后就退出,如果设置为false,那么代表Run Loop在执行完任务后不退出,而是一直等到超时后才退出。该方法返回Run Loop的退出状态:

  • CFRunLoopRunResult.Finished:表示Run Loop已分派执行完任务,并且再无任务执行的情况下退出。
  • CFRunLoopRunResult.Stopped:表示Run Loop通过CFRunLoopStop(_ rl: CFRunLoop!)函数强制退出。
  • CFRunLoopRunResult.TimedOut:表示Run Loop因为超时时间到而退出。
  • CFRunLoopRunResult.HandledSource:表示Run Loop已执行完任务而退出,改状态只有在returnAfterSourceHandled设置为true时才会出现。

退出Run Loop

退出Run Loop的方式总体来说有三种:

  • 启动Run Loop时设置超时时间。
  • 强制退出Run Loop。
  • 移除Run Loop中的事件源,从而使Run Loop退出。

第一种方式是推荐使用的方式,因为可以给Run Loop设置可控的运行时间,让它执行完所有的任务以及给观察者发送通知。第二种强制退出Run Loop主要是应对无条件启动Run Loop的情况。第三种方式是最不推荐的方式,虽然在理论上说当Run Loop中没有任何数据源时会立即退出,但是在实际情况中我们创建的二级线程除了执行我们指定的任务外,有可能系统还会让其执行一些系统层面的任务,而且这些任务我们一般无法知晓,所以用这种方式退出Run Loop往往会存在延迟退出。

Run Loop对象的线程安全性

Run Loop对象的线程安全性取决于我们使用哪种API去操作。Core Foundation框架中的CFRunLoop对象是线程安全的,我们可以在任何线程中使用。Cocoa框架的NSRunLoop对象是线程不安全的,我们必须在拥有Run Loop的当前线程中操作Run Loop,如果操作了不属于当前线程的Run loop,会导致异常和各种潜在的问题发生。

自定义Run Loop事件源

Cocoa框架因为是较为高层的框架,所以没有提供操作较为底层的Run Loop事件源相关的接口和对象,所以我们只能使用Core Foundation框架中的对象和函数创建事件源并给Run Loop设置事件源。

创建Run Loop事件源对象

我们定义自己的Run Loop事件源首先就是需要创建事件源,我们来看看创建事件源的方法:

func CFRunLoopSourceCreate(_ allocator: CFAllocator!, _ order: CFIndex, _ context: UnsafeMutablePointer<CFRunLoopSourceContext>) -> CFRunLoopSource!
  1. allocator:该参数为对象内存分配器,一般使用默认的分配器kCFAllocatorDefault
  2. order:事件源优先级,当Run Loop中有多个接收相同事件的事件源被标记为待执行时,那么就根据该优先级判断,0为最高优先级别。
  3. context:事件源上下文。

Run Loop事件源上下文很重要,我们来看看它的结构:

struct CFRunLoopSourceContext { 
var version: CFIndex
var info: UnsafeMutablePointer<Void>
var retain: ((UnsafePointer<Void>) -> UnsafePointer<Void>)!
var release: ((UnsafePointer<Void>) -> Void)!
var copyDescription: ((UnsafePointer<Void>) -> Unmanaged<CFString>!)!
var equal: ((UnsafePointer<Void>, UnsafePointer<Void>) -> DarwinBoolean)!
var hash: ((UnsafePointer<Void>) -> CFHashCode)!
var schedule: ((UnsafeMutablePointer<Void>, CFRunLoop!, CFString!) -> Void)!
var cancel: ((UnsafeMutablePointer<Void>, CFRunLoop!, CFString!) -> Void)!
var perform: ((UnsafeMutablePointer<Void>) -> Void)!
init()
init(version version: CFIndex, info info: UnsafeMutablePointer<Void>, retain retain: ((UnsafePointer<Void>) -> UnsafePointer<Void>)!, release release: ((UnsafePointer<Void>) -> Void)!, copyDescription copyDescription: ((UnsafePointer<Void>) -> Unmanaged<CFString>!)!, equal equal: ((UnsafePointer<Void>, UnsafePointer<Void>) -> DarwinBoolean)!, hash hash: ((UnsafePointer<Void>) -> CFHashCode)!, schedule schedule: ((UnsafeMutablePointer<Void>, CFRunLoop!, CFString!) -> Void)!, cancel cancel: ((UnsafeMutablePointer<Void>, CFRunLoop!, CFString!) -> Void)!, perform perform: ((UnsafeMutablePointer<Void>) -> Void)!)
}

该结构体中我们需要关注的是前两个和后三个属性:

  1. version:事件源上下文的版本,必须设置为0。
  2. info:上下文中retainreleasecopyDescriptionequalhashschedulecancelperform这八个回调函数所有者对象的指针。
  3. schedule:该回调函数的作用是将该事件源与给它发送事件消息的线程进行关联,也就是说如果主线程想要给该事件源发送事件消息,那么首先主线程得能获取到该事件源。
  4. cancel:该回调函数的作用是使该事件源失效。
  5. perform:该回调函数的作用是执行其他线程或当前线程给该事件源发来的事件消息。

将事件源添加至Run Loop

事件源创建好之后,接下来就是将其添加到指定某个模式的Run Loop中,我们来看看这个方法:

func CFRunLoopAddSource(_ rl: CFRunLoop!, _ source: CFRunLoopSource!, _ mode: CFString!)
  1. rl:希望添加事件源的Run Loop对象,类型是CFRunLoop
  2. source:我们创建好的事件源。
  3. mode:Run Loop的模式。(可以回顾之前文章)

我们再来看看这个方法都干了些什么:

void CFRunLoopAddSource(CFRunLoopRef rl, CFRunLoopSourceRef rls, CFStringRef modeName) {

.....

__CFRunLoopSourceSchedule(rls, rl, rlm);

.....

}

static void __CFRunLoopSourceSchedule(CFRunLoopSourceRef rls, CFRunLoopRef rl, CFRunLoopModeRef rlm) {

.....

if (0 == rls->_context.version0.version) {
if (NULL != rls->_context.version0.schedule) {
rls->_context.version0.schedule(rls->_context.version0.info, rl, rlm->_name);
}
}

.....

}

从上述的代码片段可以看出,在CFRunLoopAddSource中调用了__CFRunLoopSourceSchedule内部函数,而该函数中正是执行了Run Loop事件源上下文中的schedule回调函数。也就是说当把事件源添加到Run Loop中后就会将事件源与给它发送事件消息的线程进行关联。

标记事件源及唤醒Run Loop

前面的文章中说过,srouce0类型,也就是非port类型的事件源都需要进行手动标记,标记完还需要手动唤醒Run Loop,下面我们来看看这两个方法:

func CFRunLoopSourceSignal(_ source: CFRunLoopSource!)

func CFRunLoopWakeUp(_ rl: CFRunLoop!)

这里需要注意的是唤醒Run Loop并不等价与启动Run Loop,因为启动Run Loop时需要对Run Loop进行模式、时限的设置,而唤醒Run Loop只是当已启动的Run Loop休眠时重新让其运行。

执行Run Loop事件源的任务

唤醒Run Loop意味着让休眠的Run Loop重新运行,那么我们就从启动Run Loop,让其开始运行的方法看起:

extension NSRunLoop {

.....

public func runUntilDate(limitDate: NSDate) {
while runMode(NSDefaultRunLoopMode, beforeDate: limitDate) && limitDate.timeIntervalSinceReferenceDate > CFAbsoluteTimeGetCurrent() { }
}

public func runMode(mode: String, beforeDate limitDate: NSDate) -> Bool {

.....

let limitTime = limitDate.timeIntervalSinceReferenceDate
let ti = limitTime - CFAbsoluteTimeGetCurrent()
CFRunLoopRunInMode(modeArg, ti, true)
return true
}

}

SInt32 CFRunLoopRunInMode(CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) {
CHECK_FOR_FORK();
return CFRunLoopRunSpecific(CFRunLoopGetCurrent(), modeName, seconds, returnAfterSourceHandled);
}

SInt32 CFRunLoopRunSpecific(CFRunLoopRef rl, CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) {

.....

result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, false);

.....

return result;
}

static int32_t __CFRunLoopRun(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFTimeInterval seconds, Boolean stopAfterHandle, Boolean waitIfEmpty) {

.....

__CFRunLoopDoSources0(rl, rlm, stopAfterHandle);

.....

}

static Boolean __CFRunLoopDoSources0(CFRunLoopRef rl, CFRunLoopModeRef rlm, Boolean stopAfterHandle) {

CFTypeRef sources = NULL;

.....

if (__CFRunLoopSourceIsSignaled(rls)) {

.....

rls->_context.version0.perform(rls->_context.version0.info);

.....

}

.....

}

从上述代码片段中可以看出,当Run Loop运行后会调用内部函数__CFRunLoopDoSources0执行自定义事件源的任务,在执行之前会通过内部函数__CFRunLoopSourceIsSignaled(rls)判断事件源是否已被标记为待执行,然后执行Run Loop事件上下文中的perform回调函数。

移除Run Loop事件源

当我们自定义的事件源完成使命后就可以将其从Run Loop中移除,我们来看看对应的方法:

func CFRunLoopRemoveSource(_ rl: CFRunLoop!, _ source: CFRunLoopSource!, _ mode: CFString!)

void CFRunLoopRemoveSource(CFRunLoopRef rl, CFRunLoopSourceRef rls, CFStringRef modeName) {

.....

__CFRunLoopSourceCancel(rls, rl, rlm);

.....

}

static void __CFRunLoopSourceCancel(CFRunLoopSourceRef rls, CFRunLoopRef rl, CFRunLoopModeRef rlm) {
if (0 == rls->_context.version0.version) {
if (NULL != rls->_context.version0.cancel) {
rls->_context.version0.cancel(rls->_context.version0.info, rl, rlm->_name);
}
}

.....

}

从上述代码片段可以看出,当我们调用了CFRunLoopRemoveSource方法后,其实是执行了Run Loop事件源上下文中的cancel回调函数。

自定义Run Loop事件源的实际运用

在讲解示例之前,我们先来看看示例Demo的效果:

LearnThread-5

在这个示例中,创建了两个自定义事件源,一个添加到主线程中,另一个添加到二级线程中。主线程给二级线程中的自定义事件源发送事件消息,目的是让其改变所有UICollectionViewCell的透明度,当二级线程收到事件消息后执行计算每个UICollectionViewCell透明度的任务,然后再给主线程的自定义事件源发送事件消息,让其更新UICollectionViewCell的透明度并显示。下面来看看类图:

LearnThread-6

整个工程一共就这六个类:

  • MainCollectionViewController:程序主控制器,启动程序、展示UI及计算UICollectionViewCell透明度的相关方法。
  • MainThreadRunLoopSource:主线程自定义事件源管理对象,负责初始化事件源,将事件源添加至指定线程,标记事件源并唤醒指定Run Loop以及包含上文中说过的事件源最主要的三个回调方法。
  • MainThreadRunLoopSourceContext:主线程自定义事件源上下文,可获取到对应的事件源及添加了该事件源的Run Loop。
  • SecondaryThreadRunLoopSource:二级线程自定义事件源管理对象,负责初始化事件源,将事件源添加至指定线程,标记事件源并唤醒指定Run Loop以及包含上文中说过的事件源最主要的三个回调方法。
  • SecondaryThreadRunLoopSourceContext:二级线程自定义事件源上下文,可获取到对应的事件源及添加了该事件源的Run Loop。
  • AppDelegate:应用程序代理类,这里零时充当为各自定义事件源回调方法执行内容的管理类。

下面我按照程序的运行顺序一一对这些类及属性和方法进行简单说明。

程序开始运行

MainCollectionViewController类中与UI展示相关的方法在这里就不再累赘了。点击Start按钮,调用start()方法,初始化MainThreadRunLoopSource对象,在这个过程中初始化了CFRunLoopSourceContext对象并且创建CFRunLoopSource对象以及初始化该事件源的指令池:

let mainThreadRunLoopSource = MainThreadRunLoopSource()

mainThreadRunLoopSource.addToCurrentRunLoop()
var runloopSourceContext = CFRunLoopSourceContext(version: 0, info: unsafeBitCast(self, UnsafeMutablePointer<Void>.self), retain: nil, release: nil, copyDescription: nil, equal: nil, hash: nil, schedule: runloopSourceScheduleRoutine(), cancel: runloopSourceCancelRoutine(), perform: runloopSourcePerformRoutine())

runloopSource = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &runloopSourceContext)

commandBuffer = Array<SecondaryThreadRunLoopSourceContext>()

这里需要注意的是CFRunLoopSourceContextinit方法中的第二个参数和CFRunLoopSourceCreate方法的第三个参数都是指针,那么在Swift中,将对象转换为指针的方法有两种:

  • 使用unsafeBitCast方法,该方法会将第一个参数的内容按照第二个参数的类型进行转换。一般当需要对象与指针来回转换时使用该方法。
  • 在对象前面加&符号,表示传入指针地址。

当主线程的自定义事件源初始化完成之后,调用addToCurrentRunLoop()方法,将事件源添加至当前Run Loop中,即主线程的Run Loop:

let cfrunloop = CFRunLoopGetCurrent()

if let rls = runloopSource {

CFRunLoopAddSource(cfrunloop, rls, kCFRunLoopDefaultMode)

}

接下来创建二级线程,并且让其执行二级线程的配置任务:

let secondaryThread = NSThread(target: self, selector: "startThreadWithRunloop", object: nil)

secondaryThread.start()

在二级线程中同样初始化自定义事件源,并将将其添加至二级线程的Run Loop中,然后启动Run Loop:

func startThreadWithRunloop() {

autoreleasepool{

var done = false

let secondaryThreadRunLoopSource = SecondaryThreadRunLoopSource()

secondaryThreadRunLoopSource.addToCurrentRunLoop()

repeat {

let result = CFRunLoopRunInMode(kCFRunLoopDefaultMode, 5, true)

if ((result == CFRunLoopRunResult.Stopped) || (result == CFRunLoopRunResult.Finished)) {

done = true;

}

} while(!done)

}

}

执行事件源的schedule回调函数

前文中说过将事件源添加至Run Loop后会触发事件源的schedule回调函数,所以当执行完mainThreadRunLoopSource.addToCurrentRunLoop()这句代码后,便会触发主线程自定义事件源的schedule回调函数:

func runloopSourceScheduleRoutine() -> @convention(c) (UnsafeMutablePointer<Void>, CFRunLoop!, CFString!) -> Void {

return { (info, runloop, runloopMode) -> Void in

let mainThreadRunloopSource = unsafeBitCast(info, MainThreadRunLoopSource.self)

let mainThreadRunloopSourceContext = MainThreadRunLoopSourceContext(runloop: runloop, runloopSource: mainThreadRunloopSource)

let appDelegate = UIApplication.sharedApplication().delegate as! AppDelegate

appDelegate.performSelector("registerMainThreadRunLoopSource:", withObject: mainThreadRunloopSourceContext)

}

}

这里还需注意的是在Swift2.0中,如果一个作为回调函数方法的返回类型是指向函数的指针,这类指针可以转换为闭包,并且要在闭包前面加上@convention(c)标注。在runloopSourceScheduleRoutine()方法中,获取到主线程事件源对象并初始化事件源上下文对象,然后将该事件源上下文对象传给AppDelegate的对应方法注册该事件源上下文对象:

func registerMainThreadRunLoopSource(runloopSourceContext: MainThreadRunLoopSourceContext) {

mainThreadRunloopSourceContext = runloopSourceContext

}

自然当在二级线程中执行完secondaryThreadRunLoopSource.addToCurrentRunLoop()这句代码后,也会触发二级线程自定义事件源的schedule回调函数:

func runloopSourceScheduleRoutine() -> @convention(c) (UnsafeMutablePointer<Void>, CFRunLoop!, CFString!) -> Void {

return { (info, runloop, runloopMode) -> Void in

let secondaryThreadRunloopSource = unsafeBitCast(info, SecondaryThreadRunLoopSource.self)

let secondaryThreadRunloopSourceContext = SecondaryThreadRunLoopSourceContext(runloop: runloop, runloopSource: secondaryThreadRunloopSource)

let appDelegate = UIApplication.sharedApplication().delegate as! AppDelegate

appDelegate.performSelectorOnMainThread("registerSecondaryThreadRunLoopSource:", withObject: secondaryThreadRunloopSourceContext, waitUntilDone: true)

}

}

这里要注意的是,在该方法中同样是将二级线程事件源上下文对象传给了AppDelegate的对应方法,但是这里用了performSelectorOnMainThread方法,让其在主线程中执行,目的在于注册完上下文对象后就接着从主线程给二级线程发送事件消息了,其实我将这里作为了主线程触发二级线程执行任务的触发点:

func registerSecondaryThreadRunLoopSource(runloopSourceContext: SecondaryThreadRunLoopSourceContext) {

secondaryThreadRunloopSourceContext = runloopSourceContext

sendCommandToSecondaryThread()

}

func sendCommandToSecondaryThread() {

secondaryThreadRunloopSourceContext?.runloopSource?.commandBuffer?.append(mainThreadRunloopSourceContext!)

secondaryThreadRunloopSourceContext?.runloopSource?.signalSourceAndWakeUpRunloop(secondaryThreadRunloopSourceContext!.runloop!)

}

从上述代码中可以看到在sendCommandToSecondaryThread()方法中,将主线程的事件源上下文放入了二级线程事件源的指令池中,这里我设计的是只要指令池中有内容就代表事件源需要执行后续任务了。然后执行了二级线程事件源的signalSourceAndWakeUpRunloop()方法,给其标记为待执行,并唤醒二级线程的Run Loop:

func signalSourceAndWakeUpRunloop(runloop: CFRunLoopRef) {

CFRunLoopSourceSignal(runloopSource)

CFRunLoopWakeUp(runloop)

}

执行事件源的perform回调函数

当二级线程事件源被标记并且二级线程Run Loop被唤醒后,就会触发事件源的perform回调函数:

func runloopSourcePerformRoutine() -> @convention(c) (UnsafeMutablePointer<Void>) -> Void {

return { info -> Void in

let appDelegate = UIApplication.sharedApplication().delegate as! AppDelegate

appDelegate.performSelector("performSecondaryThreadRunLoopSourceTask")

}

}

二级线程事件源的perform回调函数会在当前线程,也就是二级线程中执行AppDelegate中的对应方法:

func performSecondaryThreadRunLoopSourceTask() {

if secondaryThreadRunloopSourceContext!.runloopSource!.commandBuffer!.count > 0 {

mainCollectionViewController!.generateRandomAlpha()

let mainThreadRunloopSourceContext = secondaryThreadRunloopSourceContext!.runloopSource!.commandBuffer![0]

secondaryThreadRunloopSourceContext!.runloopSource!.commandBuffer!.removeAll()

mainThreadRunloopSourceContext.runloopSource?.commandBuffer?.append(secondaryThreadRunloopSourceContext!)

mainThreadRunloopSourceContext.runloopSource?.signalSourceAndWakeUpRunloop(mainThreadRunloopSourceContext.runloop!)

}

}

从上述代码中可以看到,先会判断二级线程事件源的指令池中有没有内容,如果有的话,那么执行计算UICollectionViewCell透明度的任务,然后从指令池中获取到主线程事件源上下文对象,将二级线程事件源上下文对象放入主线程事件源的指令池中,并将主线程事件源标记为待执行,然后唤醒主线程Run Loop。之后便会触发主线程事件源的perform回调函数:

func runloopSourcePerformRoutine() -> @convention(c) (UnsafeMutablePointer<Void>) -> Void {

return { info -> Void in

let appDelegate = UIApplication.sharedApplication().delegate as! AppDelegate

appDelegate.performSelector("performMainThreadRunLoopSourceTask")

}

}
func performMainThreadRunLoopSourceTask() {

if mainThreadRunloopSourceContext!.runloopSource!.commandBuffer!.count > 0 {

mainThreadRunloopSourceContext!.runloopSource!.commandBuffer!.removeAll()

mainCollectionViewController!.collectionView.reloadData()

let timer = NSTimer(timeInterval: 1, target: self, selector: "sendCommandToSecondaryThread", userInfo: nil, repeats: false)

NSRunLoop.mainRunLoop().addTimer(timer, forMode: NSDefaultRunLoopMode)

}

}

performMainThreadRunLoopSourceTask()方法中同样会先判断主线程事件源的指令池是否有内容,然后执行MainCollectionViewController中的刷新UI的方法,最后再次给二级线程发送事件消息,以此循环。大家可以去Github下载该示例的源码,编译环境是Xcode7.2,然后可以自己试着在界面中添加一个Stop按钮,让事件源执行cancel回调函数。

本文首发CSDN,如需转载请与CSDN联系。

记得第一次读这个文档还是3年前,那时也只是泛读。如今关于iOS多线程的文章层出不穷,但我觉得若想更好的领会各个实践者的文章,应该先仔细读读官方的相关文档,打好基础,定会有更好的效果。文章中有对官方文档的翻译,也有自己的理解,官方文档中代码片段的示例在这篇文章中都进行了完整的重写,还有一些文档中没有的代码示例,并且都使用Swift完成,给大家一些Objc与Swift转换的参考。
官方文档地址:Threading Programming Guide

线程属性配置

线程也是具有若干属性的,自然一些属性也是可配置的,在启动线程之前我们可以对其进行配置,比如线程占用的内存空间大小、线程持久层中的数据、设置线程类型、优先级等。

配置线程的栈空间大小

在前文中提到过线程对内存空间的消耗,其中一部分就是线程栈,我们可以对线程栈的大小进行配置:

  • Cocoa框架:在OS X v10.5之后的版本和iOS2.0之后的版本中,我们可以通过修改NSThread类的stackSize属性,改变二级线程的线程栈大小,不过这里要注意的是该属性的单位是字节,并且设置的大小必须得是4KB的倍数。
  • POSIX API:通过pthread_attr_- setstacksize函数给线程属性pthread_attr_t结构体设置线程栈大小,然后在使用pthread_create函数创建线程时将线程属性传入即可。

注意:在使用Cocoa框架的前提下修改线程栈时,不能使用NSThreaddetachNewThreadSelector: toTarget:withObject:方法,因为上文中说过,该方法先创建线程,即刻便启动了线程,所以根本没有机会修改线程属性。

配置线程存储字典

每一个线程,在整个生命周期里都会有一个字典,以key-value的形式存储着在线程执行过程中你希望保存下来的各种类型的数据,比如一个常驻线程的运行状态,线程可以在任何时候访问该字典里的数据。

在Cocoa框架中,可以通过NSThread类的threadDictionary属性,获取到NSMutableDictionary类型对象,然后自定义key值,存入任何里先储存的对象或数据。如果使用POSIX线程,可以使用pthread_setspecificpthread_getspecific函数设置获取线程字典。

配置线程类型

在上文中提到过,线程有Joinable和Detached类型,大多数非底层的线程默认都是Detached类型的,相比Joinable类型的线程来说,Detached类型的线程不用与其他线程结合,并且在执行完任务后可自动被系统回收资源,而且主线程不会因此而阻塞,这着实要方便许多。

使用NSThread创建的线程默认都是Detached类型,而且似乎也不能将其设置为Joinable类型。而使用POSIX API创建的线程则默认为Joinable类型,而且这也是唯一创建Joinable类型线程的方式。通过POSIX API可以在创建线程前通过函数pthread_attr_setdetachstate更新线程属性,将其设置为不同的类型,如果线程已经创建,那么可以使用pthread_detach函数改变其类型。Joinable类型的线程还有一个特性,那就是在终止之前可以将数据传给与之相结合的线程,从而达到线程之间的交互。即将要终止的线程可以通过pthread_exit函数传递指针或者任务执行的结果,然后与之结合的线程可以通过pthread_join函数接受数据。

虽然通过POSIX API创建的线程使用和管理起来较为复杂和麻烦,但这也说明这种方式更为灵活,更能满足不同的使用场景和需求。比如当执行一些关键的任务,不能被打断的任务,像执行I/O操作之类。

设置线程优先级

每一个新创建的二级线程都有它自己的默认优先级,内核会根据线程的各属性通过分配算法计算出线程的优先级。这里需要明确一个概念,高优先级的线程虽然会更早的运行,但这其中并没有执行时间效率的因素,也就是说高优先级的线程会更早的执行它的任务,但在执行任务的时间长短方面并没有特别之处。

不论是通过NSThread创建线程还是通过POSIX API创建线程,他们都提供了设置线程优先级的方法。我们可以通过NSThread的类方法setThreadPriority:设置优先级,因为线程的优先级由0.0~1.0表示,所以设置优先级时也一样。我们也可以通过pthread_setschedparam函数设置线程优先级。

注意:设置线程的优先级时可以在线程运行时设置。

虽然我们可以调节线程的优先级,但不到必要时还是不建议调节线程的优先级。因为一旦调高了某个线程的优先级,与低优先级线程的优先等级差距太大,就有可能导致低优先级线程永远得不到运行的机会,从而产生性能瓶颈。比如说有两个线程A和B,起初优先级相差无几,那么在执行任务的时候都会相继无序的运行,如果将线程A的优先级调高,并且当线程A不会因为执行的任务而阻塞时,线程B就可能一直不能运行,此时如果线程A中执行的任务需要与线程B中任务进行数据交互,而迟迟得不到线程B中的结果,此时线程A就会被阻塞,那么程序的性能自然就会产生瓶颈。

线程执行的任务

在任何平台,线程存在的价值和意义都是一样的,那就是执行任务,不论是方法、函数或一段代码,除了依照语言语法正常编写外,还有一些额外需要大家注意的事项。

Autorelease Pool

在Xcode4.3之前,我们都处在手动管理引用计数的时代,代码里满是retainrelease的方法,所以那个时候,被线程执行的任务中,为了能自动处理大量对象的retainrelease操作,都会使用NSAutoreleasePool类创建自动释放池,它的作用是将线程中要执行的任务都放在自动释放池中,自动释放池会捕获所有任务中的对象,在任务结束或线程关闭之时自动释放这些对象:

- (void)myThreadMainRoutine
{

NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; // 顶层自动释放池

// 线程执行任务的逻辑代码

[pool release];

}

到了自动引用计数(ARC)时代,就不能使用NSAutoreleasePool进行自动释放池管理了,而是新加了@autoreleasepool代码块语法来创建自动释放池:

- (void)myThreadMainRoutine
{

@autoreleasepool {

// 线程执行任务的逻辑代码

}

}

我们知道每个应用程序都是运行在一个主线程里的,而线程都至少得有一个自动释放池,所以说整个应用其实是跑在一个自动释放池中的。大家都知道C系语言中,程序的入口函数都是main函数,当我们创建一个Objective-C的iOS应用后,Xcode会在Supporting Files目录下自动为我们创建一个main.m文件:

LearnThread-2

main.m这个文件中就能证实上面说的那点:

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

以上都是在Objective-C中,但在Swift中,就有点不一样了,NSAutoreleasePool@autoreleasepool都不能用了,取而代之的是Swift提供的一个方法func autoreleasepool(code: () -> ()),接收的参数为一个闭包,我们可以这样使用:

func performInBackground() {

autoreleasepool({

// 线程执行任务的逻辑代码

print("I am a event, perform in Background Thread.")

})

}

根据尾随闭包的写法,还可以这样使用:

func performInBackground() {

autoreleasepool{

// 线程执行任务的逻辑代码

print("I am a event, perform in Background Thread.")

}

}

有些人可能会问在ARC的时代下为什么还要用自动释放池呢?比如在SDWebImage中就大量使用了@autoreleasepool代码块,其原因就是为了避免内存峰值,大家都知道在MRC时代,除了retainrelease方法外,还有一个常用的方法是autorelease,用来延迟释放对象,它释放对象的时机是当前runloop结束时。到了ARC时代,虽然不用我们手动管理内存了,但其自动管理的本质与MRC时是一样的,只不过由编译器帮我们在合适的地方加上了这三个方法,所以说如果在一个线程执行的任务中大量产生需要autorelease的对象时,因为不能及时释放对象,所以就很有可能产生内存峰值。那么在这种任务中在特定的时候使用@autorelease代码块,帮助释放对象,就可以有效的防止内存峰值的发生。

设置异常处理

在线程执行任务的时候,难免会出现异常,如果不能及时捕获异常任由其抛出,就会导致整个应用程序退出。在Swift2.0中,Apple提供了新的异常控制处理机制,让我们能像Java中一样形如流水的捕获处理异常。所以在线程执行的任务中,我们尽量使用异常处理机制,提高健壮性。

创建Runloop

大家知道,一个线程只能执行一个任务,当任务结束后也就意味着这个线程也要结束,频繁的创建线程也是挺消耗资源的一件事,于是就有了常驻线程,前文介绍线程相关概念时也提到过:

简单的来说,RunLoop用于管理和监听异步添加到线程中的事件,当有事件输入时,系统唤醒线程并将事件分派给RunLoop,当没有需要处理的事件时,RunLoop会让线程进入休眠状态。这样就能让线程常驻在进程中,而不会过多的消耗系统资源,达到有事做事,没事睡觉的效果。

如果想要线程不结束,那就要被执行的任务不结束,让被执行的任务不结束显然不靠谱,那么就需要一个机制,能占着线程。该机制就是事件循环机制(Eventloop),体现在代码中就是一个do-while循环,不断的接收事件消息、处理事件、等待新事件消息,除非接收到一个让其退出的事件消息,否则它将一直这么循环着,线程自然就不会结束。Runloop就是管理消息和事件,并提供Eventloop函数的对象,线程执行的任务其实就是在Runloop对象的Eventloop函数里运行。关于Runloop更详细的知识及配置
操作在后文中会有讲述。

终止线程

打个不恰当的比方,人终有一死,或正常生老病死,或非正常出事故意外而亡,前者尚合情合理后者悲痛欲绝。线程也一样,有正常终止结束,也有非正常的强制结束,不管是线程本身还是应用程序都希望线程能正常结束,因为正常结束也就意味着被执行的任务正常执行完成,从而让线程处理完后事随即结束,如果在任务执行途中强制终止线程,会导致线程没有机会处理后事,也就是正常释放资源对象等,这样会给应用程序带来例如内存溢出这类潜在的问题,所以强烈不推荐强制终止线程的做法。

如果确实有在任务执行途中终止线程的需求,那么可以使用Runloop,在任务执行过程中定期查看是否有收到终止任务的事件消息,这样一来可以在任务执行途中判断出终止任务的信号,然后进行终止任务的相关处理,比如保存数据等,二来可以让线程有充分的时间释放资源。

Run Loop

Run Loops是线程中的基础结构,在上文中也提到过,Run Loops其实是一个事件循环机制,用来分配、分派线程接受到的事件任务,同时可以让线程成为一个常驻线程,即有任务时处理任务,没任务时休眠,且不消耗资源。在实际应用时,Run Loop的生命周期并不全是自动完成的,还是需要人工进行配置,不论是Cocoa框架还是Core Foundation框架都提供了Run Loop的相关对象对其进行配置和管理。

注:Core Foundation框架是一组C语言接口,它们为iOS应用程序提供基本数据管理和服务功能,比如线程和Run Loop、端口、Socket、时间日期等。

在所有的线程中,不论是主线程还是二级线程,都不需要显示的创建Run Loop对象,这里的显示指的是通过任何create打头的方法创建Run Loop。对于主线程来说,当应用程序通过UIApplicationMain启动时,主线程中的Run Loop就已经创建并启动了,而且也配置好了。那么如果是二级线程,则需要我们手动先获取Run Loop,然后再手动进行配置并启动。下面的章节会向大家详细介绍Run Loop的知识。

注:在二级线程中获取Run Loop有两种方式,通过NSRunloop的类方法currentRunLoop获取Run Loop对象(NSRunLoop),或者通过Core Foundation框架中的CFRunLoopGetCurrent()函数获取当前线程的Run Loop对象(CFRunLoop)。NSRunLoopCFRunLoop的上层封装。

let nsrunloop = NSRunLoop.currentRunLoop()

let cfrunloop = CFRunLoopGetCurrent()

Run Loop的事件来源

Run Loop有两个事件来源,一个是Input source,接收来自其他线程或应用程序(进程)的异步事件消息,并将消息分派给对应的事件处理方法。另一个是Timer source,接收定期循环执行或定时执行的同步事件消息,同样会将消息分派给对应的事件处理方法。

LearnThread-3

上图展示了Run Loop的两类事件来源,以及在Input source中的两种不同的子类型,它们分别对应着Run Loop中不同的处理器。当不同的事件源接收到消息后,通过NSRunLooprunUntilDate:方法启动运行Run Loop,将事件消息分派给对应的处理器执行,一直到指定的时间时退出Run Loop。

Run Loop的观察者

Run Loop的观察者可以理解为Run Loop自身运行状态的监听器,它可以监听Run Loop的下面这些运行状态:

  • Run Loop准备开始运行时。
  • 当Run Loop准备要执行一个Timer Source事件时。
  • 当Run Loop准备要执行一个Input Source事件时。
  • 当Run Loop准备休眠时。
  • 当Run Loop被进入的事件消息唤醒并且还没有开始让处理器执行事件消息时。
  • 退出Run Loop时。

Run Loop的观察者在NSRunloop中没有提供相关接口,所以我们需要通过Core Foundation框架使用它,可以通过CFRunLoopObserverCreate方法创建Run Loop的观察者,类型为CFRunLoopObserverRef,它其实是CFRunLoopObserver的重定义名称。上述的那些可以被监听的运行状态被封装在了CFRunLoopActivity结构体中,对应关系如下:

  • CFRunLoopActivity.Entry
  • CFRunLoopActivity.BeforeTimers
  • CFRunLoopActivity.BeforeSources
  • CFRunLoopActivity.BeforeWaiting
  • CFRunLoopActivity.AfterWaiting
  • CFRunLoopActivity.Exit

Run Loop的观察者和Timer事件类似,可以只使用一次,也可以重复使用,在创建观察者时可以设置。如果只使用一次,那么当监听到对应的状态后会自行移除,如果是重复使用的,那么会留在Run Loop中多次监听Run Loop相同的运行状态。

Run Loop Modes

Run Loop Modes可以称之为Run Loop模式,这个模式可以理解为对Run Loop各种设置项的不同组合,举个例子,iPhone手机运行的iOS有很多系统设置项,假设白天我打开蜂窝数据,晚上我关闭蜂窝数据,而打开无线网络,到睡觉时我关闭蜂窝数据和无线网络,而打开飞行模式。假设在这三个时段中其他的所有设置项都相同,而只有这三个设置项不同,那么就可以说我的手机有三种不同的设置模式,对应着不同的时间段。那么Run Loop的设置项是什么呢?那自然就是前文中提到的不同的事件来源以及观察者了,比如说,Run Loop的模式A(Mode A),只包含接收Timer Source事件源的事件消息以及监听Run Loop运行时的观察者,而模式B(Mode B)只包含接收Input Source事件源的事件消息以及监听Run Loop准备休眠时和退出Run Loop时的观察者,如下图所示:

LearnThread-4

所以说,Run Loop的模式就是不同类型的数据源和不同观察者的集合,当Run Loop运行时要设置它的模式,也就是告知Run Loop只需要关心这个集合中的数据源类型和观察者,其他的一概不予理会。那么通过模式,就可以让Run Loop过滤掉它不关心的一些事件,以及避免被无关的观察者打扰。如果有不在当前模式中的数据源发来事件消息,那只能等Run Loop改为包含有该数据源类型的模式时,才能处理事件消息。

在Cocoa框架和Core Foundation框架中,已经为我们预定义了一些Run Loop模式:

  • 默认模式:在NSRunloop中的定义为NSDefaultRunLoopMode,在CFRunloop中的定义为kCFRunLoopDefaultMode。该模式包含的事件源囊括了除网络链接操作的大多数操作以及时间事件,用于当前Run Loop处于空闲状态等待事件时,以及Run Loop开始运行时。
  • NSConnectionReplyMode:该模式用于监听NSConnection相关对象的返回结果和状态,在系统内部使用,我们一般不会使用该模式。
  • NSModalPanelRunLoopMode:该模式用于过滤在模态面板中处理的事件(Mac App)。
  • NSEventTrackingRunLoopMode:该模式用于跟踪用户与界面交互的事件。
  • 模式集合:或者叫模式组,顾名思义就是将多个模式组成一个组,然后将模式组认为是一个模式设置给Run Loop,在NSRunloop中的定义为NSRunLoopCommonModes,在CFRunloop中的定义为kCFRunLoopCommonModes。系统提供的模式组名为Common Modes,它默认包含NSDefaultRunLoopMode、NSModalPanelRunLoopMode、NSEventTrackingRunLoopMode这三个模式。

以上五种系统预定的模式中,前四种属于只读模式,也就是我们无法修改它们包含的事件源类型和观察者类型。而模式组我们可以通过Core Foundation框架提供的CFRunLoopAddCommonMode(_ rl: CFRunLoop!, _ mode: CFString!)方法添加新的模式,甚至是我们自定义的模式。这里需要注意的是,既然在使用时,模式组是被当作一个模式使用的,那么自然可以给它设置不同类型的事件源或观察者,当给模式组设置事件源或观察者时,实际是给该模式组包含的所有模式设置。比如说给模式组设置了一个监听Run Loop准备休眠时的观察者,那么该模式组里的所有模式都会被设置该观察者。

Input Source

前文中说过,Input Sources接收到各种操作输入事件消息,然后异步的分派给对应事件处理方法。在Input Sources中又分两大类的事件源,一类是基于端口事件源(Port-based source),在CFRunLoopSourceRef的结构中为source1,主要通过监听应用程序的Mach端口接收事件消息并分派,该类型的事件源可以主动唤醒Run Loop。另一类是自定义事件源(Custom source),在CFRunLoopSourceRef的结构中为source0,一般是接收其他线程的事件消息并分派给当前线程的Run Loop,比如performSwlwctor:onThread:...系列方法,该类型的事件源无法自动唤醒Run Loop,而是需要手动将事件源设置为待执行的标记,然后再手动唤醒Run Loop。虽然这两种类型的事件源接收事件消息的方式不一样,但是当接收到消息后,对消息的分派机制是完全相同的。

Port-Based Source

Cocoa框架和Core Foundation框架都提供了相关的对象和函数用于创建基于端口的事件源。在Cocoa框架中,实现基于端口的事件源主要是通过NSPort类实现的,它代表了交流通道,也就是说在不同的线程的Run Loop中都存在NSPort,那么它们之间就可以通过发送与接收消息(NSPortMessage)互相通信。所以我们只需要通过NSPort类的类方法port创建对象实例,然后通过NSRunloop的方法将其添加到Run Loop中,或者在创建二级线程时将创建好的NSPort对象传入即可,无需我们再做消息、消息上下文、事件源等其他配置,都由Run Loop自行配置好了。而在Core Foundation框架中就比较麻烦一些,大多数配置都需要我们手动配置,在后面会详细举例说明。

Custom Input Source

Cocoa框架中没有提供创建自定义事件源的相关接口,我们只能通过Core Foundation框架中提供的对象和函数创建自定义事件源,手动配置事件源各个阶段要处理的逻辑,比如创建CFRunLoopSourceRef事件源对象,通过CFRunLoopScheduleCallBack回调函数配置事件源上下文并注册事件源,通过CFRunLoopPerformCallBack回调函数处理接收到事件消息后的逻辑,通过CFRunLoopCancelCallBack函数销毁事件源等等,在后文中会有详细举例说明。

虽然Cocoa框架没有提供创建自定义事件源的相关对象和接口,但是它为我们预定义好了一些事件源,能让我们在当前线程、其他二级线程、主线程中执行我们希望被执行的方法,让我们看看NSObject中的这些方法:

func performSelectorOnMainThread(_ aSelector: Selector, withObject arg: AnyObject?, waitUntilDone wait: Bool)

func performSelectorOnMainThread(_ aSelector: Selector, withObject arg: AnyObject?, waitUntilDone wait: Bool, modes array: [String]?)

这两个方法允许我们将当前线程中对象的方法让主线程去执行,可以选择是否阻塞当前线程,以及希望被执行的方法作为事件消息被何种Run Loop模式监听。

注:如果在主线程中使用该方法,当选择阻塞当前线程,那么发送的方法会立即被主线程执行,若选择不阻塞当前线程,那么被发送的方法将被排进主线程Run Loop的事件队列中,并等待执行。

func performSelector(_ aSelector: Selector, withObject anArgument: AnyObject?, afterDelay delay: NSTimeInterval)

func performSelector(_ aSelector: Selector, withObject anArgument: AnyObject?, afterDelay delay: NSTimeInterval, inModes modes: [String])

这两个方法允许我们给当前线程发送事件消息,当前线程接收到消息后会依次加入Run Loop的事件消息队列中,等待Run Loop迭代执行。该方法还可以指定消息延迟发送时间及消息希望被何种Run Loop模式监听。

注:该方法中的延迟时间并不是延迟Run Loop执行事件消息的事件,而是延迟向当前线程发送事件消息的时间。另外,即便不设置延迟时间,那么发送的事件消息也不一定立即被执行,因为在Run Loop的事件消息队列中可以已有若干等待执行的消息。

func performSelector(_ aSelector: Selector, onThread thr: NSThread, withObject arg: AnyObject?, waitUntilDone wait: Bool)

func performSelector(_ aSelector: Selector, onThread thr: NSThread, withObject arg: AnyObject?, waitUntilDone wait: Bool, modes array: [String]?)

这两个方法允许我们给其他二级线程发送事件消息,前提是要取得目标二级线程的NSThread对象实例,该方法同样提供了是否阻塞当前线程的选项和设置Run Loop模式的选项。

注:使用该方法给二级线程发送事件消息时要确保目标线程正在运行,换句话说就是目标线程要有启动着的Run Loop。并且保证目标线程执行的任务要在应用程序代理执行applicationDidFinishLaunching:方法前完成,否则主线程就结束了,目标线程自然也就结束了。

func performSelectorInBackground(_ aSelector: Selector, withObject arg: AnyObject?)

该方法允许我们在当前应用程序中创建一个二级线程,并将指定的事件消息发送给新创建的二级线程。

class func cancelPreviousPerformRequestsWithTarget(_ aTarget: AnyObject)

class func cancelPreviousPerformRequestsWithTarget(_ aTarget: AnyObject, selector aSelector: Selector, object anArgument: AnyObject?)

这两个方法是NSObject的类方法,第一个方法作用是在当前线程中取消Run Lop中某对象通过performSelector:withObject:afterDelay:方法发送的所有事件消息执行请求。第二个方法多了两个过滤参数,那就是方法名称和参数,取消指定方法名和参数的事件消息执行请求。

Timer Source

Timer Source顾名思义就是向Run Loop发送在将来某一时间执行或周期性重复执行的同步事件消息。当某线程不需要其他线程通知而需要自己通知自己执行任务时就可以用这种事件源。举个应用场景,在iOS应用中,我们经常会用到搜索功能,而且一些搜索框具有自动搜索的能力,也就是说不用我们点击搜索按钮,只需要输入完我想要搜索的内容就会自动搜索,大家想一想如果每输入一个字就开始立即搜索,不但没有意义,性能开销也大,用户体验自然也很糟糕,我们希望当输入完这句话,或至少输入一部分之后再开始搜索,所以我们就可以在开始输入内容时向执行搜索功能的线程发送定时搜索的事件消息,让其在若干时间后再执行搜索任务,这样就有缓冲时间输入搜索内容了。

这里需要注意的是Timer Source发送给Run Loop的周期性执行任务的重复时间是相对时间。比如说给Run Loop发送了一个每隔5秒执行一次的任务,每次执行任务的正常时间为2秒,执行5次后终止,假设该任务被立即执行,那么当该任务终止时应该历时30秒,但当第一次执行时出现了问题,导致任务执行了20秒,那么该任务只能再执行一次就终止了,执行的这一次其实就是第5次,也就是说不论任务的执行时间延迟与否,Run Loop都会按照初始的时间间隔执行任务,并非按Finish-To-Finish去算的,所以一旦中间任务有延时,那么就会丢失任务执行次数。关于Timer Source的使用,在后文中会有详细举例说明。

Run Loop内部运行逻辑

在Run Loop的运行生命周期中,无时无刻都伴随着执行等待执行的各种任务以及在不同的运行状态时通知不同的观察者,下面我们看看Run Loop中的运行逻辑到底是怎样的:

  1. 通知对应观察者Run Loop准备开始运行。
  2. 通知对应观察者准备执行定时任务。
  3. 通知对应观察者准备执行自定义事件源的任务。
  4. 开始执行自定义事件源任务。
  5. 如果有基于端口事件源的任务准备待执行,那么立即执行该任务。然后跳到步骤9继续运转。
  6. 通知对应观察者线程进入休眠。
  7. 如果有下面的事件发生,则唤醒线程:
    1. 接收到基于端口事件源的任务。
    2. 定时任务到了该执行的时间点。
    3. Run Loop的超时时间到期。
    4. Run Loop被手动唤醒。
  8. 通知对应观察者线程被唤醒。
  9. 执行等待执行的任务。
    1. 如果有定时任务已启动,执行定时任务并重启Run Loop。然后跳到步骤2继续运转。
    2. 如果有非定时器事件源的任务待执行,那么分派执行该任务。
    3. 如果Run Loop被手动唤醒,重启Run Loop。然后跳转到步骤2继续运转。
  10. 通知对应观察者已退出Run Loop。

以上这些Run Loop中的步骤也不是每一步都会触发,举一个例子:
1.对应观察者接收到通知Run Loop准备开始运行 -> 3.对应观察者接收到通知Run Loop准备执行自定义事件源任务 -> 4.开始执行自定义事件源任务 -> 任务执行完毕且没有其他任务待执行 -> 6.线程进入休眠状态,并通知对应观察者 -> 7.接收到定时任务并唤醒线程 -> 8.通知对应观察者线程被唤醒 -> 9.执行定时任务并重启Run Loop -> 2.通知对应观察者准备执行定时任务 -> Run Loop执行定时任务,并在等待下次执行任务的间隔中线程休眠 -> 6.线程进入休眠状态,并通知对应观察者…

这里需要注意的一点是从上面的运行逻辑中可以看出,当观察者接收到执行任务的通知时,Run Loop并没有真正开始执行任务,所以观察者接收到通知的时间与Run Loop真正执行任务的时间有时间差,一般情况下这点时间差影响不大,但如果你需要通过观察者知道Run Loop执行任务的确切时间,并根据这个时间要进行后续操作的话,那么就需要通过结合多个观察者接收到的通知共同确定了。一般通过监听准备执行任务的观察者、监听线程进入休眠的观察者、监听线程被唤醒的观察者共同确定执行任务的确切时间。

本文首发CSDN,如需转载请与CSDN联系。

记得第一次读这个文档还是3年前,那时也只是泛读。如今关于iOS多线程的文章层出不穷,但我觉得若想更好的领会各个实践者的文章,应该先仔细读读官方的相关文档,打好基础,定会有更好的效果。文章中有对官方文档的翻译,也有自己的理解,官方文档中代码片段的示例在这篇文章中都进行了完整的重写,还有一些文档中没有的代码示例,并且都使用Swift完成,给大家一些Objc与Swift转换的参考。
官方文档地址:Threading Programming Guide

什么是线程

我们设想在应用程序中,每行代码的执行都有一个执行路径并对应一个执行容器。线程,可以让应用程序中的代码通过多个执行路径执行,从而达到多个代码块同时在不同的执行路径下执行运算,即多任务同时执行。

在系统中,每个程序都是并行状态的,但是并不是一直持续着活跃状态,而是由系统根据程序的需要适时的分配执行时间和内存。在每个程序中,或许存在多个线程,执行着不同的任务,那么系统对程序执行的管理实际上就是对程序中线程的管理,比如适时的将某个线程安排到负载较小的内核中执行,或者阻止正在运行的优先级较低的线程,给优先级较高的线程让路等。所以说线程的运转需要内核级别和应用程序级别相互协调,即内核级别负责将事件分发给不同的线程,并将线程安排在合理的内核上执行以及管理线程的优先级,而应用程序级别是通过代码管理和操控线程的属性及状态。

为什么要使用线程

回到iOS,我们开发的App至少都有一个线程,称之为主线程,线程中执行方法或函数的原则是先进先出原则,一个接一个的执行。假设在我们的App中有从远程下载图片的功能,并且该功能放在主线程中执行,那么当下载一个1080p高清图片时,就会需要耗费较长的时间,如果主线程中下载功能后面还有其他待执行的方法,那么只能等待下载功能完成之后,才能继续执行。所以此时对于用户来说,得不到任何来自App的响应,那么很容易认为是你的App出问题了,如此糟糕的用户体验,足以让用户将你的App打入冷宫甚至删除。

如果我们使用另外一个线程专门处理下载功能,那么该线程和主线程同时执行,对于用户而言,此时可以由主线程对用户做出合适的响应,而下载在另一个线程中同时进行着。所以使用线程对提高程序的用户体验、性能无疑是最好的方法。

使用线程会导致的问题

俗话说天下没有免费的午餐,诚然多线程能提高程序的性能、用户体验,但是在光鲜的背后还是要承担一定风险的。使用多线程势必会增加开发人员写代码花费的时间,因为代码的复杂度变高了,开发人员斟酌的频率就会变高,线程与线程之间有交互,容错率就会降低,开发人员调试的时间就会变多。由于多线程依然共享内存,所以会发生两个线程同时对某个数据进行操作,这样很容易使程序的执行结果发生错误。总而言之,多线程好,但使用时要知其根本,做到佩弦自急。

实现多任务并发执行任务的解决方案

因为线程本身相对比较低层,它实现程序中并发执行任务功能的方式也较为复杂,所以我们如果想使用好线程,那么就必须要真正理解线程,要明白在我们的程序中使用线程之后会带来哪些潜在的风险,所谓知己知彼方能百战不殆。同时,我们也不能滥用线程,该用的时候用,不该用的时候就不要画蛇添足。毕竟,使用线程会增加内存的消耗以及CPU得运算时间,要避免物极必反。在真正理解线程之前,我们先看看在OS X和iOS中提供的不那么底层的实现多任务并发执行的解决方案:

  • Operation object:该技术出现在OS X 10.5中,通过将要执行的任务封装成操作对象的方式实现任务在多线程中执行。任务可以理解为你要想执行的一段代码。在这个操作对象中不光包含要执行的任务,还包含线程管理的内容,使用时通常与操作队列对象联合使用,操作队列对象会管理操作对象如何使用线程,所以我们只需要关心要执行的任务本身即可。

  • GCD:该技术出现在OS X 10.6中,它与Operation Object的初衷类似,就是让开发者只关注要执行的任务本身,而不需要去关注线程的管理。你只需要创建好任务,然后将任务添加到一个工作队列里即可,该工作队列会根据当前CPU性能及内核的负载情况,将任务安排到合适的线程中去执行。

  • Idle-time notification:该技术主要用于处理优先级相对比较低、执行时间比较短的任务,让应用程序在空闲的时候执行这类任务。Cocoa框架提供NSNotificationQueue对象处理空闲时间通知,通过使用NSPostWhenIdle选项,向队列发送空闲时间通知的请求。

  • Asynchronous functions:系统中有一些支持异步的函数,可以自动让你的代码并行执行。这些异步函数可能通过应用程序的守护进程或者自定义的线程执行你的代码,与主进程或主线程分离,达到并行执行任务的功能。

  • Timers:我们也可以在应用程序主线程中使用定时器去执行一些比较轻量级的、有一定周期性的任务。

  • Separate processes:虽然通过另起一个进程比线程更加重量级,但是在某些情况下要比使用线程更好一些,比如你需要的执行的任务和你的应用程序在展现数据和使用方面没有什么关系,但是可以优化你的应用程序的运行环境,或者提高应用程序获取数据的效率等。

初识线程概念

线程技术

说到OS X和iOS中的线程技术,就不得不说GNU Mach。Apple操作系统中的线程技术是基于Mach线程技术实现的,所以本身就带有线程基本的特性,比如PEM。Mach线程我们几乎不会用到,一般编程中我们可能会使用POSIX API创建线程。

GNU Mach:GNU是一个类UNIX操作系统,它采用GNU Hurd作为操作系统内核,而GNU Mach是基于GNU Hurd内核技术的微内核。
POSIX:可移植操作系统接口(Portable Operating System Interface of UNIX),它定义了操作系统应该为应用程序提供的接口标准, 是IEEE为要在各种UNIX操作系统上运行的软件而定义的一系列API标准的总称。
PEM:Preemptive Execution Model,以任务的优先级决定立即执行还是延后执行,或者安排至不同的内核执行。

我们来看看OS X和iOS中主要的两种线程技术:

  • Cocoa Threads:Cocoa框架中提供了NSThreadNSObject类供我们进行线程相关的操作。
  • POSIX Threads:POSIX的线程API实际是基于C语言的线程接口,这些接口在使用线程和配置线程方面更加容易和灵活。

在应用程序层面,不管是什么平台,线程的运行方式都是大体相同的,在线程的运行过程中一般都会经历三种状态,即运行中、准备运行、阻塞。如果某个线程在当前处于不活跃状态,也即是非运行中状态,那么它有可能是处于阻塞状态并在等待执行任务的输入。也有可能已经有任务输入,处于准备运行状态,只是在等待被分派。当我们终止线程后,它会永久性的被系统回收,因为毕竟线程会占用一定的系统内存和CPU运算时间,所以一般情况下,我们放入二级线程(非主线程)中的任务都是比较重要和有意义的任务。

RunLoops

上一节提到当线程终止后就会永久被系统收回,如果你还有任务需要另起线程执行,就要重新创建线程以及配置,但这也不是必须的,我们可以让线程在空闲的时候休眠,当有任务需要执行时唤醒,就像主线程一样,此时就要用到RunLoop。

简单的来说,RunLoop用于管理和监听异步添加到线程中的事件,当有事件输入时,系统唤醒线程并将事件分派给RunLoop,当没有需要处理的事件时,RunLoop会让线程进入休眠状态。这样就能让线程常驻在进程中,而不会过多的消耗系统资源,达到有事做事,没事睡觉的效果。

主线程中的RunLoop系统已经自动帮我们配置好了,但是我们自己创建的线程,还需要对RunLoop配置一番才可以使用,在后面的章节中都会有详细介绍。

同步策略

诚然,使用线程好处多多,但是之前也提到过,使用线程也是会存在一定问题的,那就是资源竞争,当两个线程在同一时间操作同一个变量时,就会产生问题。一种解决方案是让不同的线程拥有各自独有的变量,虽然可以解决问题,但不是最优方案。较为优雅一些的方案则是使用线程中的同步策略来解决该问题。

常用的同步策略有线程锁、状态位、原子操作。线程锁较为简单粗暴,简单的说当一个线程在操作变量时会挂上一把互斥锁,如果另一个线程先要操作该变量,它就得获得这把锁,但是锁只有一个,必须等第一个线程释放互斥锁后,才可以被其他线程获取,所以这样就解决了资源竞争的问题。状态位策略是通过线程或任务的执行情况生成一个状态,这个状态即像门卫又像协管员,一是阻止线程进行,二是以合适的执行顺序安排协调各个任务。第三个策略则是原子操作,相对前两个策略要更轻量级一些,它能通过硬件指令保证变量在更新完成之后才能被其他线程访问。

线程之间的交互

虽然我们尽量让每个线程完成独立的任务,但是有些时候我们需要将二级线程中任务的执行结果发送到主线程中进一步进行操作,那么线程之间的交互就不可避免的发生,幸运的是进程中的线程是共享进程空间的,所以实现线程之间的交互也不是那么困难,比如通过发送messages、全局变量、同步策略等都可以实现,在后面的章节中都会有详细介绍。

使用线程时需要注意的事项

无规矩不成方圆,做任何事如果乱来,那必定会出现各种问题。因为线程相对比较底层,所以当我们对线程理解的不是特别透彻时直接创建线程,并手动管理线程,势必会出现正确性和性能上的各种问题,所以就有了这节对使用线程的一些建议。

避免直接创建线程

创建并管理线程在代码层面相对比较复杂和繁琐,一个不留神就会产生一些潜在的问题。OS X和iOS都提供了较为上层的创建使用线程的API,就是前面提到一些多任务并发执行的解决方案,比如GCD、Operation objects。使用它们可以帮我们规避在管理线程和处理线程性能方面可能出现的问题,提高多线程操作时的性能和健壮性。

让线程执行有价值的任务

前文中提到过,线程消耗的系统资源不容小视,所以当我们手动创建和管理线程时,尤其要注意这一点。要保证另起线程执行的任务是有意义的、重要的任务,而且该终止的线程要终止,不要让线程有任何空闲时间,以保证系统资源的最优利用。

避免资源竞争

进程中的线程是共享该进程空间的,所以很容易出现多个线程对同一个变量进行操作从而导致程序执行结果错误的情况。如果为每个线程都提供一份变量的拷贝,的确是可以解决这个问题,但是在开发中这样会造成更大的弊端,所以前文中提到了一些同步策略,能帮助我们达到线程交互及解决资源竞争的目的。但是在理论上还是会有出错的可能,比如让线程在指定的顺序下对某个变量依次进行操作。所以在程序设计阶段应该尽量避免线程之间的资源竞争及减少线程之间的交互。

用户界面与线程

用户界面的更新、对用户事件的响应都应该放在主线程中,避免线程不安全的情况,以及能方便的管理UI界面。目前Cocoa框架默认对UI的操作都要在主线程中完成,即使不强制要求,我们也应该这样做。但是有一些情况比较特殊,比如对图片的处理,因为处理图片的过程并不是显性的,所以处理的过程可以放在二级线程中,当处理完成后,再在主线程中显示结果。这样可以有效的提升应用的性能。

清楚当线程结束时应该做什么

当用户退出应用后,理论上该应用进程中的所有线程都会立即被结束。但是如果此时正好有一个二级线程在后台处理其他任务,比如说下载或者正在存储一些数据。那么此时就要判断正在处理的这些任务是否要保留,如果要丢弃,那么直接结束所有线程即可,但是如果要保留,那么就需要主线程等待正在处理任务的二级线程,从而延迟应用退出。

这里处理时有两种情况,如果自行创建的线程并手动管理,那么要使用POSIX API创建具有joinable特性的二级线程,使主线程与之相关联。如果是使用Cocoa框架,那么可以使用applicationShouldTerminate:代理方法延迟应用关闭,当二级线程处理完任务后回调replyToApplicationShouldTerminate:通知到主线程,然后关闭应用。

异常处理

每个线程都有捕获当前任务在执行时产生的异常的责任,不论是主线程还是二级线程。如果二级线程产生的异常需要交由主线程处理是也不能任由其抛出,而是先将其捕获,然后向主线程发送消息,告知主线程当前的情况。当消息发出后二级线程可根据需求选择继续处理其他的任务还是终止线程。

尽可能少的使用常驻线程

前文中提到过,可以为一些经常需要执行的、具有周期性的、量级较小的任务创建常驻线程,以减少创建关闭线程的资源消耗,但是不能滥用常驻线程。理论上,一个线程执行完任务后就应该关闭,并且关闭线程的最佳时机是执行完任务的后一秒。目的是为了避免空闲线程占用过多的资源从而导致一些潜在的问题。

确保类库的线程安全

如果我们在开发应用的相关功能,我们完全可以控制这块功能是否需要多线程去完成,但是当我们在开发一个供别人使用的类库时,就没法灵活的控制了。所以只能假设使用我们的类库必定会在多线程的环境中使用,这样我们可以通过锁机制确保线程安全。但是如果我们的类库没有在多线程环境中使用呢?那就会白白浪费掉对锁进行操作的相关资源,只能说使用锁机制可以保证类库线程安全的万无一失,但性能方面会大打折扣。

另一种方式是让使用我们类库的应用要对类库进行明确地初始化,不管是主线程还是二级线程,换句话说也就是让每个线程都有一份我们类库的内容,这样也可以有效的保证类库线程安全。在Cocoa框架中,还有一种可选的方式,就是可以为NSWillBecomeMultiThreadedNotification注册一个观察者,目的是当应用变为多线程环境时可以通知到我们的类库,从而采取相关措施,但这种方式不保险,有可能当类库已经被多线程环境中的代码使用后才收到通知。总而言之,如果开发类库,那么务必要确保其线程安全。

线程的资源消耗

在OS X和iOS中,每个应用其实就是一个进程,一个进程中由一个或多个线程组成,每个线程代表了所属应用中代码的执行路径。通常情况下应用始于主线程中的主函数,当需要有其他功能在二级线程中与主线程并行执行时,便可以创建其他二级线程。

一旦二级线程被创建,那么它就是一个独立的实体,线程与线程之间是没有任何关联的,它们有各自的执行堆栈,由内核单独为每个线程分派运行时的执行任务。虽然每个线程是独立实体,但是它们之间是可以相互交互的,在实际的应用中,这类需求是很常见的,因为它们共享所属进程的内存空间,并且拥有相同的读写权,所以也很容易实现线程之间的交互。既然一个应用中可能会有多个线程协作完成功能,所以管理线程就是重中之重了,这一章节会从线程的资源消耗、创建、配置、使用、关闭这几个关键点梳理实际运用中的线程管理。

线程的资源消耗主要分为三类,一类是内存空间的消耗、一类是创建线程消耗的时间、另一类是对开发人员开发成本的消耗。

内存空间的消耗又分为两部分,一部分是内核内存空间,另一部分是应用程序使用的内存空间,每个线程在创建时就会申请这两部分的内存空间。申请内核内存空间是用来存储管理和协调线程的核心数据结构的,而申请应用程序的内存空间是用来存储线程栈和一些初始化数据的。对于用户级别的二级线程来说,对应用程序内存空间的消耗是可以配置的,比如线程栈的空间大小等。下面是两种内存空间通常的消耗情况:

  • 内核内存空间:主要存储线程的核心数据结构,每个线程大约会占用1KB的空间。
  • 应用程序内存空间:主要存储线程栈和初始化数据,主线程在OS X中大约占8MB空间,在iOS中大约占1MB。二级线程在两种系统中通常占大约512KB,但是上面提到二级线程在这块是可以配置的,所以可配置的最小空间为16KB,而且配置的空间大小必须是4KB的倍数。

注意:二级线程在创建时只是申请了内存程序空间,但还并没有真正分配给二级线程,只有当二级线程执行代码需要空间时才会真正分配。

线程的创建时间取决于机器硬件的性能,但通常大约在90毫秒,虽然在我们看来90毫秒很短,但当频繁的创建线程时就会影响到CPU处理其他任务的时间。所以现在往往都会使用线程池,避免频繁的创建全新的线程。

前文中提到过设计和开发多线程的应用较单线程要复杂的多,要注意的事项在上文中就提出了八条,针对每条注意事项,都要花费不少时间去设计代码和测试。所以总体来说如果涉及到多线程,务必会增加开发人员的开发测试时间,但是换来的是应用程序具有更好的健壮性和高性能,所谓慢工出细活。

创建线程

说到创建线程,就得说说线程的两种类型,JoinableDetach。Joinable类型的线程可以被其他线程回收其资源和终止。举个例子,如果一个Joinable的线程与主线程结合,那么当主线程准备结束而该二级线程还没有结束的时候,主线程会被阻塞等待该二级线程,当二级线程结束后由主线程回收其占用资源并将其关闭。如果在主线程还没有结束时,该二级线程结束了,那么它不但不会关闭,而且资源也不会被系统收回,只是等待主线程处理。而Detach的线程则相反,会自行结束关闭线程并且有系统回收其资源。

在OS X和iOS系统中有多种创建线程的方法,不同方法创建出的线程可能会有不同的线程属性,但就线程本身来说并没有什么差异。下面来看看创建线程的不同方法。

使用NSThread创建线程

使用NSThread创建线程有两种方式:

  • detachNewThreadSelector:toTarget:withObject::该方法是一个类方法,适用于OS X所有的版本和iOS2.0之后的版本。该方法其实完成了两个动作,先是创建线程,然后启动线程。通过方法名称就可以得知,该方法创建的线程为Detach类型的线程。
  • 创建NSThread对象:这种方法适用于OS X 10.5之后的版本和iOS2.0之后的版本。该方法通过创建NSThread对象,使用它的start()方法启动线程,该方法的好处是可以在启动前通过NSThread对象的各个属性进行配置,待配置妥当后再调用start()方法启动线程。该方法创建的线程也是Detach类型的线程。

detachNewThreadSelector:toTarget:withObject:

该方法有三个参数:

  • selector:发送给线程的消息,或者说是让线程执行的任务。这里需要注意的是该任务最多只能有一个参数,并且不能有返回值。
  • target:在新的线程中接收消息的对象。
  • object:传给target对象的参数,也就是传入selector中的参数。

下面来看一个简单示例:

import Foundation

class TestThread {

func launch() {

print("First event in Main Thread.")

NSThread.detachNewThreadSelector("methodInSecondaryThread:", toTarget: self, withObject: "I am a argument")

print("Second event in Main Thread.")

}

func methodInSecondaryThread(arg: String) {

print("\(arg) of event in Secondary Thread.")

}

}

let testThread = TestThread()
testThread.launch()

上述代码定义了一个类TestThread,包含两个方法launch()methodInSecondaryThread()lanch()方法中用print()函数模拟事件,在两个事件中创建一个二级线程,用于执行methodInSecondaryThread()方法,在该方法中执行其他事件。执行看看结果如何:

Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[NSThread initWithTarget:selector:object:]: target does not implement selector (*** -[LearnThread.TestThread methodInSecondaryThread])'

结果很不幸,报错了,原因很简单,因为我们的代码是Swift,而NSThread继承了NSObject是Objective-C世界的东西,所以需要对代码进行修改,有两种方法:

// 1. 让NSTread继承NSObject
class TestThread: NSObject {

// 2. 在methodInSecondaryThread()方法前添加@objc
@objc func methodInSecondaryThread(arg: String) {

我习惯让类继承NSObject

import Foundation

class TestThread: NSObject {

func launch() {

print("First event in Main Thread.")

NSThread.detachNewThreadSelector("methodInSecondaryThread:", toTarget: self, withObject: "I am a argument")

print("Second event in Main Thread.")

}

func methodInSecondaryThread(arg: String) {

print("\(arg) of event in Secondary Thread.")

}

}

let testThread = TestThread()
testThread.launch()

继续运行看看效果:

First event in Main Thread.
Second event in Main Thread.

运行成功了,但似乎少点什么东西,methodInSecondaryThread()方法中的内容并没有打印出来,难道线程没有执行吗?我们通过Instruments可以看到,在运行过程中二级线程是创建过的:

LearnThread-1

导致这个问题的原因和上文介绍的线程类型有关系。因为主线程运行很快,快到当主线程结束时我们创建的二级线程还没来得及执行methodInSecondaryThread()方法,而通过detachNewThreadSelector:toTarget:withObject:创建的二级线程是Detach类型的,没有与主线程结合,所以主线程也不会等待,当主线程结束,进程结束,二级线程自然也结束了。解决这个问题的办法就是让二级线程有执行任务的时间,所以我们可以让主线程停顿几秒,让二级线程完成它的任务:

import Foundation

class TestThread: NSObject {

func launch() {

print("First event in Main Thread.")

NSThread.detachNewThreadSelector("methodInSecondaryThread:", toTarget: self, withObject: "I am a argument")

sleep(3)

print("Second event in Main Thread.")

}

func methodInSecondaryThread(arg: String) {

print("\(arg) of event in Secondary Thread.")

}

}

let testThread = TestThread()
testThread.launch()

再运行就可以看到正确地结果了:

First event in Main Thread.
I am a argument of event in Secondary Thread.
Second event in Main Thread.

创建NSThread对象

我们可以通过initWithTarget:selector:object:方法实例化一个NSThread对象,该方法的三个参数其实与detachNewThreadSelector:toTarget:withObject:方法的参数一样,只是顺序不一样而已:

import Foundation

class TestThread: NSObject {

func launch() {

print("First event in Main Thread.")

let secondaryThread = NSThread(target: self, selector: "methodInSecondaryThread:", object: "I am a argument")

secondaryThread.start()

sleep(3)

print("Second event in Main Thread.")

}

func methodInSecondaryThread(arg: String) {

print("\(arg) of event in Secondary Thread.")

}

}

let testThread = TestThread()
testThread.launch()

上述的代码的运行结果自然也是一样的:

First event in Main Thread.
I am a argument of event in Secondary Thread.
Second event in Main Thread.

这种方法依然只能在二级线程中执行最多只有一个参数的函数或方法,如果想要执行多参数的任务,可以将参数放入集合中传递,当然被执行的任务得能正确接收到参数集合。或者可以通过另外一种方法,那就是通过创建继承NSThread的类,然后重写main()方法来实现:

import Foundation

class CustomThread: NSThread {

var arg1: String!
var arg2: String!

init(arg1: String, arg2: String) {

self.arg1 = arg1
self.arg2 = arg2

}

override func main() {

print("\(self.arg1), \(self.arg2), we are the arguments in Secondary Thread.")

}

}

class TestThread: NSObject {

func launch() {

print("First event in Main Thread.")

let customThread = CustomThread(arg1: "I am arg1", arg2: "I am arg2")

customThread.start()

sleep(3)

print("Second event in Main Thread.")

}

func methodInSecondaryThread(arg: String) {

print("\(arg) of event in Secondary Thread.")

}

}

let testThread = TestThread()
testThread.launch()

如上述代码所示,我们创建了CustomThread类,并继承了NSThread,然后通过初始化方法传参,再重写main()方法处理相关任务。执行结果如下:

First event in Main Thread.
I am arg1, I am arg2, we are the arguments in Secondary Thread.
Second event in Main Thread.

使用NSObject创建线程

在OS X和iOS中,NSObject对象本身就具有创建线程的能力,所以只要是继承了NSObject的类自然也具备这个能力:

import Foundation

class TestThread: NSObject {

func launch() {

print("First event in Main Thread.")

performSelectorInBackground("performInBackground", withObject: nil)

sleep(3)

print("Second event in Main Thread.")

}

func performInBackground() {

print("I am a event, perform in Background Thread.")

}

}

let testThread = TestThread()
testThread.launch()

上述代码中的TestThread类继承了NSObject类,那么就可以通过performSelectorInBackground:withObject:方法创建二级线程,该方法只有两个参数:

  • selector:发送给线程的消息,或者说是让线程执行的任务。这里需要注意的是该任务最多只能有一个参数,并且不能有返回值。
  • object:传给target对象的参数,也就是传入selector中的参数。

该方法创建的线程也是Detach类型的。以上这几种方式都是基于Cocoa框架实现的,大家可以使用NSThread的类方法isMultiThreaded去检验,在合适的地方插入这行代码print(NSThread.isMultiThreaded()),看看程序的线程状态。

使用POSIX API创建线程

在OS X和iOS中,可以通过POSIX API创建线程,上文中提到过,POSIX的线程API实际是基于C语言的线程接口,这些接口在使用线程和配置线程方面更加容易和灵活,移植性也比较强,但由于相对较为底层,如果不熟悉C语言,上手成本会比较高,NSThread就是基于POSIX线程API封装而成的。

POSIX API通过int pthread_create(pthread_t *restrict thread, const pthread_attr_t *restrict attr, void *(*start_routine)(void *), void *restrict arg);函数创建线程:

  • thread:线程标识符。
  • attr:线程属性设置。
  • start_routine:线程函数的起始地址。
  • arg:传递给start_routine的参数。
  • 返回值:成功返回0,出错返回-1。

大体的参数其实和使用NSThread创建线程基本一致,不过需要注意的是通过pthread_create()创建的线程是Joinable类型的,如果要将新线程设置为Detach类型,需要在创建前使用pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);函数设置其线程属性。

在Cocoa框架中,上文提到的那些同步机制,比如线程锁,当二级线程创建后才就会自动生成。如果在程序中使用POSIX API创建线程,那么Cocoa框架是无法得知当前程序已处于多线程状态的,所以就不会自动开启相关的同步机制,而当我们又没有通过POSIX API手动控制的话,就有可能导致应用程序崩溃的情况。另外要注意的一点是Cocoa框架中的线程锁是不能操作通过POSIX API创建的线程的,反之亦然。所以当Cocoa框架与POSIX API混用的时候,在同步机制方面一定要配套使用。

作者:Kelvin Lau 原文地址:Beginning tvOS Development with TVML Tutorial

在2015年9月9日的产品发布会中,Apple宣布了新一代的Apple TV以及tvOS,并且在tvOS中集成了App Store。这使得我们多年以来想在Apple TV上开发专属应用的梦想成真了。

我和其他教程团队的成员已经开始深入研究tvOS SDK,并且正在努力为你们准备一些有价值的tvOS教程。在你开这篇文章之前,Chris Wagner已经写了一篇关于tvOS初步印象的文章,我也基于这篇文章,设计了第一个tvOS的教程。

译者注:可参阅Chris Wagner文章的中译版一个iOS开发者对tvOS SDK的初探

在这篇教程中,你将会使用TVML开发你的第一款tvOS应用。信不信由你,你将会使用JavaScript管理你应用的逻辑以及创建TVML模板来展现你应用的UI。

当教程结束后,你应该可以基本理解如果通过TVML和TVJS管理、控制tvOS应用。现在就我们开始吧。

注意:该教程需要Xcode7.1或更高的版本,你们可以在这里下载。虽然你们可以跟着该教程一步一步的进行操作,但我还是建议你们能储备一些基本的JavaScript知识。

选择开发方式

Apple为开发tvOS应用提供了两种方式:

  1. TVML Apps:这类应用是使用完整的新开发技术开发的,比如TVML、TVJS、TVMLKit。在稍后我会解释这些简称的含义以及如何使用它们。
  2. Custom Apps:这类应用是使用我们已经比较熟悉的开发技术进行开发的,比如大家熟知的一些iOS框架和特性,像Storyboard、UIKit、Auto Layout等。

这两种方式没有孰优孰劣之分,都是Apple推荐的方法,只是按需所取,以及你更想尝试哪种方式。

在这篇教程中,你们的目标是开发以个能播放RWDevCon讨论视频的tvOS应用:

tvOS-1

虽然用上述两种方式都可以开发这个应用,但是使用TVML会更加容易一些。所以这就是你在这篇教程中要学和要做的东西。

什么是TVML?

正如我刚才提到的,第一种开发tvOS应用的方式是通过TVML、TVJS和TVMLKit这些新的技术实现的。如果你们对这些简称比较陌生,不要惊慌,因为他们本就是新鲜玩意。这里我简单解释一下:

  • TVML是一种XML格式,基于“Television Markup Language”。
  • TVJS是基于JavaScript APIs的脚本语言,它可以根据TVML中定义的内容展示应用。
  • TVMLKit是连接TVML、TVJS和原生tvOS应用的工具包。

如果你是一名经常使用原生API开发原生iOS应用的开发者,那么看到这些类似前端的技术可能会有点退缩。但希望你能保持一颗学习的心,学习了解这些新的强大的特性。

我在这列举一个非常典型的Apple TV的应用场景。大家可以想象一下:你们想把一些内容或信息展现给用户,这些内容和信息储存在你们的服务器上,并且这些内容的格式、查询方式都服务于iPhone或iPad中的应用,那么你肯定会希望你的tvOS中的应用也能方便的使用这些内容,并做到与iOS中应用有相似的展现、查询、导航方式。

tvOS-2

上述的这个场景恰恰是我们这片教程中的场景。我们已经有RWDevCon网站,上面有许多技术讨论视频,所以运用TVML模板应该很容易实现。并且我们也没有很严格的用户界面的需求,所以我们可以简单方便的使用Apple提供的UI模板

tvOS-3

简而言之:

  • 开发TVML App:如果你主要是通过tvOS应用展现一些内容,不论是音频、视频、文本、图片,并且你已经有服务器存储这些资源。那么使用TVML开发是不错的选择。
  • 开发Custom App:如果你希望用户不只是被动的通过你的tvOS应用观看或收听内容,而是希望用户与应用有更多的交互,给用户高质量的用户体验。那么你应该选择使用iOS的相关技术开发自定义的应用。

现在你们已经大概了解了TVML是如何工作的,以及我们为什么要在这篇教程中使用TVML开发tvOS应用。想要更深入的了解,最好的办法就是由你们在实践中去学习、理解了。让我们开始动手吧!

准备工作

首先你们要确保已经下载并安装了Xcode7.1或更高版本。

然后通过 File\New\Project 创建新工程,在侧边栏选择 tvOS\Application\Single View Application 模板,然后点击 Next

tvOS-4

项目名称输入 RWDevCon ,语言选择 Swift ,确保下面的两个复选框为未选中状态,也就是不使用Core Data和单元测试,然后点击 Next

tvOS-5

选择一个目录,点击 Save 保存你的项目。Xcode会为你创建一个带有Storyboard的空工程(如果你开发自定义UI的tvOS应用,那么你需要使用Storyboard)。

然而在该教程中你不需要使用Storybard,因为我们会使用TVML来展示应用的UI,而不是用Storybard去设计UI。所以将 Main.storyboardViewController.swift 删去,在提示框中选择 Move To Trash 彻底删除。

接着打开 Info.plist 文件,删掉Main storybaord file base name属性。最后添加新的属性App Transport Security Settings(区分大小写),以及它的子属性Allow Arbitrary Loads,并将其值设为YES

tvOS-6

注意:在iOS9中,Apple不允许应用链接非HHTPS协议的服务,所以刚才的操作是很有必要的,因为在该教程中,你们将会以HTTP协议访问本地的服务器,所以你需要在Info.plist中添加上述属性以便允许应用通过HTTP协议访问服务器。

加载你的TVML

tvOS应用的生命周期开始于AppDelegate。在这里,你将创建TVApplicationController以及应用上下文,并将它们传给主要的JavaScript文件。

打开AppDelegate.swift并做下面这些事:

  • 删除所有的方法。
  • 导入TVMLKit
  • 使AppDelegate遵循TVApplicationControllerDelegate协议。

当完成这些事后,你的AppDelegate.swift看起来应该像这样:


import UIKit
import TVMLKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, TVApplicationControllerDelegate {

var window: UIWindow?

}

接着添加下面这些属性:


var appController: TVApplicationController?
static let TVBaseURL = "http://localhost:9001/"
static let TVBootURL = "\(AppDelegate.TVBaseURL)js/application.js"

TVApplicationControllerTVMLKit中的一个类,它负责与你的服务器的交互。TVBaseURLTVBootURL包含了你的服务器的地址和JavaScript文件的地址,该JavaScript文件稍后会运行在你的服务器中。

接在在AppDelegate中添加如下方法:


func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
window = UIWindow(frame: UIScreen.mainScreen().bounds)

// 1
let appControllerContext = TVApplicationControllerContext()

// 2
guard let javaScriptURL = NSURL(string: AppDelegate.TVBootURL) else {
fatalError("unable to create NSURL")
}
appControllerContext.javaScriptApplicationURL = javaScriptURL
appControllerContext.launchOptions["BASEURL"] = AppDelegate.TVBaseURL

// 3
appController = TVApplicationController(context: appControllerContext, window: window, delegate: self)
return true
}

这些代码相对还是比较容易理解的:

  1. 这里你首先创建了一个应用上下文TVApplicationControllerContext的实例,用于稍后初始化你的TVApplicationController。你可以理解为给一个简单的对象设置了一些属性,比如服务器的URL,然后该对象又作为属性设置给了另一个对象。
  2. 给应用上下文这个对象实例设置了两个简单的属性:主JavaScript文件的路径和服务器的地址。
  3. 通过你刚才设置好的应用上下文初始化TVApplicationController。此时就完全由Apple代码来接管了,他会加载到你的主JavaScript文件,并开始执行其内容。

所以到目前为止,是时候让Xcode休息一会了,因为接下来你们将要编写JavaScript了。

The JavaScript

在客户端-服务端这类的tvOS应用中,你的JavaScript文件通常在应用连接的服务器中。在该教程中,你们将会在Mac上搭建一个简单的服务器。

客户端代码

为了方便起见,我们把JavaScript文件放在桌面,在你们的 桌面 文件夹中新建一个文件夹名为 client 。在client文件夹中再新建一个文件夹名为 js 。该文件夹将作为你的JavaScript文件的容器。

通过你使用的编辑JavaScript的IDE,新建一个JavaScript文件,名为 application.js ,将它保存在你刚才新建的 js 文件夹中。然后在 application.js 中添加如下代码:


App.onLaunch = function(options) {
// 1
var alert = createAlert("Hello World", ""); //第二个参数传入空字符串
navigationDocument.presentModal(alert);
}

// 2
var createAlert = function(title, description) {
var alertString = `<?xml version="1.0" encoding="UTF-8" ?>
<document>
<alertTemplate>
<title>${title}</title>
<description>${description}</description>
</alertTemplate>
</document>`
var parser = new DOMParser();
var alertDoc = parser.parseFromString(alertString, "application/xml");
return alertDoc
}

App.onLaunch是处理JavaScript文件的入口方法。之前在 AppDelegate.swift 中已经初始化好的TVApplicationController会将TVApplicationControllerContext传到这。之后你会使用到上下文中的内容,但是现在,我们只创建一个简单的提示界面并显示在屏幕上。

  1. 通过下面定义的createAlert函数,我们获得到了为我们展现界面的TVML文件。navigationDocument类似于iOS中的UINavigationController,它提供像栈一样的方式,可以推出或压进展现界面的TVML文件。
  2. createAlert是一个返回TVML文件的函数,你可以将它看作类似iOS中的UIAlertController

写到这顺便提一下,Apple已经提供了18种TVML模板供我们使用,你们可以在该Apple TV Markup Language Reference中查阅完成的模板列表。

上述代码中的 alertTemplate 就是这18个模板中的其中一个,它的主要用于展示重要信息,比如通过一段消息提示用户在继续操作之前需要执行其他的操作等。此时,距离你们编译运行你们的第一个tvOS应用已为时不远了。

配置服务器

打开 Terminal 输入如下命令:


cd ~/Desktop/client
python -m SimpleHTTPServer 9001

这两行命令的作用是在先前创建的client目录中开启一个基于Python的web服务器。现在,你们可以准备起飞了!

回到你的Xcode项目中编译运行程序。你应该可以看到你的第一个tvOS TVML应用了!

tvOS-7

我不知道你们的感觉如何,但是当我第一次运行成功后,我的感受就像下面这个家伙一样:

tvOS-8

在继续进行教程之前,我想花点时间对你们目前已经完成的工作作以总结:

  1. 你们创建了TVApplicationController实例。它用于管理JavaScript代码。
  2. 你们创建了TVApplicationControllerContext实例,并在创建TVApplicationController时将其与之关联。应用上下文有一个launchOption属性,用来构建我们的BASEURL,也就是服务器的地址。该应用上下文也用于配置tvOS应用与哪个服务器连接。
  3. 控制器被传到了JavaScript代码中。App.onLaunch作为整个JavaScript文件的入口方法,你们定义了createAlert函数,返回TVML提示信息模板文件,并由navigationDocument管理并展现界面。最后将“Hello World”显示在屏幕上。

即使现在你们使用的服务器是运行在本机的,但是你们仍然可以连接一个真实的远程的服务器,可能是一个连着数据库的服务器。你们感受并想象一下应用场景,应该会很酷,对吧?

完善TVML模板

我之前提到过,createAlert是一个返回TVML模板文件的函数。有很多属性可由我们在TVML文件中编辑修改,作为一个实验性质的小例子,你们将会在当前的 alertTemplate 中添加一个按钮。回到你们的JavaScript代码中,将目光聚焦在createAlert函数上,在模板中添加一个按钮:


var alertString = `<?xml version="1.0" encoding="UTF-8" ?>
<document>
<alertTemplate>
<title>${title}</title>
<description>${description}</description>
<button>
<text>OK</text>
</button>
</alertTemplate>
</document>`

这里解释一下上述代码:

  1. 一个TVML文件的第一级标签是<document>,也就是整个模板内容是由<document></document>包起来的。
  2. 接着你们开始定义模板。使用Apple提供的 alertTemplate 模板,通过createAlert函数将其返回。
  3. 在该模板里,根据Apple的Apple TV Markup Language Reference文档规范,添加了按钮、标题、描述三个标签。

保存你们刚才编辑的JavaScript文件,再次编译运行。你们看到在提示信息下面出现了一个按钮。瞧,TVML是不是将构建tvOS UI变得很简单!

tvOS-9

注意:在一个模板中,你能添加的元素数量和类型基于这个的模板的类型。比如,一个 loading Template 就不允许有任何按钮出现。此外,你可以自定义字体、颜色和其他一些属性。但是这些知识已经超越了该教程的范畴。你们可以查阅Apple TV Markup Language Reference文档去了解更多TVML模板的信息。

丰富JavaScript客户端

到目前为止,你们已经完成了一些工作,并且正按照我们的指引一步一步达成目标。在这一节中,你们将要花一点时间在不同的JavaScript文件中将一些逻辑抽象出来,便于能更好的重用。

client/js 文件夹中新建一个JavaScript文件,名为 Presenter.js 。在该文件中,你们将定义Presenter类用于处理导航各个界面,或者说各个TVML模板文件,并且处理事件响应。在 Presenter.js 中添加如下代码:


var Presenter = {
// 1
makeDocument: function(resource) {
if (!Presenter.parser) {
Presenter.parser = new DOMParser();
}
var doc = Presenter.parser.parseFromString(resource, "application/xml");
return doc;
},
// 2
modalDialogPresenter: function(xml) {
navigationDocument.presentModal(xml);
},

// 3
pushDocument: function(xml) {
navigationDocument.pushDocument(xml);
},
}

让我们解释一下上述代码:

  1. 还记得你们在之前createAlert函数中用过的DOMParser类么,它可以将TVML字符串转换为可用于展示的TVML模板对象。因为该类不需要多次创建实例,所以采用单例模式创建它。然后通过DOMParserparseFormString()方法将TVML字符串转为模板对象。
  2. modalDialogPresenter方法通过传入的TVML模板文件,将其模态的展现在屏幕上。
  3. pushDocument方法是在导航栈中推送一个TVML模板文件,相当于在iOS中push出一个界面。

在之后,你们还会用到Presenter类管理选中处理操作。现在,让我们使用Presenter类对之前的JavaScript代码进行重构。将App.onLaunch中的代码替换为如下代码:


App.onLaunch = function(options) {
// 1
var javascriptFiles = [
`${options.BASEURL}js/Presenter.js`
];
// 2
evaluateScripts(javascriptFiles, function(success) {
if(success) {
var alert = createAlert("Hello World!", "");
Presenter.modalDialogPresenter(alert);
} else {
// 3 Handle the error CHALLENGE!//inside else statement of evaluateScripts.
}
});
}

这些代码相对还是比较容易理解的,我们来看一下:

  1. 首先创建一个新的JavaScript文件的数组。然后通过options参数获取到BASEURL属性,并组装Presenter.js的路径。这里的options就是之前我们在AppDelegate类中创建的TVApplicationControllerContextBASEURL自然也是那时我们设置的。
  2. evaluateScripts将加载JavaScript文件。
  3. 这里,你应该处理异常信息,稍后我们完善这里。

在继续进行之前,编译运行程序,确保JavaScript文件修改过之后程序仍能正常运行。此时,我们通过Presenter类对JavaScript代码的重构有了一个良好的开端:

tvOS-10

现在,看看上面代码中被注释的那行,你们能否自行完成对异常处理的挑战呢。如果evaluateScripts处理失败,可能是因为JavaScript文件的路径写错了,那么你可能会希望在此时显示一个提示消息给用户。提示: 之所以在这里出现了异常,是因为Presenter类加载失败导致,所以在这里你不能使用Presenter类显示提示信息的界面。

你应该通过之前所学到的知识来解决该问题。如果你们觉得有困难,那么可以参照下面的代码:


//将这两行代码插入evaluateScripts的else代码块中.
var errorDoc = createAlert("Evaluate Scripts Error", "Error attempting to evaluate external JavaScript files.");
navigationDocument.presentModal(errorDoc);

想要测试错误信息,你们可以修改一下JavaScript文件的路径,比如把Presenter.js改为Presentr.js


${options.BASEURL}js/Presentr.js

使用CatalogTemplate

catalogTemplate模板同样也是Apple提供的18个模板中的一个。它的作用是以分组的形式展现内容,用它来展示你们最喜欢的RWDevCon视频最好不过了! catalogTemplate有许多有意思的元素:

tvOS-11

复合元素和简单元素

该模板中的banner元素在应用顶部,用于展示应用基本信息,比如名称、标题等。它本身是一个 复合元素 ,也就是说它是由多个 简单元素 组合而成。比如,在banner中很显然有标题,那么该标题就是一个简单的title元素,并且在title背后还有背景图片,这又是另外一个简单元素background。所以banner是由两个简单元素组合而成。

让我们来试试这个模板吧。打开 client 文件夹,在 js 文件夹的同级目录新建两个文件夹,分别命名为 imagestemplates 。此时你的 client 文件夹里的内容应该是这样的:

tvOS-12

你们会需要图片构建模板中的Cells,在我们这个场景中就是一个一个的视频,图片自然就是视频的封面了。我已经为你们准备好了封面图片,你们可以从这里下载。下载成功后,将他们解压放在刚才你们创建的 images 文件夹中。

现在,你们即将要做的工作是在屏幕中显示图片!新建一个JavaScript文件,命名为 RWDevConTemplate.xml.js ,将其存在 templates 文件夹中。

打开 RWDevConTemplate.xml.js ,添加如下代码:


var Template = function() { return `<?xml version="1.0" encoding="UTF-8" ?>
<document>
<catalogTemplate>
<banner>
<title>RWDevConHighlights</title>
</banner>
</catalogTemplate>
</document>`
}

现在,我们试图通过catalogTemplate模板显示一个Banner条。但在使用只包含模板信息的JavaScript文件之前,我们需要通过某种方法让其他的JavaScript文件知道该文件的存在并能加载其模板信息,因为当前它没有通过任何方式向其他JavaScript文件暴露过。所以我们要创建的最后一个JavaScript文件: ResourceLoader.js 就是用来解决该问题的!

ResourceLoader

新建一个JavaScript文件,命名为 ResourceLoader.js ,保存在 js 文件夹中,和 application.jsPresenter.js 一起。打开 ResourceLoader.js 添加如下代码:


function ResourceLoader(baseurl) {
this.BASEURL = baseurl;
}

ResourceLoader.prototype.loadResource = function(resource, callback) {
var self = this;
evaluateScripts([resource], function(success) {
if(success) {
var resource = Template.call(self);
callback.call(self, resource);
} else {
var title = "Resource Loader Error",
description = `Error loading resource '${resource}'. \n\n Try again later.`,
alert = createAlert(title, description);
navigationDocument.presentModal(alert);
}
});
}

不用过于担心看不懂这些代码逐行的含义,你们只要清楚这些代码的作用是加载其他模板文件就可以了。

之前我们的主屏显示的是“Hello World”的提示信息模板,现在试着将它换成我们创建的RWDevConTemplate。打开 application.js 文件,根据如下代码修改之前的代码:


// 1
var resourceLoader;

App.onLaunch = function(options) {
// 2
var javascriptFiles = [
`${options.BASEURL}js/ResourceLoader.js`,
`${options.BASEURL}js/Presenter.js`
];

evaluateScripts(javascriptFiles, function(success) {
if(success) {
// 3
resourceLoader = new ResourceLoader(options.BASEURL);
resourceLoader.loadResource(`${options.BASEURL}templates/RWDevConTemplate.xml.js`, function(resource) {
var doc = Presenter.makeDocument(resource);
Presenter.pushDocument(doc);
});
} else {
var errorDoc = createAlert("Evaluate Scripts Error", "Error attempting to evaluate external JavaScript files.");
navigationDocument.presentModal(errorDoc);
}
});
}

// 先不管createAlert函数

此时你们已经对之前的代码进行了三处的修改:

  1. 申明了一个resourceLoader变量。
  2. ResourceLoader.js 文件添加到JavaScript文件数组中。
  3. 使用resourceLoader加载TVML模板,然后使用Presenter展现在屏幕上。

编译运行程序,你们应该会看到如下界面:

tvOS-13

恭喜你们,现在你们已经可以通过更好的方式从JavaScript文件中加载TVML模板信息了,而不再使用硬编码写死在代码里!

完善catalogTemplate

你管你们信不信,我们要做的tvOS应用马上要接近尾声了。通过TVML开发tvOS应用最优雅的一件事就是添加界面元素非常之简单。

打开 RWDevConTemplate.xml.js 文件,按照如下代码更新之前代码:


var Template = function() { return `<?xml version="1.0" encoding="UTF-8" ?>
<document>
<catalogTemplate>
<banner>
<title>RWDevConHighlights</title>
</banner>
//add stuff here
//1.
<list>
<section>
//2.
<listItemLockup>
<title>Inspiration Videos</title>
<decorationLabel>13</decorationLabel>
</listItemLockup>
</section>
</list>
</catalogTemplate>
</document>`
}
  1. 在上面的代码中,新定义了一个list标签,该标签中的内容就是显示在屏幕上除了Banner以外的全部内容。
  2. listItemLockup代表一个组,它以listItemLockup标签开头。在该标签中,通过title标签定义了它的名称“Inspiration Videos”,然后通过decorationLabel标签定义了该组中包含内容的数量。

编译运行程序,在模拟器中你们会看到如下界面:

tvOS-14

看着还不赖吧!

完成catalogTemplate

最后,我们准备在模板中添加cell,用于展示每一个视频。打开 RWDevConTemplate.xml.js 添加如下代码:


var Template = function() { return `<?xml version="1.0" encoding="UTF-8" ?>
<document>
<catalogTemplate>
<banner>
<title>RWDevConHighlights</title>
</banner>
<list>
<section>
<listItemLockup>
<title>Inspiration Videos</title>
<decorationLabel>13</decorationLabel>
//1. add from here
<relatedContent>
<grid>
<section>
//2
<lockup videoURL="http://www.rwdevcon.com/videos/Ray-Wenderlich-Teamwork.mp4">
<img src="${this.BASEURL}images/ray.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Ryan-Nystrom-Contributing.mp4">
<img src="${this.BASEURL}images/ryan.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Matthijs-Hollemans-Math-Isnt-Scary.mp4">
<img src="${this.BASEURL}images/matthijs.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Vicki-Wenderlich-Identity.mp4">
<img src="${this.BASEURL}images/vicki.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Alexis-Gallagher-Identity.mp4">
<img src="${this.BASEURL}images/alexis.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Marin-Todorov-RW-Folklore.mp4">
<img src="${this.BASEURL}images/marin.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Chris-Wagner-Craftsmanship.mp4">
<img src="${this.BASEURL}images/chris.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Cesare-Rocchi-Cognition.mp4">
<img src="${this.BASEURL}images/cesare.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Ellen-Shapiro-Starting-Over.mp4">
<img src="${this.BASEURL}images/ellen.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Jake-Gundersen-Opportunity.mp4">
<img src="${this.BASEURL}images/jake.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Kim-Pedersen-Finishing.mp4">
<img src="${this.BASEURL}images/kim.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Tammy-Coron-Possible.mp4">
<img src="${this.BASEURL}images/tammy.png" width="500" height="308" />
</lockup>
<lockup videoURL="http://www.rwdevcon.com/videos/Saul-Mora-NSBrief.mp4">
<img src="${this.BASEURL}images/saul.png" width="500" height="308" />
</lockup>
</section>
</grid>
</relatedContent>
</listItemLockup>
</section>
</list>
</catalogTemplate>
</document>`
}
  1. 从上述代码中可以看到,在listItemLockup标签中添加了relatedContent,该标签是的作用是显示图中红色圆圈区域的:

tvOS-15

  1. 每个lockup代表一个视频,每个该标签中都有videoURL的属性,它的值就是 RWDevCon 网站上视频的地址。对于之后播放视频至关重要。

编译运行程序,会看到被你赋予新生命力的应用:

tvOS-16

现在,我们已经在“Inspiration Videos”这个组里添加了若干视频。让我们打开遥控器的模拟器,选中模拟器,在菜单栏中选择 Hardware\Show Apple TV Remote 。你可以通过遥控器中的 option 键选择不同的视频。

播放视频

到目前为止,我们已经构建好了应用的页面,看起来还是不错的。此时,你们可以再想想如果用iOS框架完成你们现在已经完成的布局,应该如何做。Apple把一些UI的细节全都抽象了出来,通过一个个模板提供给我们使用,可以让我们简单方便的通过模板创建出完美的界面,不得不说Apple做的太棒了。

接下来让我们完成最后两个遗留的功能:选择视频和播放视频。

选择事件

你们可能已经注意到了,当按下 enter 键或者在 Apple TV Remote 选择视频时并没有什么反应,所以是时候来实现选择视频的功能了。

打开Presenter,添加如下代码:


load: function(event) {
//1
var self = this,
ele = event.target,
videoURL = ele.getAttribute("videoURL")
if(videoURL) {
//2
var player = new Player();
var playlist = new Playlist();
var mediaItem = new MediaItem("video", videoURL);

player.playlist = playlist;
player.playlist.push(mediaItem);
player.present();
}
},
  1. load函数用来处理视频选择事件。它相当于iOS中的@IBAction,该函数的event参数相当于sender参数。每个event都有一个target,每个target关联着模板中的lockup元素。一个lockup代表应用中的一个视频,它里面有视频封面的属性,以及视频地址videoURL属性。
  2. 播放视频非常简单。PlayerTVJS 框架提供的一个类,负责所有视频播放的相关功能。你们所要做的只是添加一个播放列表playlist,然后将要播放的项目mediaItem添加到播放列表里。最后通过player.present()方法就可以播放视频了。

现在你们已经实现了选择视频后的响应事件。是时候将选择事件与每个视频关联在一起了。打开 application.js 文件,在App.onLaunch方法中添加如下代码:


App.onLaunch = function(options) {
//...
//在resourceLoader.loadResource中...
var doc = Presenter.makeDocument(resource);
doc.addEventListener("select", Presenter.load.bind(Presenter)); //add this line
Presenter.pushDocument(doc);
//...
}

上述代码中的addEventListener方法相当于iOS中按钮的@IBAction。编译运行程序,选择一个视频播放,你会看到一个完美的视频播放应用:

tvOS-17

大家可以在这里下载教程中的完整项目:clientRWDevCon

本文首发CSDN,如需转载请与CSDN联系。

在很多人的心目中iPhone的经典机型应该是iPhone4,精湛的工艺、完美的屏幕配上暗色的锁屏壁纸,看着屏幕底部闪烁的slide to unlock字样,高逼格彰显无遗。时至今日无论iPhone机身样式如何改变,屏幕尺寸如何改变,iOS系统如何改变,唯有锁屏底部闪烁的滑动来解锁不变。它的动画效果是如何实现的呢,这篇文章会告诉你们答案。

CAGradientLayer

新建一个应用名为GradientAnimation,打开Main.storyboard,将ViewController的View背景色设置为灰黑色,拖一个UIView到ViewController中,将其颜色设置为无色并设置好布局约束:

GradientAnimation - 1

接着我们拖一个UILabel到刚才拖入的UIView中,设置高宽等同于它的父视图:

GradientAnimation - 2

我对该UILabel的属性设置如下:

GradientAnimation - 4

之后,我们在ViewController中添加UIView和UILabel的Outlet:

GradientAnimation - 5

接下来回到ViewController.swift,我们添加一个常量属性gradientLayer


let gradientLayer = CAGradientLayer()

这里出现了CALayer的另一个子类CAGradientLayer,这个类的作用就是能在Layer上绘制出渐变颜色的效果,然后在viewDidLoad()中添加如下代码:


gradientLayer.bounds = CGRect(x: 0, y: 0, width: backgroundView.frame.size.width, height: backgroundView.frame.size.height)
gradientLayer.position = CGPoint(x: backgroundView.frame.size.width/2, y: backgroundView.frame.size.height/2)

上述两行的代码是设置Layer的大小及位置,这在上两篇文章中已经讲过,这里就不再累赘了。接着我们继续添加两行代码:


gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)

既然CAGradientLayer可以绘制出渐变颜色的效果,那自然有颜色渐变的方向,所以这两行代码的作用就是设置颜色渐变的起始点和结束点,这两个属性共同决定了颜色渐变的方向:

GradientAnimation - 3

从上面的示意图中可以看出,CAGradientLayer是通过起始点和结束点的坐标位置来决定颜色渐变的方向的,起始点的默认值是(0.5, 0),结束点的默认值是(0.5, 1),也就是说默认的颜色渐变方向是沿垂直中线从上往下渐变的,我们在这里将它改成了沿水平中线从左往右渐变。

接下来我们设置CAGradientLayer的渐变颜色,接着添加如下代码:


gradientLayer.colors = [
UIColor.blackColor().CGColor,
UIColor.whiteColor().CGColor,
UIColor.blackColor().CGColor
]

CAGradientLayer的colors属性类型是一个数组[AnyObject],这就意味着我们可以实现多个颜色的渐变效果,并且可以规定各个颜色的顺序。不过在我们这个示例中我们只需要两种颜色,不过需要注意的是虽然颜色只有两种,但是整个颜色渐变的过程中有三个原色点,那就是黑、白、黑,所以我们在这个数组中也需要按照原色点的数量和顺序添加相应的颜色,哪怕颜色都是一样的。

我们既然设置了渐变的三个原色,那么就要对这原色出现的位置进行设置,接着添加如下代码:


gradientLayer.locations = [0.2, 0.5, 0.8]

从上述代码中不难看出,我们将第一个黑色原色出现的位置设置在了整个Layer长度的十分之二的位置,第二个白色原色在中间,第三个黑色原色在十分之八的位置:

GradientAnimation - 6

设置完CAGradientLayer的相关属性后,我们将gradientLayer添加到backgroundView的Layer中:


backgroundView.layer.addSublayer(gradientLayer)

现在我们编译运行一下代码看看效果:

GradientAnimation - 7

接下来我们需要让颜色渐变动起来,先创建一个方法gradinetAnimate(),在方法中添加如下代码:


let gradient = CABasicAnimation(keyPath: "locations")
gradient.fromValue = [0, 0, 0.25]
gradient.toValue = [0.75, 1, 1]
gradient.duration = 2.5
gradient.repeatCount = HUGE
gradientLayer.addAnimation(gradient, forKey: nil)

首先,创建了一个locations类型的动画实例gradient,将fromValue属性,也就是起始位置的属性设置为[0, 0, 0.25],它的意思是动画开始前,黑色、白色这两个原色的位置在整个Layer的最前端,第二个黑色原色在0.25的位置:

GradientAnimation - 8

而结束位置toValue,将白色和第二个黑色原色位置设置在整个Layer的末端,第一个黑色原色在0.75的位置:

GradientAnimation - 9

从图中可以看出,此时整个Layer都变成了黑色。也就是说,在整个动画中,第一个黑色原色从0移动到0.75的位置,白色原色从0移动到1的位置,第二个黑色原色从0.25移动到1的位置。然后设置动画时间为2.5秒,无线重复次数,最后将gradient动画添加到gradientLayer中。我们在viewDidAppear()方法中调用该动画方法gradientAnimate(),编译运行看看效果:

GradientAnimation - 10

动画效果还不赖,但是如何将颜色渐变的动画作用在UILabel的文字上呢?其实非常简单,就是让UILabel上的文字称为CAGradientLayer的遮罩即可,我们先在ViewController中定义一个常量:


var text = "DevTalking"

然后在viewDidAppear()中的gradientAnimate()方法之前添加如下代码:


textLabel.text = text
gradientLayer.mask = textLabel.layer

我们再编译运行代码看看效果:

GradientAnimation - 11

到目前为止,锁屏中滑动来解锁的动画效果就完成了,这个动画效果在Facebook的Paper应用中也有使用。下一节,我们在该动画的基础上对文字再加点小动画。

Text Animation

首先我们打开AppDelegate.swift,在import UIKit下面添加一个方法:


func delay(seconds seconds: Double, completion:()->()) {
let intervalTime = dispatch_time(DISPATCH_TIME_NOW, Int64( Double(NSEC_PER_SEC) * seconds ))

dispatch_after(intervalTime, dispatch_get_main_queue(), {
completion()
})
}

这个方法的作用如其名称一样,是一个延迟方法,该方法的第一个参数是想要延迟的时间,第二个参数是一个闭包,也就是延迟的主体。这个方法用到了GCD的知识,dispatch_time主要是用于创建一个类型为dispatch_time_t的相对时间,它的第一个参数指的是起始时间,一般都是用预定义的DISPATCH_TIME_NOW作为第一个参数的值,代表当前的时间。第二个参数代表时间间隔,注意这个参数需要的时间单位是纳秒,所以我们使用预定义的NSEC_PER_SEC纳秒单位乘以希望间隔的秒数。

dispatch_after用于在队列中定时执行任务,当你想在一段时间后执行一个任务,那么就可以用这个函数。该函数的第一个参数指定延迟的时间,第二个参数指定一个队列,用于添加任务,第三个参数是一个闭包,也就是要执行的任务。

然后回到ViewController.swift,添加如下方法:


func textAnimate(text: String) {
if text.characters.count > 0 {
textLabel.text = "\(textLabel.text!)\(text.substringToIndex(text.startIndex.successor()))"
delay(seconds: 0.4, completion: {
self.textAnimate(text.substringFromIndex(text.startIndex.successor()))
})
}
}

该方法的参数text就是UILabel中要显示的文字内容。substringToIndex(_ to: Int)方法的作用是从字符串的开头一直截取到指定的位置,但不包括该指定位置的字符。text.startIndex.successor()这句意思是从text的起始位置开始取后面的一个字符。substringFromIndex(_ from: Int)方法的作用是以指定位置并包括指定位置的字符开始,一直截取之后的全部字符。所以整个方法的作用是每隔0.4秒显示一个字符,直到将整个字符串显示完。最后在viewDidAppear()中注释掉textLabel.text = text这行代码,并在方法最后调用textAnimate(text)方法。编译运行代码看看最终效果:

GradientAnimation - 12

参考文献来自:iOS Animations by Tutorials