Swift 程式語言

開發者指南:如何利用 Core Bluetooth 製作一個監控心率 App

開發者指南:如何利用 Core Bluetooth 製作一個監控心率 App
開發者指南:如何利用 Core Bluetooth 製作一個監控心率 App
In: Swift 程式語言, Xcode

作為 iOS 開發人員,我們非常清楚人類喜歡可連接的應用程式。人類喜歡透過無線設備與其他人相互聯繫,我們期望可以與設備溝通,我們亦開始喜歡、並期望這些無線設備 (通常是「可穿戴設備」) 可以收集和分析關於自己的數據。很多設備已經成為生活中不可缺少的一部分,我們以一個常用短語來形容它們 ── “Internet of Things” 或 “IoT” (物聯網)。目前全球有數十億無線通信設備,在本教程中,我們將專注於其中一部分:Bluetooth®.

我將解釋 Bluetooth® 背後的基本概念與技術,以及:

  • 解釋為何精通 Bluetooth® 軟體開發,可以為你帶來大量就業機會;
  • 提醒你在發佈一個使用 Bluetooth® 的應用程式前,必先通過「認證」;
  • 為你提供 Apple Core Bluetooth 框架的概述 (另外也請參考這篇文章)
  • 最後,引導你以 Swift 4 語法開發一個 iOS 應用程式,利用 Bluetooth® 透過 Core Bluetooth 監控心率。
注意: 請閱讀文章包含的連結,那些都是重要的資訊,讓開發者完全理解 Bluetooth® 的運作、以及 Apple 如何支援 Bluetooth®。

藍牙 ── 一項發展蓬勃的技術

要在一篇文章討論整個物聯網的軟體開發並不可能,但這些無線設備的數據很有啟發性。互聯的設備無處不在,它們的預計增長率更是驚人。說到我們今天討論的題目,就是在「短距離」中使用 Bluetooth® 和 WiFi 等技術,並配合如流動網絡 (例如 CDMA) 的技術添加至「廣泛範疇」,你將會發現,預計相關設備市場規模將從 2014 年的 125 億台裝置,到 2022 年迅速增長至 300 億台。

Bluetooth® 是一種短距離無線通信科技的標準規範。Bluetooth Special Interest Group (Bluetooth SIG,藍芽技術聯盟) 管理並保護這項技術背後的發展、演變和知識產權。SIG 確保 Bluetooth® 標準的硬體和軟體製造商、開發商和銷售商符合標準規範。

根據 Bluetooth SIG 資料顯示,「今年將有近 40 億台設備使用藍牙進行連接,包括手機、平板電腦、個人電腦或這些裝置彼此互連。」重點投資於短距離通信技術的 Ellisys 也有相同意見,他們估計「2018 年將出口近 40 億台藍牙設備。」,是單單明年就出口 40 億台新的 Bluetooth® 設備

在產業趨勢上,「市場和消費者數據」統計公司 Statista 指,全球備有 Bluetooth® 的設備數量估計將從 2012 年的 35 億,到 2018 年增長至 100 億

Bluetooth® 對你的事業有何幫助?

專門研究 iOS 開發的 「IoT 藍牙應用」專門店 Dogtown Media, LLC 宣稱,「麥肯錫全球研究院的專家指出,物聯網在未來 9 年內將為環球經濟帶來超過 6 萬億美元的收益。」這對於我們這些 iOS 開發者有甚麼意義? Dogtown 說,「未來幾年肯定非常刺激而多產,對於有前瞻思維的創業公司和企業家來說,這可能非常有利可圖。」意思即是,保持前瞻思維和創業精神,利用 Bluetooth® 了解應用程式開發,因為下一份工作或合約很有可能需要用到這項技能。

聲明:

  • 我與 Dogtown Media, LLC 沒有任何關係。只是在資料搜集時找到了該公司的網站,並看到它們專注於 iOS Bluetooth® 發展。
  • 我是 Bluetooth SIG 的 “Adopter” 級別會員。

提交 Core Bluetooth 應用程式作審查前

自 Bluetooth® 首次亮相以來,我經常看到開發人員一找到些參考資料,就直接投入無線設備的應用程式開發中,並提交 Bluetooth® 應用程式到 Apple App Store,但這樣是不對的。

引述自 Bluetooth SIG,「所有使用藍牙的產品都必須完成藍牙認證過程。」我聽過有人說:「有那麼多以 Bluetooth® 為基礎的應用程式,沒有人會注意到我的。」不要這樣想,Bluetooth® 技術向應用程式開發人員提供版權、專利和許可,如果希望自己的應用程式脫穎而出,並展示已整合的 Bluetooth® 技術,請記住:

The Bluetooth® trademarks–including the BLUETOOTH word mark, the figure mark (the runic “B” and oval design), and the combination mark (Bluetooth word mark and design)–are owned by the Bluetooth SIG. Only members of the Bluetooth SIG with properly qualified and declared products may display, feature or use any of the trademarks. In order to protect the trademarks, the Bluetooth SIG administers an enforcement program that monitors the market and performs audits to ensure members using the trademarks are doing so in accordance with the Bluetooth brand guide and in relation to goods and services that have successfully completed the qualification process.

