iOS中的「回调(callback)」

本文主要参考:《Object-C 编程 Big Nerd Ranch Guide》一书第24章

本文适读对象:

  • 想系统了解iOS中若干种回调机制的朋友;
  • 想初步了解Block语法的朋友。
  • 没有自己亲自实现过委托、通告、Block进行回调(传递数据)的朋友;

先用一张图总结本文

《iOS中的「回调(callback)」》 iOS中的回调(callback)

「回调(callback)」的定义:

“A callback lets you write a piece of code and then associate that code with a particular event. When the event happens, your code is executed.”

——摘自《Object-C Programming:The Big Nerd Ranch Guide 2nd》P613

解读如下:

callback(回调)就是一段「代码」,我们会通过某种途径,将这段「代码」和一个特定的事件(event)联系起来,当特定事件(event)发生后,这段「代码」被执行。

很好,简单粗暴。

为什么要有「回调(callback)」?

「上帝说要有callback,于是就有了callback。」——佚名

在这里,斗胆将程序分为两种:

  • 「非事件驱动」型程序
  • 「事件驱动(event-driven)」型程序

「非事件驱动」型程序。

这类程序,遵循这样一个流程:启动程序 -> 执行程序(代码) -> 退出程序。程序会在执行完所有代码后,立刻退出,中途不会有任何事件(event)发生(除非有bug)。

比如,我们用Xcode新建一个OS X下的Command Line Tool工具,直接在main.m文件中的main函数写一段从1加到100的代码,然后打印结果出来。如下图:

《iOS中的「回调(callback)」》 启动程序->执行代码->退出程序

其中「Program ended with exit code: 0」就表示正确退出了程序。

「事件驱动(event-driven)」型程序

这类程序,遵循这样一个流程:启动程序 -> 等待事件(event) -> 事件被触发 -> 执行callback(回调) -> 继续等待事件(event) -> 人为退出程序

打个比方,我想用淘宝APP帮手机充值,一打开APP,它并不会马上跳到充值页面,是要等待我的点击事件,当点击了充值的按钮,才会跳到充值页面(执行了callback)。

所以,大家应该很容易联想到,iOS的应用几乎都是「事件驱动(event-driven)」的,应用一经启动,就在等待事件的发生,当发生某个事件(比如点击了某个按钮),应用就会执行某段代码(callback)进行响应。

这里的「事件(event)」,是非常宽泛的,可以是使用者的一次点击、可以是系统的一次通知、可以是服务器返回的一次数据、可以是蓝牙外设连接成功后,发送给手机的一条指令等等。

所以,我们得出结论——上帝说:我们需要callback(回调)。

iOS中的Run loop

我们知道自己需要callback,那在iOS中,具体要怎么实现呢?

首先要有专门的人负责等待事件(event),如果没有这个人,程序就会像「非事件驱动」型程序一样,一个劲地从头跑到尾,就结束了~

这砖找谁搬?

苹果工程师找了一个OC类型的对象,专门干这活儿——等待事件(event)的发生。它就是NSRunLoop实例。看名字就大概能猜到,它会不断循环(loop)。

NSRunLoop实例会持续等待着,当特定事件发生时,触发回调(callback)。

调用以下方法,即可得到一个run loop。

[[NSRunLoop currentRunLoop] run];

所以,在上述例子中加入一个run loop,这个程序就永远不会退出了(除非人为关闭),有了这个run loop,就可以等待事件(event)的发生了,如下:

《iOS中的「回调(callback)」》 添加run loop,等待事件(event)发生

注意,已经没有「Program ended with exit code: 0」——表示成功退出程序这一句了。

当然,新建iOS工程时,已经帮你干这活了,不需要你再手动去实现。

Objective-C中4种实现「回调(callback)」的途径

好了,有了run loop做基础,我们就可以具体去实现iOS中的各种callback(回调)了。

Objective-C中有4种途径可以实现回调:

1、Target-action/目标-动作对

先看代码:

// 为按钮添加回调——Target-action/目标-动作对
// 第一个参数:发送消息给谁
// 第二个参数:事件发生后,执行什么代码(回调)
// 第三个参数:发生哪类型的点击事件会触发回调
[button addTarget:self
           action:@selector(click:)
 forControlEvents:UIControlEventTouchUpInside];

