1. 程式人生 > >初識低功耗藍芽

初識低功耗藍芽

初識低功耗藍芽

Android 4.3(API Level 18)開始引入Bluetooth Low Energy(BLE,低功耗藍芽)的核心功能並提供了相應的 API, 應用程式通過這些 API 掃描藍芽裝置、查詢 services、讀寫裝置的 characteristics(屬性特徵)等操作。

Android BLE 使用的藍芽協議是 GATT 協議,有關該協議的詳細內容可以參見藍芽官方文件。以下我引用一張官網的圖來大概說明 Android 開發中我們需要了解的一些 Bluetooth Low Energy 的專業術語。

藍芽協議圖

 

Service

一個低功耗藍芽裝置可以定義許多 Service, Service 可以理解為一個功能的集合。裝置中每一個不同的 Service 都有一個 128 bit 的 UUID 作為這個 Service 的獨立標誌。藍芽核心規範制定了兩種不同的UUID,一種是基本的UUID,一種是代替基本UUID的16位UUID。所有的藍芽技術聯盟定義UUID共用了一個基本的UUID:
0x0000xxxx-0000-1000-8000-00805F9B34FB
為了進一步簡化基本UUID,每一個藍芽技術聯盟定義的屬性有一個唯一的16位UUID,以代替上面的基本UUID的‘x’部分。例如,心率測量特性使用0X2A37作為它的16位UUID,因此它完整的128位UUID為:
0x00002A37-0000-1000-8000-00805F9B34FB

Characteristic

在 Service 下面,又包括了許多的獨立資料項,我們把這些獨立的資料項稱作 Characteristic。同樣的,每一個 Characteristic 也有一個唯一的 UUID 作為識別符號。在 Android 開發中,建立藍芽連線後,我們說的通過藍芽傳送資料給外圍裝置就是往這些 Characteristic 中的 Value 欄位寫入資料;外圍裝置傳送資料給手機就是監聽這些 Charateristic 中的 Value 欄位有沒有變化,如果發生了變化,手機的 BLE API 就會收到一個監聽的回撥。

更詳細的內容可以參見
GATT Profile 簡介


通用屬性配置檔案(GATT)及其服務,特性與屬性介紹
GATT specification
GATT Services
藍芽【GATT】協議介紹


Android BLE API 簡介

