蓝牙体重秤是健康类App里最常见的硬件外设之一,但真正动手接入时,很多开发者会发现秤传过来的数据根本看不懂——一串十六进制字节,既不知道哪一段是体重,也不知道小数点在哪里。这篇文章从零开始,用Core Bluetooth框架连接一台标准的BLE体重秤,把体重数据完整地解析出来,再结合用户身高算出BMI,最后用图表把体重趋势画出来,形成一套完整可落地的方案。

一、Core Bluetooth连接体重秤的基本流程
Core Bluetooth是苹果官方提供的BLE(低功耗蓝牙)开发框架,核心角色分两种:中心设备(Central)和外设(Peripheral)。iPhone作为中心设备,体重秤作为外设。整个通信流程可以概括为五步:扫描、连接、发现服务、发现特征、订阅通知。
体重秤普遍遵循GATT标准中的Weight Scale服务(UUID为0x181D),其中包含Weight Measurement特征(UUID为0x2A9D)。这个特征是Notify类型的,也就是说App不需要主动去读,只要订阅了它,秤每次称重完成就会主动把数据推过来。下面是完整的连接实现代码:
import CoreBluetooth
class WeightScaleManager: NSObject, ObservableObject {
var centralManager: CBCentralManager!
var peripheral: CBPeripheral?
// Weight Scale 服务的标准UUID
let weightServiceUUID = CBUUID(string: "181D")
let weightCharacteristicUUID = CBUUID(string: "2A9D")
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: nil)
}
func startScan() {
guard centralManager.state == .poweredOn else { return }
// 通过服务UUID过滤,只扫描体重秤,避免扫到一堆无关设备
centralManager.scanForPeripherals(withServices: [weightServiceUUID],
options: nil)
}
}
extension WeightScaleManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state == .poweredOn {
startScan()
}
}
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
self.peripheral = peripheral
centralManager.stopScan()
centralManager.connect(peripheral, options: nil)
}
func centralManager(_ central: CBCentralManager,
didConnect peripheral: CBPeripheral) {
peripheral.delegate = self
peripheral.discoverServices([weightServiceUUID])
}
}
extension WeightScaleManager: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral,
didDiscoverServices error: Error?) {
peripheral.services?.forEach { service in
peripheral.discoverCharacteristics([weightCharacteristicUUID],
for: service)
}
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
service.characteristics?.forEach { char in
if char.uuid == weightCharacteristicUUID {
// 订阅Notify,之后每次称重数据都会自动推送
peripheral.setNotifyValue(true, for: char)
}
}
}
}
几个容易踩的坑需要注意。第一,扫描时一定要带服务UUID过滤,如果传nil会扫出周围所有的蓝牙设备,既耗电又难以区分哪个是秤。第二,centralManagerDidUpdateState回调里必须判断状态是poweredOn才能开始扫描,蓝牙还没打开就去扫会直接崩溃或静默失败。第三,Info.plist里必须声明NSBluetoothAlwaysUsageDescription,否则App在iOS 13以上会直接被拒绝访问蓝牙。
二、解析称重数据:字节格式与单位换算
连接成功后,秤推送过来的数据是一个Data对象,真正的难点在于怎么把这几个字节变成人能看懂的体重数字。按照GATT规范,Weight Measurement特征的数据格式是:第一个字节是标志位,后续字节才是体重值。标志位的第0位决定单位(0表示公制SI,1表示英制),第1位决定时间戳是否存在,第2位决定用户ID和BMI是否存在。
公制单位下,体重值以0.005千克为分辨率存储,也就是说实际体重等于原始数值乘以0.005。英制则是以0.01磅为分辨率。这就是为什么直接把字节当整数读出来会得到一个莫名其妙的大数字。解析代码如下:
struct WeightRecord {
let weightKg: Double
let timestamp: Date
}
func parseWeightData(_ data: Data) -> WeightRecord? {
let bytes = [UInt8](data)
guard bytes.count >= 3 else { return nil }
let flags = bytes[0]
let isImperial = (flags & 0x01) != 0
// 体重值是UInt16小端序,低字节在前
let rawValue = UInt16(bytes[1]) | (UInt16(bytes[2]) << 8)
let weightKg: Double
if isImperial {
let pounds = Double(rawValue) * 0.01
weightKg = pounds * 0.45359237
} else {
weightKg = Double(rawValue) * 0.005
}
return WeightRecord(weightKg: weightKg, timestamp: Date())
}
// 在特征值回调中调用
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard let data = characteristic.value,
characteristic.uuid == weightCharacteristicUUID else { return }
if let record = parseWeightData(data) {
print("当前体重:\(record.weightKg) kg")
}
}
除了单位换算,还有两个实际问题要处理。一是字节序,GATT规定数值一律是小端序,低位字节在前,很多开发者习惯性地按大端序去拼,结果体重永远不对。二是数据稳定性判断,人站在秤上时数值会连续跳动,通常的做法是收集一段时间内的推送数据,当连续三次数值差异小于0.05千克时,才认为称重稳定,把这条记录入库。示例如下:
class WeightStabilizer {
private var recentValues: [Double] = []
/// 返回nil表示数据还未稳定
func addValue(_ value: Double) -> Double? {
recentValues.append(value)
if recentValues.count > 3 {
recentValues.removeFirst()
}
guard recentValues.count == 3 else { return nil }
let maxV = recentValues.max()!
let minV = recentValues.min()!
// 连续三次差异都在0.05kg以内,判定稳定
if maxV - minV < 0.05 {
return recentValues.reduce(0, +) / 3.0
}
return nil
}
}
三、BMI计算与体重趋势图表展示
拿到稳定的体重数据后,BMI计算就很简单了:体重(千克)除以身高(米)的平方。按照中国的成人标准,BMI低于18.5为偏瘦,18.5到23.9为正常,24到27.9为超重,28及以上为肥胖。建议在App里把这个区间判断也做上,用户看到的就不只是一个干巴巴的数字。
enum BMICategory: String {
case underweight = "偏瘦"
case normal = "正常"
case overweight = "超重"
case obese = "肥胖"
}
func calculateBMI(weightKg: Double, heightMeters: Double) -> (Double, BMICategory) {
let bmi = weightKg / (heightMeters * heightMeters)
let category: BMICategory
switch bmi {
case ..<18.5: category = .underweight
case 18.5..<24: category = .normal
case 24..<28: category = .overweight
default: category = .obese
}
return (bmi, category)
}
趋势图表推荐用Swift Charts,这是苹果官方图表框架,iOS 16以上可用,不需要引入第三方库。假设我们已经把历史称重记录存在数组里,画一条带标记点的体重趋势线只需要十几行代码。为了图表更易读,可以给BMI正常区间画一条背景带,用户一眼就能看出自己离健康范围有多远。
import SwiftUI
import Charts
struct WeightTrendView: View {
let records: [WeightRecord]
var body: some View {
Chart {
// BMI 18.5~24 对应的体重区间背景
ForEach(0..<1, id: \.self) { _ in
RectangleMark(
xStart: .value("开始", records.first!.timestamp),
xEnd: .value("结束", records.last!.timestamp),
yStart: .value("下限", 18.5 * 1.7 * 1.7),
yEnd: .value("上限", 24.0 * 1.7 * 1.7)
)
.foregroundStyle(.green.opacity(0.1))
}
ForEach(records, id: \.timestamp) { record in
LineMark(
x: .value("日期", record.timestamp),
y: .value("体重", record.weightKg)
)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("日期", record.timestamp),
y: .value("体重", record.weightKg)
)
}
}
.chartYAxisLabel("kg")
.frame(height: 300)
.padding()
}
}
如果需要兼容iOS 16以下的系统,可以换成Charts第三方库或者DGCharts(原Charts/ Daniel Gindi Charts),思路完全一致:横轴时间、纵轴体重、数据源就是本地存储的记录数组。历史数据的存储建议直接用SwiftDB或Core Data,每次稳定称重落一条记录,包含体重、BMI、时间戳三个字段,图表每次进入页面时从数据库重新读取即可。
最后还有一点体验上的建议:秤断开连接后要监听didDisconnectPeripheral回调,自动重连一次,因为用户称重时经常离手机较远导致断连。把这些细节都处理好,一个体验完整的蓝牙体重秤功能就算真正落地了。
Core BluetoothiOS蓝牙开发BMI计算修改时间:2026-09-15 03:04:45