AppAndroid架构设计Android

Android BLE开发详解和FastBle源码解析

2018-03-21  本文已影响3338人  陈利健

因为自己的项目中有用到了蓝牙相关的功能,所以之前也断断续续地针对蓝牙通信尤其是BLE通信进行了一番探索,整理出了一个开源框架FastBle与各位分享经验。
源码地址:

https://github.com/Jasonchenlijian/FastBle

随着对FastBle框架关注的人越来越多,与我讨论问题的小伙伴也多起来,所以整理了一篇文章,详细介绍一下框架的用法,一些坑,还有我对Android BLE开发实践方面的理解。

文章分为3个部分:


1. FastBle的使用

1.1 声明权限

<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

1.2. 初始化及配置

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    BleManager.getInstance().init(getApplication());
    BleManager.getInstance()
            .enableLog(true)
            .setReConnectCount(1, 5000)
            .setOperateTimeout(5000);
}

在使用之前,需要事先调用初始化init(Application app)方法。此外,可以进行一些自定义的配置,比如是否显示框架内部日志,重连次数和重连时间间隔,以及操作超时时间。

1.3. 扫描外围设备

APP作为中心设备,想要与外围硬件设备建立蓝牙通信的前提是首先得到设备对象,途径是扫描。在调用扫描方法之前,你首先应该先处理下面的准备工作。

onScanStarted(boolean success): 会回到主线程,参数表示本次扫描动作是否开启成功。由于蓝牙没有打开,上一次扫描没有结束等原因,会造成扫描开启失败。
onLeScan(BleDevice bleDevice):扫描过程中所有被扫描到的结果回调。由于扫描及过滤的过程是在工作线程中的,此方法也处于工作线程中。同一个设备会在不同的时间,携带自身不同的状态(比如信号强度等),出现在这个回调方法中,出现次数取决于周围的设备量及外围设备的广播间隔。
onScanning(BleDevice bleDevice):扫描过程中的所有过滤后的结果回调。与onLeScan区别之处在于:它会回到主线程;同一个设备只会出现一次;出现的设备是经过扫描过滤规则过滤后的设备。
onScanFinished(List<BleDevice> scanResultList):本次扫描时段内所有被扫描且过滤后的设备集合。它会回到主线程,相当于onScanning设备之和。

1.4. 设备信息

扫描得到的BLE外围设备,会以BleDevice对象的形式,作为后续操作的最小单元对象。它本身含有这些信息:
String getName():蓝牙广播名
String getMac():蓝牙Mac地址
byte[] getScanRecord(): 被扫描到时候携带的广播数据
int getRssi() :被扫描到时候的信号强度
后续进行设备连接、断开、判断设备状态,读写操作等时候,都会用到这个对象。可以把它理解为外围蓝牙设备的载体,所有对外围蓝牙设备的操作,都通过这个对象来传导。

1.5. 连接、断连、监控连接状态

拿到设备对象之后,可以进行连接操作。

    BleManager.getInstance().connect(bleDevice, new BleGattCallback() {
        @Override
        public void onStartConnect() {
        }

        @Override
        public void onConnectFail(BleException exception) {
        }

        @Override
        public void onConnectSuccess(BleDevice bleDevice, BluetoothGatt gatt, int status) {
        }

        @Override
        public void onDisConnected(boolean isActiveDisConnected, BleDevice bleDevice, BluetoothGatt gatt, int status) {
        }
    });

onStartConnect():开始进行连接。
onConnectFail(BleException exception):连接不成功。
onConnectSuccess(BleDevice bleDevice, BluetoothGatt gatt, int status):连接成功并发现服务。
onDisConnected(boolean isActiveDisConnected, BleDevice bleDevice, BluetoothGatt gatt, int status):连接断开,特指连接后再断开的情况。在这里可以监控设备的连接状态,一旦连接断开,可以根据自身情况考虑对BleDevice对象进行重连操作。需要注意的是,断开和重连之间最好间隔一段时间,否则可能会出现长时间连接不上的情况。此外,如果通过调用disconnect(BleDevice bleDevice)方法,主动断开蓝牙连接的结果也会在这个方法中回调,此时isActiveDisConnected将会是true。