BluetoothAdapter
BluetoothAdapter 擁有基本的藍芽操作,例如開啟藍芽掃描,使用已知的 MAC 地址 (BluetoothAdapter#getRemoteDevice)例項化一個 BluetoothDevice 用於連線藍芽裝置的操作等等。

BluetoothDevice
代表一個遠端藍芽裝置。這個類可以讓你連線所代表的藍芽裝置或者獲取一些有關它的資訊,例如它的名字,地址和繫結狀態等等。

BluetoothGatt
這個類提供了 Bluetooth GATT 的基本功能。例如重新連線藍芽裝置,發現藍芽裝置的 Service 等等。

BluetoothGattService
這一個類通過 BluetoothGatt#getService 獲得,如果當前服務不可見那麼將返回一個 null。這一個類對應上面說過的 Service。我們可以通過這個類的 getCharacteristic(UUID uuid) 進一步獲取 Characteristic 實現藍芽資料的雙向傳輸。

BluetoothGattCharacteristic
這個類對應上面提到的 Characteristic。通過這個類定義需要往外圍裝置寫入的資料和讀取外圍裝置傳送過來的資料。


Android 藍芽開發示例

第一步:宣告所需要的許可權

<uses-permission android:name="android.permission.BLUETOOTH"/> 使用藍芽所需要的許可權
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/> 使用掃描和設定藍芽的許可權(申明這一個許可權必須申明上面一個許可權)

在Android5.0之前,是預設申請GPS硬體功能的。而在Android 5.0 之後,需要在manifest 中申明GPS硬體模組功能的使用。

    <!-- Needed only if your app targets Android 5.0 (API level 21) or higher. -->
    <uses-feature android:name="android.hardware.location.gps" />

在 Android 6.0 及以上,還需要開啟位置許可權。如果應用沒有位置許可權,藍芽掃描功能不能使用(其它藍芽操作例如連線藍芽裝置和寫入資料不受影響)。

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

第二步:連線藍芽前的初始化工作

在建立藍芽連線之前,需要確認裝置支援 BLE。如果支援,再確認藍芽是否開啟。如果藍芽沒有開啟,可以使用 BLuetoothAdapter 類來開啟藍芽。

  1. 獲取 BluetoothAdapter
    private BluetoothAdapter mBluetoothAdapter;
        
    // Initializes Bluetooth adapter.
    final BluetoothManager bluetoothManager =
        (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
    mBluetoothAdapter = bluetoothManager.getAdapter();
  1. 如果檢測到藍芽沒有開啟,嘗試開啟藍芽
    // Ensures Bluetooth is available on the device and it is enabled. If not,
    // displays a dialog requesting user permission to enable Bluetooth.
    if (mBluetoothAdapter == null || !mBluetoothAdapter.isEnabled()) {
            Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
            startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT);
    }

第三步:掃描藍芽裝置

外圍裝置開啟藍芽後,會廣播出許多的關於該裝置的資料資訊,例如 mac 地址,uuid 等等。通過這些資料我們可以篩選出需要的裝置。

在 BluetoothAdapter 中,我們可以看到有兩個掃描藍芽的方法。第一個方法可以指定只掃描含有特定 UUID Service 的藍芽裝置,第二個方法則是掃描全部藍芽裝置。

boolean startLeScan(UUID[] serviceUuids, BluetoothAdapter.LeScanCallback callback)
boolean startLeScan(BluetoothAdapter.LeScanCallback callback)

開啟藍芽掃描

final BluetoothAdapter.LeScanCallback callback = new BluetoothAdapter.LeScanCallback() {
    @Override
    public void onLeScan(final BluetoothDevice device, int rssi, byte[] scanRecord) {
        bluetoothDeviceArrayList.add(device);
        Log.d(TAG, "run: scanning...");
    }
};

mBluetoothAdapter.startLeScan(callback);

在 LeScanCallback 回撥的方法中,第一個引數是代表藍芽裝置的類,可以通過這個類建立藍芽連接獲取關於這一個裝置的一系列詳細的引數,例如名字,MAC 地址等等;第二個引數是藍芽的訊號強弱指標,通過藍芽的訊號指標,我們可以大概計算出藍芽裝置離手機的距離。計算公式為:d = 10^((abs(RSSI) - A) / (10 * n));第三個引數是藍芽廣播出來的廣告資料。
當執行上面的程式碼之後,一旦發現藍芽裝置,LeScanCallback 就會被回撥,直到 stopLeScan 被呼叫。出現在回撥中的裝置會重複出現,所以如果我們需要通過 BluetoothDevice 獲取外圍裝置的地址手動過濾掉已經發現的外圍裝置。

停止藍芽掃描

void    stopLeScan(BluetoothAdapter.LeScanCallback callback)

通過呼叫 BluetoothAdapter#stopLeScan 可以停止正在進行的藍芽掃描。這裡需要注意的是,傳入的回撥必須是開啟藍芽掃描時傳入的回撥,否則藍芽掃描不會停止。

由於藍芽掃描的操作比較消耗手機的能量。所以我們不能一直開著藍芽,必須設定一段時間之後關閉藍芽掃描。示例程式碼如下:

private void scanLeDevice(final boolean enable) {
    if (enable) {
        // Stops scanning after a pre-defined scan period.
        // 預先定義停止藍芽掃描的時間(因為藍芽掃描需要消耗較多的電量)
        mHandler.postDelayed(new Runnable() {
            @Override
            public void run() {
                mScanning = false;
                mBluetoothAdapter.stopLeScan(mLeScanCallback);
            }
        }, SCAN_PERIOD);
        mScanning = true;

        // 定義一個回撥介面供掃描結束處理
        mBluetoothAdapter.startLeScan(mLeScanCallback);
    } else {
        mScanning = false;
        mBluetoothAdapter.stopLeScan(mLeScanCallback);
    }
}

第四步:連線藍芽裝置

連線藍芽裝置可以通過 BluetoothDevice#ConnectGatt 方法連線,也可以通過 BluetoothGatt#connect 方法進行重新連線。以下分別是兩個方法的官方說明:

BluetoothDevice#connectGatt
BluetoothGatt   connectGatt(Context context, boolean autoConnect, BluetoothGattCallback callback)

第二個引數表示是否需要自動連線。如果設定為 true, 表示如果裝置斷開了,會不斷的嘗試自動連線。設定為 false 表示只進行一次連線嘗試。
第三個引數是連線後進行的一系列操作的回撥,例如連線和斷開連線的回撥,發現服務的回撥,成功寫入資料,成功讀取資料的回撥等等。

BluetoothGatt#connect
boolean connect()

呼叫這一個方法相當與呼叫 BluetoothDevice#connectGatt 且第二個引數 autoConnect 設定為 true。

當呼叫藍芽的連線方法之後,藍芽會非同步執行藍芽連線的操作,如果連線成功會回撥 BluetoothGattCalbackl#onConnectionStateChange 方法。這個方法執行的執行緒是一個 Binder 執行緒,所以不建議直接在這個執行緒處理耗時的任務,因為這可能導致藍芽相關的執行緒被阻塞。

void    onConnectionStateChange(BluetoothGatt gatt, int status, int newState)

這一個方法有三個引數,第一個就藍芽裝置的 Gatt 服務連線類。
第二個引數代表是否成功執行了連線操作,如果為 BluetoothGatt.GATT_SUCCESS 表示成功執行連線操作,第三個引數才有效,否則說明這次連線嘗試不成功。有時候,我們會遇到 status == 133 的情況,根據網上大部分人的說法,這是因為 Android 最多支援連線 6 到 7 個左右的藍芽裝置,如果超出了這個數量就無法再連線了。所以當我們斷開藍芽裝置的連線時,還必須呼叫 BluetoothGatt#close 方法釋放連線資源。否則,在多次嘗試連線藍芽裝置之後很快就會超出這一個限制,導致出現這一個錯誤再也無法連線藍芽裝置。
第三個引數代表當前裝置的連線狀態,如果 newState == BluetoothProfile.STATE_CONNECTED 說明裝置已經連線,可以進行下一步的操作了(發現藍芽服務,也就是 Service)。當藍芽裝置斷開連線時,這一個方法也會被回撥,其中的 newState == BluetoothProfile.STATE_DISCONNECTED。

第五步:發現服務

在成功連線到藍芽裝置之後才能進行這一個步驟,也就是說在 BluetoothGattCalbackl#onConnectionStateChang 方法被成功回撥且表示成功連線之後呼叫 BluetoothGatt#discoverService 這一個方法。當這一個方法被呼叫之後,系統會非同步執行發現服務的過程,直到 BluetoothGattCallback#onServicesDiscovered 被系統回撥之後,手機裝置和藍芽裝置才算是真正建立了可通訊的連線。

到這一步,我們已經成功和藍芽裝置建立了可通訊的連線,接下來就可以執行相應的藍芽通訊操作了,例如寫入資料,讀取藍芽裝置的資料等等。

讀取資料

當我們發現服務之後就可以通過 BluetoothGatt#getService 獲取 BluetoothGattService,接著通過 BluetoothGattService#getCharactristic 獲取 BluetoothGattCharactristic。
通過 BluetoothGattCharactristic#readCharacteristic 方法可以通知系統去讀取特定的資料。如果系統讀取到了藍芽裝置傳送過來的資料就會呼叫 BluetoothGattCallback#onCharacteristicRead 方法。通過 BluetoothGattCharacteristic#getValue 可以讀取到藍芽裝置的資料。以下是程式碼示例:

@Override
public void onCharacteristicRead(final BluetoothGatt gatt,
                                    final BluetoothGattCharacteristic characteristic,
                                    final int status) {

    Log.d(TAG, "callback characteristic read status " + status
            + " in thread " + Thread.currentThread());
    if (status == BluetoothGatt.GATT_SUCCESS) {
        Log.d(TAG, "read value: " + characteristic.getValue());
    }

}


// 讀取資料
BluetoothGattService service = gattt.getService(SERVICE_UUID);
BluetoothGattCharacteristic characteristic = gatt.getCharacteristic(CHARACTER_UUID);
gatt.readCharacteristic();

寫入資料

和讀取資料一樣,在執行寫入資料前需要獲取到 BluetoothGattCharactristic。接著執行一下步驟:

  1. 呼叫 BluetoothGattCharactristic#setValue 傳入需要寫入的資料(藍芽最多單次1支援 20 個位元組資料的傳輸,如果需要傳輸的資料大於這一個位元組則需要分包傳輸)。
  2. 呼叫 BluetoothGattCharactristic#writeCharacteristic 方法通知系統非同步往裝置寫入資料。
  3. 系統回撥 BluetoothGattCallback#onCharacteristicWrite 方法通知資料已經完成寫入。此時,我們需要執行 BluetoothGattCharactristic#getValue 方法檢查一下寫入的資料是否我們需要傳送的資料,如果不是按照專案的需要判斷是否需要重發。
    以下是示例程式碼:
@Override
public void onCharacteristicWrite(final BluetoothGatt gatt,
                                    final BluetoothGattCharacteristic characteristic,
                                    final int status) {
    Log.d(TAG, "callback characteristic write in thread " + Thread.currentThread());
    if(!characteristic.getValue().equal(sendValue)) {
        // 執行重發策略
        gatt.writeCharacteristic(characteristic);
    }
}

//往藍芽資料通道的寫入資料
BluetoothGattService service = gattt.getService(SERVICE_UUID);
BluetoothGattCharacteristic characteristic = gatt.getCharacteristic(CHARACTER_UUID);
characteristic.setValue(sendValue);
gatt.writeCharacteristic(characteristic);

向藍芽設備註冊監聽實現實時讀取藍芽裝置的資料

BLE app通常需要獲取裝置中characteristic 變化的通知。下面的程式碼演示了怎麼為一個Characteristic 設定一個監聽。

mBluetoothGatt.setCharacteristicNotification(characteristic, enabled);

BluetoothGattDescriptor descriptor = characteristic.getDescriptor(
        UUID.fromString(SampleGattAttributes.CLIENT_CHARACTERISTIC_CONFIG));
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
mBluetoothGatt.writeDescriptor(descriptor);

值得注意的是,除了通過 BluetoothGatt#setCharacteristicNotification 開啟 Android 端接收通知的開關,還需要往 Characteristic 的 Descriptor 屬性寫入開啟通知的資料開關使得當硬體的資料改變時,主動往手機發送資料。

最後一步:斷開連線

當我們連線藍芽裝置完成一系列的藍芽操作之後就可以斷開藍芽裝置的連線了。通過 BluetoothGatt#disconnect 可以斷開正在連線的藍芽裝置。當這一個方法被呼叫之後,系統會非同步回撥 BluetoothGattCallback#onConnectionStateChange 方法。通過這個方法的 newState 引數可以判斷是連線成功還是斷開成功的回撥。
由於 Android 藍芽連線裝置的資源有限,當我們執行斷開藍芽操作之後必須執行 BluetoothGatt#close 方法釋放資源。需要注意的是通過 BluetoothGatt#close 方法也可以執行斷開藍芽的操作,不過 BluetoothGattCallback#onConnectionStateChange 將不會收到任何回撥。此時如果執行 BluetoothGatt#connect 方法會得到一個藍芽 API 的空指標異常。所以,我們推薦的寫法是當藍芽成功連線之後,通過 BluetoothGatt#disconnect 斷開藍芽的連線,緊接著在 BluetoothGattCallback#onConnectionStateChange 執行 BluetoothGatt#close 方法釋放資源。
以下是程式碼示例:

    @Override
    public void onConnectionStateChange(final BluetoothGatt gatt, final int status,
                                    final int newState) {
        Log.d(TAG, "onConnectionStateChange: thread "
                + Thread.currentThread() + " status " + newState);

        if (status != BluetoothGatt.GATT_SUCCESS) {
            String err = "Cannot connect device with error status: " + status;
      // 當嘗試連線失敗的時候呼叫 disconnect 方法是不會引起這個方法回撥的,所以這裡
                //   直接回調就可以了。
            gatt.close();
            Log.e(TAG, err);
            return;
        }

        if (newState == BluetoothProfile.STATE_CONNECTED) {
            gatt.discoverService();
        } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
            gatt.close();
        }
    }

