Android BLE開發小記

我啥時候說啦jj發表於2018-01-03

自己封裝的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 含義,以及協議幀結構

相關文章