1.6. GATT协议

BLE连接都是建立在 GATT (Generic Attribute Profile) 协议之上。GATT 是一个在蓝牙连接之上的发送和接收很短的数据段的通用规范,这些很短的数据段被称为属性(Attribute)。它定义两个 BLE 设备通过Service 和 Characteristic 进行通信。GATT 就是使用了 ATT(Attribute Protocol)协议,ATT 协议把 Service, Characteristic以及对应的数据保存在一个查找表中,次查找表使用 16 bit ID 作为每一项的索引。

关于GATT这部分内容会在下面重点讲解。总之,中心设备和外设需要双向通信的话,唯一的方式就是建立 GATT 连接。当连接成功之后,外围设备与中心设备之间就建立起了GATT连接。
上面讲到的connect(BleDevice bleDevice, BleGattCallback bleGattCallback)方法其实是有返回值的,这个返回值就是BluetoothGatt。当然还有其他方式可以获取BluetoothGatt对象,连接成功后,调用:

BluetoothGatt gatt = BleManager.getInstance().getBluetoothGatt(BleDevice bleDevice);

通过BluetoothGatt对象作为连接桥梁,中心设备可以获取外围设备的很多信息,以及双向通信。

首先,就可以获取这个蓝牙设备所拥有的Service和Characteristic。每一个属性都可以被定义作不同的用途,通过它们来进行协议通信。下面的方法,就是通过BluetoothGatt,查找出所有的Service和Characteristic的UUID:

    List<BluetoothGattService> serviceList = bluetoothGatt.getServices();
    for (BluetoothGattService service : serviceList) {
        UUID uuid_service = service.getUuid();

        List<BluetoothGattCharacteristic> characteristicList= service.getCharacteristics();
        for(BluetoothGattCharacteristic characteristic : characteristicList) {
            UUID uuid_chara = characteristic.getUuid();
        }
    }

1.7. 协议通信

APP与设备建立了连接,并且知道了Service和Characteristic(需要与硬件协议沟通确认)之后,我们就可以通过BLE协议进行通信了。通信的桥梁,主要就是是通过 标准的或者自定义的Characteristic,中文我们称之为“特征”。我们可以从 Characteristic 读数据和写数据。这样就实现了双向的通信。站在APP作为中心设备的角度,常用于数据交互的通信方式主要有3种:接收通知、写、读,此外还有设置最大传输单元,获取实时信号强度等通信操作。

在BLE设备通信过程中,有几点经验分享给大家:


2. BLE开发实践方面的理解

在分解FastBle源码之前,我首先介绍一下BLE通信一些理论知识。

2.1 蓝牙简介

蓝牙是一种近距离无线通信技术。它的特性就是近距离通信,典型距离是 10 米以内,传输速度最高可达 24 Mbps,支持多连接,安全性高,非常适合用智能设备上。

2.2 蓝牙技术的版本演进

2.3 Android上BLE功能的逐步演进

在Android开发过程中,版本的碎片化一直是需要考虑的问题,再加上厂商定制及蓝牙本身也和Android一样一直在发展过程中,所以对于每一个版本支持什么功能,是我们需要知道的。

中心模式和外设模式是什么意思?

2.4 蓝牙的广播和扫描

以下内容部分参考自BLE Introduction
关于这部分内容,需要引入一个概念,GAP(Generic Access Profile),它用来控制设备连接和广播。GAP 使你的设备被其他设备可见,并决定了你的设备是否可以或者怎样与设备进行交互。例如 Beacon 设备就只是向外发送广播,不支持连接;小米手环就可以与中心设备建立连接。

