iOS开发技术程序员iOS Developer

iOS开发多线程-NSOperation \ GCD详解

2017-12-19  本文已影响52人  Mister志伟
iOS多线程开发必须知道的概念名词:
1. 进程
2. 线程
进程和线程的关系

多线程(英语:multithreading),是指从软件或者硬件上实现多个线程并发执行的技术。

多线程好处多的同时也易引发一些问题,“数据竞争”-多个线程操作同一资源时可能会导致数据的不一致;“死锁”-两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象;使用太多的线程会消耗大量内存,因为每个线程都有自己的寄存器。进程太多的时候也有消耗太大的问题。

3. 串行(serial) & 并发(concurrent)& 并行

串行是同步线程的实现方式,就是任务A执行结束才能开始执行B,单个线程只能执行一个任务。
并发并行其实是异步线程实现的两种形式。并行其实是真正的异步,多核CUP可以同时开启多条线程供多个任务同时执行,互不干扰。并发是伪异步,单个CUP一个时刻只能有一个线程执行,想执行多个任务就必须不断切换执行任务的线程。

4. 同步 & 异步

同步:多个任务情况下,一个任务A执行结束,才可以执行另一个任务B。只存在一个线程。
异步:多个任务情况下,一个任务A正在执行,同时可以执行另一个任务B。任务B不用等待任务A结束才执行。存在多条线程。

5. 调度队列(Dispatch Queue)

调度队列是执行处理的队列也是GCD的基本概念,它按照执行任务添加的顺序(即FIFO-先进先出顺序)执行处理。调度队列在执行处理时存在两种Dispatch Queue,一种是等待现在执行中处理的Serial Dispatch Queue,另一种是不等待现在执行中处理的Concurrent Dispatch Queue,稍后会对这两种队列详细的介绍。官方的说法是有三种队列,还要一种叫Main dispatch queueMain dispatch queue其实也可以归为Serial Dispatch Queue,不过由于它是主线程队列所以单拿了出来。

iOS多线程技术对比

pthread(POSIX thread)是一套通用的多线程API,适用于Unix、Linux、Windows等系统,跨平台、可移植的C语言框架,线程生命周期由开发者管理,使用难度大。GCD的底层实现库中也有用到Libc(pthreads)

NSThread是这几种方法里面相对轻量级的,但需要管理线程的生命周期、同步、加锁问题,这会导致一定的性能开销,同时在多个线程开发时不便于开发维护。详细使用可参考这篇博客

1>任务可以添加依赖关系,即便是异步执行也可以给部分任务执行顺序;
2>添加的任务如果不是已经执行,取消是比较方便的;
3>可以监听任务的状态进而做其他的处理;
4>任务优先级设置比较方便,GCD可以给队列设置优先级而且只有3种;
5>最大并发数设置;GCD实现比较复杂些(用信号量);
6>继承NSOperation自定义。

NSOperation

NSOperation实现多线程主要步骤是:
1> 封装执行的操作到一个NSOperation对象中

2> 将封装的NSOperation对象添加到NSOperationQueue

3> 系统会自动为NSOperation对象封装的任务开启一条线程执行 或者 不加入队列调用-(void)start:在主线程执行

- (void)operationManage{
    // 这样在主线程执行其实是画蛇添足的,只是为了做说明而写
    NSBlockOperation *downloadImgPng = [NSBlockOperation blockOperationWithBlock:^{
       //downloadImage 任务
        NSLog(@"png -- 当前线程%@",[NSThread currentThread]);
    }];
    [downloadImgPng start];
}
//  NSInvocationOperation执行方式
- (void)operationManage{
    NSInvocationOperation *invocationOperation = [[NSInvocationOperation alloc] initWithTarget:self selector:@selector(downloadImage) object:nil];

    NSOperationQueue *operationQueue = [[NSOperationQueue alloc] init];
    // 同一时间最多开启的线程数 
    operationQueue.maxConcurrentOperationCount = 6;
    [operationQueue addOperation:invocationOperation];

    // 取消所有队列中的任务
//    [operationQueue cancelAllOperations];
    
    // 取消执行的任务
//    [invocationOperation cancel];
}

