基于Adafruit Feather nRF52832与iOS的BLE数据采集与实时图表显示
1. 项目概述与核心价值如果你手头有一块Adafruit Feather nRF52832开发板想让它采集的传感器数据实时显示在iPhone上却苦于找不到一个现成、好用的App那么这个项目就是为你准备的。我最近完成了一个从硬件固件到iOS App的完整开发流程核心目标就是让Feather nRF52832通过蓝牙低功耗BLE向iPhone发送数据并在手机端用漂亮的折线图实时展示出来。整个过程涉及Arduino编程、iOS的CoreBluetooth框架以及Charts图表库的使用算是一个典型的物联网端到端应用原型。这个方案的价值在于它为你提供了一个可复用的基础框架。无论是想监测环境温湿度、记录运动传感器的加速度还是想远程查看某个设备的运行状态你都可以基于这个框架快速搭建起自己的数据采集与可视化系统。Adafruit Feather nRF52832本身集成了强大的nRF52832 SoC蓝牙功能稳定且功耗极低非常适合作为各种物联网项目的“大脑”。而iOS设备作为数据接收和展示终端拥有出色的交互体验和普及度。通过这个项目你将掌握如何打通这两个平台实现数据的无线传输与可视化为你的创意项目增添一个专业的移动端界面。2. 硬件准备与Arduino环境搭建2.1 开发板选型与核心特性解析我选择Adafruit Feather nRF52832作为硬件核心主要基于几个关键考量。首先它采用了Nordic Semiconductor的nRF52832系统级芯片这颗芯片集成了ARM Cortex-M4F内核、512KB Flash和64KB RAM性能足以应对多数嵌入式任务。更重要的是它内置了蓝牙5.0低功耗射频模块这意味着我们无需外接复杂的蓝牙模块简化了硬件设计和连线。其次Feather生态系统的兼容性很好板载了锂聚合物电池充电管理芯片和STEMMA QT/Qwiic连接器方便后续扩展各种I2C传感器。最后Adafruit提供了完善的Arduino核心支持库使得我们可以用熟悉的Arduino IDE和语法进行开发大大降低了嵌入式编程的门槛。在开始之前你需要准备以下硬件Adafruit Feather nRF52832开发板一块。一台运行macOS的电脑用于iOS开发和Arduino编程。一根Micro-USB数据线用于给开发板供电和上传程序。一部iPhone或iPad系统版本建议iOS 14及以上用于运行和测试我们开发的App。需要特别注意的是iOS模拟器无法模拟蓝牙硬件因此必须在真机上测试。2.2 Arduino IDE配置与Bootloader更新要让Arduino IDE识别并支持Feather nRF52832我们需要先添加对应的板卡支持包。打开Arduino IDE进入“文件”-“首选项”。在“附加开发板管理器网址”框中粘贴以下URL每行一个https://adafruit.github.io/arduino-board-index/package_adafruit_index.json https://sandeepmistry.github.io/arduino-nRF5/package_nRF5_boards_index.json注意不同教程可能推荐不同的JSON文件地址。adafruit.github.io这个地址是Adafruit官方维护的包含了他们所有板子的定义是最稳定可靠的选择。添加多个源有时会导致冲突。添加完成后点击“确定”关闭首选项。接着进入“工具”-“开发板”-“开发板管理器”。在顶部的搜索框中输入“Adafruit nRF52”等待列表刷新后你应该能看到“Adafruit nRF52 by Adafruit”这个条目。点击它然后选择安装最新版本。这个过程会下载并安装所有必要的工具链和库文件包括ARM GCC编译器、nRF5x命令行工具以及Adafruit的BLE库等。安装完成后在“工具”-“开发板”菜单下你应该能找到“Adafruit nRF52 Boards”分组选择其中的“Adafruit Feather nRF52832”。接下来还需要选择正确的“编程器”。对于通过USB上传代码我们需要使用“Adafruit nRF52 Bootloader”作为编程器。端口Port选择你的Feather板子所连接的USB串口。在首次上传代码前强烈建议检查并更新板载的Bootloader。Bootloader是板子上的一段小程序负责接收来自电脑的固件并烧录到主芯片中。一个过旧或有问题的Bootloader可能导致上传失败。更新方法很简单在选中了正确的开发板和编程器后点击“工具”菜单在最下方找到“Burn Bootloader”并点击。IDE会通过USB连接向板子写入最新的Bootloader。这个过程通常很快完成后你的Feather nRF52832就为编程做好了准备。3. Arduino固件开发实现BLE数据发送3.1 理解BLE通信模型与Adafruit BLE库在编写代码前我们需要简单理解一下BLE蓝牙低功耗的通信模型。BLE设备通常分为两类外围设备和中央设备。我们的Feather nRF52832在这个项目中扮演外围设备的角色它像是一个服务提供者广播自己的存在并等待连接。而iPhone则作为中央设备主动扫描并连接外围设备。外围设备通过服务和特征值来组织数据。一个服务可以包含多个特征值。特征值是实际进行数据读写操作的最小单元。例如我们可以定义一个“数据采集服务”里面包含一个“模拟数据特征值”中央设备通过订阅这个特征值就能在外围设备数据更新时自动收到通知。Adafruit为nRF52系列提供了强大的Adafruit_Bluefruit_nRF52库它封装了底层复杂的BLE协议栈提供了简单易用的API。我们将基于这个库中的一个示例进行修改。3.2 修改bleuart示例代码在Arduino IDE中确保已选择“Adafruit Feather nRF52832”开发板。然后通过“文件”-“示例”-“Adafruit Bluefruit nRF52 Libraries”-“Peripheral”-“bleuart”打开示例代码。这个示例实现了一个简单的BLE UART服务允许中央设备像使用串口一样收发文本数据非常适合我们的场景。我们需要对示例代码进行几处关键修改核心目标是让板子自动、持续地读取模拟引脚A0的电压值并通过BLE发送出去而不是等待串口输入。第一处修改在loop()函数中实现自动数据发送找到原代码中loop()函数里等待串口输入并转发的那段代码通常在while (Serial.available())循环内。我们将用以下代码替换它实现每500毫秒读取一次A0引脚并发送void loop() { // 等待足够的时间因为我们有有限的传输缓冲区 delay(500); char buf[64]; // 从nRF52832的A0引脚读取模拟值0-1023 int sensorValue analogRead(A0); // 将整型数值转换为字符串因为BLE特征值通常处理字符数据 String valueString String(sensorValue); // 将字符串转换为字符数组以便通过BLE发送 valueString.toCharArray(buf, 64); // 通过BLE UART服务发送字符数组 bleuart.write(buf, strlen(buf)); }这段代码的逻辑很清晰analogRead(A0)读取引脚电压映射到0-1023的整数值转换成字符串然后通过bleuart.write()函数发送出去。delay(500)控制了数据发送的频率这里设置为每秒发送2次数据对于多数传感器监控场景已经足够你也可以根据需求调整。第二处修改引入连接状态控制循环原示例的while (Serial.available())循环意味着只有串口监视器有输入时才会进入循环发送数据。我们希望App一连接就自动开始发送。为此我们需要一个标志位来跟踪BLE连接状态。在文件顶部定义全局变量区添加一个布尔变量bool bleConnected false;将loop()函数中的while (Serial.available())条件改为while (bleConnected)。这样只要bleConnected为true循环就会持续执行我们上面添加的数据发送代码。在BLE连接成功的回调函数connect_callback()中添加一行代码在连接建立时将标志位置truebleConnected true;在BLE断开连接的回调函数disconnect_callback()中添加一行代码在连接断开时将标志位置falsebleConnected false;完成这些修改后将代码上传到你的Feather nRF52832。上传成功后打开Arduino IDE的串口监视器波特率115200你应该能看到板子启动并开始广播BLE信号的日志信息。此时用手机蓝牙设置扫描应该能发现一个名为“Adafruit Bluefruit LE”的设备这是库的默认名称可在代码中修改。至此硬件端的准备工作就全部完成了。实操心得在修改代码时务必注意变量作用域和函数调用顺序。bleConnected变量必须在所有使用它的函数如loop,connect_callback之前声明为全局变量。另外Adafruit的BLE库初始化需要一定时间在setup()函数中调用Bluefruit.begin()后建议添加一小段延时delay(500)确保蓝牙模块完全启动后再进行后续操作可以避免一些奇怪的连接失败问题。4. iOS开发环境配置与项目初始化4.1 Xcode项目创建与基础设置iOS开发需要在macOS上进行并使用Xcode作为集成开发环境。首先确保你的Mac上安装了最新稳定版本的Xcode可以从Mac App Store免费下载。打开Xcode选择“Create a new Xcode project”。在模板选择界面确保平台选择为“iOS”然后选择“App”模板点击“Next”。接下来是项目配置页面有几个关键项需要填写Product Name你的应用名称例如“BLEGraphViewer”。Team你的Apple开发者账号团队。如果你只是个人开发并在真机上测试你需要有一个免费的Apple ID账户并在此处登录。Xcode会自动帮你创建免费的开发证书。如果没有团队可以点击“Add Account...”进行添加。Organization Identifier组织标识符通常采用“com.你的名字”的反域名格式例如“com.yourname”。这个标识符和产品名共同组成应用的Bundle Identifier它是应用在系统内的唯一ID。Interface选择“Storyboard”。这是苹果传统的UI构建方式对于初学者和大多数项目来说更直观易懂。Language选择“Swift”。Swift是苹果主推的现代编程语言比Objective-C更简洁安全。确保不勾选“Use Core Data”和“Include Tests”以保持项目简洁。点击“Next”选择项目保存的位置然后点击“Create”。一个全新的iOS项目就创建好了。4.2 配置蓝牙权限与安装Charts图表库iOS系统出于隐私和安全考虑对蓝牙访问有严格的权限要求。我们必须明确告知用户应用需要使用蓝牙否则应用将无法扫描或连接任何设备。在Xcode左侧的项目导航器中找到并点击名为Info.plist的文件。这个文件以属性列表的形式存储了应用的各种配置信息。在任意一行上右键选择“Add Row”。在出现的键值列表中滚动或搜索找到以下两项并分别添加Privacy - Bluetooth Always Usage Description用于描述应用为何需要始终使用蓝牙即使应用在后台。值可以填写为“本应用需要通过蓝牙连接您的Feather设备以接收传感器数据”。Privacy - Peripheral Usage Description用于描述应用为何需要使用蓝牙外围设备功能。值可以填写为“用于扫描和连接您的Feather蓝牙设备”。添加这两项后当应用首次尝试使用蓝牙时系统会向用户弹出提示框显示你填写的描述请求用户授权。接下来我们需要引入一个强大的第三方图表库——Charts来绘制接收到的数据曲线。在iOS开发中我们通常使用CocoaPods来管理第三方库依赖。CocoaPods是一个Ruby gem需要先在系统上安装。打开终端Terminal使用cd命令进入你刚才创建的Xcode项目所在的目录。确认目录下存在.xcodeproj文件。然后根据你的Mac芯片类型执行安装命令Intel芯片Macsudo gem install cocoapodsApple Silicon (M1/M2/M3) 芯片Macsudo arch -x86_64 gem install ffi arch -x86_64 pod install注意M系列芯片的Mac在安装某些Ruby gem时可能会遇到架构兼容性问题使用arch -x86_64前缀可以强制在x86_64架构下运行安装命令避免问题。安装完成后仍在项目根目录下初始化CocoaPods并安装Charts库pod init这个命令会在当前目录生成一个名为Podfile的配置文件。用文本编辑器打开它open Podfile你会看到类似以下内容# Uncomment the next line to define a global platform for your project # platform :ios, 9.0 target BLEGraphViewer do # Comment the next line if you dont want to use dynamic frameworks use_frameworks! # Pods for BLEGraphViewer end在target BLEGraphViewer do和end之间添加一行pod Charts保存并关闭Podfile。回到终端运行安装命令pod installCocoaPods会自动下载Charts库及其依赖并生成一个新的.xcworkspace工作空间文件。非常重要从此以后你必须使用这个新生成的.xcworkspace文件来打开和编辑你的项目而不是原来的.xcodeproj文件。关闭Xcode双击打开BLEGraphViewer.xcworkspace你会看到项目导航器中多了一个Pods项目里面包含了Charts库。5. iOS应用界面设计与Swift代码实现5.1 使用Storyboard构建用户界面在Xcode中打开.xcworkspace文件后在左侧项目导航器中找到并点击Main.storyboard文件。中间区域会显示一个空白的iPhone画布这就是我们设计界面的地方。首先我们需要在界面顶部添加一个导航栏。点击画布底部工具栏的“”按钮或使用快捷键CmdShiftL打开库面板。在搜索框中输入“Navigation Bar”将其拖拽到画布顶部。导航栏会自动贴合状态栏。双击导航栏中间的“Title”文字将其修改为你应用的名字比如“BLE数据图表”。接下来我们从库面板中拖拽其他需要的控件到画布上UILabel (标签)拖拽两个到画布上。一个放在左上角用来显示连接状态将其文本改为“未连接”文本颜色暂时设为红色。这个控件在代码中我们将命名为connectStatusLabel。另一个放在画布中部偏左文本改为“显示图表”这个将命名为showGraphLabel。UISwitch (开关)拖拽一个到“显示图表”标签的右侧用于控制图表是否显示。将其命名为showGraphSwitch默认状态设为关闭Off。UIButton (按钮)我们需要一个刷新/扫描按钮。由于导航栏右侧通常放置操作按钮我们可以直接使用导航栏的UIBarButtonItem。在库中搜索“Bar Button Item”拖拽到导航栏的右侧区域。点击这个按钮在右侧属性检查器中将“System Item”从“Custom”改为“Refresh”。这样它就显示为一个标准的刷新图标。我们将在代码中为其关联一个动作。控件摆放好后需要为它们添加约束。约束定义了控件相对于父视图或彼此之间的位置和大小关系确保应用在不同尺寸的iPhone上都能正确布局。以“显示图表”标签为例选中这个UILabel。点击画布右下角的“Add New Constraints”按钮看起来像个TIE战斗机。我们需要固定它的左边缘和上边缘。在弹出面板中点击左箭头和上箭头旁边的红色实线使其变为红色表示添加该约束。在旁边的输入框中可以设置具体的距离值例如左边距20点上边距100点。确保“Constrain to margins”复选框是未勾选状态。这样约束就是相对于屏幕边缘而不是安全区域边距位置更可控。点击“Add 2 Constraints”。用同样的方法为开关、连接状态标签和导航栏添加上相应的约束。对于导航栏通常只需要固定其顶部、左侧和右侧与父视图对齐即可。添加完所有约束后可以点击画布底部的“Resolve Auto Layout Issues”按钮三角形尺子图标选择“Update Frames”让Xcode根据约束自动调整控件的位置和大小。5.2 连接界面与代码Outlet与Action界面设计好后需要将其与Swift代码关联起来这样我们才能在代码中控制这些界面元素并响应用户的操作。首先确保Main.storyboard和ViewController.swift文件在Xcode中并排打开可以使用右上角的“Adjust Editor Options”按钮选择并列视图。点击画布右上角的“双环”图标打开辅助编辑器右侧会自动显示ViewController.swift文件。创建Outlet输出口Outlet允许我们在代码中引用界面上的控件。按住Ctrl键从“显示图表”标签拖拽一条线到ViewController.swift文件中class ViewController: UIViewController {这行下面的大括号内。松开鼠标后会弹出一个对话框。将“Connection”类型保持为“Outlet”在“Name”字段输入showGraphLabel点击“Connect”。这样就创建了一个名为showGraphLabel的属性它指向故事板中的那个标签。重复此过程为“连接状态”标签创建connectStatusLabel为开关创建showGraphSwitch。创建Action动作Action用于响应用户对控件的操作比如点击按钮、切换开关。按住Ctrl键从导航栏的刷新按钮拖拽到ViewController.swift文件中。在弹出对话框中将“Connection”类型改为“Action”。在“Name”字段输入refreshButtonTapped事件类型保持为“Touch Up Inside”点击“Connect”。这会在代码中生成一个IBAction方法当用户点击刷新按钮时这个方法就会被调用。用同样的方法为showGraphSwitch开关创建一个Action命名为switchValueChanged事件类型为“Value Changed”。5.3 实现CoreBluetooth核心逻辑现在进入核心的蓝牙通信代码部分。首先在ViewController.swift文件顶部导入必要的框架import UIKit import CoreBluetooth // 用于蓝牙通信 import Charts // 用于绘制图表接着我们需要让ViewController类遵循CBCentralManagerDelegate和CBPeripheralDelegate协议。这两个协议是CoreBluetooth框架的核心前者用于管理中央设备我们的手机的状态和扫描后者用于管理与外围设备Feather连接后的交互。修改类声明class ViewController: UIViewController, CBCentralManagerDelegate, CBPeripheralDelegate {然后在类内部定义一些必要的属性和常量// CoreBluetooth 管理器负责扫描和连接 var centralManager: CBCentralManager! // 当前连接的外围设备 var connectedPeripheral: CBPeripheral? // 用于接收数据的特征值从Feather读取 var rxCharacteristic: CBCharacteristic? // 用于发送数据的特征值向Feather写入本例未使用 var txCharacteristic: CBCharacteristic? // 存储扫描到的设备列表和信号强度 var peripheralList: [CBPeripheral] [] var rssiList: [NSNumber] [] // Feather nRF52832 使用的BLE服务与特征值UUID // 这些UUID必须与Arduino代码中Adafruit BLE库使用的保持一致 let BLE_Service_UUID CBUUID(string: 6E400001-B5A3-F393-E0A9-E50E24DCCA9E) let BLE_Characteristic_uuid_Rx CBUUID(string: 6E400003-B5A3-F393-E0A9-E50E24DCCA9E) // RX (Receive) let BLE_Characteristic_uuid_Tx CBUUID(string: 6E400002-B5A3-F393-E0A9-E50E24DCCA9E) // TX (Transmit) // 存储接收到的数据 var receivedData: [Double] [] // 图表显示开关状态 var showGraphIsOn false // 定时器用于控制扫描时长 var scanTimer: Timer?在viewDidLoad()方法中进行初始化设置override func viewDidLoad() { super.viewDidLoad() // 初始化中央管理器并设置当前类为其代理 centralManager CBCentralManager(delegate: self, queue: nil) // 初始化界面状态 connectStatusLabel.text 未连接 connectStatusLabel.textColor .red showGraphSwitch.isOn false }接下来实现CBCentralManagerDelegate的核心方法// 当蓝牙中心管理器状态更新时调用例如用户打开或关闭手机蓝牙 func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: // 蓝牙已开启可以开始扫描 print(蓝牙已就绪) startScanning() case .poweredOff: print(蓝牙已关闭) connectStatusLabel.text 蓝牙未开启 connectStatusLabel.textColor .red // 可以在这里提示用户打开蓝牙 case .unsupported, .unauthorized, .unknown, .resetting: // 处理其他不支持或未知状态 print(蓝牙不可用: \(central.state)) connectStatusLabel.text 蓝牙不可用 connectStatusLabel.textColor .red unknown default: print(未知的蓝牙状态) } } // 开始扫描外围设备 func startScanning() { print(开始扫描BLE设备...) peripheralList.removeAll() rssiList.removeAll() // 扫描指定服务UUID的设备这样能更快找到我们的Feather centralManager.scanForPeripherals(withServices: [BLE_Service_UUID], options: nil) // 设置一个10秒后停止扫描的定时器 scanTimer Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in self?.stopScanning() } } // 停止扫描 func stopScanning() { centralManager.stopScan() print(停止扫描) // 如果扫描期间没有找到设备可以更新UI提示 if peripheralList.isEmpty { connectStatusLabel.text 未找到设备 } } // 当扫描发现外围设备时调用 func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { print(发现设备: \(peripheral.name ?? 未知设备), RSSI: \(RSSI)) // 避免重复添加同一设备 if !peripheralList.contains(peripheral) { peripheralList.append(peripheral) rssiList.append(RSSI) // 通常我们连接第一个发现的设备假设就是我们的Feather // 在实际应用中你可能需要让用户从列表中选择 if connectedPeripheral nil { connectToPeripheral(peripheral) } } } // 连接到指定外围设备 func connectToPeripheral(_ peripheral: CBPeripheral) { centralManager.stopScan() // 停止扫描准备连接 scanTimer?.invalidate() connectedPeripheral peripheral connectedPeripheral?.delegate self centralManager.connect(peripheral, options: nil) connectStatusLabel.text 连接中... connectStatusLabel.textColor .orange } // 连接成功 func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { print(连接成功: \(peripheral.name ?? 未知设备)) connectStatusLabel.text 已连接 connectStatusLabel.textColor .systemBlue // 连接成功后开始发现设备提供的服务 peripheral.discoverServices([BLE_Service_UUID]) } // 连接失败 func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { print(连接失败: \(error?.localizedDescription ?? 未知错误)) connectStatusLabel.text 连接失败 connectStatusLabel.textColor .red connectedPeripheral nil } // 连接断开 func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { print(连接断开) connectStatusLabel.text 未连接 connectStatusLabel.textColor .red connectedPeripheral nil // 可以尝试自动重连 // centralManager.connect(peripheral, options: nil) }当连接成功并发现服务后会进入CBPeripheralDelegate的方法流// 发现服务 func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error error { print(发现服务错误: \(error.localizedDescription)) return } guard let services peripheral.services else { return } for service in services { print(发现服务: \(service.uuid)) // 查找我们需要的服务 if service.uuid BLE_Service_UUID { // 发现该服务下的特征值 peripheral.discoverCharacteristics([BLE_Characteristic_uuid_Rx, BLE_Characteristic_uuid_Tx], for: service) } } } // 发现特征值 func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let error error { print(发现特征值错误: \(error.localizedDescription)) return } guard let characteristics service.characteristics else { return } for characteristic in characteristics { print(发现特征值: \(characteristic.uuid)) if characteristic.uuid BLE_Characteristic_uuid_Rx { // 这是我们接收数据的特征值 rxCharacteristic characteristic // 订阅这个特征值这样当Feather发送新数据时我们会自动收到通知 peripheral.setNotifyValue(true, for: characteristic) } else if characteristic.uuid BLE_Characteristic_uuid_Tx { // 这是我们发送数据的特征值本例中未使用 txCharacteristic characteristic } } } // 收到特征值更新的通知即Feather发送了新数据 func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { if let error error { print(接收数据错误: \(error.localizedDescription)) return } guard let data characteristic.value else { return } // 将接收到的数据Data类型转换为字符串 if let receivedString String(data: data, encoding: .utf8) { print(收到数据: \(receivedString)) // 尝试将字符串转换为整数Arduino发送的模拟值 if let intValue Int(receivedString) { // 将整数转换为Double用于图表并添加到数据数组 let dataPoint Double(intValue) receivedData.append(dataPoint) // 如果数据太多移除最旧的数据保持最近100个点 if receivedData.count 100 { receivedData.removeFirst() } // 如果图表开关打开则更新图表 if showGraphIsOn { updateGraph() } } } }最后实现刷新按钮和开关的Action方法以及更新图表的函数// 刷新按钮点击事件 IBAction func refreshButtonTapped(_ sender: UIBarButtonItem) { print(手动刷新) // 如果已连接则断开并重新扫描 if let peripheral connectedPeripheral { centralManager.cancelPeripheralConnection(peripheral) } startScanning() } // 开关状态改变事件 IBAction func switchValueChanged(_ sender: UISwitch) { showGraphIsOn sender.isOn if showGraphIsOn !receivedData.isEmpty { updateGraph() } else { // 清空图表 clearGraph() } } // 更新图表 func updateGraph() { // 1. 创建图表数据条目数组 var chartDataEntries: [ChartDataEntry] [] for (index, value) in receivedData.enumerated() { let entry ChartDataEntry(x: Double(index), y: value) chartDataEntries.append(entry) } // 2. 创建数据集 let dataSet LineChartDataSet(entries: chartDataEntries, label: 传感器数据) // 自定义数据集样式 dataSet.colors [.systemBlue] // 线条颜色 dataSet.lineWidth 2.0 dataSet.circleColors [.systemRed] // 数据点颜色 dataSet.circleRadius 4.0 dataSet.drawCircleHoleEnabled false dataSet.mode .linear // 折线模式 dataSet.drawValuesEnabled false // 不显示每个点的数值 // 3. 创建图表数据对象并赋值给图表视图 let chartData LineChartData(dataSet: dataSet) // 假设你在Storyboard中拖入了一个LineChartView并创建了Outlet命名为lineChartView lineChartView.data chartData // 4. 可选自定义图表视图外观 lineChartView.xAxis.labelPosition .bottom lineChartView.xAxis.drawGridLinesEnabled false lineChartView.rightAxis.enabled false // 关闭右侧Y轴 lineChartView.leftAxis.drawGridLinesEnabled true lineChartView.legend.enabled false // 不显示图例 lineChartView.chartDescription?.text 实时数据流 // 图表描述 } // 清空图表 func clearGraph() { lineChartView.clear() // 或者设置为空数据 // lineChartView.data nil }注意事项在实现updateGraph函数前你还需要回到Main.storyboard从库中拖拽一个LineChartView到界面上需要先在库中搜索“Chart”因为它是第三方控件并为其添加约束使其占据屏幕主要区域。然后像之前创建Label的Outlet一样为这个LineChartView创建一个名为lineChartView的Outlet。6. 真机调试、问题排查与优化建议6.1 真机部署与测试流程代码编写完成后最关键的一步是在真机上测试。请确保你的iPhone通过USB连接到Mac。选择真机设备在Xcode窗口顶部的Scheme工具栏中将运行目标从模拟器例如“iPhone 14 Pro”改为你连接的iPhone设备名称。签名与配置Xcode可能会自动管理签名。如果出现签名错误你需要在项目导航器中选择顶层的项目文件蓝色图标。选择“Signing Capabilities”标签页。确保在“Team”下拉框中选择了你的Apple ID账户。Xcode会自动为你创建临时的开发描述文件。如果失败可能需要去苹果开发者网站检查账户状态。首次运行点击Xcode左上角的运行▶按钮。Xcode会将应用编译并安装到你的iPhone上。首次安装时可能会遇到“未受信任的开发者”提示。你需要到iPhone的“设置”-“通用”-“VPN与设备管理”或“设备管理”中找到你的开发者证书点击“信任”。授权蓝牙首次打开App并尝试使用蓝牙时iOS会弹出系统对话框请求蓝牙使用权限。务必点击“允许”否则App将无法工作。测试流程确保你的Feather nRF52832已上电通过USB连接电脑或电池。在iPhone上打开App点击右上角的刷新按钮。App应开始扫描并在几秒内显示“已连接”。此时Arduino代码应该正在以每秒2次的频率发送A0引脚的模拟值如果A0悬空数值会随机浮动。打开“显示图表”开关你应该能看到一条实时波动的折线图。6.2 常见问题与排查技巧在实际开发中你可能会遇到以下问题这里提供排查思路App扫描不到设备检查硬件确认Feather nRF52832已正确供电且Arduino代码已成功上传。观察板载的红色LEDAdafruit BLE库通常会让其在广播时闪烁。检查UUID这是最常见的问题。确保iOS代码中的BLE_Service_UUID和BLE_Characteristic_uuid_Rx/Tx与Arduino代码中Adafruit BLE库使用的UUID完全一致。Adafruit的bleuart示例使用固定的UUID通常就是代码中写的那一串。你可以通过Arduino串口监视器查看启动日志确认服务UUID。检查手机蓝牙确保iPhone蓝牙已开启并且没有连接着其他蓝牙设备尤其是音频设备有时这会影响扫描。重启大法重启iPhone和Feather开发板。连接成功但收不到数据检查特征值订阅在didDiscoverCharacteristicsFor方法中确保对BLE_Characteristic_uuid_Rx特征值调用了peripheral.setNotifyValue(true, for: characteristic)。没有订阅就不会收到数据更新通知。检查数据解析在didUpdateValueFor方法中添加打印语句print(Raw data: \(data))查看接收到的原始数据。确认Arduino发送的是纯数字字符串且iOS端用String(data: data, encoding: .utf8)能正确转换。检查Arduino端确认Arduino的loop()函数中的delay(500)和bleuart.write代码确实在执行。可以通过串口监视器打印调试信息来确认。图表不显示或显示异常检查Outlet连接确认Storyboard中的LineChartView是否与ViewController.swift中的lineChartView属性正确连接。可以在viewDidLoad中尝试设置lineChartView.backgroundColor .lightGray来测试视图是否加载。检查数据格式ChartDataEntry的x, y值需要是Double类型。确保从字符串转换来的intValue被正确地转换为Double。检查数据数组在updateGraph()函数开头打印receivedData数组确认其中有数据且格式正确。应用崩溃检查可选值解包Swift是强类型语言频繁使用!强制解包可能为nil的变量会导致崩溃。尽量使用if let或guard let进行安全解包。检查线程所有UI更新必须在主线程进行。如果在蓝牙回调中直接更新UI可能会引发问题。可以使用DispatchQueue.main.async { }将UI更新代码包起来。查看控制台Xcode底部的控制台会输出详细的崩溃日志和错误信息这是最重要的调试依据。6.3 项目优化与扩展方向这个基础项目可以沿多个方向进行优化和扩展数据稳定性与滤波直接从ADC读取的数据可能带有噪声。可以在Arduino端或iOS端添加简单的软件滤波算法如移动平均滤波或中值滤波让曲线更平滑。多设备连接与选择当前代码自动连接第一个发现的设备。可以修改为扫描后列出所有发现的设备显示名称和信号强度RSSI让用户手动选择要连接哪一个。数据持久化将接收到的数据存储到iPhone本地如使用CoreData或Realm数据库并增加历史数据查看功能。图表功能增强利用Charts库的强大功能增加缩放、拖拽、显示十字线、显示具体数值、切换不同图表类型如柱状图、散点图等功能。连接稳定性增加自动重连机制。在didDisconnectPeripheral回调中可以尝试重新连接并设置重连次数上限和延迟提升用户体验。发送控制指令当前是单向数据流。你可以利用txCharacteristic从iOS App向Feather发送指令例如改变采样频率、控制一个LED开关实现双向交互。更换传感器将Arduino代码中analogRead(A0)的引脚连接到真正的传感器如DHT11温湿度、MPU6050加速度陀螺仪、光敏电阻等即可快速搭建不同的监测应用。通过这个项目你不仅实现了一个具体的BLE数据可视化应用更重要的是掌握了iOS与嵌入式硬件通过BLE通信的完整链路。这套框架具有很强的通用性你可以以此为起点探索更广阔的物联网应用开发。