請參閱 Bluetooth SIG 關於認證的常見問題

What happens if I don’t qualify my product?

If you do not qualify your product, you become subject to enforcement action. Read the updated policy here where we outline the escalation schedule. If no corrective actions are taken, your Bluetooth SIG membership could be suspended or revoked.

不要做愚蠢或冒險的事了!最重要的是,我們所有人都應該保持正直誠實,需要時就應該付授權費,並嚴格遵守標準,這樣才可以互相合作。數千人在開發 Bluetooth® 標準與幾項專利上,貢獻了數千小時的工作和數百萬美元的費用,才創造出一套有用的知識資產。

別被我嚇著了

人們經常會被一些嚴厲字詞嚇到,例如「商標」、「專利」、「版權」、「認證」、「成員資格」,特別是「強制執行」。不要擔心使用 Bluetooth® 進行開發,加入 Bluetooth SIG 是免費的!只需點擊此處,然後:

Start by becoming an Adopter member. Membership is required to build products that use Bluetooth technology, and Adopter membership includes the following benefits:

• A license to build products using Bluetooth technology under and in compliance with the Bluetooth Patent/Copyright License Agreement

• A license to use the Bluetooth trademarks on qualified products under and in compliance with the Bluetooth Trademark License Agreement

• The ability to network and collaborate with tens of thousands of Bluetooth SIG members in a wide variety of industries–from chip manufacturers to application developers, device makers and service providers

• The ability to participate in SIG expert groups, study groups, and sub groups within working groups

• Access to tools such as the Profile Tuning Suite (PTS), providing protocol and interoperability testing…

成為 Bluetooth SIG 成員

擁有 SIG 成員資格有不少實質助益,你可以免費接觸到教學包、培訓視頻、網絡研討會、開發者論壇、開發人員支持服務、白皮書、產品測試工具,成為成員更可確保你的應用程式符合國際監管要求 (主要涉及無線電頻率輻射)。

只要成為成員就可以增加曝光率。我的公司是 SIG 成員,因此它出現在 Bluetooth SIG 會員目錄中:

Bluetooth membership

開發了一個應用程式後,一旦通過 SIG 取得資格、並獲准將其納入 Apple App Store,你的產品也會被 SIG 公開列出,增加曝光機會。

認證應用程式相當容易便宜

如果你滿意自己的 Core Bluetooth 應用程式,並且已準備好提交給 Apple App Store 進行審核時,請先停一停,並到 Bluetooth SIG 網站「認證你的產品」。SIG 為你提供一個 “Launch Studio”,讓你在線上完成藍牙認證過程。

對於大多數如本教程中實作的 “GATT-based Profile Client (應用程式)”,認證和列出費用為 100 美元,這費用可以確保你的程式碼符合 Bluetooth® 標準規範,並做更多的測試。最後,你可以使用 Bluetooth® 的 logo 來標籤你的應用程式,這個 logo 在全球得到認可,消費者認知度高達 92%。

請不要擔心這 100 美元的花費,你為公司處理這些 Bluetooth® 規範薪酬可以更高。

了解 Core Bluetooth

大多數情況下,使用 Bluetooth® 設備是非常簡單的,但開發與 Bluetooth® 通訊的軟體卻可以非常複雜,所以 Apple 就創建了 Core Bluetooth 框架

The Core Bluetooth framework lets your iOS and Mac apps communicate with Bluetooth low energy devices. For example, your app can discover, explore, and interact with low energy peripheral devices, such as heart rate monitors, digital thermostats, and even other iOS devices.

The framework is an abstraction of the Bluetooth 4.0 specification for use with low energy devices. That said, it hides many of the low-level details of the specification from you, the developer, making it much easier for you to develop apps that interact with Bluetooth low energy devices. Because the framework is based on the specification, some concepts and terminology from the specification have been adopted. …

請注意裡面提及的 “low energy devices”。當使用 Core Bluetooth 時,我們不需要負責處理傳統 Bluetooth® 設備,即無線揚聲器。利用這些設備的進行通訊可能會很快耗盡電池電量。Core Bluetooth 是一種用於「低功耗藍牙 (BLE)」的 API,也稱為「藍牙 4.0」,專為傳遞少量數據而設,所以 BLE 用電量很少。BLE 設備一個很好的例子就是心率監測儀 (HRM),它大約每秒只發送幾個 bytes 的數據,所以我們可以戴著 HRM 和 iPhone 來跑一小時步,從而記錄他們跑步期間的心率,又不會消耗太多電量。請注意,隨著本文的推進,像 BLE 這樣的首字母縮略詞數量會逐步增加。

你必須學習一個新的詞彙,這樣我們才能繼續討論 Core Bluetooth

客戶端/ 伺服器和消費者/ 生產者模型方面,想想 BLE 協定。

central-peripheral

外圍設備 (Peripheral)

外圍設備是一個像 HRM 的硬/ 軟體,多數 HRM 設備會收集和/ 或計算數據,例如每分鐘的心跳率、HRM 的電池電量、以及 “RR-Interval” 等,然後將這些數據傳輸給另一個或多個想要這些訊息的個體。外圍設備的角色是 ServerProducerWahoo TICKRPolar H7Scosche Rhythm+ 是市場上一些比較流行的 HRM。

