Android BLE开发小记

自己封装的BLE库(5.0以上)

这里不记录具体代码规则,后面会给出参考文章,别人已经写很详细了,我就单纯记录下踩过的坑吧;

1. 版本支持

Android 从 4.3(API Level 18) 开始支持低功耗蓝牙(Bluetooth low energy),但是只支持作为中心设备(Central)模式,这就意味着 Android 设备只能主动扫描和链接其他外围设备(Peripheral),从 Android 5.0(API Level 21) 开始两种模式都支持。
P.S. 不过也不是5.0以上就全部都支持,之前测试到魅族M2貌似就开不起peripheral模式,毕竟硬件相关,很难保证,我同事之前开发时候甚至碰到过某些设备会固定少发一个字节,也是坑啊...

2. 踩过的坑

2.1 开启peripheral模式

之前以为开启了手机蓝牙和gps功能, 手机就能被central设备搜索到, 那是经典蓝牙, 要想启用BLE功能并作为peripheral从机,需要使用 BluetoothLeAdvertiser 开启广播模式:
P.S. BLE链接不会弹出连接请求,比经典蓝牙方便,毕竟不打扰用户,另外,查到的资料说,BLE central大概最多同时链接7台设备左右;

/**
 * 开启广播模式,用于本机被其他central设备搜索到
 */
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
fun startAdvertising() {
    if (isBluetoothEnable()
            && !isAdvertising
            && isSupportAdvertisement
            && mBluetoothLeAdvertiser != null
            && mGattServer != null) {
        val success = mGattServerCallBack.setupServices(mGattServer)
        Logger.d("startAdvertising result  = $success ", TAG)
        if (success) {
            mBluetoothLeAdvertiser?.startAdvertising(createAdSettings(true, 0), createAdData(), mAdCallback)
        }
    } else {
        Logger.d("startAdvertising fail", TAG)
    }
}

2.2 蓝牙地址动态变化

参考这篇
Google在Android6.0上修改了获取设备标识信息功能:

// 以下方法固定返回:  02:00:00:00:00:00
WifiInfo.getMacAddress()
BluetoothAdapter.getAddress()

坑爹的是,假设central设备扫描得到peripheral的蓝牙地址记为: A , 连接同一台peripheral设备时获取的蓝牙地址记为B, A跟B还不一致,又动态变化了,真是坑啊:

之所以会想要记录设备蓝牙地址,是想作为唯一标识符,在转传信息时,不要再回传到数据来源方, 比如 A 发送数据给 B, B再往其他设备转传时,就不需要回传给A了,但是地址动态变化的话,我就没辙了,有解决方案的话麻烦告知我一下;

// 低功耗蓝牙扫描回调
var mLeScanCallback: ScanCallback? = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult?) {
        super.onScanResult(callbackType, result)
        //                Logger.d("scan successful $result")
        // 这里通过ScanResult获取到的蓝牙地址A,跟通过手机系统设置页面查看得到的蓝牙地址是不同的,而且每次重新开启peripheral模式后,同一台手机的蓝牙地址就又变化了
        // 
        // 另外,同一台设备会在短时间内被扫描到很多次,因此不是需要对设备进行过滤判断
        addBleDevice(result)
    }

      override fun onBatchScanResults(results: MutableList<ScanResult>?) {
          super.onBatchScanResults(results)
          results?.forEach { addBleDevice(it) }
      }

      override fun onScanFailed(errorCode: Int) {
        super.onScanFailed(errorCode)
        if (ScanCallback.SCAN_FAILED_ALREADY_STARTED != errorCode) {
            isScanningBle = false
        }
        Logger.d("scan failed errorCode = $errorCode")
      }
}

2.3 自定义characteristic UUID

之前以为只要符合uuid模式: 00000000-0000-0000-0000-000000000000(8-4-4-4-12)随便定义即可, 后来看了 这篇 才发现不是这样的,能自定义的只是其中一部分,有兴趣的可以去研究下 BLE文档;
0000????-0000-1000-8000-00805f9b34fb ????就表示4个可以自定义16进制数