在 GAP 中蓝牙设备可以向外广播数据包,广播包分为两部分: Advertising Data Payload(广播数据)和 Scan Response Data Payload(扫描回复),每种数据最长可以包含 31 byte。这里广播数据是必需的,因为外设必需不停的向外广播,让中心设备知道它的存在。扫描回复是可选的,中心设备可以向外设请求扫描回复,这里包含一些设备额外的信息,例如设备的名字。在 Android 中,系统会把这两个数据拼接在一起,返回一个 62 字节的数组。这些广播数据可以自己手动去解析,在 Android 5.0 也提供 ScanRecord 帮你解析,直接可以通过这个类获得有意义的数据。广播中可以有哪些数据类型呢?设备连接属性,标识设备支持的 BLE 模式,这个是必须的。设备名字,设备包含的关键 GATT service,或者 Service data,厂商自定义数据等等。


广播流程

外围设备会设定一个广播间隔,每个广播间隔中,它会重新发送自己的广播数据。广播间隔越长,越省电,同时也不太容易扫描到。

刚刚讲到,GAP决定了你的设备怎样与其他设备进行交互。答案是有2种方式:

2.5 BLE通信基础

BLE通信的基础有两个重要的概念,ATT和GATT。


3. FastBle源码解析

通过上面BLE的基础理论,我们可以分析到,BLE通信实际上就是先由客户端发起与服务端的连接,再通过服务端的找到其Characteristic进行两者间的数据交互。

在FastBle源码中,首先看BleManager中的connect()方法:

public BluetoothGatt connect(BleDevice bleDevice, BleGattCallback bleGattCallback) {
    if (bleGattCallback == null) {
        throw new IllegalArgumentException("BleGattCallback can not be Null!");
    }

    if (!isBlueEnable()) {
        BleLog.e("Bluetooth not enable!");
        bleGattCallback.onConnectFail(new OtherException("Bluetooth not enable!"));
        return null;
    }

    if (Looper.myLooper() == null || Looper.myLooper() != Looper.getMainLooper()) {
        BleLog.w("Be careful: currentThread is not MainThread!");
    }

    if (bleDevice == null || bleDevice.getDevice() == null) {
        bleGattCallback.onConnectFail(new OtherException("Not Found Device Exception Occurred!"));
    } else {
        BleBluetooth bleBluetooth = new BleBluetooth(bleDevice);
        boolean autoConnect = bleScanRuleConfig.isAutoConnect();
        return bleBluetooth.connect(bleDevice, autoConnect, bleGattCallback);
    }

    return null;
}

这个方法将扫描到的外围设备对象传入,通过一些必要的条件判断之后,调用bleBluetooth.connect()进行连接。我们去看一下BleBluetooth这个类:

public BleBluetooth(BleDevice bleDevice) {
    this.bleDevice = bleDevice;
}

上面的BleBluetooth的构造方法是传入一个蓝牙设备对象。由此可见,一个BleBluetooth可能代表你的Android与这一个外围设备整个交互过程,从开始连接,到中间数据交互,一直到断开连接的整个过程。在多连接情况下,有多少外围设备,设备池中就维护着多少个BleBluetooth对象。

MultipleBluetoothController就是控制多设备连接的。它里面有增加和移除设备的方法,如下图的addBleBluetoothremoveBleBluetooth,传入的参数就是BleBluetooth对象,验证了上面的说法。

public synchronized void addBleBluetooth(BleBluetooth bleBluetooth) {
    if (bleBluetooth == null) {
        return;
    }
    if (!bleLruHashMap.containsKey(bleBluetooth.getDeviceKey())) {
        bleLruHashMap.put(bleBluetooth.getDeviceKey(), bleBluetooth);
    }
}

public synchronized void removeBleBluetooth(BleBluetooth bleBluetooth) {
    if (bleBluetooth == null) {
        return;
    }
    if (bleLruHashMap.containsKey(bleBluetooth.getDeviceKey())) {
        bleLruHashMap.remove(bleBluetooth.getDeviceKey());
    }
}

回到BleBlutoothconnect方法:

public synchronized BluetoothGatt connect(BleDevice bleDevice,
                                          boolean autoConnect,
                                          BleGattCallback callback) {
    addConnectGattCallback(callback);
    isMainThread = Looper.myLooper() != null && Looper.myLooper() == Looper.getMainLooper();
    BluetoothGatt gatt;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        gatt = bleDevice.getDevice().connectGatt(BleManager.getInstance().getContext(),
                autoConnect, coreGattCallback, TRANSPORT_LE);
    } else {
        gatt = bleDevice.getDevice().connectGatt(BleManager.getInstance().getContext(),
                autoConnect, coreGattCallback);
    }
    if (gatt != null) {
        if (bleGattCallback != null)
            bleGattCallback.onStartConnect();
        connectState = BleConnectState.CONNECT_CONNECTING;
    }
    return gatt;
}

可见,最终也是调用了原生API中的BluetoothDeviceconnectGatt()方法。在蓝牙原理分析中讲到,连接过程中要创建一个BluetoothGattCallback,用来作为回调,这个类非常重要,所有的 GATT 操作的回调都在这里。而此处的coreGattCallback应该就扮演着这个角色,它是BluetoothGattCallback的实现类对象,对操作回调结果做了封装和分发。

private BluetoothGattCallback coreGattCallback = new BluetoothGattCallback() {

    @Override
    public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
        super.onConnectionStateChange(gatt, status, newState);

        if (newState == BluetoothGatt.STATE_CONNECTED) {
            gatt.discoverServices();

        } else if (newState == BluetoothGatt.STATE_DISCONNECTED) {
            closeBluetoothGatt();
            BleManager.getInstance().getMultipleBluetoothController().removeBleBluetooth(BleBluetooth.this);

            if (connectState == BleConnectState.CONNECT_CONNECTING) {
                connectState = BleConnectState.CONNECT_FAILURE;

                if (isMainThread) {
                    Message message = handler.obtainMessage();
                    message.what = BleMsg.MSG_CONNECT_FAIL;
                    message.obj = new BleConnectStateParameter(bleGattCallback, gatt, status);
                    handler.sendMessage(message);
                } else {
                    if (bleGattCallback != null)
                        bleGattCallback.onConnectFail(new ConnectException(gatt, status));
                }

            } else if (connectState == BleConnectState.CONNECT_CONNECTED) {
                connectState = BleConnectState.CONNECT_DISCONNECT;

                if (isMainThread) {
                    Message message = handler.obtainMessage();
                    message.what = BleMsg.MSG_DISCONNECTED;
                    BleConnectStateParameter para = new BleConnectStateParameter(bleGattCallback, gatt, status);
                    para.setAcitive(isActiveDisconnect);
                    para.setBleDevice(getDevice());
                    message.obj = para;
                    handler.sendMessage(message);
                } else {
                    if (bleGattCallback != null)
                        bleGattCallback.onDisConnected(isActiveDisconnect, bleDevice, gatt, status);
                }
            }
        }
    }

    @Override
    public void onServicesDiscovered(BluetoothGatt gatt, int status) {
        super.onServicesDiscovered(gatt, status);
        BleLog.i("BluetoothGattCallback:onServicesDiscovered "
                + '\n' + "status: " + status
                + '\n' + "currentThread: " + Thread.currentThread().getId());

        if (status == BluetoothGatt.GATT_SUCCESS) {
            bluetoothGatt = gatt;
            connectState = BleConnectState.CONNECT_CONNECTED;
            isActiveDisconnect = false;
            BleManager.getInstance().getMultipleBluetoothController().addBleBluetooth(BleBluetooth.this);

            if (isMainThread) {
                Message message = handler.obtainMessage();
                message.what = BleMsg.MSG_CONNECT_SUCCESS;
                BleConnectStateParameter para = new BleConnectStateParameter(bleGattCallback, gatt, status);
                para.setBleDevice(getDevice());
                message.obj = para;
                handler.sendMessage(message);
            } else {
                if (bleGattCallback != null)
                    bleGattCallback.onConnectSuccess(getDevice(), gatt, status);
            }
        } else {
            closeBluetoothGatt();
            connectState = BleConnectState.CONNECT_FAILURE;

            if (isMainThread) {
                Message message = handler.obtainMessage();
                message.what = BleMsg.MSG_CONNECT_FAIL;
                message.obj = new BleConnectStateParameter(bleGattCallback, gatt, status);
                handler.sendMessage(message);
            } else {
                if (bleGattCallback != null)
                    bleGattCallback.onConnectFail(new ConnectException(gatt, status));
            }
        }
    }

    @Override
    public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
        super.onCharacteristicChanged(gatt, characteristic);

        Iterator iterator = bleNotifyCallbackHashMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry entry = (Map.Entry) iterator.next();
            Object callback = entry.getValue();
            if (callback instanceof BleNotifyCallback) {
                BleNotifyCallback bleNotifyCallback = (BleNotifyCallback) callback;
                if (characteristic.getUuid().toString().equalsIgnoreCase(bleNotifyCallback.getKey())) {
                    Handler handler = bleNotifyCallback.getHandler();
                    if (handler != null) {
                        Message message = handler.obtainMessage();
                        message.what = BleMsg.MSG_CHA_NOTIFY_DATA_CHANGE;
                        message.obj = bleNotifyCallback;
                        Bundle bundle = new Bundle();
                        bundle.putByteArray(BleMsg.KEY_NOTIFY_BUNDLE_VALUE, characteristic.getValue());
                        message.setData(bundle);
                        handler.sendMessage(message);
                    }
                }
            }
        }

        iterator = bleIndicateCallbackHashMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry entry = (Map.Entry) iterator.next();
            Object callback = entry.getValue();
            if (callback instanceof BleIndicateCallback) {
                BleIndicateCallback bleIndicateCallback = (BleIndicateCallback) callback;
                if (characteristic.getUuid().toString().equalsIgnoreCase(bleIndicateCallback.getKey())) {
                    Handler handler = bleIndicateCallback.getHandler();
                    if (handler != null) {
                        Message message = handler.obtainMessage();
                        message.what = BleMsg.MSG_CHA_INDICATE_DATA_CHANGE;
                        message.obj = bleIndicateCallback;
                        Bundle bundle = new Bundle();
                        bundle.putByteArray(BleMsg.KEY_INDICATE_BUNDLE_VALUE, characteristic.getValue());
                        message.setData(bundle);
                        handler.sendMessage(message);
                    }
                }
            }
        }
    }

    @Override
    public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
        super.onDescriptorWrite(gatt, descriptor, status);

        Iterator iterator = bleNotifyCallbackHashMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry entry = (Map.Entry) iterator.next();
            Object callback = entry.getValue();
            if (callback instanceof BleNotifyCallback) {
                BleNotifyCallback bleNotifyCallback = (BleNotifyCallback) callback;
                if (descriptor.getCharacteristic().getUuid().toString().equalsIgnoreCase(bleNotifyCallback.getKey())) {
                    Handler handler = bleNotifyCallback.getHandler();
                    if (handler != null) {
                        Message message = handler.obtainMessage();
                        message.what = BleMsg.MSG_CHA_NOTIFY_RESULT;
                        message.obj = bleNotifyCallback;
                        Bundle bundle = new Bundle();
                        bundle.putInt(BleMsg.KEY_NOTIFY_BUNDLE_STATUS, status);
                        message.setData(bundle);
                        handler.sendMessage(message);
                    }
                }
            }
        }

        iterator = bleIndicateCallbackHashMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry entry = (Map.Entry) iterator.next();
            Object callback = entry.getValue();
            if (callback instanceof BleIndicateCallback) {
                BleIndicateCallback bleIndicateCallback = (BleIndicateCallback) callback;
                if (descriptor.getCharacteristic().getUuid().toString().equalsIgnoreCase(bleIndicateCallback.getKey())) {
                    Handler handler = bleIndicateCallback.getHandler();
                    if (handler != null) {
                        Message message = handler.obtainMessage();
                        message.what = BleMsg.MSG_CHA_INDICATE_RESULT;
                        message.obj = bleIndicateCallback;
                        Bundle bundle = new Bundle();
                        bundle.putInt(BleMsg.KEY_INDICATE_BUNDLE_STATUS, status);
                        message.setData(bundle);
                        handler.sendMessage(message);
                    }
                }
            }
        }
    }

    @Override
    public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
        super.onCharacteristicWrite(gatt, characteristic, status);

        Iterator iterator = bleWriteCallbackHashMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry entry = (Map.Entry) iterator.next();
            Object callback = entry.getValue();
            if (callback instanceof BleWriteCallback) {
                BleWriteCallback bleWriteCallback = (BleWriteCallback) callback;
                if (characteristic.getUuid().toString().equalsIgnoreCase(bleWriteCallback.getKey())) {
                    Handler handler = bleWriteCallback.getHandler();
                    if (handler != null) {
                        Message message = handler.obtainMessage();
                        message.what = BleMsg.MSG_CHA_WRITE_RESULT;
                        message.obj = bleWriteCallback;
                        Bundle bundle = new Bundle();
                        bundle.putInt(BleMsg.KEY_WRITE_BUNDLE_STATUS, status);
                        bundle.putByteArray(BleMsg.KEY_WRITE_BUNDLE_VALUE, characteristic.getValue());
                        message.setData(bundle);
                        handler.sendMessage(message);
                    }
                }
            }
        }
    }

    @Override
    public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
        super.onCharacteristicRead(gatt, characteristic, status);

        Iterator iterator = bleReadCallbackHashMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry entry = (Map.Entry) iterator.next();
            Object callback = entry.getValue();
            if (callback instanceof BleReadCallback) {
                BleReadCallback bleReadCallback = (BleReadCallback) callback;
                if (characteristic.getUuid().toString().equalsIgnoreCase(bleReadCallback.getKey())) {
                    Handler handler = bleReadCallback.getHandler();
                    if (handler != null) {
                        Message message = handler.obtainMessage();
                        message.what = BleMsg.MSG_CHA_READ_RESULT;
                        message.obj = bleReadCallback;
                        Bundle bundle = new Bundle();
                        bundle.putInt(BleMsg.KEY_READ_BUNDLE_STATUS, status);
                        bundle.putByteArray(BleMsg.KEY_READ_BUNDLE_VALUE, characteristic.getValue());
                        message.setData(bundle);
                        handler.sendMessage(message);
                    }
                }
            }
        }
    }
};