編寫 Swift 4 程式碼來連接這三個設備時,你就會知道像 BLE 這樣的標準有多重要。

檢視 Core Bluetooth
參考Apple官方文件:

CBPeripheralDelegate

The delegate of a CBPeripheral object must adopt the CBPeripheralDelegate protocol. The delegate uses this protocol’s methods to monitor the discovery, exploration, and interaction of a remote peripheral’s services and properties. There are no required methods in this protocol.

中心設備 (Central)

中心設備是像 iPhone、iPad、MacBook、iMac 等軟/ 硬體,這些設備可以執行應用程式來掃描 Bluetooth® 外圍設備如 HRM,中心設備的角色是 ClientConsumer。它們連接到 HRM 以使用外圍設備抽取的數據,如每分鐘的心跳斗率、電池電量和 “RR-Interval” 等,中心設備接收這些數據,並加以操作運算,例如加值計算數據 (value-added calculations)、或者僅通過用戶介面呈現數據、及/ 或儲存數據用於未來分析、呈現及/ 或大量數據分析 (就像統計分析般,需要有足夠數據才可以反映有意義的趨勢)。

檢視 Core Bluetooth
參考 Apple 官方文件:

The CBCentralManagerDelegate protocol defines the methods that a delegate of a CBCentralManager object must adopt. The optional methods of the protocol allow the delegate to monitor the discovery, connectivity, and retrieval of peripheral devices. The only required method of the protocol indicates the availability of the central manager, and is called when the central manager’s state is updated.

透過廣播通訊 (Advertising) 尋找外圍設備

如果你的 iPhone 或 iPad 無法找到並連接到像 HRM 這樣的外圍設備,會令人非常頭疼。因此,外圍設備會不斷地以無線方式播放小片段 (數據包),例如 「我是一個Scosche Rhythm + 心率監視器;我提供不同功能,例如測量佩戴者的心跳率;我提供不同資訊,例如電池電量」。當中心設備想取得心跳率資料,並偵測到這個外圍設備時,就會進行連結,外圍設備亦會停止廣播通訊。

你可能已經試過用 iPhone -> Settings -> Bluetooth 來開關 Bluetooth® (傳統和 BLE),當開啟了 Bluetooth® 時,就可以查看 iPhone 掃描到的設備並作連接。下圖展示了我從掃描到發現設備、並將 iPhone 連接到 Scosche Rhythm + HRM 的過程:

引述自 Apple:

Peripherals broadcast some of the data they have in the form of advertising packets. An advertising packet is a relatively small bundle of data that may contain useful information about what a peripheral has to offer, such as the peripheral’s name and primary functionality. For instance, a digital thermostat may advertise that it provides the current temperature of a room. In Bluetooth low energy, advertising is the primary way that peripherals make their presence known.

A central, on the other hand, can scan and listen for any peripheral device that is advertising information that it’s interested in…

在文章後半部,我將會示範如何在 Swift 4 程式碼中掃描並連接外圍設備。

外圍設備的 Service

這裡指的 Service 可能與你所想的不同。Service 描述外圍設備提供的一些主要特性或功能,我所指的不是像每分鐘心跳率這樣的具體測量,而是一個類別,描述外圍設備有哪些與心臟相關的測量工具可用。

引述自 Apple:

A service is a collection of data and associated behaviors for accomplishing a function or feature of a device (or portions of that device). For example, one service of a heart rate monitor may be to expose heart rate data from the monitor’s heart rate sensor.

要具體定義一個 Bluetooth® “Service”,我們應該查看 Bluetooth SIG 的 “GATT Services” 列表,其中 GATT 代表 “Generic Attributes (通用屬性)”

向下滾動瀏覽 Services 列表,在名稱欄位找尋 “Heart Rate”。請注意,相應的 Uniform Type Identifier 應為 “org.bluetooth.service.heart_rate”,Assigned Number 則為 0x180D,我們將在下面的程式碼中使用 0x180D 這數值。

點擊 “Heart Rate”。你將會進入一個頁面,以粗體字顯示 Name: Heart Rate。你會看到 Summary 指出,”The HEART RATE Service exposes heart rate and other data related to a heart rate sensor intended for fitness applications (HEART RATE Service 提供心跳率和其他與心率傳感器相關的數據,適用於健身應用程式)”,向下滾動頁面,你會發現 Heart Rate Service 本身不能提供每分鐘的心跳率,這個 Service 會集合不同數據,我們稱之為 Characteristic。最後,你會得到一個由 Characteristic 提供的資訊: 每分鐘心跳率。

檢視 Core Bluetooth
參考 Apple 官方文件:

CBService and its subclass CBMutableService represent a peripheral’s service–a collection of data and associated behaviors for accomplishing a function or feature of a device (or portions of that device). CBService objects in particular represent services of a remote peripheral device (represented by a CBPeripheral object). Services are either primary or secondary and may contain a number of characteristics or included services (references to other services).

一個外圍設備 Service 所屬的 Characteristic

外圍設備的 Service 通常被拆解為更小、但相關的資訊,而 Characteristics 則是我們尋找具體資訊的地方,即實際的數據。同樣地,參閱 Apple 官方說明:

Services themselves are made up of either characteristics or included services (that is, references to other services). A characteristic provides further details about a peripheral’s service. For example, the heart rate service just described may contain one characteristic that describes the intended body location of the device’s heart rate sensor and another characteristic that transmits heart rate measurement data.

繼續以 HRM 為範例,請返回以粗體顯示 Name: Heart Rate頁面。向下滾動找尋 Service Characteristics,這是一個包含大量 Meta Data (關於數據的資訊) 的大表格。請查找 Heart Rate Measurement,然後點擊 org.bluetooth.characteristic.heart_rate_measurement 並仔細看看,我稍後會解釋這個頁面。

檢視 Core Bluetooth
參考 Apple 官方文件:

CBCharacteristic and its subclass CBMutableCharacteristic represent further information about a peripheral’s service. CBCharacteristic objects in particular represent the characteristics of a remote peripheral’s service (remote peripheral devices are represented by CBPeripheral objects). A characteristic contains a single value and any number of descriptors describing that value. The properties of a characteristic determine how the value of the characteristic can be used and how the descriptors can be accessed.

GATT Specifications

在處理一個需要利用 Core Bluetooth 與 Bluetooth® 外圍設備進行溝通的應用程式時,你應該先到 Bluetooth SIG 的網站。

一起看看構建一個應用程式的過程。查看 GATT Specifications 部分,然後在 GATT Services 下查找外圍設備的 Services。

以本文的 HRM 範例為例,首先在 GATT Services 頁面中的 Name 欄位內尋找 “Heart Rate” 一詞 (它也是一個超連結) ,點擊 “Heart Rate” 連結並查看整個頁面,請注意 Assigned Number (0x180D),然後向下滾動到 Service Characteristics 表單,仔細查看表格並找出感興趣的 Characteristics。

在這個範例中,請查看 Heart Rate MeasurementBody Sensor Location 部分,然後點擊它們各自的詳細頁面連接 org.bluetooth.characteristic.heart_rate_measurementorg.bluetooth.characteristic.body_sensor_location

Heart Rate Measurement 頁面中,記下 Assigned Number (0x2A37),然後查看資料,看看這些將被發送到建議 HRM 應用程式的 Bluetooth® 編碼數據結構如何解碼,必須編寫程式碼才能將 Bluetooth® 編碼數據轉換為可讀格式。

回到 strong>Body Sensor Location 頁面,記下 Assigned Number (0x2A38),然後查看協定,了解這些將被發送到建議 HRM 應用程式的 Bluetooth® 編碼數據結構如何解碼,必須編寫程式碼才能將 Bluetooth® 編碼數據轉換為可讀格式。

在本教程中,我會向你提供更多詳細資訊,特別是當我展示用於與 BLE HRM 進行通訊的應用程式碼時。

如果你加入 Bluetooth SIG,則可以獲得有關使用 Services 和 Characteristics 進行編程的更詳細資訊。

編寫 Core Bluetooth 程式碼

閱讀本教程時,你必須對 iOS 應用程式開發的知識有基礎了解,包括 Swift 編程語言,和 Xcode Single View App 模板,測試範例應用程式的用戶介面 (UI) 相當簡單,包括 Auto Layout,其程式碼如下所示。

下文將逐步描述程式碼,我也會在程式碼中為每個步驟提供註解。因此,當你閱讀步驟時,請參閱程式碼中相應的步驟,整個過程基本上是線性的,只要記住一些步驟代表回調 ── 調用委託方法。

在實際應用程式中,我會將 Core Bluetooth 組件從協定或類別中分割出來,即將 UI 和核心功能分開。但是這個程式碼的目的是向你展示 Core Bluetooth 的操作,為了專注討論這一點,我的專案很簡單且有價值,一篇就包含了所有重點。

認識範例應用程式

本教程的範例應用程序,用戶介面也很簡單。當應用程式啟動時,就會開始掃描 HRM,掃描時你會看到螢幕上顯示並旋轉的 UIActivityIndicatorView。如正方形的 UIView 標記為紅色,則表示沒有連接 HRM。一旦找到並開始連接 HRM,UIActivityIndicatorView 將停止旋轉並隱藏,紅色的 UIView 會變為綠色。當 HRM 完全連接,HRM 的 Retail Device 名稱、和佩戴者身體上的預期放置位置將顯示,然後開始約每秒讀取並顯示佩戴者每分鐘的心跳次數。大多數 HRM 每秒發送一次心率,我製作了心率數字的脈動動畫,讓這應用看起來更吸引。當 HRM 斷開連接時,所有的資訊內容都會清空,方形 UIView 變回紅色,UIActivityIndicatorView 將顯示並開始旋轉,然後再次開始掃描 HRM。

這是我的應用程式在三個不同品牌的 HRM 呈現的樣貌 ── Scosche Rhythm+、Wahoo TICKR、和 Polar H7:

Rhythm+ 使用紅外線「看」我的血管來確定心率,而 TICKR 和 H7 則使用電極檢測電脈衝來確定心率。

閱覽程式碼

你可以在下一節找到完整的程式碼,在這裡,我將引導讀者完成實作。