以上代码,用人话来讲,大意就是:当按钮被点击后(某事件(event)被触发了),执行本类(self)中的click:方法(回调)。

再看一个NSTimer对象的代码:

// 一个自定义类对象
Logger *logger = [[Logger alloc] init];

// 为NSTimer对象添加回调——Target-action/目标-动作对
// 第一个参数:发生哪种类型的点击事件会触发回调(这里表示2秒后触发回调)
// 第二个参数:发送消息给一个Logger实例(Logger是自定义的类)
// 第三个参数:事件发生后,执行什么代码(回调)
// 第四个参数:如果有需要传递的数据,可以放在这里
// 第五个参数:这个计时器是否重复执行(也就是说是否重复执行回调)
__unused NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:2.0
                                                           target:logger
                                                         selector:@selector(logSomething:)
                                                         userInfo:nil
                                                          repeats:YES];

以上代码,用人话来讲就是:创建一个定时器,2秒后(某事件(event)被触发了),执行logger对象所属类的logSomething:方法(回调)。而且是重复地执行。

所以,Target-action/目标-动作对,就是「当事件发生时,向指定的对象发送某个特定的消息」。

以上是书中的描述,但谁是target,谁又是action,搞得含糊不清。所以更倾向于这样理解:

当事件发生时,执行某个类(target)的某个方法(action)。

这里的「某个类」,指的是target参数(例子1是self,例子2是logger)所属类;而「方法」,也就是该类已经实现的某个方法(例子1是click:方法,例子2是logSomething:方法),就是action。

2、Helper objects/辅助对象

「Helper objects/辅助对象」,可以先这样理解:某些功能,找其他类来辅助实现。

常见的就是「delegates/委托」和「/data sources数据源」。下面我们来动手实现一下「delegates/委托」。

先假设有这么一个需求:我们需要用手机通过BLE(低功耗蓝牙)连接8个蓝牙设备,成功连接到8个蓝牙设备后,弹出提示框,提示使用者已经成功连接了多少个蓝牙设备。

先看代码实现:

我们新建一个类,叫MyCnetralManager,专门负责手机和蓝牙模块之前的通讯,这个类的.h文件如下:

#import <Foundation/Foundation.h>

// 步骤1:声明一份协议(OC中的协议一般写在类中的.h文件)
// 这个协议只有一个方法
@protocol MyCnetralManagerDelegate <NSObject>

// 标记了optional关键字,表示协议中这个方法是可选择性实现(也就是可以不实现)
@optional
/**
 *  这个方法通知「被委托对象」,所有设备已经连接上了.
 *
 *  @param devicesCount 传递连接上的设备数量给被委托对象
 */
- (void)allDevicesDidConnected:(NSInteger)devicesCount;
@end

@interface MyCnetralManager : NSObject

// 步骤2:声明delegate属性
@property (weak) id<MyCnetralManagerDelegate> delegate;
@end

.m文件如下:

#import "MyCnetralManager.h"
// 导入CoreBluetooth蓝牙框架(就是用这个框架进行BLE开发的)
@import CoreBluetooth;

/// 默认需要连接的硬件为8个
const NSInteger defaultDivicesCount = 8;

@interface MyCnetralManager ()<CBCentralManagerDelegate>

/// CBCentralManager对象
@property (strong, nonatomic) CBCentralManager *bleManager;

/// 对已经连接上的设备进行计数
@property (nonatomic) NSInteger connectedDiviceCount;
@end


@implementation MyCnetralManager

// 这里省略蓝牙搜索、连接、发现「服务」、发现「特征」等过程

// 在这里,我们也是应用了官方的「delegates/委托」(CBCentralManagerDelegate),实现发生某些事件后,再执行某些代码(回调)
#pragma mark - CBCentralManagerDelegate
// 这个方法标记了@required,所以一定要实现
- (void)centralManagerDidUpdateState:(CBCentralManager *)central {
// (手机)蓝牙状态改变后的回调(比如手机打开蓝牙、关闭蓝牙,都会调用这个方法)
}