在收到连接状态、读、写、通知等操作的结果回调之后,通过消息队列机制,交由相应的Handler去处理。那处理消息的Handler在哪里?举例其中的write操作Handler handler = bleWriteCallback.getHandler();,handler对象被包含在了这个write操作的callback中。

public abstract class BleBaseCallback {

    private String key;
    private Handler handler;

    public String getKey() {
        return key;
    }

    public void setKey(String key) {
        this.key = key;
    }

    public Handler getHandler() {
        return handler;
    }

    public void setHandler(Handler handler) {
        this.handler = handler;
    }
}

所有的操作的callback都继承自这个BleBaseCallback抽象类,它有两个成员变量。一个key,标识着这个callback归属于哪一个Characteristic的操作;另一个handler,用于传递底层发来的操作结果,最终将结果交由callback去抛给调用者,完成一次接口回调。

private void handleCharacteristicWriteCallback(BleWriteCallback bleWriteCallback,
                                               String uuid_write) {
    if (bleWriteCallback != null) {
        writeMsgInit();
        bleWriteCallback.setKey(uuid_write);
        bleWriteCallback.setHandler(mHandler);
        mBleBluetooth.addWriteCallback(uuid_write, bleWriteCallback);
        mHandler.sendMessageDelayed(
                mHandler.obtainMessage(BleMsg.MSG_CHA_WRITE_START, bleWriteCallback),
                BleManager.getInstance().getOperateTimeout());
    }
}

上面这段源码解释了这个机制,每一次write操作之后,都会对传入的callback进行唯一性标记,再通过handler用来传递操作结果,同时将这个callback加入这个设备的BleBlutooth对象的callback池中管理。
这样就形成了APP维持一个设备连接池,一个设备连接池管理多个设备管理者,一个设备管理者管理多个不同类别的callback集合,一个callback集合中含有多个同类的不同特征的callback。

架构图
上一篇下一篇

猜你喜欢

热点阅读