Step 0.00:必須導入 CoreBluetooth 框架。

Step 0.0: 將 GATT Assigned Numbers 指定為常數,這樣可以令 Bluetooth® 規格的標識符更具可讀性和可維護性,適用於 “Heart Rate” Service 的 “Heart Rate Measurement” Characteristic,以及 “Body Sensor Location” Characteristic。

Step 0.1:HeartRateMonitorViewController 成為 UIViewController 的子類,並且使 HeartRateMonitorViewController 符合 CBCentralManagerDelegateCBPeripheralDelegate 協定。我正在使用協定和委任設計模式 (delegation design pattern),我在這篇這篇 AppCoda 文章講述過。我們將從兩個協定實作方法,先調用一些 Core Bluetooth 方法,然後 Core Bluetooth 回應我們自己的請求,再調用某些方法。

Step 0.2: 我們在代表 CBCentralManagerCBPeripheral 類別的 HeartRateMonitorViewController 類別中定義了實例變量,以便它們維持應用程式的持續時間。

Step 1: 我們為中心設備創建一個同步的背景隊列,讓 Core Bluetooth 的動作在背景進行,藉此讓 UI 維持響應狀態。假設在更複雜的應用程式中,HRM 可能會運行數小時,為用戶收集心率數據,用戶可能想同時使用其他應用程式功能,例如配置應用程式設定。又例如用戶在跑步的時候,想同時使用 Core Location 來追蹤路線,有了這個功能,用戶就能在收集並顯示心率數據的同時,收集和/ 或查看他們的地理位置。

Step 2: 創建中心設備來掃描、連接、管理和收集來自外圍設備的數據,這是必要的步驟,Core Bluetooth 沒有中心設備就無法運行。同樣重要的是,由於 HeartRateMonitorViewController 採用 CBCentralManagerDelegate,因此我們將 centralManager 的 Delegate 屬性設置為 HeartRateMonitorViewController (self)。我們還為中心設備指定了 DispatchQueue

Step 3.1: 這個 centralManagerDidUpdateState 方法是基於設備的 Bluetooth® 狀態調用的。在 Settings 應用程式時,我們應該為用戶有意或無意中關閉了 Bluetooth® 的情形做好準備。只有在 Bluetooth® 處於 .poweredOn 狀態時,我們才能掃描外圍設備。

Step 3.2: 中心設備必須掃描可用的外圍設備,但前題是設備 (如 iPhone) 的 Bluetooth® 是開啟的狀態,我們現在要做的事,就是上文「透過廣播通訊尋找外圍設備」的部分。在廣播通訊,我們只尋找 Heart Rate Service 的 HRM,透過將 CBUUIDs 添加到 serviceUUIDs 陣列參數,我們可以偵測並連接到具有特定 Service 的更多外圍設備。例如,在某些與健康相關的應用程式中,我們可以偵測並連接到 HRM 血壓監視器或 BPM (如果要使用 BPM,我們需要使用另一個 CBPeripheral 類實例變數)。請注意,如果我們執行這項調用:

centralManager?.scanForPeripherals(withServices: nil)

… 我們就可以偵測範圍內所有 Bluetooth® 設備的廣播通訊,這可能對某些類型的 Bluetooth® 工具應用程式非常實用。

Step 4.1: 查看此應用程式可以連接的可用外圍設備 (HRM),這個 didDiscover 方法告訴我們,中心設備在掃描時偵測到那些正在進行廣播通訊的 HRM。

Step 4.2: 我們必須替剛剛偵測到的外圍設備儲存一個參考 (reference) 於一個會持續的類實例變數中,如果我們只使用一個局部變數,就會出現問題。

Step 4.3: 由於 HeartRateMonitorViewController 採用 CBPeripheralDelegate 協定,因此 peripheralHeartRateMonitor 物件必須將其 delegate 屬性設置為 HeartRateMonitorViewController (self)。

Step 5: 我們指示中心設備停止掃描 didDiscover,以延長電池使用時間。如果連接的 HRM/ 外圍設備斷開連接,我們可以重新進行掃描。

Step 6: 當仍在 didDiscover 當中時,我們連接到一個已發現的可用外圍設備 HRM。

Step 7: didConnect 方法會在「成功與外圍設備連接時被調用」。請注意「成功」這個詞,如果發現到外圍設備但無法連接,你就需要進行一些除錯工作。我亦刷新 UI 以顯示已連接的外圍設備,並提示我已停止掃描等工作。

Step 8: 當仍在 didConnect 方法中,我們尋找可用的外圍設備 Service,具體而言,我們希望找到 Heart Rate Service (0x180D)。

Step 9: 調用 didDiscoverServices 方法,代表在連接的外圍設備上偵測到 “Heart Rate” Service。請記住,我們必須尋找可用的 Characteristic。在這裡,我反覆瀏覽所有 Heart Rate Service 的 Characteristics,並在稍後挑選我想要的。你可以訪問 Bluetooth SIG 網站的 “Heart Rate” Service 頁面,向下滑動到 Service Characteristics 部分,查看三個可用的 Characteristics。

Step 10: 這個 didDiscoverCharacteristicsFor service 方法確認我們偵測到可用 Service 中的所有 Characteristics。