2.4 跟iOS通讯时循环写入数据失败

我们是通过 Characteristic 来写入的, 它有个属性来指明发送时不需要响应: BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE , 而我在跟iOS交互时,貌似这个字段双方设定不一致,导致发送后一直没收到响应,然后iOS就一直重发;
因此,需要在作为peripheral模式时,添加的characteristic需要设置为: BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE;
另外,作为central设备往其他设备发送消息时,也需要添加该属性:

  1. Android和iOS使用同一套BLE协议,因此可以通讯,如果是wifi direct的话,就不行了;
  2. Android 4.3虽然也支持central模式,但是查到的文章有说在跟iOS参数交互时有问题,而我使用4.3来搜索其他Android设备也经常找不到,因此就直接不考虑了,从5.0开始;
/**
 * 接收数据时,通过本类回调处理
 */
class GattServerCallBack : BluetoothGattServerCallback() {

    companion object {
        private val TAG = "GattServerCallBack"
    }

    private var mGattServer: BluetoothGattServer? = null

    /**
     * 初始化需要用来转传数据的 service/characteristic
     * */
    private val mRelayService by lazy {
        val service = BluetoothGattService(UUID.fromString(BleConstant.RELAY_SERVICE_UUID), BluetoothGattService.SERVICE_TYPE_PRIMARY)
        val characteristic = BluetoothGattCharacteristic(
                UUID.fromString(BleConstant.RELAY_CHARACTERISTIC_UUID),
                BluetoothGattCharacteristic.PROPERTY_READ
                        or BluetoothGattCharacteristic.PROPERTY_WRITE
                        or BluetoothGattCharacteristic.PROPERTY_NOTIFY
                        or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, // 这里设定不需要回应,也可选择需要响应模式
                BluetoothGattCharacteristic.PERMISSION_READ
                        or BluetoothGattCharacteristic.PERMISSION_WRITE)// 可写模式,不同ble设备间通过本characteristic来传输数据
        characteristic.setValue(BlePara.adCharacteristicValue)

        val addCharacteristic = service.addCharacteristic(characteristic)
        Logger.d("addCharacteristic result = $addCharacteristic", TAG)
        service
    }

    /**
     * 广播开始后,设置一个用于接收消息的service
     * 后续有数据传入时,会触发 [org.lynxz.ble_lib.callbacks.GattServerCallBack.onCharacteristicWriteRequest]
     * */
    fun setupServices(gattServer: BluetoothGattServer?): Boolean {
        if (gattServer == null) {
            return false
        }

        // 设置一个GattService以及BluetoothGattCharacteristic
        mGattServer = gattServer
        val service = mGattServer?.getService(UUID.fromString(BleConstant.RELAY_SERVICE_UUID))
        if (service == null) {
            val addResult = mGattServer?.addService(mRelayService)
            Logger.d("  -> 添加自定义service...result = $addResult", TAG)
        } else {
            Logger.d("  -> 添加自定义service... service已存在,不用重复添加", TAG)
        }
        return true
    }

    override fun onCharacteristicWriteRequest(device: BluetoothDevice?, requestId: Int, characteristic: BluetoothGattCharacteristic?, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) {
        super.onCharacteristicWriteRequest(device, requestId, characteristic, preparedWrite, responseNeeded, offset, value)
        // 按需发送响应
        var responseResult = true
        if (responseNeeded) responseResult = mGattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null) ?: false
        Logger.d("responseNeeded = $responseNeeded ,send response result = $responseResult , receive data length = ${value?.size}")
    }
}
// 作为central设备,通过characteristic发送数据时
val service = gatt.getService(UUID.fromString("*********")) ?: return false
        val relayChar = service.getCharacteristic(UUID.fromString("*********")) ?: return false

val headPackage = ByteArray(20)
relayChar.value = headPackage
        relayChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
val result = gatt.writeCharacteristic(relayChar)

2.5 发送超过20字节数据

