最近開發了一套iOS原生的藍牙SDK,總結了一些有價值的踩過的坑,分享出來給有需要的同學做個參考。
一、藍牙的使用
iOS有一套封裝好的完善的藍牙API ,可以很便捷的實現與藍牙的連接和通信,藍牙通信的大體流程如下,先對基本流程和術語有個初步的理解,然后我們在細細的從代碼級別從零開始來實現一個藍牙通信的示例。
藍牙的通信大體分為如下三個步驟
在整個流程中,我們需要理解兩個基本概念,【藍牙設備的服務】和【藍牙的特征】
藍牙設備的服務(Service)
在低功耗藍牙通信(BLE)里,通信是基于 GATT(Generic Attribute Profile) 的。簡單來說,藍牙設備的服務就類似與藍牙的功能模塊,一個藍牙設備會有多個服務,用來描述設備提供什么功能,每一個功能就對應一個服務,每一個藍牙的服務 (Service)都有一個唯一的UUID(通用唯一標識符) 來標識,如果有需要的話可以通過UUID來作為唯一標識符來區分。
藍牙的特征(Characteristic)
每一個藍牙的服務都包含多個特征,這個命名很抽象,但是特征才是我們在進行藍牙通信的最基本的通信單元,比如寫特征和監聽特征。我們可以查詢指定服務下擁有那些特征,然后通過這些特征來與藍牙進行通信,比如我們可以通過【寫特征】來進行藍牙數據的寫入,【監聽特征】來收取藍牙的回復。
當我們連接上藍牙設備之后,我們可以先查詢它擁有那些服務,然后再查詢每個服務下面有那些特征來給我們使用,接著就可以跟藍牙設備來進行通信了。
接下來我們通過代碼來實現一個藍牙通信的示例,來講解每一個步驟的具體使用,每一步都會有詳細的分步代碼(全量代碼會在文內貼出以便查詢)
1.搜索藍牙設備
配置項目權限
在進行搜索藍牙設備之前,我們需要先在項目里配置上藍牙的權限聲明,具體位置在這里
伸手黨自取:
NSBluetoothWhileInUseUsageDescription TestBluetooth 想要使用藍牙
Privacy - Bluetooth Peripheral Usage Description TestBluetooth想要使用藍牙
Privacy - Bluetooth Always Usage Description TestBluetooth想要使用藍牙
(僅做示例使用,具體文案請根據不同場景自行優化)
配置好之后就可以正式開始藍牙使用的開發了,廢話不多說,直接上代碼
首先 我們需要創建一個中央設備管理器CBCentralManager,它是 CoreBluetooth 框架 提供的一個核心類,功能就是用來掃描藍牙設備、連接藍牙設備、斷開藍牙設備、通過代理監聽設備連接的成功和失敗,是我們實現藍牙功能的第一步。
創建中央設備管理器
dispatch_queue_t centralQueue = dispatch_queue_create("Central_Queue",DISPATCH_QUEUE_SERIAL);self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:centralQueue];
我這里為了防止影響主線程的性能,所以單獨創建了一個串行隊列用來實現藍牙的所有功能、通信和監聽,也建議各位用這種方式來實現,因為藍牙功能絕大部分都是在后臺運行的,通過串行隊列也可以使得通信處理順序不會錯亂,但是記得更新頁面的時候需要回到主線程來更新。
掃描藍牙設備
掃描藍牙設備的功能極其簡單
系統api 如下
/*!* @method scanForPeripheralsWithServices:options:** @param serviceUUIDs A list of <code>CBUUID</code> objects representing the service(s) to scan for.* @param options An optional dictionary specifying options for the scan.** @discussion Starts scanning for peripherals that are advertising any of the services listed in <i>serviceUUIDs</i>. Although strongly discouraged,* if <i>serviceUUIDs</i> is <i>nil</i> all discovered peripherals will be returned. If the central is already scanning with different* <i>serviceUUIDs</i> or <i>options</i>, the provided parameters will replace them.* Applications that have specified the <code>bluetooth-central</code> background mode are allowed to scan while backgrounded, with two* caveats: the scan must specify one or more service types in <i>serviceUUIDs</i>, and the <code>CBCentralManagerScanOptionAllowDuplicatesKey</code>* scan option will be ignored.** @see centralManager:didDiscoverPeripheral:advertisementData:RSSI:* @seealso CBCentralManagerScanOptionAllowDuplicatesKey* @seealso CBCentralManagerScanOptionSolicitedServiceUUIDsKey**/
- (void)scanForPeripheralsWithServices:(nullable NSArray<CBUUID *> *)serviceUUIDs options:(nullable NSDictionary<NSString *, id> *)options;
其中
serviceUUIDs:你希望掃描的藍牙服務列表(可為 nil 掃描所有外設)。
options:可選的掃描參數,控制掃描行為和結果。
options有一個很實用的選項,是否允許重復掃描
NSDictionary *options = @{ CBCentralManagerScanOptionAllowDuplicatesKey : @YES };
[centralManager scanForPeripheralsWithServices:nil options:options];
如果設置為Yes,會重復返回同一個外設,常用于實時更新信號強度 RSSI 或觀察廣告包變化,默認情況下我們傳nil就可以了,系統會自動去重,每個外設只會返回一次,即使掃描過程中設備的 RSSI 或廣告包發生變化,也不會重復觸發回調。
調用方法來掃描設備
[self.centralManager scanForPeripheralsWithServices:nil options:nil];
然后我們需要實現CBCentralManagerDelegate來監聽搜索到的藍牙設備,搜索到的藍牙設備會通過CBCentralManagerDelegate中的回調方法中返回
CBCentralManagerDelegate回調中有幾個常用的方法如下,介紹及參數都在備注里進行了說明:
#pragma mark CBCentralManagerDelegate
/*- 藍牙狀態更改時回調,當系統藍牙開啟和關閉的時候,也會回調該方法,可通過CBManagerStatePoweredOn枚舉來判斷當前藍牙是否可用,當系統權限正確配置且系統藍牙開關開啟的時候才會返回CBManagerStatePoweredOn*/
- (void)centralManagerDidUpdateState:(CBCentralManager *)central
{NSLog(@"藍牙狀態變更");switch (central.state) {case CBManagerStateUnknown: //未知狀態NSLog(@"中央設備藍牙狀態:未知狀態");break;case CBManagerStateResetting: //重啟狀態NSLog(@"中央設備藍牙狀態:重啟狀態");break;case CBManagerStateUnsupported: //不支持NSLog(@"中央設備藍牙狀態:不支持");break;case CBManagerStateUnauthorized: //未授權NSLog(@"中央設備藍牙狀態:未授權");break;case CBManagerStatePoweredOff: //藍牙未開啟NSLog(@"中央設備藍牙狀態:藍牙未開啟");break;case CBManagerStatePoweredOn: //藍牙開啟{NSLog(@"中央設備藍牙狀態:藍牙開啟");}break;default:break;}
}/*發現藍牙設備回調- Parameters:- central: 中央設備- peripheral: 藍牙外設- advertisementData: 廣播數據- RSSI: 信號強度*/
-(void)centralManager:(CBCentralManager *)central didDiscoverPeripheral:(CBPeripheral *)peripheral advertisementData:(NSDictionary<NSString *,id> *)advertisementData RSSI:(NSNumber *)RSSI{NSLog(@"發現設備: %@ 藍牙強度RSSI:%d", peripheral.name ?: @"未知設備",[RSSI intValue]);if(![self.dataList containsObject:peripheral])[self.dataList addObject:peripheral];// 獲取 ManufacturerDataif([peripheral.name containsString:@"88"]){NSData *manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey];if (manufacturerData) {[self parseManufacturerData:manufacturerData];}}dispatch_async(dispatch_get_main_queue(), ^{[self.deviceTableView reloadData];});
}/*藍牙設備連接成功- Parameters:- central: 中央設備- peripheral: 藍牙外設*/
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral{NSString *deviceName = peripheral.name ?: @"未知設備";NSLog(@"藍牙設備連接成功 設備名:%@", deviceName);dispatch_async(dispatch_get_main_queue(), ^{self.stateLabel.text = deviceName;self.connectPeripheral = peripheral;self.connectPeripheral.delegate = self;});
}/*藍牙設備連接失敗- Parameters:- central: 中央設備- peripheral: 藍牙外設- error: 錯誤信息*/
-(void)centralManager:(CBCentralManager *)central didFailToConnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error{NSLog(@"藍牙設備連接失敗,連接設備名 %@",peripheral.name);
}/*藍牙設備連接斷開- Parameters:- central: 中央設備- peripheral: 藍牙外設- error: 錯誤信息*/
- (void)centralManager:(CBCentralManager *)central didDisconnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error{NSLog(@"藍牙設備斷開,連接設備名 %@",peripheral.name);
}
當掃描到藍牙設備之后,會通過
/*!* @method centralManager:didDiscoverPeripheral:advertisementData:RSSI:** @param central The central manager providing this update.* @param peripheral A <code>CBPeripheral</code> object.* @param advertisementData A dictionary containing any advertisement and scan response data.* @param RSSI The current RSSI of <i>peripheral</i>, in dBm. A value of <code>127</code> is reserved and indicates the RSSI* was not available.** @discussion This method is invoked while scanning, upon the discovery of <i>peripheral</i> by <i>central</i>. A discovered peripheral must* be retained in order to use it; otherwise, it is assumed to not be of interest and will be cleaned up by the central manager. For* a list of <i>advertisementData</i> keys, see {@link CBAdvertisementDataLocalNameKey} and other similar constants.** @seealso CBAdvertisementData.h**/
- (void)centralManager:(CBCentralManager *)central didDiscoverPeripheral:(CBPeripheral *)peripheral advertisementData:(NSDictionary<NSString *, id> *)advertisementData RSSI:(NSNumber *)RSSI;
方法將設備的詳細信息返回,其中有幾個常用的重要信息
藍牙名稱:
通過peripheral.name可以獲取的藍牙的設備名稱
RSSI:
RSSI 標識的是藍牙的強度,是以負數RSSI 表示設備接收到的信號強度大小,通常用 dBm(分貝毫瓦) 表示。
值越高(接近 0),信號越強;
值越低(負數越大),信號越弱
比如
-30 dBm → 信號非常強
-60 dBm → 信號良好
-90 dBm → 信號很弱,可能無法通信
可以通過RSSI進行距離估計,判斷發射設備和接收設備的相對距離(雖然精度受環境影響大)和連接質量判斷,用來刪除掉那些信號非常差的設備
附加信息:
advertisementData 這個數據也是非常重要的信息,advertisementData是通過鍵值對來返回藍牙設備的附屬信息,需要注意的是,advertisementData不是一次回調就可以獲取完整,而是通過掃描來逐次獲取和補充更新的,常見的信息字段如下:
CBAdvertisementDataLocalNameKey 設備本地名稱
CBAdvertisementDataServiceUUIDsKey 設備廣播的服務 UUID 數組
CBAdvertisementDataServiceDataKey 服務相關數據(字典:UUID → Data)
CBAdvertisementDataManufacturerDataKey 廠商自定義數據
CBAdvertisementDataTxPowerLevelKey 發射功率,通常用于距離估算
CBAdvertisementDataIsConnectable 是否可連接(BOOL)
因為IOS的隱私保護機制,我們在掃描藍牙的時候不能夠直接獲取到藍牙設備的mac地址,但是有的藍牙廠商會將mac地址放在CBAdvertisementDataManufacturerDataKey這個廠商自定義的字段中返回,在使用的過程中可以留意一下。
停止藍牙掃描調用方法
/*!* @method stopScan:** @discussion Stops scanning for peripherals.**/
- (void)stopScan;
實際使用方法:
[self.centralManager stopScan];
2.連接藍牙設備
通過上面的代理 ,我們就可以獲取到當前周圍藍牙設備的信息了,也可以通過藍牙搜索的回調找到我們想要連接的設備,可以使用下面的方法來進行藍牙的連接。
連接某一藍牙設備:
從我們緩存的藍牙列表里找到我們想要連接的藍牙設備,然后調用方法來進行連接
/*!* @method connectPeripheral:options:** @param peripheral The <code>CBPeripheral</code> to be connected.* @param options An optional dictionary specifying connection behavior options.** @discussion Initiates a connection to <i>peripheral</i>. Connection attempts never time out and, depending on the outcome, will result* in a call to either {@link centralManager:didConnectPeripheral:} or {@link centralManager:didFailToConnectPeripheral:error:}.* Pending attempts are cancelled automatically upon deallocation of <i>peripheral</i>, and explicitly via {@link cancelPeripheralConnection}.** @see centralManager:didConnectPeripheral:* @see centralManager:didFailToConnectPeripheral:error:* @seealso CBConnectPeripheralOptionNotifyOnConnectionKey* @seealso CBConnectPeripheralOptionNotifyOnDisconnectionKey* @seealso CBConnectPeripheralOptionNotifyOnNotificationKey* @seealso CBConnectPeripheralOptionEnableTransportBridgingKey* @seealso CBConnectPeripheralOptionRequiresANCS* @seealso CBConnectPeripheralOptionEnableAutoReconnect**/
- (void)connectPeripheral:(CBPeripheral *)peripheral options:(nullable NSDictionary<NSString *, id> *)options;
實際使用方式如下:
CBPeripheral *peripheral = self.dataList[indexPath.row];
[self.centralManager connectPeripheral:peripheral options:nil];
發起連接之后,通過CBCentralManagerDelegate代理中的方法可以監聽藍牙的連接成功與失敗
/*藍牙設備連接成功- Parameters:- central: 中央設備- peripheral: 藍牙外設*/
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral{NSString *deviceName = peripheral.name ?: @"未知設備";NSLog(@"藍牙設備連接成功 設備名:%@", deviceName);dispatch_async(dispatch_get_main_queue(), ^{self.stateLabel.text = deviceName;self.connectPeripheral = peripheral;self.connectPeripheral.delegate = self;});
}/*藍牙設備連接失敗- Parameters:- central: 中央設備- peripheral: 藍牙外設- error: 錯誤信息*/
-(void)centralManager:(CBCentralManager *)central didFailToConnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error{NSLog(@"藍牙設備連接失敗,連接設備名 %@",peripheral.name);
}
3.查詢設備服務
當設備連接成功之后,切記要緩存設備的并設置設備的代理
self.connectPeripheral = peripheral;
self.connectPeripheral.delegate = self;
否則的話后續的掃描服務、掃描服務特征的回調都是收不到的。
連接設備成功之后就可以查詢設備有那些服務,查詢設備服務的方法是
/*!* @method discoverServices:** @param serviceUUIDs A list of <code>CBUUID</code> objects representing the service types to be discovered. If <i>nil</i>,* all services will be discovered.** @discussion Discovers available service(s) on the peripheral.** @see peripheral:didDiscoverServices:*/
- (void)discoverServices:(nullable NSArray<CBUUID *> *)serviceUUIDs;
通過我們當前連接的藍牙設備的CBPeripheral對象來調用
[self.connectPeripheral discoverServices:nil];
然后實現代理CBPeripheralDelegate的一系列代理方法,其中代理方法中的
/*!* @method peripheral:didDiscoverServices:** @param peripheral The peripheral providing this information.* @param error If an error occurred, the cause of the failure.** @discussion This method returns the result of a @link discoverServices: @/link call. If the service(s) were read successfully, they can be retrieved via* <i>peripheral</i>'s @link services @/link property.**/
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(nullable NSError *)error;
會以代理回調的方式將藍牙設備的服務返還給我們
可以通過如下方式來進行解析
/*查詢到藍牙設備服務回調- Parameters:- peripheral: 藍牙外設- error: 錯誤信息*/
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(nullable NSError *)error{NSString *tipStr = @"開始查詢外設服務信息";NSLog(@"%@",tipStr);if (error) {NSString *tipStr = [NSString stringWithFormat:@"獲取外設服務失敗,原因: %@",error.userInfo];NSLog(@"%@",tipStr);}for (CBService *service in peripheral.services) {NSString *tipStr = [NSString stringWithFormat:@"發現外設服務: UUID: %@ 服務總數:%lu",service.UUID,peripheral.services.count];NSLog(@"%@",tipStr);//掃描服務特征[peripheral discoverCharacteristics:nil forService:service];}
}
通過這個方法我們便可以掃描到當前藍牙設備提供那些服務,然后可以通過查詢服務下面的特征值來找到我們想要的特征
4.查詢服務特征
查詢服務特征使用的方法是
/*!* @method discoverCharacteristics:forService:** @param characteristicUUIDs A list of <code>CBUUID</code> objects representing the characteristic types to be discovered. If <i>nil</i>,* all characteristics of <i>service</i> will be discovered.* @param service A GATT service.** @discussion Discovers the specified characteristic(s) of <i>service</i>.** @see peripheral:didDiscoverCharacteristicsForService:error:*/
- (void)discoverCharacteristics:(nullable NSArray<CBUUID *> *)characteristicUUIDs forService:(CBService *)service;
其使用方式其實在上面的掃描服務的示例方法里已經給出來了,使用方法如下
[peripheral discoverCharacteristics:nil forService:service];
然后掃描處理出來的服務特征值依然會通過CBPeripheralDelegate的代理方法返回,回調的方法如下:
/*!* @method peripheral:didDiscoverCharacteristicsForService:error:** @param peripheral The peripheral providing this information.* @param service The <code>CBService</code> object containing the characteristic(s).* @param error If an error occurred, the cause of the failure.** @discussion This method returns the result of a @link discoverCharacteristics:forService: @/link call. If the characteristic(s) were read successfully, * they can be retrieved via <i>service</i>'s <code>characteristics</code> property.*/
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(nullable NSError *)error;
具體使用方法和數據解析方法如下:
/*發現藍牙服務特征回調- Parameters:- peripheral: 藍牙外設- service: 服務- error: 錯誤信息*/
-(void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(NSError *)error{NSString *tipStr = [NSString stringWithFormat:@"開始查詢服務 %@ 特征信息:",service.UUID];NSLog(@"%@",tipStr);for (CBCharacteristic *cha in service.characteristics) {NSString *tipStr = [NSString stringWithFormat:@"發現特征: UUID == %@",cha.UUID];NSLog(@"%@",tipStr);if([cha.UUID.UUIDString isEqualToString:Write_Characteristic_Code]){self.writeCharacteristic = cha;[peripheral setNotifyValue:YES forCharacteristic:self.writeCharacteristic];NSString *tipStr = [NSString stringWithFormat:@"*** 連接寫特征值: UUID == %@ *** ",cha.UUID];NSLog(@"%@",tipStr);}if([cha.UUID.UUIDString isEqualToString:Notify_Characteristic_Code]){NSString *tipStr = [NSString stringWithFormat:@"*** 連接監聽特征值:UUID == %@ ***",cha.UUID];NSLog(@"%@",tipStr);self.notifyCharacteristic = cha;[peripheral setNotifyValue:YES forCharacteristic:self.notifyCharacteristic];}}
}
我這里定義了兩個字符常量
Write_Characteristic_Code = @“FF02”
Notify_Characteristic_Code = @“FF01”
這兩個字符串是我們跟硬件通過藍牙協議約定的通信特征值,那么如果拿到一臺沒有藍牙協議的設備的話,我們如果去查找他的讀寫特征值呢,有兩種方法
第一種方法
在 iOS 中,每個 CBCharacteristic 對象都有一個屬性:
@property(nonatomic, readonly) CBCharacteristicProperties properties;
properties 是一個位掩碼(bitmask),表示該特征支持的操作。
常用的 CBCharacteristicProperties 值
CBCharacteristicPropertyRead 可讀
CBCharacteristicPropertyWrite 可寫(無響應)
CBCharacteristicPropertyWriteWithoutResponse 可寫(無需響應)
CBCharacteristicPropertyNotify 可通知(Notify,監聽)
CBCharacteristicPropertyIndicate 可指示(Indicate,也是一種監聽,帶確認)
判斷是否是寫入特征
if (characteristic.properties & CBCharacteristicPropertyNotify || characteristic.properties & CBCharacteristicPropertyIndicate) {NSLog(@"這是監聽特征");
}
判斷是否是監聽特征
if (characteristic.properties & CBCharacteristicPropertyNotify || characteristic.properties & CBCharacteristicPropertyIndicate) {NSLog(@"這是監聽特征");
}
因為一個藍牙設備會有很多個服務,每個服務都可能會有讀寫特征,所以具體使用哪個服務來進行通信可以跟硬件協議溝通好
第二種方法
可以在iOS設備上安裝一個LightBlue調試軟件,通過軟件連接上我們的藍牙設備之后,可以在設備詳情里看到所有的服務列表和每個服務提供的特征以及每個特征的屬性,可以直接在這個調試軟件里進行藍牙的讀寫測試,然后找到我們后續通信的固定特征的UUID,后續再連接該設備的時候,只需要通過UUID去連接固定的讀寫特征作為后續溝通的工具即可。
5.使用【寫特征】/【監聽特征】來進行藍牙的【數據寫入】/【監聽藍牙回復】
寫入數據
/*!* @method writeValue:forCharacteristic:type:** @param data The value to write.* @param characteristic The characteristic whose characteristic value will be written.* @param type The type of write to be executed.** @discussion Writes <i>value</i> to <i>characteristic</i>'s characteristic value.* If the <code>CBCharacteristicWriteWithResponse</code> type is specified, {@link peripheral:didWriteValueForCharacteristic:error:}* is called with the result of the write request.* If the <code>CBCharacteristicWriteWithoutResponse</code> type is specified, and canSendWriteWithoutResponse is false, the delivery* of the data is best-effort and may not be guaranteed.** @see peripheral:didWriteValueForCharacteristic:error:* @see peripheralIsReadyToSendWriteWithoutResponse:* @see canSendWriteWithoutResponse* @see CBCharacteristicWriteType*/
- (void)writeValue:(NSData *)data forCharacteristic:(CBCharacteristic *)characteristic type:(CBCharacteristicWriteType)type;
使用該方法向藍牙設備的特征值寫入數據,這個方法接收兩個參數
第一個參數是二進制的數據塊,大部分藍牙應用層協議都是使用的十六進制數據來進行通信
第二個參數可以有兩個傳值
typedef NS_ENUM(NSInteger, CBCharacteristicWriteType) {
CBCharacteristicWriteWithResponse = 0,
CBCharacteristicWriteWithoutResponse,
};
如果選擇傳入CBCharacteristicWriteWithResponse,那么每次寫入時數據完成之后,在CBPeripheralDelegate代理中的
/*藍牙寫入完成回調- Parameters:- peripheral: 藍牙外設- characteristic: 寫入的特征- error: 錯誤信息*/
- (void)peripheral:(CBPeripheral*)peripheral didWriteValueForCharacteristic:(CBCharacteristic *)characteristic error:(NSError *)error{NSLog(@"數據寫入完成");
}
都會進行回調
如果選擇傳入CBCharacteristicWriteWithoutResponse,那么每次寫入完成之后就不會回調
下面是使用十六進制格式的通信方式來進行一次數據發送的示例
NSString *orderCode = @"FF0012450504";NSMutableData *sendData = [NSMutableData new];for (int i = 0; i < orderCode.length; i+= 2) {NSString *hexString = [orderCode substringWithRange:NSMakeRange(i, 2)];unsigned int hexValue;NSScanner *scanner = [NSScanner scannerWithString:hexString];[scanner scanHexInt:&hexValue];unsigned char send[1] = {hexValue};[sendData appendData:[NSData dataWithBytes:send length:1]];}[self.connectPeripheral writeValue:sendData forCharacteristic:self.writeCharacteristic type:CBCharacteristicWriteWithResponse];(不同協議的處理方式不一樣,根據自己的藍牙協議進行格式化即可)
切記如果傳入CBCharacteristicWriteWithResponse,希望獲取到寫入數據之后的藍牙回調的話,需要在緩存服務特征的時候設置對該特征的監聽
[peripheral setNotifyValue:YES forCharacteristic:self.writeCharacteristic];
監聽藍牙回復
監聽藍牙回復是通過監聽我們藍牙的服務監聽特征來實現的,這就需要我們在緩存監聽特征的時候來設置監聽對象,否則的話我們無法接收到藍牙的回復響應
[peripheral setNotifyValue:YES forCharacteristic:self.notifyCharacteristic];
藍牙給我們進行數據回復的時候,回調方法是
/*!* @method peripheral:didUpdateValueForCharacteristic:error:** @param peripheral The peripheral providing this information.* @param characteristic A <code>CBCharacteristic</code> object.* @param error If an error occurred, the cause of the failure.** @discussion This method is invoked after a @link readValueForCharacteristic: @/link call, or upon receipt of a notification/indication.*/
- (void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(nullable NSError *)error;
在使用這個方法的時候,我們可以通過解析藍牙回復的數據來完成一次通信
/*特征值更新回調- Parameters:- peripheral: 藍牙外設- characteristic: 更新的特征- error: 錯誤信息*/
-(void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(NSError *)error{NSData *responseData = characteristic.value;NSLog(@"收到藍牙回復數據");
}
這樣,我們就完成了一次完整的藍牙通信,是不是很簡單。
可以根據上面的功能來來實現很多常見的業務,比如緩存上一次連接藍牙的信息,這樣每次打開我們的app時都可以默認搜索并連接上一次連接的藍牙設備,這個功能很常用在實際的使用中也非常方便。
二、深入剖析高頻高負載傳輸丟包解決方案
我們通過一個真實的場景來探討高頻高負載發送藍牙數據的時候遇到問題以及相應的解決方案
先回顧一下兩種發送模式,這在我們后面的討論中會高頻的出現
WithResponse發送模式
[peripheral writeValue:chunk forCharacteristic:characteristic type:CBCharacteristicWriteWithResponse];
每發送一包數據都會得到ACK(確認包)
- (void)peripheral:(CBPeripheral*)peripheral didWriteValueForCharacteristic:(CBCharacteristic *)characteristic error:(NSError *)error
- 在這個回調里我們可以繼續發送后續數據,可以在數據發送的穩定性上得到保證
WithoutResponse發送模式
[peripheral writeValue:chunk forCharacteristic:characteristic type:CBCharacteristicWriteWithoutResponse];
發送之后沒有回調,無法確定是否發送成功,但是可以手動控制發送頻率,發送大塊數據效率會高很多
場景:
當通過藍牙協議向藍牙設備發送一張圖片的時候,圖片大小為77831個字節(76KB左右),使用6ms的固定頻率、withoutResponse方式來快速發送,分為428包發送,會有千分之五左右的丟包率
為什么會出現丟包的情況呢?
1.iOS藍牙協議的深入探究
實測結果:
通過在iphone14 pro和 iPhone 12上進行的測試,發現丟包率是不同的
在iPhone 12上的丟包率是1%,在iphone14 pro上的丟包率是0.1%
為什么一定以穩定一致性著稱的IOS系統,丟包率會相差這么多呢?原因就在藍牙協議的區別上
蘋果從 iPhone 8 和 iPhone X 這一代(2017年秋季發布)開始引入對藍牙5.0的支持
從 iPhone 14 系列(2022年秋季發布)開始引入對藍牙5.3的支持
也就是說 iPhone12使用的是藍牙5.0,iPhone 14 pro 使用的是藍牙5.3的協議
這兩種協議主要有以下幾點的區分:
鏈路層調度優化
5.0:在高數據密度場景下,鏈路調度比較死板,包和包之間的間隔依賴固化的連接間隔(Connection Interval),如果環境干擾大(Wi-Fi、其他藍牙設備),包可能延遲甚至丟失
5.3:增加了 Isochronous Channels(同步信道) 和更靈活的調度機制,能更好地插入重傳包,減少丟包幾率。
干擾抑制能力
5.3 的 Enhanced Channel Selection Algorithm #2 對頻點選擇更智能,能避開擁擠的 2.4GHz 信道(比如周圍的 Wi-Fi AP)。
5.0 在密集環境(辦公室、展會)下容易撞頻,丟包率會偏高。
重傳與延遲
BLE 本身有 鏈路層自動重傳(LL retransmit),但 5.3 在重傳延遲、優先級調度上更優化,意味著短時間高并發下數據更容易補回。
428 包的數據量其實已經接近 BLE 持續傳輸的上限,如果是 5.0,間歇性的丟包概率比 5.3 高
實測差異(參考實驗數據)
在相同干擾環境下:
BLE 5.0:高負載 + 每 10 秒 burst 傳輸時,丟包率可能在 1%~3%(取決于連接參數)。
BLE 5.3:丟包率能壓到 0.1% 以下,甚至趨近于零(如果連接參數、MTU、PHY 優化得好)。
這個數據跟我們實際測試的數據【在iPhone 12上的丟包率是1% 在iphone14 pro上的丟包率是0.1% 】也是相符的,所以在傳輸策略和接收設備不變的情況下,影響丟包率的因素就是兩臺手機使用了不同的藍牙協議版本
最核心的原因就是藍牙5.3相比于5.0有了 Isochronous Channels(同步信道),這個特性是從藍牙5.2起開始增加的,雖然主要是為 LE Audio 設計的,但它的調度機制可以減少非音頻數據的排隊延遲,提升短時間高密度傳輸的穩定性,所以丟包率大幅下降
2.傳輸策略對丟包率的影響
當使用withoutResponse機制來進行藍牙數據傳輸的時候,發送頻率對丟包率的影響也是非常大的
實測結果:
當發送間隔設置為4ms,每包大小為182字節,分為428包發送 實測丟包率為50%
當發送間隔設置為6ms,每包大小為182字節,分為428包發送 實測丟包率為千分之五
當發送間隔設置為8ms,每包大小為182字節,分為428包發送 循環發送兩千次未出現丟包的情況
(為什么會使用182個字節來進行分包,后面會解釋具體的原因。)
所以如果使用withoutResponse機制來進行藍牙傳輸,并且以固定間隔頻率發送數據的話,要根據通信設備之間的實際交互能力來制定發送間隔頻率。
3.MTU(Maximum Transmission Unit,最大傳輸單元)對傳輸速率的影響
BLE 傳輸數據是通過 Characteristic 的 Value 完成的,每次寫入/通知的數據包大小受 MTU(Maximum Transmission Unit,最大傳輸單元) 限制,MTU = ATT 層能承載的最大字節數,包含協議開銷
iOS 9 及之前:
沒有 MTU 協商機制
有效負載最大是 20 字節(ATT 默認 MTU = 23 字節,去掉 3 字節協議頭 = 20 字節數據)
iOS 10 及之后:
支持 MTU 協商(Peripheral 和 Central 在連接時協商最大 MTU)
理論上:
iOS 最大支持 185 字節有效數據(MTU = 185 + 3 = 188)
有些資料提到 iOS 實際最大 MTU 是 185,這是 Apple 文檔和測試得到的結論
當發送的數據超過MTU的時候,系統底層會對需要傳輸的數據進行拆分,然后分包發送,但是系統藍牙底層能支持的拆分數據緩沖量是有限的,當傳輸的數據遠大于MTU的時候,CoreBluetooth 的發送隊列直接被撐爆,底層丟棄多余部分(這個后面會深入解析)
4.如何獲取藍牙MTU單包發送數據的大小
當我們需要進行穩定可靠的數據傳輸的時候,可以選擇WithResponse模式來進行傳輸,當我們傳輸的數據大于MTU的時候,藍牙底層會將我們的數據進行拆分發送,每發送一包收到ACK之后才會發送下一包,經過實測,76KB的數據在藍牙5.3上會發送16秒左右,這個時效對于現在的數據傳輸速率來說確實慢太多了。
所以我們可以采取另一種發送頻率更快的方式,使用WithoutResponse模式來進行發送,這樣我們可以控制發送的速率,經過合理的搭配之后,76KB的數據只需要三秒左右即可完成傳輸,這對于用戶來說就是質的提升。
但是WithoutResponse如果使用不當的話,會有諸多問題,如果每包發送的數據太大,那么藍牙底層會丟棄緩存不了的數據,會出現緩存溢出和丟包的情況,這就需要我們選擇一個合適的分包大小和發送頻率,而發送頻率又受到分包大小的限制,這就需要根據實際情況來調試選擇一個最合適的分包大小和頻率的搭配。
對于WithoutResponse模式下的分包大小,應當選擇小于MTU的,一包即可將數據發送完成、不需要系統底層再去分包發送的大小。
在IOS 10之后的系統里,我們可以使用maximumWriteValueLengthForType來獲取這兩種模式下的MTU
使用方式如下:
NSUInteger writeWithResponseMTU = [self.connectPeripheral maximumWriteValueLengthForType:CBCharacteristicWriteWithResponse];NSUInteger writeoutWithResponseMTU = [self.connectPeripheral maximumWriteValueLengthForType:CBCharacteristicWriteWithoutResponse];NSLog(@"maxMTU WithResponse = %lu WithoutResponse = %lu",writeWithResponseMTU,writeoutWithResponseMTU);
輸出結果如下
這樣我們就可以獲取到在WithResponse與WithoutResponse模式下分別支持的MTU大小了,而且獲取到數據結果是減去協議句柄消耗的大小,我們在這兩種模式下發送的數據應當小于這兩個模式的數據大小來進行傳輸。
5.對maximumWriteValueLengthForType獲取藍牙MTU單包發送數據的大小的更深入討論
需要注意的是,當我們連接完藍牙之后立刻去獲取協商MTU的話,獲取到的數據是不正確的,如下圖所示:
當我們連接完藍牙設備之后,立刻獲取WithoutResponse的MTU,返回的結果是20,但是延遲一秒之后在獲取的話,卻變成了244。
這是因為連接設備完成之后會有一個藍牙協商的階段,如果連接之后立刻獲取的話,獲取的到是安全的默認的MTU,在藍牙4.3的協議的時候,單包20個字節是MTU的安全發送閾值,所以默認會返回20個字節。這個協商過程通常在0.5-1秒內協商完成,所以我們應當在藍牙連接完成之后延遲一秒來獲取協商后的MTU,而且拿到數據之后需要驗證一下是否是默認值,如果并不是的話可以延遲在獲取一次協商后的MTU。
6.小于MTU的發送數據就一定不會分包嗎
先說結論:不一定
實測結果:對于同一張圖片,使用WithoutResponse模式下,每包發送500個字節和每包發送182個字節,耗時分別是20秒和16秒。
如果按照設計來說。500個字節小于協商后的512個字節,應當是屬于效率最高且穩定安全的發送策略,為什么反而每包發送182個字節的反而傳輸效率更高呢?原因就是系統進行了分包。
即使 iOS 給你返回 512 的 maximumWriteValueLengthForType,鏈路層并不一定能單次吞下 512 字節,可能還要切分成多包。
例如:外設/連接參數限制下,鏈路層可能一次只能傳 251B(BLE 5.0 2M PHY 常見值),那寫 512B 就會被拆成 3 個鏈路層包(251+251+10)。
WithResponse = 每個 ATT Write Request → 等外設返回 Write Response 才算完成
如果你每次寫 512B,系統需要在 鏈路層拆成多包再等確認,一旦有延遲,整體 RTT 就被放大,這是 Apple 明確開放的優化,不是默認的分包機制。
所以這其實是 iOS 在 WithoutResponse 模式下的特殊策略,而 182B 恰好能讓鏈路層單包完成(或 2 包以內),所以 每次確認的延遲更短,整體吞吐反而更高
有些外設(特別是 MCU 上的 BLE 協議棧,比如 Nordic / TI)對 512B 的大包處理效率很差,需要更多緩沖和拷貝,但 182B 屬于 ATT 層常見的“安全長度”,外設處理快、應答也快
? 512 看似更大,但實際上傳輸時:
? 多分包 + 多次鏈路層調度 → RTT 放大。
? 182 更小,但更符合鏈路層的最優分包大小 → 整體效率更高。
? 所以 MTU 最大 ≠ 最快,最佳 chunk size 要結合:
? 外設 buffer 能力
? 鏈路層最大 PDU (常見 185, 251)
? RTT + 硬件棧優化程度
[peripheral maximumWriteValueLengthForType:…] 返回的數值,只能理解為 iOS CoreBluetooth 層愿意讓你一次 writeValue 調用傳進去的最大字節數。它并不等于底層物理鏈路一定會“整塊”發出去。真正的數據傳輸要經過:
1. ATT 層 (Attribute Protocol)
? 你調用 writeValue:forCharacteristic:type:,會被封裝成一個 ATT PDU。
? 這個 PDU 的負載部分最大就是你傳的長度。
2. L2CAP 層 / LL 層
? ATT PDU 可能大于鏈路層一次能承載的大小(比如常見的 27B、185B、251B)。
? 超過就會被 拆包 (fragmentation) 成多個 Link Layer PDU 逐個發出。
? 這時候,即使你上傳了 512B,也要拆成 2~3 個 LL 包,分幾次發送。
3. 外設處理能力
? 外設的緩沖區可能也有限,如果它不能一次收下 512B,也會分次處理。
maximumWriteValueLengthForType = “上層允許你寫的最大數據塊”
? 并不保證物理層單包發出
? 真正的“最優 chunk size”要取決于:
? 鏈路層最大 PDU(一般 185B 或 251B)
? 外設的 buffer 能力
? ACK 往返延遲
7.直接使用WithoutResponse傳輸大塊數據的深入探究
實測場景:
當使用WithoutResponse模式,直接將圖片的77831個字節的數據一起發送出去的時候,會發現藍牙不會報錯,但是接收設備也不會報錯,原因是什么呢?
這其實是 iOS CoreBluetooth 的 WriteWithoutResponse 特性 + 流控機制共同作用的結果
因為CBCharacteristicWriteWithoutResponse 模式下,不會有 ACK(確認包),而且iOS 在 API 設計上也不會報錯,即使一次調用寫入的數據遠遠大于 maximumWriteValueLengthForType,所以直接 writeValue: 77831 字節,Xcode 控制臺不會提示任何錯誤。雖然 WithoutResponse 允許比 MTU 大的數據寫入,但它不是無限制的
Apple 底層會對超大數據做截斷 / 丟棄,超過緩沖區的部分直接沒發,所以看起來“沒傳輸”,其實是數據超限被丟掉了
WriteWithoutResponse 本身沒有響應機制,如果一次扔 70KB 下去,CoreBluetooth 的發送隊列直接被撐爆,底層丟棄多余部分,iOS 只保證 maximumWriteValueLengthForType: 范圍內的數據可靠寫入,所以要發送大數據(比如圖片、固件升級包、傳感器日志),必須分包 + 流控。
整包發送最大數據長度參考
數據大小 | 結果 | 原因 |
---|---|---|
< 20~30 KB | 通常成功 | iOS 自動拆包 + 內部隊列可承受 |
> 70 KB | 寫失敗 | 隊列被撐爆,內部拆包機制不夠,系統拒絕寫入 |
極大數據(幾十 MB) | 必須手動拆包 + 回調控制 | 保證每次寫入不超過 MTU 并等待回調,避免隊列溢出 |
8.直接使用WithoutResponse最大可掛載隊列的分包數
iOS 在 Write Without Response 模式下,會在底層維護一個 發送隊列(內核 BLE 堆棧),緩存調用 writeValue:forCharacteristic:type:.withoutResponse 的數據包。
當隊列滿時:
canSendWriteWithoutResponse 返回 NO
系統會延遲寫入,直到觸發 peripheralIsReadyToSendWriteWithoutResponse 回調
系統/設備情況 可掛在隊列的分包數(約)
早期 iOS 10–12 1–3
iOS 13+ / iPhone 8+ 4–8
高速 2M PHY + 小包 8–12
這些都是經驗值,具體數值取決于:
? 每包大小(chunk size = MTU-3)
? BLE PHY(1M / 2M / Coded)
? 手機型號、系統版本
? 外設接收緩沖和處理速度
最后還是要根據實際的設備調試結果來確定
底層隊列容量不是固定的,一般 3–6 包,強穩健可測試到 8–12,發送大數據必須結合流控,不能一次性塞滿隊列
9. 使用WithoutResponse傳輸 分包 + 流控傳輸發送大數據實際示例
分包 獲取最大可寫長度:
NSUInteger mtu = [peripheral maximumWriteValueLengthForType:CBCharacteristicWriteWithoutResponse];
每次發送數據前,判斷是否可以進行數據傳輸:
[peripheral canSendWriteWithoutResponse]
逐包寫入,每次寫入一片
先獲取到協商后的MTU,然后再進行
[peripheral writeValue:chunk forCharacteristic:characteristic type:CBCharacteristicWriteWithoutResponse];
流控
不能一口氣寫完所有分片,否則 iOS 內部隊列會溢出丟包
需要監聽 -peripheralIsReadyToSendWriteWithoutResponse: 回調:
- (void)peripheralIsReadyToSendWriteWithoutResponse:(CBPeripheral *)peripheral {
// 在這里繼續寫下一包
}
代碼示例:
@interface BLEDataSender : NSObject <CBPeripheralDelegate>@property (nonatomic, strong) CBPeripheral *peripheral;
@property (nonatomic, strong) CBCharacteristic *characteristic;
@property (nonatomic, strong) NSMutableArray<NSData *> *chunks; // 數據分片隊列
@property (nonatomic, assign) NSInteger inFlightCount; // 當前發送中的包數
@property (nonatomic, assign) NSInteger inFlightLimit; // 最大同時發送包數 對于藍牙5.0的設備來說 設置 4–8 是最合適的
@property (nonatomic, assign) BOOL sending; // 是否正在發送@end@implementation BLEDataSender- (instancetype)initWithPeripheral:(CBPeripheral *)peripheral characteristic:(CBCharacteristic *)characteristic {if (self = [super init]) {self.peripheral = peripheral;self.characteristic = characteristic;self.inFlightLimit = 4; // 可根據外設能力調整self.inFlightCount = 0;self.sending = NO;self.chunks = [NSMutableArray array];self.peripheral.delegate = self;}return self;
}// 分包準備數據
- (void)prepareDataToSend:(NSData *)data {[self.chunks removeAllObjects];NSUInteger mtu = [self.peripheral maximumWriteValueLengthForType:CBCharacteristicWriteWithoutResponse];NSUInteger offset = 0;while (offset < data.length) {NSUInteger chunkSize = MIN(mtu, data.length - offset);NSData *chunk = [data subdataWithRange:NSMakeRange(offset, chunkSize)];[self.chunks addObject:chunk];offset += chunkSize;}
}// 開始發送
- (void)startSending {if (self.chunks.count == 0) return;self.sending = YES;[self sendNextChunks];
}// 核心發送邏輯
- (void)sendNextChunks {while (self.sending &&self.inFlightCount < self.inFlightLimit &&self.chunks.count > 0 &&[self.peripheral canSendWriteWithoutResponse]) {NSData *chunk = self.chunks.firstObject;[self.chunks removeObjectAtIndex:0];[self.peripheral writeValue:chunk forCharacteristic:self.characteristic type:CBCharacteristicWriteWithoutResponse];self.inFlightCount += 1;}if (self.chunks.count == 0 && self.inFlightCount == 0) {self.sending = NO;NSLog(@"全部數據發送完成");}
}// CBPeripheralDelegate 回調
- (void)peripheralIsReadyToSendWriteWithoutResponse:(CBPeripheral *)peripheral {// 有空余緩沖時觸發self.inFlightCount = MAX(0, self.inFlightCount - 1);[self sendNextChunks];
}@end
10.使用WithResponse傳輸大塊數據方案示例
代碼示例:
#import <CoreBluetooth/CoreBluetooth.h>@interface BLEDataSender : NSObject <CBPeripheralDelegate>@property (nonatomic, strong) CBPeripheral *peripheral;
@property (nonatomic, strong) CBCharacteristic *writeChar;@property (nonatomic, strong) NSData *bigData; // 要發送的大數據
@property (nonatomic, assign) NSUInteger offset; // 已經發送到的位置
@property (nonatomic, assign) NSUInteger mtu; // 每次可寫數據大小@end@implementation BLEDataSender- (instancetype)initWithPeripheral:(CBPeripheral *)peripheral writeChar:(CBCharacteristic *)characteristic {if (self = [super init]) {self.peripheral = peripheral;self.writeChar = characteristic;self.peripheral.delegate = self;// 注意:WithResponse 和 WithoutResponse 的 maximumWriteValueLengthForType 可能不同self.mtu = [peripheral maximumWriteValueLengthForType:CBCharacteristicWriteWithResponse];}return self;
}/// 開始發送大數據
- (void)sendBigData:(NSData *)data {self.bigData = data;self.offset = 0;[self sendNextChunk];
}/// 分包發送
- (void)sendNextChunk {if (self.offset >= self.bigData.length) {NSLog(@" 大數據發送完成,總長度 %lu 字節", (unsigned long)self.bigData.length);return;}NSUInteger chunkSize = MIN(self.mtu, self.bigData.length - self.offset);NSData *chunk = [self.bigData subdataWithRange:NSMakeRange(self.offset, chunkSize)];// 發送數據 (WithResponse 模式)[self.peripheral writeValue:chunkforCharacteristic:self.writeChartype:CBCharacteristicWriteWithResponse];NSLog(@"發送第 %lu ~ %lu 字節", (unsigned long)self.offset, (unsigned long)(self.offset + chunkSize));
}/// 發送完成回調 (WithResponse 模式會走這里)
- (void)peripheral:(CBPeripheral *)peripheral
didWriteValueForCharacteristic:(CBCharacteristic *)characteristicerror:(NSError *)error {if (error) {NSLog(@" 寫入失敗: %@", error);return;}// 上一包確認成功,繼續發下一包self.offset += self.mtu;[self sendNextChunk];
}@end
結語:
對于高頻高負載的數據發送來說
如果追求絕對的穩定可靠性,那么使用withResponse的ACK應答發送模式并使用協商后的MTU來進行數據傳輸是最穩定的,穩定的應答模式可以保證數據雙方的通信和數據傳輸得到保障:
如果希望在穩定性和傳輸效率之間得到平衡:那么可以通過withoutResponse和協商后的MTU,搭配分包+流控模式來進行數據傳輸,每次發包前判斷canSendWriteWithoutResponse是否可用,發包完之后監聽peripheralIsReadyToSendWriteWithoutResponse來控制隊列容量,采用這種方式可以在性能和穩定性之間得到平衡。
藍牙傳輸容易受到外界環境干擾,要實現穩定的數據傳輸,需要通過大量測試來覆蓋盡可能多的使用場景。當出現意外問題時,應將其視為積累經驗、優化傳輸方案的關鍵契機。只有不斷嘗試、持續打磨代碼,才能使整體傳輸更加穩定可靠。
(項目源碼在頁面頂部,可自行下載)