转载

读 Concurrency Programming Guide 笔记(一)

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

并发任务是指多个任务在某一时刻同时运行。在过去,一提到并发执行任务,首当其冲的解决方案就是在程序中创建多个线程来实现,但是线程本身较为底层,而且管理的难度比较大,如果想做倒最优的线程数量、最恰当的线程创建销毁时机是很难的,以至于虽然达到了并发执行任务的目的,但却以降低程序性能为代价,所以往往得不偿失。

鉴于上述的原因,于是一些实现并发任务的其他方案出现了。在OS X和iOS系统中采用了多种实现并发执行任务的方法,与直接创建线程不同,这些方法让开发者只需要关注要执行的任务,然后让系统执行它们即可,不需要关心线程管理的问题,为开发者提供了一个简单而高效的并发任务编程模式。

其中一种实现任务异步执行的技术就是Grand Central Dispatch(GCD),该技术封提供了系统级别的线程管理功能,我们在使用它时只需要定义我们希望执行的任务,然后将任务添加到对应的分派执行队列中即可。另外一个技术是Operation queues,具体的实现是Objective-C中的 NSOperationQueue 对象,它的作用和GCD很相似,同样只需要我们定义好任务,然后添加到对应的操作队列中即可,其他与线程管理相关的事都由 NSOperationQueue 帮我们完成。

Dispatch Queues简述

Dispatch Queues是基于C语言的,执行自定义任务的技术,从字面意思理解其实就是执行任务的队列,使用GCD执行的任务都是放在这个队列中执行的,当然队列的数量可以有多个,类型也不止一种。一个Dispatch queue可以串行的执行任务,也可以并行的执行任务,但不管哪种执行任务的方式,都遵循先进先出的原则。串行队列一次只能执行一个任务,当前任务执行完后才能执行下一个任务,并且执行任务的顺序和添加任务的顺序是一致的。并行队列自然是可同时执行多个任务,不需要等待上个任务完成后才执行下个任务。我们来看看Dispatch queue还有哪些好的特性:

  • 有简单宜用,通俗易懂的编程接口。
  • 提供了自动管理的线程池。
  • 可自动调节队列装载任务的速度。
  • 更优的内存使用率。
  • 使用户不用担心死锁的问题。
  • 提供了比线程锁更优的同步机制。

使用Dispatch Queue时,需要将任务封装为一个函数或者一个 blockblock 是Objective-C中对闭包的实现,在OS X 10.6和iOS 4.0时引入的,在Swift中直接为闭包。

Dispatch Sources简述

Dispatch Source是GCD中的一个基本类型,从字面意思可称为调度源,它的作用是当有一些特定的较底层的系统事件发生时,调度源会捕捉到这些事件,然后可以做其他的逻辑处理,调度源有多种类型,分别监听对应类型的系统事件。我们来看看它都有哪些类型:

  • Timer Dispatch Source:定时调度源。
  • Signal Dispatch Source:监听UNIX信号调度源,比如监听代表挂起指令的SIGSTOP信号。
  • Descriptor Dispatch Source:监听文件相关操作和Socket相关操作的调度源。
  • Process Dispatch Source:监听进程相关状态的调度源。
  • Mach port Dispatch Source:监听Mach相关事件的调度源。
  • Custom Dispatch Source:监听自定义事件的调度源。

Dispatch Source是GCD中很有意思也很有用的一个特性,根据不同类型的调度源,我们可以监听较为底层的系统行为,不论在实现功能方面还是调试功能方面都非常游有用,后文中会再详细讲述。

Operation Queues简述

Operation Queue与Dispatch Queue很类似,都是有任务队列或操作队列的概念,只不过它是由Cocoa框架中的 NSOperationQueue 类实现的,它俩最主要的区别是任务的执行顺序,在Dispatch Queue中,任务永远都是遵循先进先出的原则,而Operation Queue加入了其他的任务执行顺序特性,使下一个任务的开始不再取决于上个任务是否已完成。

上文说过,使用Dispatch Queue时,需要将任务封装为一个函数或者闭包。而在Operation Queue中,需要将任务封装为一个 NSOpertaion 对象,然后放入操作队列执行。同时该对象还自带键值观察(KVO)通知特性,可以很方便的监听任务的执行进程。