- (void)downloadImage{

}
//  NSBlockOperation执行方式
- (void)operationManage{
    NSBlockOperation *blockOperation = [NSBlockOperation blockOperationWithBlock:^{
       //downloadImage 任务
    }];
    
    NSOperationQueue *operationQueue = [[NSOperationQueue alloc] init];
    // 同一时间最多开启的线程数
    operationQueue.maxConcurrentOperationCount = 6;
    [operationQueue addOperation:blockOperation];
}
- (void)operationManage{
    /*
     * 多任务队列添加依赖--串行执行
     * 一般多任务队列默认是并行执行,添加依赖可按依赖条件顺序执行
     */
    NSBlockOperation *downloadImgPng = [NSBlockOperation blockOperationWithBlock:^{
       //downloadImage 任务
        NSLog(@"png -- 当前线程%@",[NSThread currentThread]);
    }];
    
    NSBlockOperation *downloadImgJpg = [NSBlockOperation blockOperationWithBlock:^{
        //downloadImage 任务
        NSLog(@"jpg -- 当前线程%@",[NSThread currentThread]);
    }];
    [downloadImgJpg addDependency:downloadImgPng];
    
    NSBlockOperation *downloadImgPdf = [NSBlockOperation blockOperationWithBlock:^{
        //downloadImage 任务
        NSLog(@"pdf -- 当前线程%@",[NSThread currentThread]);
    }];
    [downloadImgPdf addDependency:downloadImgJpg];
    
    NSOperationQueue *operationQueue = [[NSOperationQueue alloc] init];
    // 同一时间最多开启的线程数
    operationQueue.maxConcurrentOperationCount = 6;

    [operationQueue addOperations:@[downloadImgPng,downloadImgJpg,downloadImgPdf] waitUntilFinished:NO];
}

NSOperation对象执行状态监听

NSOperation对象执行状态.png
/*
此属性指定应用于添加到队列中的操作对象的服务级别。如果操作对象具有显式的服务水平集,则使用该值。此属性的默认值取决于您创建队列的方式。自己创建的队列,默认值是NSOperationQualityOfServiceBackground。为队列的mainqueue方法返回,默认值是nsoperationqualityofserviceuserinteractive和不能改变的。

服务级别影响给定操作对象访问系统资源的优先级,如CPU时间、网络资源、磁盘资源等。具有较高服务质量级别的操作在系统资源上被赋予更大的优先权,以便它们能更快地执行任务。您使用服务级别确保响应显式用户请求的操作优先于不重要的工作。
*/
@property NSQualityOfService qualityOfService;
typedef enum NSQualityOfService : NSInteger {
    NSQualityOfServiceUserInteractive = 0x21,
    NSQualityOfServiceUserInitiated = 0x19,
    NSQualityOfServiceUtility = 0x11,
    NSQualityOfServiceBackground = 0x09,
    NSQualityOfServiceDefault = -1
} NSQualityOfService;

NSQualityOfServiceUserInteractive 用于直接提供交互式UI的工作。例如,处理控件事件或绘制到屏幕上。
NSQualityOfServiceUserInitiated 用于执行用户明确要求的工作,必须立即提交结果,以便进一步进行用户交互。例如,在用户在邮件列表中选择邮件后,加载电子邮件。
NSQualityOfServiceUtility 用于执行用户不太可能立即等待结果的工作。这项工作可能是用户要求的,也可能是自动启动的,并且经常使用非模态进度指示器在用户可见的时间尺度上运行。例如,周期性内容更新或大容量文件操作,如媒体导入。
NSQualityOfServiceBackground用于非用户发起或可见的工作。一般来说,用户不知道这项工作甚至正在发生。例如,预取内容,搜索索引、备份或同步与外部系统的数据。
NSQualityOfServiceDefault指示没有明确的服务质量信息。只要有可能,适当的服务质量由可用的来源决定。否则,选择的可能是NSQualityOfServiceUserInteractiveNSQualityOfServiceUtility之间服务水平的任意一种。