// 手机每成功连接一个设备(某事件被触发),这个方法都会被调用(回调)
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral {
    // 每连接成功一个设备,计数加1
    _connectedDiviceCount++;
    
    // 实现我们自己的「delegates/委托」(MyCnetralManagerDelegate)
    // 如果连接上设备数量已经等于事先定义好的数量(8个),就通知委托对象已经连接成功所有设备,并传递连接数量。
    if (_connectedDiviceCount == defaultDivicesCount) {
        // 步骤3
        if ([_delegate respondsToSelector:@selector(allDevicesDidConnected:)]) {
            [_delegate allDevicesDidConnected:_connectedDiviceCount];
        }
    }
}

@end

以上三个步骤,我们已经实现:当连接成功8个蓝牙设备后,向委托对象发送消息allDevicesDidConnected:,并传递一个参数——连接成功设备的数量。

接下来,我们要找到正真干活(显示提示框)的人,找谁呢?找其中一个控制器,如下(某个控制器的.m文件):

我们的目录结构大概如下:

《iOS中的「回调(callback)」》 大概会有这两个类

#import "ViewController.h"
#import "MyCnetralManager.h"

// 遵守协议
@interface ViewController ()<MyCnetralManagerDelegate>

/// 声明一个提示框对象
@property (nonatomic, strong) UIAlertView *alertView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    MyCnetralManager *manager = [[MyCnetralManager alloc] init];
    // 委托谁(找谁干活),就设置delegate是谁
    // 初接触委托的人经常会忘记这步
    // 如果在XIB文件,也可以通过拖线来完成,就不需要用代码实现
    manager.delegate = self;
    
    // 上面这句,可以理解为:MyCnetralManager类委托ViewController类做一些事情。
}

#pragma mark - MyCnetralManagerDelegate
// 实现协议(MyCnetralManagerDelegate)中的方法
// devicesCount是MyCnetralManager类传过来的一个参数
- (void)allDevicesDidConnected:(NSInteger)devicesCount {
    // 拿到参数,显示提示框
    if (!_alertView) {
        _alertView = [[UIAlertView alloc] initWithTitle:@"注意!"
                                                message:[NSString stringWithFormat:@"已经成功连接%@个设备", @(devicesCount)]
                                               delegate:self
                                      cancelButtonTitle:@"OK"
                                      otherButtonTitles:nil, nil];
    }
    
    [_alertView show];
}
@end

到此,我们就自己实现了一次简单的委托。

可以翻译成这样的人话:MyCnetralManager委托ViewController做一件事——成功连接所有设备后,显示提示框。

而书上是这样描述的:「当某事件发生时,向遵守相应协议的辅助对象发送消息。」

上述例子可以这样说:「当成功连接8个蓝牙设备后,向遵守MyCnetralManagerDelegate协议的ViewController对象发送allDevicesDidConnected:消息(并传递一个参数)」

为什么不在CBCentralManagerDelegate中的centralManager:didConnectPeripheral:直接弹出提示框提示使用者,而要搞得这么「复杂」?如果有这个疑问,可以移步到我在知乎回答的问题:如何用简单明了的话解释一下什么是 Objective-C 中的委托?或许可以解答你的部分疑问。

至于「data sources/数据源」,常用UITableView的朋友,应该比较熟悉了,本质上和上面讲的委托,一回事儿。(不过我还没有自己实现过~)

3、Notifications/通告

Notification也可以翻译成「通知」,但是为了不和iOS中的「本地通知」、「远程通知」这类「通知」混淆,这里将Notification统一翻译成「通告」,会比较好区分。

实现上面同样的需求,用通告的方式,就会变成这样:

先在MyCnetralManager.m文件中发送通告

#import "MyCnetralManager.h"
@import CoreBluetooth;

/// 默认需要连接的硬件为8个
const NSInteger defaultDivicesCount = 8;

/// 定义通告的名称
static NSString *kNotificationAllDevicesDidConnected = @"com.YourCompanyName.YourProjectName.AllDevicesDidConnected";

/// 用于创建字典的key
static NSString *totalConnectedDevicesKey = @"totalConnectedDevices";

@interface MyCnetralManager ()<CBCentralManagerDelegate>