藍芽操作的注意事項

  1. 藍芽的寫入操作( 包括 Descriptor 的寫入操作), 讀取操作必須序列化進行. 寫入資料和讀取資料是不能同時進行的, 如果呼叫了寫入資料的方法, 馬上呼叫又呼叫寫入資料或者讀取資料的方法,第二次呼叫的方法會立即返回 false, 代表當前無法進行操作. 詳情可以參考 藍芽讀寫操作返回 false,為什麼多次讀寫只有一次回撥?
  2. Android 連線外圍裝置的數量有限,當不需要連線藍芽裝置的時候,必須呼叫 BluetoothGatt#close 方法釋放資源。詳細的參考可以看這裡 Android BLE 藍芽開發的各種坑
  3. 藍芽 API 連線藍芽裝置的超時時間大概在 20s 左右,具體時間看系統實現。有時候某些裝置進行藍芽連線的時間會很長,大概十多秒。如果自己手動設定了連線超時時間(例如通過 Handler#postDelay 設定了 5s 後沒有進入 BluetoothGattCallback#onConnectionStateChange 就執行 BluetoothGatt#close 操作強制釋放斷開連線釋放資源)在某些裝置上可能會導致接下來幾次的連線嘗試都會在 BluetoothGattCallback#onConnectionStateChange 返回 state == 133。另外可以參考這篇吐槽 Android 中 BLE 連接出現“BluetoothGatt status 133”的解決方法
  4. 所有的藍芽操作使用 Handler 固定在一條執行緒操作,這樣能省去很多因為執行緒不同步導致的麻煩