扩展阅读
BLE默认单次传输长度为20字节, 对于超过该长度的数据,有两种方式进行处理:

  1. 修改MTU值(最大为512字节)
    在跟iOS交互的时候,发现它一次性可以往Android发送512字节(Android使用默认设定),后来才发现Android设备间也可以重新指定该值,不过使用这种方式的话,我测试到有这种现象: mtu设置回调成功,central设备发送数据也成功,但peripheral设备却不能完整接收到,比如我设置512字节,但收到的可能只有140字节,因此我没有采用这种方式:
mGattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
        super.onConnectionStateChange(gatt, status, newState)
        val device = gatt.device
        Logger.d("onConnectionStateChange newState =  $newState  ${device.address}")
        if (BluetoothGatt.STATE_CONNECTED == newState) {
            gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
            Logger.d("设置mtu结果 : ${gatt.requestMtu(BlePara.mtu)}"
        } else if (BluetoothGatt.STATE_DISCONNECTED == newState) {
            gatt.close()
        }
    }

    // mtu设置成功后才去搜索service/characteristic,然后才可以传输数据
    override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
        super.onMtuChanged(gatt, mtu, status)
        Logger.d(" mtu = $mtu  $status")
        if (status == BluetoothGatt.GATT_SUCCESS) {
             gatt.discoverServices();
        }
    }
}
  1. 对数据进行分包操作,添加控制信息


    蓝牙数据分包.png

分为三部分,每个分包固定20字节:
a. head包,包含一些控制信息,如传送的数据长度,用于整合数据包
b. 用户要传送的数据内容(可加密);
c. tail包,所有数据发送完成后,发送一个结束信息(主要是避免head包发送失败时,接收方一直在等待发送结束,当然,若是tail包也发送失败,则需要通过接收超时机制来控制)
P.S. 跟iOS的同学交流后发现,iOS设备间单次最大也只是能发送512字节,因此应该也有分包的需求;

2.6 分包发送时间间隔过长的问题

stack overflow
连续通过characteristic写入数据时,相邻分包之间需要间隔一下,之前测试发现100ms失败率比较大,200ms就比较ok,但是也有一定概率失败,而且,单包20字节
,我要传输的数据基本都要400字节左右,总耗时(包括连接等)就可能达到5s以上,感觉时间还是太长,两种方式来避免:

  1. 修改 requestConnectionPriority() 值为 BluetoothGatt.CONNECTION_PRIORITY_HIGH
    这样设定后,分包之间设置为20ms就没再发现有出问题过(至少我手头的机型没出错过)
private var mGattCallback: BluetoothGattCallback? = null
mGattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
        super.onConnectionStateChange(gatt, status, newState)
        val device = gatt.device
        Logger.d("onConnectionStateChange newState =  $newState  ${device.address}")
        if (BluetoothGatt.STATE_CONNECTED == newState) {
            Logger.d("onConnectionStateChange STATE_CONNECTED = $newState ,gatt == mGatt? = ${gatt == mGatt}")
            // 发送大数据时设置如此,有人建议发送完成后要设置成默认的: CONNECTION_PRIORITY_BALANCED
            gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
            // REFACTOR: 17/06/2017 可以设置mtu大小,若启用此方式,则请在onMtuChanged()回调成功后再搜索及发送数据,但Android之间测试发现接收方有些只能收到152个字节,暂时不考虑,后续研究
            //  Logger.d("设置mtu结果 : ${gatt.requestMtu(BlePara.mtu)}"
            // 连接成功,开始搜索service
            gatt.discoverServices()
        } else if (BluetoothGatt.STATE_DISCONNECTED == newState) {
            // gatt连接断开
            Logger.d("onConnectionStateChange STATE_DISCONNECTED = $newState")
            gatt.close()
        }
    }
}
  1. 添加错误重传机制,重传时间间隔增加
    发送分包时不可避免可能出错,若默认分包间隔为20ms,发送失败后,可尝试重传一次,重传时的时间间隔略微设定大些,如200ms,这样仍能有效减小总发送时间;