设计并发任务时应该注意的事项

虽然并发执行任务可以提高程序对用户操作的响应速度,最大化使用内核,提升应用的效率,但是这些都是建立在正确合理使用并发任务技术,以及应用程序确实需要使用这类技术的前提下。如果使用不得当,或者对简单的应用程序画蛇添足,那么反而会因为使用了并发任务技术而导致应用程序性能下降,另一方面开发人员面对的代码复杂度也会增加,维护成本同样会上升。所以在准备使用这类技术前一定要三思而行,从性能、开发成本、维护成本等多个方面去考虑是否需要使用并发任务技术。

考虑是否需要用只是第一步,当确定使用后更不能盲目的就开始开发,因为并发任务技术的使用需要侵入应用程序的整个开发生命周期,所以在应用开发之初,就是考虑如何根据这类技术去设计并发任务,考虑应用中任务的类型、任务中使用的数据结构等等,否则亡羊补牢也为时已晚。这一节主要说说在设计并发任务时应该注意哪些事。

梳理应用程序中的任务

在动手写代码前,尽量根据需求,穷举应用中的任务以及在任务中涉及到的对象何数据结构,然后分析这些任务的优先级和触发类型,比如罗列出哪些任务是由用户操作触发的,哪些是任务是无需用户参与触发的。

当把任务根据优先级梳理好后,就可以从高优先级的任务开始逐个分析,考虑任务在执行过程中涉及到哪些对象和数据结构,是否会修改变量,被修改的变量是否会对其他变量产生影响,以及任务的执行结果对整个程序产生什么影响等。举个简单的例子,如果一个任务中对某个变量进行了修改,并且这个变量不会对其他变量产生影响,而且任务的执行结果也相对比较独立,那么像这种任务就最合适让它异步去执行。

进一步细分任务中的执行单元

任务可以是一个方法,也可以是一个方法中的一段逻辑,不论是一个方法还是一段逻辑,我们都可以从中拆分出若干个执行单元,然后进一步分析这些执行单元,如果多个执行单元必须得按照特定得顺序执行,而且这一组执行单元的执行结果想对独立,那么可以将这若干执行单元视为执行单元组,可以考虑让该执行单元组异步执行,其他不需要按照特定顺序的执行单元可以分别让它们异步执行。可以使用的技术可以用GCD或者Operation Queue。

在拆分执行单元时,尽量拆的细一点,不要担心执行单元的数量过多,因为GCD和Operation Queue有着高性能的线程管理机制,不需要担心过多的使用任务队列会造成性能损耗。

确定合适的队列

当我们将任务分解为一个个执行单元并分析之后,下一步就是将这些执行单元封装在 block 中或者封装为 NSOperation 对象来使用GCD或Operation Queues,但在这之前还需要我们根据执行单元确定好适合的队列,不管是Dispatch queue还是Operation queue,都需要明确是使用串行队列还是并行队列,确定是将多个执行单元放入一个队列中还是分别放入多个队列中,以及使用正确优先级的队列。

提高效率的其他技巧

在使用任务队列时注意以下几点,可以有效的提高执行效率:

  • 如果应用比较吃内存,那么建议在任务中直接计算一些需要的值,这样比从主存中加载要来的快。
  • 尽早确定顺序执行的任务,尽量将其改为并行任务,比如说有多个任务存在资源竞争问题,那么可以根据情况分别为每个任务拷贝一份该资源,从而避免顺序执行任务,以提高执行效率。
  • 避免使用线程锁机制。在使用GCD或Operation Queues技术时基本不需要使用线程锁,因为有串行队列的存在。
  • 尽量使用系统提供的框架达到并发任务的目的,一些系统提供的框架本身就有一些方法函数可以让任务并发执行,比如 UIView 提供的一系列动画的方法等。

Operation Queues

Operation Queue技术由Cocoa框架提供,用于实现任务并发异步执行的技术,该技术基于面向对象概念。该技术中最主要的两个元素就是Operation对象和Operation队列,我们先来看看Operation对象。

Operation Objects