Step 11: 首先,我為可用的 Body Sensor Location Characteristic 訂閱單次通知 ── “Read”。移動到 “Heart Rate” Service 頁面,並注意這邊的 Characteristic 被標記為「強制讀取 (Read Mandatory)」。調用 peripheral.readValue 會導致 peripheral:didUpdateValueForCharacteristic:error: 隨後被呼叫,這樣我就可以解碼這個 Characteristic 供人使用。其次,我針對可用的 Heart Rate Measurement Characteristic 訂閱定期通知 ── “Notify”。移動到 “Heart Rate” Service 頁面,並注意這個 Characteristic 被標記為「強制性通知 (Notify Mandatory)」。調用 peripheral.setNotifyValue 會導致 peripheral:didUpdateValueForCharacteristic:error: 隨後被呼叫,且頻率間隔約是每秒,因此,我可以解碼這個 Characteristic 供人使用。

Step 12: 由於我訂閱了 Body Sensor Location (0x2A38) Characteristic 的數據讀取需求,以及 Heart Rate Measurement (0x2A37) Characteristic 的定期通知,如果它們發出單次通知或定期更新,我將會得到它們兩個的 binary 值。

Step 13: 將 BLE Heart Rate Measurement 數據解碼為可讀格式。針對此特性,請移動至GATT Specification頁面,第一個 byte 是關於其他數據的 meta data (Flags),Specs 告訴我要看第一個 byte 的最低有效位 (Least Significant Bit),即 Heart Rate Value Format bit。如果它是 0 (zero),心率在第二個 byte 中就會是UINT8,我從來沒有遇到過使用第二個 byte 以外的其他 HRM,包括我在這裡展示的三個 HRM,所以我會忽略 Heart Rate Value Format bit 為 1 (one) 的例子。我有看過所有提出的實作,但從未能夠測試它們,我不會就我無法實作的東西發表任何文章。

Step 14: 將 BLE Body Sensor Location 數據解碼為可讀格式,針對此特性,請轉至 GATT Specification 頁面。這個很簡單,數值 1,2,3,4,5,6 或 7 是以 8 bits 儲存,為了解碼,每個數值相對的文本字符串會被顯示。

Step 15: 當外圍設備與中心設備斷開連接時,請採取適當的措施,我選擇更新 UI 並 ……

Step 16: 開始掃描廣播通訊 Heart Rate Service (0x180D) 的外圍設備。

範例專案原始碼

這裡是上文討論內容的完整原始碼:

import UIKit

// STEP 0.00: MUST include the CoreBluetooth framework
import CoreBluetooth

// STEP 0.0: specify GATT "Assigned Numbers" as
// constants so they're readable and updatable

// MARK: - Core Bluetooth service IDs
let BLE_Heart_Rate_Service_CBUUID = CBUUID(string: "0x180D")

// MARK: - Core Bluetooth characteristic IDs
let BLE_Heart_Rate_Measurement_Characteristic_CBUUID = CBUUID(string: "0x2A37")
let BLE_Body_Sensor_Location_Characteristic_CBUUID = CBUUID(string: "0x2A38")

// STEP 0.1: this class adopts both the central and peripheral delegates
// and therefore must conform to these protocols' requirements
class HeartRateMonitorViewController: UIViewController, CBCentralManagerDelegate, CBPeripheralDelegate {
    
    // MARK: - Core Bluetooth class member variables
    
    // STEP 0.2: create instance variables of the
    // CBCentralManager and CBPeripheral so they
    // persist for the duration of the app's life
    var centralManager: CBCentralManager?
    var peripheralHeartRateMonitor: CBPeripheral?
    
    // MARK: - UI outlets / member variables
    
    @IBOutlet weak var connectingActivityIndicator: UIActivityIndicatorView!
    @IBOutlet weak var connectionStatusView: UIView!
    @IBOutlet weak var brandNameTextField: UITextField!
    @IBOutlet weak var sensorLocationTextField: UITextField!
    @IBOutlet weak var beatsPerMinuteLabel: UILabel!
    @IBOutlet weak var bluetoothOffLabel: UILabel!
    
    // HealthKit setup
    let healthKitInterface = HealthKitInterface()
    
    // MARK: - UIViewController delegate
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        
        // initially, we're scanning and not connected
        connectingActivityIndicator.backgroundColor = UIColor.white
        connectingActivityIndicator.startAnimating()
        connectionStatusView.backgroundColor = UIColor.red
        brandNameTextField.text = "----"
        sensorLocationTextField.text = "----"
        beatsPerMinuteLabel.text = "---"
        // just in case Bluetooth is turned off
        bluetoothOffLabel.alpha = 0.0
        
        // STEP 1: create a concurrent background queue for the central
        let centralQueue: DispatchQueue = DispatchQueue(label: "com.iosbrain.centralQueueName", attributes: .concurrent)
        // STEP 2: create a central to scan for, connect to,
        // manage, and collect data from peripherals
        centralManager = CBCentralManager(delegate: self, queue: centralQueue)
        
        // read heart rate data from HKHealthStore
        // healthKitInterface.readHeartRateData()
        