/// CBCentralManager对象
@property (strong, nonatomic) CBCentralManager *bleManager;

/// 对已经连接上的设备计数
@property (nonatomic) NSInteger connectedDiviceCount;

@end

@implementation MyCnetralManager

// 在这里,我们也是应用了官方的「delegates/委托」(CBCentralManagerDelegate),实现发生某些事件后,再执行某些代码(回调)
#pragma mark - CBCentralManagerDelegate
// 这个方法标记了@required,所以一定要实现
- (void)centralManagerDidUpdateState:(CBCentralManager *)central {
// (手机)蓝牙状态改变后的回调(比如手机打开蓝牙、关闭蓝牙,都会调用这个方法)
}

// 成功连接一个蓝牙设备的回调(官方框架)
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral {
    // 每连接成功一个设备,计数加1
    _connectedDiviceCount++;
    
    // 如果连接上设备数量已经等于事先定义好的数量(8个),就通知委托对象已经连接成功所有设备,并传递连接数量。
    if (_connectedDiviceCount == defaultDivicesCount) {
        // 发送通告
        // 第一个参数:通告名称
        // 第二个参数:谁发送的通告
        // 第三个参数:需要传递的额外数据(是一个字典)
        [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationAllDevicesDidConnected
                                                            object:nil
                                                          userInfo:@{totalConnectedDevicesKey:[NSNumber numberWithInteger:_connectedDiviceCount]}];
    }
}

@end

然后在ViewController.m中的viewDidLoad方法内「监测」这个通告:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 方案一:传统的selector形式
    // 观察通告kNotificationAllDevicesDidConnected,一接收到这个通告,就执行showAlertView:方法(回调)
    // 第一个参数:将谁注册为观察者(这里将自己(控制器类自身)注册为观察者)
    // 第二个参数:接到通告后,要执行什么方法(代码/回调)
    // 第三个参数:接收哪个通告(通告的名称)
    // 第四个参数:接收谁发送的通告(nil表示无论谁发送,只要是kNotificationAllDevicesDidConnected,都接收)
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(showAlertView:)
                                                 name:kNotificationAllDevicesDidConnected
                                               object:nil];
    
    
    // 方案二:Block形式(Block会在下文展开)
    // 用Block语法,使代码更集中、简洁
    // 观察通告kNotificationAllDevicesDidConnected,一接收到这个通告,就执行Block中的代码(回调)
    /*
    [[NSNotificationCenter defaultCenter] addObserverForName:kNotificationAllDevicesDidConnected
                                                      object:nil
                                                       queue:nil
                                                  usingBlock:^(NSNotification * _Nonnull note) {
        // 弹出提示框
        if (!_alertView) {
            _alertView = [[UIAlertView alloc] initWithTitle:@"注意!"
                                                    message:[NSString stringWithFormat:@"已经成功连接%@个设备", note.userInfo[totalConnectedDevicesKey]]
                                                   delegate:self
                                          cancelButtonTitle:@"OK"
                                          otherButtonTitles:nil, nil];
        }
        [_alertView show];
                                                      
    }];
    */
}

// 没错,方案一中没有用Block语法,所以这里还要写一个方法
// 方案一中,接收到通告后要执行的方法
- (void)showAlertView:(NSNotification *)note {
    // 弹出提示框
    if (!_alertView) {
        _alertView = [[UIAlertView alloc] initWithTitle:@"注意!"
                                                message:[NSString stringWithFormat:@"已经成功连接%@个设备", note.userInfo[totalConnectedDevicesKey]]
                                               delegate:self
                                      cancelButtonTitle:@"OK"
                                      otherButtonTitles:nil, nil];
    }
    [_alertView show];
}

所以,苹果提供了一个叫做「通告中心」的对象,可以通过[NSNotificationCenter defaultCenter]获得,利用这个通告中心,我们可以「发通告」、「监测(接收)通告」,利用这个机制,实现回调。

上面这个例子,可以说成:「当成功连接8个蓝牙设备后,向通告中心发布kNotificationAllDevicesDidConnected通告(一个字符串),并通过userInfo(一个字典)这个参数传递设备的数量;然后通告中心会转发通告出去;这时候在监测该通告的ViewController类收到通告后,就会执行相应的代码(回调)」。