/*
此属性包含操作的相对优先级。这个值是用来影响其中的操作和执行顺序出列。
官方建议:为了确定优先级,应该始终使用这些常量(而不是定义的值)。
*/
@property NSOperationQueuePriority queuePriority;
typedef NS_ENUM(NSInteger, NSOperationQueuePriority) {
    NSOperationQueuePriorityVeryLow = -8L,
    NSOperationQueuePriorityLow = -4L,
    NSOperationQueuePriorityNormal = 0,
    NSOperationQueuePriorityHigh = 4,
    NSOperationQueuePriorityVeryHigh = 8
};

以上两个属性通过设置合适的值,能让资源利用更加合理。其他的API可查看官方文档使用,特别注意的是- (void)waitUntilFinished;这个接口要慎用,该接口绝不能在主线程调用,会产生死锁卡死主线程。一般来讲用- (void)addDependency:(NSOperation *)op;依赖API就够了,简单易用。

- (void)waitUntilFinished;接口的文档解释:

An operation object must never call this method on itself and should avoid calling it on any operations submitted to the same operation queue as itself. Doing so can cause the operation to deadlock. Instead, other parts of your app may call this method as needed to prevent other tasks from completing until the target operation object finishes. It is generally safe to call this method on an operation that is in a different operation queue, although it is still possible to create deadlocks if each operation waits on the other.
A typical use for this method would be to call it from the code that created the operation in the first place. After submitting the operation to a queue, you would call this method to wait until that operation finished executing.

翻译:操作对象绝不能自己调用这个方法,应该避免在提交给同一操作队列的任何操作中调用它。这样做可能导致操作死锁。相反,应用程序的其他部分可以根据需要调用此方法,以防止其他任务完成,直到目标操作对象完成为止。一般来说,在不同的操作队列中调用这种方法是安全的,但如果每个操作都等待另一个操作,仍然有可能造成死锁。
这种方法的一个典型用途是首先从创建操作的代码调用它。在向队列提交操作之后,您将调用此方法等待该操作完成执行。

waitUntilFinished简单使用.png

GCD的主要API使用

GCD实现多线程的步骤主要有2步:
1>创建队列
2>添加执行任务到队列中
也可以是1步,添加执行任务到系统标准队列
没错,就是这么简单易用!以下是代码片段:

// 创建队列 ,手动创建的队列需要做释放处理,因为Dispatch Queue并没有被作为OC对象处理
/*
 * 1.创建串行队列
 * 串行队列有两种创建方式
 */
    dispatch_queue_t serialQueue = dispatch_queue_create("com.example.gcd.mytest", NULL);
    // 释放队列
    dispatch_release(serialQueue);

    dispatch_queue_t queue = dispatch_queue_create("com.example.gcd.mytest", DISPATCH_QUEUE_SERIAL);
    // 释放队列
    dispatch_release(queue);

// 2.创建并行队列
    dispatch_queue_t concurrentQueue = dispatch_queue_create("com.example.gcd.mytest", DISPATCH_QUEUE_CONCURRENT);
// 3.添加同步任务到队列中
    dispatch_sync(concurrentQueue, ^{
        // 执行任务
    });
    
// 4.添加异步任务到队列中
    dispatch_async(concurrentQueue, ^{
       // 执行任务
    });
    
// 5.释放队列( iOS 6.0 or Mac OS X 10.8 以上系统可以管理GCD对象无需手动释放)
    dispatch_release(concurrentQueue);
// 一步搞定
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        // 执行操作
    });

GCD的队列主要有两种Serial Dispatch QueueConcurrent Dispatch Queue。前者只有一个线程,后者根据任务量,可以开启多条线程。当然多个Serial Dispatch Queue是可以并行执行的。

两种队列和线程的关系.png