        // read gender type from HKHealthStore
        // healthKitInterface.readGenderType()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
    
    // MARK: - CBCentralManagerDelegate methods

    // STEP 3.1: this method is called based on
    // the device's Bluetooth state; we can ONLY
    // scan for peripherals if Bluetooth is .poweredOn
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        
        switch central.state {
        
        case .unknown:
            print("Bluetooth status is UNKNOWN")
            bluetoothOffLabel.alpha = 1.0
        case .resetting:
            print("Bluetooth status is RESETTING")
            bluetoothOffLabel.alpha = 1.0
        case .unsupported:
            print("Bluetooth status is UNSUPPORTED")
            bluetoothOffLabel.alpha = 1.0
        case .unauthorized:
            print("Bluetooth status is UNAUTHORIZED")
            bluetoothOffLabel.alpha = 1.0
        case .poweredOff:
            print("Bluetooth status is POWERED OFF")
            bluetoothOffLabel.alpha = 1.0
        case .poweredOn:
            print("Bluetooth status is POWERED ON")
            
            DispatchQueue.main.async { () -> Void in
                self.bluetoothOffLabel.alpha = 0.0
                self.connectingActivityIndicator.startAnimating()
            }
            
            // STEP 3.2: scan for peripherals that we're interested in
            centralManager?.scanForPeripherals(withServices: [BLE_Heart_Rate_Service_CBUUID])
            
        } // END switch
        
    } // END func centralManagerDidUpdateState
    
    // STEP 4.1: discover what peripheral devices OF INTEREST
    // are available for this app to connect to
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        
        print(peripheral.name!)
        decodePeripheralState(peripheralState: peripheral.state)
        // STEP 4.2: MUST store a reference to the peripheral in
        // class instance variable
        peripheralHeartRateMonitor = peripheral
        // STEP 4.3: since HeartRateMonitorViewController
        // adopts the CBPeripheralDelegate protocol,
        // the peripheralHeartRateMonitor must set its
        // delegate property to HeartRateMonitorViewController
        // (self)
        peripheralHeartRateMonitor?.delegate = self
        
        // STEP 5: stop scanning to preserve battery life;
        // re-scan if disconnected
        centralManager?.stopScan()
        
        // STEP 6: connect to the discovered peripheral of interest
        centralManager?.connect(peripheralHeartRateMonitor!)
        
    } // END func centralManager(... didDiscover peripheral
    
    // STEP 7: "Invoked when a connection is successfully created with a peripheral."
    // we can only move forwards when we know the connection
    // to the peripheral succeeded
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        
        DispatchQueue.main.async { () -> Void in
            
            self.brandNameTextField.text = peripheral.name!
            self.connectionStatusView.backgroundColor = UIColor.green
            self.beatsPerMinuteLabel.text = "---"
            self.sensorLocationTextField.text = "----"
            self.connectingActivityIndicator.stopAnimating()
            
        }
        
        // STEP 8: look for services of interest on peripheral
        peripheralHeartRateMonitor?.discoverServices([BLE_Heart_Rate_Service_CBUUID])

    } // END func centralManager(... didConnect peripheral
    
    // STEP 15: when a peripheral disconnects, take
    // use-case-appropriate action
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        
        // print("Disconnected!")
        
        DispatchQueue.main.async { () -> Void in
            
            self.brandNameTextField.text = "----"
            self.connectionStatusView.backgroundColor = UIColor.red
            self.beatsPerMinuteLabel.text = "---"
            self.sensorLocationTextField.text = "----"
            self.connectingActivityIndicator.startAnimating()
            
        }
        
        // STEP 16: in this use-case, start scanning
        // for the same peripheral or another, as long
        // as they're HRMs, to come back online
        centralManager?.scanForPeripherals(withServices: [BLE_Heart_Rate_Service_CBUUID])
        
    } // END func centralManager(... didDisconnectPeripheral peripheral

    // MARK: - CBPeripheralDelegate methods
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        
        for service in peripheral.services! {
            
            if service.uuid == BLE_Heart_Rate_Service_CBUUID {
                
                print("Service: \(service)")
                
                // STEP 9: look for characteristics of interest
                // within services of interest
                peripheral.discoverCharacteristics(nil, for: service)
                
            }
            
        }
        
    } // END func peripheral(... didDiscoverServices
    
    // STEP 10: confirm we've discovered characteristics
    // of interest within services of interest
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        
        for characteristic in service.characteristics! {
            print(characteristic)
            
            if characteristic.uuid == BLE_Body_Sensor_Location_Characteristic_CBUUID {
                
                // STEP 11: subscribe to a single notification
                // for characteristic of interest;
                // "When you call this method to read
                // the value of a characteristic, the peripheral
                // calls ... peripheral:didUpdateValueForCharacteristic:error:
                //
                // Read    Mandatory
                //
                peripheral.readValue(for: characteristic)
                
            }

            if characteristic.uuid == BLE_Heart_Rate_Measurement_Characteristic_CBUUID {

                // STEP 11: subscribe to regular notifications
                // for characteristic of interest;
                // "When you enable notifications for the
                // characteristic’s value, the peripheral calls
                // ... peripheral(_:didUpdateValueFor:error:)
                //
                // Notify    Mandatory
                //
                peripheral.setNotifyValue(true, for: characteristic)
                
            }
            
        } // END for
        
    } // END func peripheral(... didDiscoverCharacteristicsFor service
    
    // STEP 12: we're notified whenever a characteristic
    // value updates regularly or posts once; read and
    // decipher the characteristic value(s) that we've
    // subscribed to
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        
        if characteristic.uuid == BLE_Heart_Rate_Measurement_Characteristic_CBUUID {
            
            // STEP 13: we generally have to decode BLE
            // data into human readable format
            let heartRate = deriveBeatsPerMinute(using: characteristic)
            
            DispatchQueue.main.async { () -> Void in
                
                UIView.animate(withDuration: 1.0, animations: {
                    self.beatsPerMinuteLabel.alpha = 1.0
                    self.beatsPerMinuteLabel.text = String(heartRate)
                }, completion: { (true) in
                    self.beatsPerMinuteLabel.alpha = 0.0
                })
                
            } // END DispatchQueue.main.async...

        } // END if characteristic.uuid ==...
        
        if characteristic.uuid == BLE_Body_Sensor_Location_Characteristic_CBUUID {
            
            // STEP 14: we generally have to decode BLE
            // data into human readable format
            let sensorLocation = readSensorLocation(using: characteristic)

            DispatchQueue.main.async { () -> Void in
                self.sensorLocationTextField.text = sensorLocation
            }
        } // END if characteristic.uuid ==...
        
    } // END func peripheral(... didUpdateValueFor characteristic
    
    // MARK: - Utilities
    
    func deriveBeatsPerMinute(using heartRateMeasurementCharacteristic: CBCharacteristic) -> Int {
        
        let heartRateValue = heartRateMeasurementCharacteristic.value!
        // convert to an array of unsigned 8-bit integers
        let buffer = [UInt8](heartRateValue)

        // UInt8: "An 8-bit unsigned integer value type."
        
        // the first byte (8 bits) in the buffer is flags
        // (meta data governing the rest of the packet);
        // if the least significant bit (LSB) is 0,
        // the heart rate (bpm) is UInt8, if LSB is 1, BPM is UInt16
        if ((buffer[0] & 0x01) == 0) {
            // second byte: "Heart Rate Value Format is set to UINT8."
            print("BPM is UInt8")
            // write heart rate to HKHealthStore
            // healthKitInterface.writeHeartRateData(heartRate: Int(buffer[1]))
            return Int(buffer[1])
        } else { // I've never seen this use case, so I'll
                 // leave it to theoroticians to argue
            // 2nd and 3rd bytes: "Heart Rate Value Format is set to UINT16."
            print("BPM is UInt16")
            return -1
        }
        
    } // END func deriveBeatsPerMinute
    
    func readSensorLocation(using sensorLocationCharacteristic: CBCharacteristic) -> String {
        
        let sensorLocationValue = sensorLocationCharacteristic.value!
        // convert to an array of unsigned 8-bit integers
        let buffer = [UInt8](sensorLocationValue)
        var sensorLocation = ""
        
        // look at just 8 bits
        if buffer[0] == 1
        {
            sensorLocation = "Chest"
        }
        else if buffer[0] == 2
        {
            sensorLocation = "Wrist"
        }
        else
        {
            sensorLocation = "N/A"
        }
        
        return sensorLocation
        
    } // END func readSensorLocation
    
    func decodePeripheralState(peripheralState: CBPeripheralState) {
        
        switch peripheralState {
            case .disconnected:
                print("Peripheral state: disconnected")
            case .connected:
                print("Peripheral state: connected")
            case .connecting:
                print("Peripheral state: connecting")
            case .disconnecting:
                print("Peripheral state: disconnecting")
        }
        
    } // END func decodePeripheralState(peripheralState

} // END class HeartRateMonitorViewController