4、Blocks

Block算是Objective-C中比较高阶的内容。这样理解吧,Block其实就是在大括号里面的一大段代码,这段代码,会在某事件(event)发生后被执行。

Block的一些语法

先看看一些Block相关的语法,熟悉一下:

  • Block常量:
    // Block常量 
    ^{
        NSLog(@"我是一个Block常量。^是辨识我身份的标志。记得最后加分号哦,因为我就是一个常量,就像数字「5;」一样");
    };

  • 带实参、会返回值的Block:
    // 有实参,有返回值的Block
    ^(double dividend, double divisor) {
        NSLog(@"我是一个有参数、有返回值的Block");
        double quotient = dividend / divisor;
        return quotient;
    };

  • 声明Block变量:
    // 声明一个Block变量(无返回值;有参数), 
   void (^YourBlockName)(id, NSString *, NSUInteger, BOOL *);
   
   // 或
   void (^YourBlockName)(id obj, NSString *yourString, NSUInteger deviceCount, BOOL *stop);

  • 给Block变量赋值:
    // 给你的Block变量赋值
    // 等号左边是一个Block变量,等号右边是一个Block常量,将常量赋值给变量
    YourBlockName = ^(id array, NSString *theString, NSUInteger count, BOOL *stop){
       // Do something what you want.
    };

  • Block的声明、赋值一起进行:
    // 声明、赋值一起
    void (^YourBlockName)(id, NSString *, NSUInteger, BOOL *) = ^(id array, NSString *theString, NSUInteger count, BOOL *stop){
        // Do something what you want.
    };
    
    // 其实就像 int a = 5; 一样(只是Block比较长而已,语法有点怪而已)

  • 用C语言的typedef关键字给Block命名为一种新的数据类型(最常用这种形式)。
    // 在文件顶部(#import之下)用typedef将Block重新定义为一种新的数据类型
    typedef void(^YourBlockName)(id, NSString *, NSUInteger, BOOL *);
    
    
    // 利用新的数据类型,声明一个Block变量
    @property (nonatomic, strong) YourBlockName yourBlock;
    
    // 再对这个变量进行必要的操作(赋值)

以上是关于Block的一些语法,帮助不熟悉的朋友熟悉一下。它其实就是大括号括起来的一段代码,只是语法有点「怪异」而已,而且可以作为方法中的参数进行传递。(在Swift中,与之对应的貌似是「闭包(Closures)」)。

利用Block实现回调

下面,来看一下如何用Block实现回调(实现上面一样的需求):

在MyCnetralManager.h文件

#import <Foundation/Foundation.h>
@import CoreBluetooth;

// 步骤1:
// 将Block重新定义为一种新的数据类型
// 这个Block无返回值;有一个参数(类型为NSUInteger)
typedef void(^AllDevicesDidConnectedBlock)(NSUInteger divicesCount);

@interface MyCnetralManager : NSObject

// 步骤2:
// 声明一个(Block)变量
@property (nonatomic, strong) AllDevicesDidConnectedBlock callbackForAllDevicesDidConnected;

// 步骤3:
// 声明一个以上述Block作为参数的方法
- (void)callbackForAllDevicesDidConnected:(AllDevicesDidConnectedBlock)allDevicesDidConnectedBlock;
@end

在MyCnetralManager.m文件

// 成功连接一个蓝牙设备的回调(官方框架)
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral {
    // 每连接成功一个设备,计数加1
    _connectedDiviceCount++;
    
    if (_connectedDiviceCount == defaultDivicesCount) {
        // 步骤4:
        // 实现Block回调并进行数据传递
        if (self.callbackForAllDevicesDidConnected) {
            self.callbackForAllDevicesDidConnected(_connectedDiviceCount);
        }
    }
}

- (void)callbackForAllDevicesDidConnected:(AllDevicesDidConnectedBlock)allDevicesDidConnectedBlock {
    // 步骤5:
    // 给我们的Block变量赋值
    self.callbackForAllDevicesDidConnected = allDevicesDidConnectedBlock;
}

最后在ViewController.m中的viewDidLoad方法内进行回调:

- (void)viewDidLoad {
    [super viewDidLoad];

    _myCentralManager = [[MyCnetralManager alloc] init];
    
    // 利用Block进行回调
    // (调用了MyCnetralManager的callbackForAllDevicesDidConnected:方法,传递了一个Block参数)
    [_myCentralManager callbackForAllDevicesDidConnected:^(NSUInteger devicesCount) {
        NSLog(@"执行了回调:已经成功连接%@个设备", @(devicesCount));
        
        // 弹出提示框
        if (!_alertView) {
            _alertView = [[UIAlertView alloc] initWithTitle:@"注意!"
                                                    message:[NSString stringWithFormat:@"已经成功连接%@个设备", @(devicesCount)]
                                                   delegate:self
                                          cancelButtonTitle:@"OK"
                                          otherButtonTitles:nil, nil];
        }
        [_alertView show];
    }];
}

以上是将Block作为一个方法的参数,实现的回调。也可以直接用Block(作为属性)进行回调,如下:

在MyCnetralManager.h文件

#import <Foundation/Foundation.h>
@import CoreBluetooth;

// 步骤1:
// 将Block重新定义为一种新的数据类型
// 这个Block无返回值;有一个参数(类型为NSUInteger)
typedef void(^AllDevicesDidConnectedBlock)(NSUInteger divicesCount);

@interface MyCnetralManager : NSObject

// 步骤2:
// 声明一个(Block)变量
@property (nonatomic, strong) AllDevicesDidConnectedBlock callbackForAllDevicesDidConnected;

// 其实就是把之前在这里的方法删除
@end

在MyCnetralManager.m文件

// 成功连接一个蓝牙设备的回调(官方框架)
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral {
    // 每连接成功一个设备,计数加1
    _connectedDiviceCount++;
    
    if (_connectedDiviceCount == defaultDivicesCount) {
        // 步骤3:
        // 利用Block回调并传数据
        if (self.callbackForAllDevicesDidConnected) {
            self.callbackForAllDevicesDidConnected(_connectedDiviceCount);
        }
    }
}

最后在ViewController.m中的viewDidLoad方法内进行回调:

- (void)viewDidLoad {
    [super viewDidLoad];

    _myCentralManager = [[MyCnetralManager alloc] init];

    // 在Block中调用self,可能会导致「引用循环」,所以使用weakSelf
    __weak typeof(self) weakSelf = self;
    
    // 直接用Block(MyCnetralManager的属性)回调
    _myCentralManager.callbackForAllDevicesDidConnected = ^(NSUInteger devicesCount) {
        NSLog(@"执行了回调:已经成功连接%@个设备", @(devicesCount));
        
        // 弹出提示框
        if (!weakSelf.alertView) {
            _alertView = [[UIAlertView alloc] initWithTitle:@"注意!"
                                                    message:[NSString stringWithFormat:@"已经成功连接%@个设备", @(devicesCount)]
                                                   delegate:self
                                          cancelButtonTitle:@"OK"
                                          otherButtonTitles:nil, nil];
        }
        [weakSelf.alertView show];
    };
}

两者的区别是:是否多一个方法。不过网上建议使用前者。个人也倾向于使用前者,因为作为方法的参数时,一敲回车,整个Block都会自动补全,而用后者,不会自动补全,要自己一个个敲。

总结

上面,简单实现了Objective-C中的4种回调。

那究竟该使用哪种回调呢?总结书上的建议:

  • 当只发生单个事件(event),只需要完成一件事情进行响应,建议用「Target-action/目标-动作对」。比如NSTimer、UIButton等。
  • 当会发生若干事件(event),要完成多件事情进行响应,建议使用「Helper objects/辅助对象」,当然了,最常见的是「delegate/委托」(另外还有「data sources/数据源」)。
  • 当发生单个事件(event),多个对象要进行响应,建议使用「Notifications/通告」
  • Block,当为了写出更简洁的代码、更好的代码结构,建议使用Block(自己总结的)。

以上,就是关于iOS中「回调(callback)」的一些入门级分享。如有谬误,请斧正,谢谢。

尊重劳动成果,转载请注明出处,谢谢。

    原文作者:AntonyWong
    原文地址: https://www.jianshu.com/p/376ba5343097
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