var result = true // 发送数据是否成功
val delay = 20 // 分包之间的延时,单位:毫秒
try {
    // 注意,这里需要延时一下,不然测试发现,基本上只能收到其中几帧的数据,失败的概率比较大
    Thread.sleep(delay.toLong())

    var i = 0
    while (i < size) {
        var to = i + 20
        if (to >= size) {
            to = size
        }
        val slice = Arrays.copyOfRange(encryptedContentBytes, i, to)
        relayChar.value = slice
        var sliceResult = gatt.writeCharacteristic(relayChar)
        Logger.d("传送第 $i ~ $to 块数据的结果: $sliceResult", TAG)
        // 发送失败时,尝试重传一次就好
        if (!sliceResult) {
            Thread.sleep(200)
            sliceResult = gatt.writeCharacteristic(relayChar)
            Logger.d(" =>重传第 $i ~ $to 块数据的结果: $sliceResult", TAG)
        }
        result = result and sliceResult
        i = to
        Thread.sleep(delay.toLong())
        // 由于只重传一次, 因此如果某个数据分包重传失败,则不必要再传后续数据,直接返回失败
        if (!result) {
            break
        }
    }
} catch (e: Exception) {
    e.printStackTrace()
    result = false
}

2.7 蓝牙抓包,日志查看

之前跟iOS交互出错后,app层回调可看到的信息比较少, 查到的资料 又都说有某个控制参数出错, 没发现characteristic设置有问题前,就想着要抓包看看具体的参数交互, 未找到实时抓包的简单方法, 倒是可以通过Android手机的hcidump功能来获取日志,然后通过 wireshark 来查看:

  1. 查看hci日志文件路径
// 我使用nexus 6p 7.1.1系统,配置文件位于如下位置:
adb shell cat /etc/bluetooth/bt_stack.conf
// 文件中有一条配置信息,指示了log文件所在路径
BtSnoopFileName=/sdcard/btsnoop_hci.log
  1. 抓取/导出hci日志
// 先清除原先的日志
adb shell rm /sdcard/btsnoop_hci.log 
//  通过手机系统打开日志功能: settings-developer options -- enable bluetooth hci snoop log
// 抓取结束后,导出log文件到pc上
adb pull /sdcard/btsnoop_hci.log

不过, 一开始做ble没经验,可以先下载些软件来测试下ble功能,这里推荐一个 nRF24L01 , 具体请参考 这篇文章, 好用, 搜索/连接/发送数据等功能一应俱全, 写完 peripheral 模式后,用它测试下,确认ok了,再来做central模式;

3. 参考资料

  1. BLE 官方文档
  2. android ble常见问题收集
  3. BLE开发的各种坑
  4. ble address动态变化
  5. wireshark bluetooth简要描述
  6. Debugging Bluetooth With An Android App
    介绍了款测试软件,使用了,觉得不错...
  7. Android BLE中传输数据的最大长度怎么破
    看完这篇才知道为啥单个分包20字节,Android传iOS单次最多可用512字节....,注意:需要在设备连接成功后再来设置,最大512,但是即使设置成功也没法直接发送,需要在回调 onMtuChanged() 显示成功后,再写数据即可;
  8. Android BLE MTU调整
  9. 低功耗蓝牙介绍
    介绍了hci日志中的 host / controller 含义,以及协议帧结构
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,506评论 25 707
  • Key Terms And Concepts 关键术语和概念 Here is a summary of key B...
    Jaesoon阅读 2,426评论 0 5
  • 安卓4.3(API 18)为BLE的核心功能提供平台支持和API,App可以利用它来发现设备、查询服务和读写特性。...
    风雨byt阅读 13,996评论 3 43
  • 拾荒者 “gege,你看!”老婆用她一贯尖而又翠的声音喊了起来,不得不说,最近她很是热心观察周边事物。 “怎么了?...
    吃熊猫买的鱼阅读 215评论 1 2
  • 版本管理工具是干什么用的? 1)备份文件。就像U盘备份我们的文件一样。我们的代码也可以进行备份。每当我们对代码有修...
    廖马儿阅读 1,520评论 0 0