Main Dispatch QueueGlobal Dispatch Queue是系统提供的标准Dispatch Queue,这两个标准队列还有一个共同的优点,那就是相比于手动创建的队列,这两个队列不需要开发者做队列释放的操作,因为这两个队列对应用程序而言是全局的,详细可查看官方文档

Main Dispatch Queue可能你已经猜到了,没错,它就是主线程队列,追加到Main Dispatch Queue的处理在主线程的RunLoop中执行,一些需要更新UI界面的操作可以放到这个线程中执行。

Global Dispatch QueueConcurrent Dispatch Queue类型的队列。所以我们一般是不用逐个生成Concurrent Dispatch Queue队列的,只要使用全局队列Global Dispatch Queue就可以了。

    // 主队列
    dispatch_queue_t mainQueue = dispatch_get_main_queue();
    
    // 全局队列
    dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

创建全局队列的APIdispatch_get_global_queue(long identifier, unsigned long flags);需要传两个参数,第一个是优先级,根据实际处理内容选择合适的优先级。第二个官方文档称是为将来使用预留的,一般传数字0即可。
Global Dispatch Queue有4个执行优先级。

#define DISPATCH_QUEUE_PRIORITY_HIGH 2               // 高优先级
#define DISPATCH_QUEUE_PRIORITY_DEFAULT 0            // 默认优先级 
#define DISPATCH_QUEUE_PRIORITY_LOW (-2)             // 低优先级
#define DISPATCH_QUEUE_PRIORITY_BACKGROUND INT16_MIN // 后台优先级

dispatch_asycn是异步处理函数,该函数不会等待任务执行完,不会一直占用当前线程。

dispatch_sync是同步处理函数,在该函数中执行的任务不执行完,该函数会一直在当前线程等待。也可以说这个函数是简化版的dispatch_group_wait函数。

dispatch_sync要慎用,因为使用不当就会引起死锁。
比如在主线程调用:

    dispatch_queue_t mianQueue = dispatch_get_main_queue();
    dispatch_async(mainQueue, ^{
        dispatch_sync(mainQueue, ^{
            NSLog(@"Hello World");
        });
    });

Serial Diapatch Queue中调用也是一样

    dispatch_queue_t queue = dispatch_queue_create("com.example.gcd.mytest", DISPATCH_QUEUE_SERIAL);
    dispatch_async(queue, ^{
        dispatch_sync(queue, ^{
            NSLog(@"Hello World");
        });
    });

死锁的原因很明显就是两个操作在同一个线程里互相等待对方执行完,一直互相等待,谁也不执行。

手动创建的队列可以通过dispatch_set_target_queue设定执行的优先级。将Dispatch Queue指定为dispatch_set_target_queue的函数参数,不仅可以变更Dispatch Queue的执行优先级,还可以作为执行阶层。

比如,将一个普通的Serial Diapatch Queue设定为与“后台优先级全局队列”一样的优先级和阶层,那么在执行时,它的执行优先级将高于其他普通的Serial Diapatch QueueConcunrrent Diapatch Queue,它的阶层也要比普通的Serial Diapatch QueueConcunrrent Diapatch Queue高,当它与“后台优先级全局队列”阶层一样时意味着,如果它在执行,其他普通的Serial Diapatch QueueConcunrrent Diapatch Queue都不能和它并行执行,必须等它执行完才能执行。

    // 创建串行队列
    dispatch_queue_t serialQueue = dispatch_queue_create("com.example.gcd.mytest", NULL);
    
    // 创建并行队列
    dispatch_queue_t concurrentQueue = dispatch_queue_create("com.example.gcd.mytest", DISPATCH_QUEUE_CONCURRENT);
    
    /*
     * 参照serialQueue的优先级设置目标队列 即concurrentQueue的优先级
     * 第一个参数为要设置优先级的queue,第二个参数是参照物,既将第一个queue的优先级和第二个queue的优先级设置一样。
     */ 
    dispatch_set_target_queue(concurrentQueue, serialQueue);

