返回 登录
0

再读苹果《Threading Programming Guide》笔记(一):初识线程

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

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


创建线程

使用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混用的时候,在同步机制方面一定要配套使用。

作者简介: 付宇轩(@DevTalking),从事Java中间件开发、iOS开发。主要主持开发企业级ETL中间件、BPM中间件、企业级移动应用,个人博客地址:http://www.devtalking.com

第一时间掌握最新移动开发相关信息和技术,请关注mobilehub公众微信号(ID: mobilehub)。

图片描述

评论