總結

我希望你喜歡這篇教程。你可以購買或借用一個 BLE 設備,來實作我的程式碼,或是編寫自己的程式碼。閱讀文章提供的所有連結內容,閱讀過 Bluetooth SIG 的網站和 Apple 有關Core Bluetooth 框架 (另請參閱此處連結) 的文檔後,你應該就會對 Bluetooth® 有基本的概念。

感謝讀者的閱讀,請享受你的工作。記得把 Bluetooth® 經驗放到履歷中,這對你的職業生涯絕對是個優勢。

以供參考,你可以查看 GitHub 上的完整原始碼

譯者簡介:陳奕先-過去為平面財經記者,專跑產業新聞,2015年起跨進軟體開發世界,希望在不同領域中培養新的視野,於新創學校ALPHA Camp畢業後,積極投入iOS程式開發,目前任職於國內電商公司。聯絡方式:電郵[email protected]

FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS

原文Working with Core Bluetooth in iOS 11

作者
Andrew Jaffee
熱愛寫作的多產作家,亦是軟體工程師、設計師、和開發員。最近專注於 Swift 的 iOS 手機 App 開發。但對於 C#、C++、.NET、JavaScript、HTML、CSS、jQuery、SQL Server、MySQL、Agile、Test Driven Development、Git、Continuous Integration、Responsive Web Design 等。
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。