dispatch_after用于延时处理,需要注意的是并不是dispatch_after在延时指定时间后执行,而是在指定时间把任务添加到队列中,相当于加了一个计时器,时间到了就把任务添加到队列中了。

栗子:
延时3秒打印Hello Word

    dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, 3ull*NSEC_PER_SEC);
    dispatch_after(time, dispatch_get_main_queue(), ^{NSLog(@"Hello World");});

dispatch_time的第一个参数是起始时间可传:DISPATCH_TIME_NOWDISPATCH_TIME_FOREVER
DISPATCH_TIME_NOW : 表示从现在开始 DISPATCH_TIME_FOREVER:表示持续等待,稍后会用到。
第二个参数是秩序多久,"ull"是C语言的数值字面量,是显示表明类型时使用的字符串(表示"unsight long long")为了精确时间写上"ull"NSEC_PER_SEC是秒时间单位的一种,表示纳秒级精确的一秒。

NSEC:纳秒。
USEC:微妙。
SEC:秒
PER:每
#define NSEC_PER_SEC 1000000000ull  // 每秒有多少纳秒
#define NSEC_PER_MSEC 1000000ull    // 每毫秒有多少纳秒
#define USEC_PER_SEC 1000000ull     // 每秒有多少毫秒
#define NSEC_PER_USEC 1000ull       // 每毫秒有多少纳秒

有时我们想等添加到队列中的所有任务都执行完再执行结束处理。Serial Dispatch Queue好说,本来就是串行的。但是Concurrent Dispatch Queue就不行了,异步的我们根本不知道哪个是最后执行完的。这个时候就可以用到Dispatch Group了。

    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_group_t group = dispatch_group_create();
    dispatch_group_async(group, queue, ^{NSLog(@"block 0");});
    dispatch_group_async(group, queue, ^{NSLog(@"block 1");});
    dispatch_group_async(group, queue, ^{NSLog(@"block 2");});
    /*
     * 无论向什么样的Dispatch Queue中添加任务,使用Dispatch Group都可以监听这个任务的执行结束
     * 所有任务结束时,会执行dispatch_group_notify函数的block
     */ 
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{NSLog(@"done");});
   
  // iOS 6.0 or Mac OS X 10.8 以上系统可以管理GCD对象无需手动释放
    dispatch_release(group);

dispatch_group_wait也可以达到同样的效果,不过前者更简洁,所以建议用dispatch_group_notify,下边是dispatch_group_wait的实现。

   dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_group_t group = dispatch_group_create();
    dispatch_group_async(group, queue, ^{NSLog(@"block 0");});
    dispatch_group_async(group, queue, ^{NSLog(@"block 1");});
    dispatch_group_async(group, queue, ^{NSLog(@"block 2");});
    
    dispatch_time_t time = dispatch_time(DISPATCH_TIME_FOREVER, 1ull*NSEC_PER_SEC);
    long result = dispatch_group_wait(group, time);
    /*
     指定DISPATCH_TIME_NOW,则不用任何等待,即可判断Dispatch Group中的处理是否执行结束
(在主线程的Runloop的每次循环中可检查执行是否结束,从而不耗费多余的等待时间)
     */
   // long result = dispatch_group_wait(group, DISPATCH_TIME_NOW);
    
    if (result == 0) {
        // group的全部任务执行完毕
    }
    else {
        // group的某个任务还在执行中
    }
    
    // iOS 6.0 or Mac OS X 10.8 以上系统可以管理GCD对象无需手动释放
    // dispatch_release(group);

GCD信号量的用法主要有两种,一种是监听1个异步执行结果,另外一种是控制线程并发数。
信号量的函数有3个:

  1. 创建信号量,传入的value要大于等于0
    dispatch_semaphore_create(long value);
  2. 增加一个信号量
    dispatch_semaphore_signal(dispatch_semaphore_t dsema);
  3. 减少1个信号量,并且信号量为0则等待,等待时间取决于timeout时间参数
    dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);

接下来看代码:

dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);// 信号量为0
    
   dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSLog(@"run task 1");
        sleep(1);
        NSLog(@"complete task 1");
        // 信号量+1
        dispatch_semaphore_signal(semaphore);
    });
    
    // 若信号量为0则一直等待
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    NSLog(@"继续执行函数");
2017-12-19 11:35:31.731268+0800 多线程[3482:317136] run task 1
2017-12-19 11:35:32.736671+0800 多线程[3482:317136] complete task 1
2017-12-19 11:35:32.737201+0800 多线程[3482:316871] 继续执行函数
// 信号量为2,相当于线程最大并发数为2
dispatch_semaphore_t semaphore = dispatch_semaphore_create(2);
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        // 若信号量为0则一直等待,不为0则信号量-1并继续执行函数
        dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        NSLog(@"run task 1");
        sleep(1);
        NSLog(@"complete task 1");
        dispatch_semaphore_signal(semaphore); // 信号量+1
    });
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        NSLog(@"run task 2");
        sleep(1);
        NSLog(@"complete task 2");
        dispatch_semaphore_signal(semaphore);
    });
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        NSLog(@"run task 3");
        sleep(1);
        NSLog(@"complete task 3");
        dispatch_semaphore_signal(semaphore);
    });
/*
1.信号量初始值为2(即线程最大并发数为2);
2.任务1执行信号量-1,任务2执行信号量-1,此时信号量为0(即两个线程已经有任务在执行,任务3只能等待);
3.当任务1或任务2执行完,则信号量+1,信号量不为0(即有一个可用线程,此时任务3可执行)。
*/
2017-12-19 11:41:28.631848+0800 多线程[3542:324794] run task 2
2017-12-19 11:41:28.631849+0800 多线程[3542:324796] run task 1
2017-12-19 11:41:29.636731+0800 多线程[3542:324794] complete task 2
2017-12-19 11:41:29.636758+0800 多线程[3542:324796] complete task 1
2017-12-19 11:41:29.637094+0800 多线程[3542:324795] run task 3
2017-12-19 11:41:30.637980+0800 多线程[3542:324795] complete task 3

关于信号量和Dispatch Group异步线程的延伸阅读

dispatch_once大家比较熟,因为线程安全的单例模式常用到它。

+(instancetype)sharedSingleton{
    static id instance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[self alloc] init];
    });
    return instance;
}

还可以用@synchronized()这个互斥锁实现单例模式:

+(instancetype)sharedSingleton{
    static id instance = nil;
    @synchronized (self) {
        if (!instance) {
            instance = [[self alloc] init];
        }
    }
    return instance;
}

在访问数据库或文件时,为避免数据竞争,前面讲到可以使用Serial Dispatch Queue。但是其实如果是读取和读取并行执行是不会引起数据竞争的,如果能把这部分操作拆分出来,那无疑会提高访问效率。dispatch_barrier_async配合手动创建的Concurrent Dispatch Queue就可以帮我们做到。在执行dispatch_barrier_async时,它会等正在执行的任务执行完开始,当它结束后其他任务才会再开始执行。在SDWebImage框架中也有用到它和它的同步函数dispatch_barrier_sync

dispatch_queue_t queue = dispatch_queue_create("com.example.gcd.mytest", DISPATCH_QUEUE_CONCURRENT);
    dispatch_async(queue, ^{
        // 执行读取操作
    });
    dispatch_async(queue, ^{
        // 执行读取操作
    });
    dispatch_barrier_async(queue, ^{
       // 执行写入操作
    });
    dispatch_async(queue, ^{
        // 执行读取操作
    });
    dispatch_async(queue, ^{
        // 执行读取操作
    });

在队列大量任务执行时,需要临时挂起队列时调用dispatch_suspend
dispatch_suspend(queue)挂起队列
dospatch_resume (queue)恢复队列

GCD还提供可多线程读取同一个大型文件的APIdispatch I/Odispatch Data,还有其他很多有意思有用的API,大家尽可以去查看官方文档学习和使用。

参考

上一篇下一篇

猜你喜欢

热点阅读