Operation对象的具体实现是Foundation框架中的 NSOperation 类,它的主要作用就是将我们希望执行的任务封装起来,然后去执行。 NSOperation 类本身是一个抽象类,在使用时需要我们创建子类去继承它,实现一些父类的方法,以达到我们使用的需求。同时Foundation框架也提供了两个已经实现好的 NSOperation 子类,供我们方便的使用:

  • NSInvocationOperation :当我们已经有一个方法需要异步去执行,此时显然没有必要为了这一个方法再去创建一个 NSOperation 的子类,所以我们就可以用 NSInvocationOperation 类来封装这个方法,然后放入操作队列去执行,以满足我们的需求。
  • NSBlockOperation :该类可以让我们同时执行多个 block 对象或闭包。

同时所有继承 NSOperation 的子类都会具有如下特性:

  • 可自动管理Operation对象之间的依赖关系,举个例子,当一个Operation对象执行之前发现它包含的任务中有依赖其他的Operation对象,并且该Operation对象还没有执行完成,那么当前的Operation对象会等待它的依赖执行完成后才会执行。
  • 支持可选的完成时回调闭包,该闭包可以在Operation对象包含的主要任务执行完之后执行。
  • 自带键值观察(KVO)通知特性,可以监听任务的执行状态。
  • 可在运行时终止任务执行。

虽然Operation Queues技术主要是通过将Operation对象放入队列中,实现并发异步的执行任务,但是我们也可以直接通过 NSOperation 类的 start 方法让其执行任务,但这样就属于同步执行任务了,我们还可以通过 NSOperation 类的 isConcurrent 方法来确定当前任务正在异步执行还是同步执行。

创建NSInvocationOperation对象

上文中已经提到过, NSInvocationOperation 对象是Foundation框架提供的 NSOperation 抽象类的实现,主要作用是方便我们将已有对象和方法封装为Operation对象,然后放入操作队列执行目标方法,同时该对象的好处是可以避免我们为已有的对象的方法逐个创建Operation对象,避免冗余代码。不过,由于 NSInvocationOperation 不是类型安全的,所以从Xcode 6.1开始,在Swift中就不能再使用该对象了。我们可以看看在Objective-c中如何创建该对象:

@implementation MyCustomClass 

