前言
戰友們,大家好!在昨天的文章結尾,我們成功地從藍牙裝置中「讀取」到了第一筆真實數據。
原本的計畫是接著介紹「寫入 (Write)」操作。但在反覆思考後,我認為將兩種接收數據的方式放在一起對比學習,能幫助我們更深刻地理解數據的流動方向。昨天我們學習的「讀取 (Read)」,是一種由我們主動發起的「拉取 (Pull)」模式,就像我們主動去信箱裡拿信。
而今天我們要學習的「訂閱通知 (Subscribe)」,則是一種被動接收的「推送 (Push)」模式。這就像快遞員在包裹送達時,主動發送一則簡訊到你手機上。我們不需要反覆檢查,而是在事件發生時,即時地收到通知。
因此,我決定稍微調整一下順序。今天,我們先來為「Subscribe」按鈕注入靈魂,學習如何傾聽來自裝置的即時數據流。這將讓我們的應用程式,從一個「詢問者」,進化為一個「傾聽者」。
Notifications
的工作模式與 readValue()
的一問一答不同,訂閱通知的流程分為兩大步:
開啟訂閱 (Subscribe):我們首先要告訴裝置:「嘿,我對這個特徵的數據很感興趣,從現在開始,只要它的值有變化,就請立刻通知我。」這個動作由 characteristic.startNotifications()
完成。
被動接收 (Listen):開啟訂閱後,我們不再使用 await
等待單一的回傳值。相反,我們需要在特徵上安裝一個「耳朵」(事件監聽器),靜靜地等待數據的到來。每當裝置推送新數據,這個「耳朵」就會被觸發,我們預先定義好的函式就會被執行。
startNotifications()
與事件監聽characteristic.startNotifications()
:
Promise
。它的作用是告訴裝置我們準備好接收通知了。成功執行代表訂閱流程已開啟。characteristic.addEventListener('characteristicvaluechanged', (event) => { ... })
:
今天最關鍵的 API。它為特徵新增一個事件監聽器。
事件名稱是固定的字串:'characteristicvaluechanged'
。
當裝置推送新數據時,這個事件就會被觸發,我們的回呼函式就會被執行。
event.target.value
:
event
物件包含了所有相關資訊。其中 event.target
就是觸發事件的那個特徵本身,而 event.target.value
就是裝置推送過來的、包含了原始數據的 DataView
物件!characteristic.stopNotifications()
和 characteristic.removeEventListener(...)
:
Subscribe
按鈕我們將再次回到 renderCharacteristic
函式,找到 if (charInfo.properties.notify)
的區塊,為它打造一個具備狀態管理的完整訂閱功能。
在 app.js
的 renderCharacteristic
函式中,找到 notify
的 if
區塊並替換為以下內容:
// 在 renderCharacteristic 函式內部 (記得要傳入 serviceUuid 參數)
if (charInfo.properties.notify) {
const notifyButton = document.createElement('button');
notifyButton.textContent = 'Subscribe';
// 用一個閉包內的變數來追蹤訂閱狀態
let isSubscribed = false;
// 將事件處理邏輯單獨定義成一個函式
// 這樣我們才能在 removeEventListener 時精準地移除它
const handleNotifications = (event) => {
// 從事件物件中取得 DataView
const valueDataView = event.target.value;
// 解析數據 (此處仍以最簡單的 uint8 為例)
const value = valueDataView.getUint8(0);
const message = `[Notification] ${charInfo.uuid}: ${value}`;
log(message); // 在我們的日誌中顯示
valueSpan.textContent = value; // 更新特徵面板上的值
// (可選) 更新中央資料庫
gattProfile.services[serviceUuid].characteristics[charInfo.uuid].value = value;
};
// 為按鈕設定點擊事件
notifyButton.onclick = async () => {
try {
// 從 gattProfile 中找到特徵的實例
const characteristic = gattProfile.services[serviceUuid].characteristics[charInfo.uuid].instance;
if (isSubscribed) {
// --- 如果已經是訂閱狀態,就執行取消訂閱 ---
await characteristic.stopNotifications();
characteristic.removeEventListener('characteristicvaluechanged', handleNotifications);
log(`> 已取消訂閱 ${charInfo.uuid}`);
notifyButton.textContent = 'Subscribe'; // 按鈕文字變回 Subscribe
isSubscribed = false;
} else {
// --- 如果尚未訂閱,就執行開始訂閱 ---
await characteristic.startNotifications();
characteristic.addEventListener('characteristicvaluechanged', handleNotifications);
log(`> 已成功訂閱 ${charInfo.uuid}. 等待通知...`);
notifyButton.textContent = 'Unsubscribe'; // 按鈕文字變為 Unsubscribe
isSubscribed = true;
}
} catch (error) {
log(`[錯誤] 操作通知失敗: ${error.message}`);
}
};
actionContainer.appendChild(notifyButton);
}
程式碼深度解析
isSubscribed
狀態變數:我們利用閉包的特性,讓每個 notify
按鈕都擁有自己獨立的 isSubscribed
狀態記錄。
handleNotifications
獨立函式:我們將處理數據的邏輯,單獨封裝成一個函式。這樣做的好處是,addEventListener
和 removeEventListener
可以準確地操作同一個函式引用,這是正確移除監聽器的標準做法。
onclick
的狀態判斷:點擊按鈕時,函式會先檢查 isSubscribed
的狀態來決定是執行訂閱還是取消訂閱的流程,實現了按鈕功能的切換。
UI/狀態同步:在每次操作成功後,我們都會同步更新按鈕的文字 (textContent
) 和 isSubscribed
變數的值,確保 UI 顯示與程式內部狀態永遠保持一致。
總結與後續
今天,我們成功為「Subscribe」按鈕注入了生命。我們學會了如何開啟 (startNotifications
) 和關閉 (stopNotifications
) 通知,並透過 addEventListener
來接收即時數據流,讓我們的應用進化成了一個強大的「傾聽者」。
至此,我們已經掌握了從裝置接收數據的兩種主要方式:一次性的「Read」和持續性的「Subscribe」。
明天,我們將回歸原定計畫綁定事件 (3): 為『寫入』按鈕注入靈魂。我們將學習 characteristic.writeValue()
,並真正地從我們的網頁,向藍牙裝置發送指令和數據,完成從「聽」到「說」的關鍵跨越。
那麼今天的內容就到這邊,感謝你能看到這裡,在這邊祝你早安、午安、晚安,我們明天見。