导读:本期聚焦于卡拉米创作的《使用Core Bluetooth实现iOS蓝牙体重秤:体重读取、BMI计算与数据分析图表展示》,敬请观看详情。蓝牙体重秤读取的数据一直是乱码?体重单位换算和BCD编码解析是关键。本文基于Core Bluetooth框架,完整讲解iOS连接BLE体重秤的流程,包括扫描设备、订阅特征值、解析称重数据的字节格式,再结合用户身高计算BMI指数,最后用图表库把一段时间的体重变化趋势直观呈现出来。文中给出可直接使用的Swift代码,涵盖GATT标准服务、字节解析细节以及常见的数据稳定判断技巧,适合正在做健康类App的开发者参考。

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

使用Core Bluetooth实现iOS蓝牙体重秤:体重读取、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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260915/56993.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。