- (NSOperation*)taskWithData:(id)data {

NSInvocationOperation* theOp = [[NSInvocationOperation alloc] initWithTarget:self selector:@selector(myTaskMethod:) object:data];

return theOp;



- (void)myTaskMethod:(id)data {

// Perform the task.

}

@end

NSInvocationOperation 对象创建好后,可以调用它父类 NSOperationstart 方法执行任务,但是这种不放在操作队列中的执行方式都是在当前线程,也就是主线程中同步执行的。

创建NSBlockOperation对象

NSBlockOperation 是另外一个由Foundation框架提供的 NSOperation 抽象类的实现类,该类的作用是将一个或多个block或闭包封装为一个Operation对象。在第一次创建 NSBlockOperation 时至少要添加一个block:

import Foundation

class TestBlockOperation {

func createBlockOperationObject() -> NSOperation {

print("The main thread num is /(NSThread.currentThread())")

let nsBlockOperation = NSBlockOperation(block: {

print("Task in first closure. The thread num is /(NSThread.currentThread())")

})

return nsBlockOperation

}

}

let testBlockOperation = TestBlockOperation()

let nsBlockOperation = testBlockOperation.createBlockOperationObject()
nsBlockOperation.start()

上面的代码中我们首先打印了主线程的线程号,然后通过 createBlockOperationObject 方法创建了一个 NSBlockOperation 对象,在初始化时的block中同样打印了当前线程的线程号,调用它父类的方法 start 后,可以看到这个block中的任务是在主线程中执行的:

The main thread id is <NSThread: 0x101502e40>{number = 1, name = main}
Task in first closure. The thread id is <NSThread: 0x101502e40>{number = 1, name = main}

然而我们也也可以通过 NSBlockOperation 对象的方法 addExecutionBlock 添加其他的block或者说任务:

import Foundation

class TestBlockOperation {

func createBlockOperationObject() -> NSOperation {

print("The main thread num is /(NSThread.currentThread())")

let nsBlockOperation = NSBlockOperation(block: {

print("Task in first closure. The thread num is /(NSThread.currentThread())")

})

// 第一种写法
nsBlockOperation.addExecutionBlock({

print("Task in second closure. The thread num is /(NSThread.currentThread())")

})

// 第二种写法
nsBlockOperation.addExecutionBlock{

print("Task in third closure. The thread num is /(NSThread.currentThread())")

}

return nsBlockOperation

}

}

let testBlockOperation = TestBlockOperation()

let nsBlockOperation = testBlockOperation.createBlockOperationObject()
nsBlockOperation.start()

当我们再执行 NSBlockOperation 时,可以看到后面添加的两个任务都在不同的二级线程中执行,此时个任务为并发异步执行:

The main thread id is <NSThread: 0x101502e40>{number = 1, name = main}
Task in first closure. The thread id is <NSThread: 0x101502e40>{number = 1, name = main}
Task in third closure. The thread id is <NSThread: 0x101009190>{number = 2, name = (null)}
Task in second closure. The thread id is <NSThread: 0x101505110>{number = 3, name = (null)}

通过上面两段代码可以观察到,当 NSBlockOperation 中只有一个block时,在调用 start 方法执行任务时不会为其另开线程,而是在当前线程中同步执行,只有当 NSBlockOperation 包含多个block时,才会为其另开二级线程,使任务并发异步执行。另外,当 NSBlockOperation 执行时,它会等待所有的block都执行完成后才会返回执行完成的状态,所以我们可以用 NSBloxkOperation 跟踪一组block的执行情况。

自定义Operation对象

如果 NSInvocationOperation 对象和 NSBlockOperation 对象都不能满足我们的需求,那么我们可以自己写一个类去继承 NSOperation ,然后实现我们的需求。在实现自定义Operation对象时,分并发执行任务的Operation对象和非并发执行任务的Operation对象。

自定义非并发Operation对象

实现非并发Operation对象相对要简单一些,通常,我们最少要实现两个方法:

  • 自定义初始化方法:主要用于在初始化自定义Operation对象时传递必要的参数。
  • main 方法:该方法就是处理主要任务的地方,你需要执行的任务都在这个方法里。

当然除了上面两个必须的方法外,也可以有被 main 方法调用的私有方法,或者属性的 getset 方法。下面以一个网络请求的例子展示如何创建自定义的Operation对象:

import Foundation

class MyNonconcurrentOperation: NSOperation {

var url: String?

init(withURL url: String) {

self.url = url

}

override func main() {

// 1.
guard let strURL = self.url else {

return

}

// 2.
var nsurl = NSURL(string: strURL)

// 3.
var session: NSURLSession? = NSURLSession.sharedSession()

// 4.
var dataTask: NSURLSessionDataTask? = session!.dataTaskWithURL(nsurl!, completionHandler: { (nsdata, nsurlrespond, nserror) in

if let error = nserror {

print("出现异常:/(error.localizedDescription)")

} else {

do {

let dict = try NSJSONSerialization.JSONObjectWithData(nsdata!, options: NSJSONReadingOptions.MutableContainers)

print(dict)

} catch {

print("出现异常")

}

}

})

// 5.
dataTask!.resume()

sleep(10)

}

}

let myNonconcurrentOperation = MyNonconcurrentOperation(withURL: "http://www.baidu.com/s?wd=ios")
myNonconcurrentOperation.start()

我们创建了自定义的Operation类 MyNonconcurrentOperation ,让其继承 NSOperation ,在 MyNonconcurrentOperation 中可以看到只有两个方法 initmain ,前者是该类的初始化方法,主要作用是初始化 url 这个参数,后者包含了任务的主体逻辑代码,我们来分析一下代码:

  1. 我们在初始化 MyNonconcurrentOperation 时,传入了我们希望请求的网络地址,改地址正确与否关系着我们这个任务是否还值得继续往下走,所以在 main 方法一开始先判断一下 url 的合法性,示例代码中判断的很简单,实际中应该使用正则表达式去判断一下。
  2. 将字符串URL转换为 NSURL
  3. 创建 NSURLSession 实例。
  4. 调用 NSURLSession 实例的 dataTaskWithURL 方法,创建 NSURLSessionDataTask 类的实例,用于请求网络。在 completionHandler 的闭包中去判断请求是否成功,返回数据是否正确以及解析数据等操作。
  5. 执行 NSURLSessionDataTask 请求网络。

当我们调用 MyNonconcurrentOperationstart 方法时,就会执行 main 方法里的逻辑了,这就是一个简单的非并发自定义Operation对象,之所以说它是非并发,因为它一般都在当前线程中执行任务,既如果你在主线程中初始化它,调用它的 start 方法,那么它就在主线程中执行,如果在二级线程中进行这些操作,那么就在二级线程中执行。

注:如果在二级线程中使用非并发自定义Operation对象,那么 main 方法中的内容应该使用 autoreleasepool{} 包起来。因为如果在二级线程中,没有主线程的自动释放池,一些资源没法被回收,所以需要加一个自动释放池,如果在主线程中就不需要了。

响应取消事件

一般情况下,当Operation对象开始执行时,就会一直执行任务,不会中断执行,但是有时需要在任务执行一半时终止任务,这时就需要Operation对象有响应任务终止命令的能力。理论上,在Operation对象执行任务的任何时间点都可以调用 NSOperation 类的 cancel 方法终止任务,那么在我们自定义的Operation对象中如何实现响应任务终止呢?我们看看下面的代码:

import Foundation

class MyNonconcurrentOperation: NSOperation {

var url: String?

init(withURL url: String) {

self.url = url

}

override func main() {

// 1.
if self.cancelled {

return

}

guard let strURL = self.url else {

return

}

var nsurl = NSURL(string: strURL)

var session: NSURLSession? = NSURLSession.sharedSession()

// 2.
if self.cancelled {

nsurl = nil

session = nil

return

}

var dataTask: NSURLSessionDataTask? = session!.dataTaskWithURL(nsurl!, completionHandler: { (nsdata, nsurlrespond, nserror) in

if let error = nserror {

print("出现异常:/(error.localizedDescription)")

} else {

// 4.
if self.cancelled {

nsurl = nil

session = nil

return

}

do {

let dict = try NSJSONSerialization.JSONObjectWithData(nsdata!, options: NSJSONReadingOptions.MutableContainers)

print(dict)

} catch {

print("出现异常")

}

}

})

// 3.
if self.cancelled {

nsurl = nil

session = nil

dataTask = nil

return

}

dataTask!.resume()

sleep(10)

}

}

let myNonconcurrentOperation = MyNonconcurrentOperation(withURL: "http://www.baidu.com/s?wd=ios")
myNonconcurrentOperation.start()
myNonconcurrentOperation.cancel()

从上述代码中可以看到,在 main 方法里加了很多对 self.cancelled 值的判断,没错,这就是响应终止执行任务的关键,因为当调用了 NSOperationcancel 方法后, cancelled 属性就会被置为 flase ,当判断到该属性的值为 false 时,代表当前任务已经被取消,我们只需释放资源返回即可。我们只有在整个任务逻辑代码中尽可以细的去判断 cancelled 属性,才可以达到较为实时的终止效果。上面代码中我分别在四个地方判断了 cancelled 属性:

  1. 在任务开始之前。
  2. 任务开始不久,这里刚创建了 NSURLNSURLSession ,所以如果判断出任务已被取消,则要释放它们的内存地址。
  3. 开始请求网络之前,这里同样要释放已经创建的变量内存地址。
  4. 网络请求期间。

自定义并发Operation对象

自定义并发Operation对象其主要实现的就是让任务在当前线程以外的线程执行,相对于非并发Operation对象注意的事项要更多一些,我们先来看要实现的两个方法:

  • init :该方法和非并发Operation对象中的作用一样,用于初始化一些属性。
  • start :该方法是自定义并发Operation对象必须要重写父类的一个方法,通常就在这个方法里创建二级线程,让任务运行在当前线程以外的线程中,从而达到并发异步执行任务的目的,所以这个方法中绝对不能调用父类的 start 方法。
  • main :该方法在非并发Operation对象中就说过,这里的作用的也是一样的,只不过在并发Operation对象中,该方法并不是必须要实现的方法,因为在 start 方法中就可以完成所有的事情,包括创建线程,配置执行环境以及任务逻辑,但我还是建议将任务相关的逻辑代码都写在该方法中,让 start 方法只负责执行环境的设置。

除了上述这三个方法以外,还有三个属性需要我们重写,就是 NSOperation 类中的 executingfinishedconcurrent 三个属性,这三个属性分别表示Operation对象是否在执行,是否执行完成以及是否是并发状态。因为并发异步执行的Operation对象并不会阻塞主线程,所以使用它的对象需要知道它的执行情况和状态,所以这三个状态是必须要设置的,下面来看看示例代码:

import Foundation

class MyConcurrentOperation: NSOperation {

var url: String?

private var ifFinished: Bool

private var ifExecuting: Bool

override var concurrent: Bool {

get { return true }

}

override var finished: Bool {

get { return self.ifFinished }

}

override var executing: Bool {

get { return self.ifExecuting }

}

init(withURL url: String) {

self.url = url

self.ifFinished = false

self.ifExecuting = false

}

override func start() {

if self.cancelled {

self.willChangeValueForKey("finished")

self.ifFinished = true

self.didChangeValueForKey("finished")

return

} else {

self.willChangeValueForKey("executing")

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

self.ifExecuting = true

self.didChangeValueForKey("executing")

}

}

override func main() {

autoreleasepool{

guard let strURL = self.url else {

return

}

var nsurl = NSURL(string: strURL)

var session: NSURLSession? = NSURLSession.sharedSession()

if self.cancelled {

nsurl = nil

session = nil

self.completeOperation()

return

}

var dataTask: NSURLSessionDataTask? = session!.dataTaskWithURL(nsurl!, completionHandler: { (nsdata, nsurlrespond, nserror) in

if let error = nserror {

print("出现异常:/(error.localizedDescription)")

} else {

if self.cancelled {

nsurl = nil

session = nil

self.completeOperation()

return

}

do {

let dict = try NSJSONSerialization.JSONObjectWithData(nsdata!, options: NSJSONReadingOptions.MutableContainers)

print(dict)

self.completeOperation()

} catch {

print("出现异常")

self.completeOperation()

}

}

})

if self.cancelled {

nsurl = nil

session = nil

dataTask = nil

self.completeOperation()

return

}

dataTask!.resume()

}

}

func completeOperation() {

self.willChangeValueForKey("finished")

self.willChangeValueForKey("executing")

self.ifFinished = true

self.ifExecuting = false

self.didChangeValueForKey("finished")

self.didChangeValueForKey("executing")

}

}

由于 NSOperationfinishedexecutingconcurrent 这三个属性都是只读的,我们无法重写它们的 setter 方法,所以我们只能靠新建的私有属性去重写它们的 getter 方法。为了自定义的Operation对象更像原生的 NSOperation 子类,我们需要通过 willChangeValueForKeydidChangeValueForKey 方法手动为 ifFinishedifExecuting 这两个属性生成KVO通知,将 keyPath 设置为原生的 finishedexecuting

上面的代码示例中有几个关键点:

  • start 方法开始之初就要判断一下Operation对象是否被终止任务。
  • main 方法中的内容要放在 autoreleasepool 中,解决在二级线程中的内存释放问题。
  • 如果判断出Operation对象的任务已经被终止,要及时修改 ifFinishedifExecuting 属性。

我们可以测试一下这个自定义的Operation对象:

import Foundation

class Test: NSObject {

private var myContext = 0

let myConcurrentOperation = MyConcurrentOperation(withURL: "http://www.baidu.com/s?wd=ios")

func launch() {

myConcurrentOperation.addObserver(self, forKeyPath: "finished", options: .New, context: &myContext)
myConcurrentOperation.addObserver(self, forKeyPath: "executing", options: .New, context: &myContext)

myConcurrentOperation.start()

sleep(5)

print(myConcurrentOperation.executing)
print(myConcurrentOperation.finished)
print(myConcurrentOperation.concurrent)

sleep(10)

}

override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {

if let change = change where context == &myContext {

if keyPath == "finished" {

print("Finish status has been changed, The new value is /(change[NSKeyValueChangeNewKey]!)")

} else if keyPath == "executing" {

print("Executing status has been changed, The new value is /(change[NSKeyValueChangeNewKey]!)")

}

}

}

deinit {

myConcurrentOperation.removeObserver(self, forKeyPath: "finished", context: &myContext)

myConcurrentOperation.removeObserver(self, forKeyPath: "executing", context: &myContext)

}

}

let test = Test()
test.launch()
原文  http://www.devtalking.com/articles/read-concurrency-programming-guide-1/
正文到此结束
Loading...