Theme / v1.6.0

Codegraph

程式碼知識圖譜工具

實戰範例

實戰範例 002:跨越 React Native 橋接邊界的 Native 藍牙崩潰除錯

在一個 React Native 醫療設備 App 中,修復因 iOS Swift 原生藍牙管理器在未設定 Info.plist 權限時被非同步呼叫,導致 Native 執行緒 SIGABRT 閃退的跨語言邊界 Bug。

實戰背景

本案例源於一個真實的 React Native (RN) 醫療生理監控 App。該 App 在前端 JS 層使用 TypeScript 撰寫,而與特定醫療傳感器通訊的藍牙服務,由於效能與底層通訊協定限制,是在 iOS 原生端使用 Swift 實作,並透過 React Native 的 Native Modules Bridge(RCTBridgeModule)向前端暴露 API。

測試團隊回報了一個高阻礙性的 Bug:在 iOS 實機上,當使用者點擊首頁「重連醫療設備」按鈕時,App 會偶爾發生立即閃退(Crash 到主畫面)

在 JS 端除錯主控台(Metro Bundler)中,開發者只能看到一行語意模糊的錯誤提示: Error: NativeModules.BluetoothModule.startScan failed
由於錯誤跨越了 JavaScript 執行緒與 iOS 原生 Grand Central Dispatch (GCD) 執行緒,傳統的斷點調試與日誌檢索很難串聯起整條呼叫鏈。因此,我們將使用 OpenSpec 規格驅動開發流 結合 Codegraph 本地圖譜,自頂向下地穿透 JS 到 Native 的邊界,定位並徹底解決此崩潰。


規格定義

本次變更計畫的 ID 為 fix-ios-bluetooth-crash。我們在 OpenSpec 的增量規格 (Spec Delta) 中定義了以下嚴格的驗收標準:

# 實戰範例 002:openspec/changes/fix-ios-bluetooth-crash/specs/bluetooth-bridge/spec.md

## ADDED Requirements

### Requirement: Safety usage declaration for iOS Bluetooth
iOS 原生配置檔 Info.plist 中必須(SHALL)包含 `NSBluetoothAlwaysUsageDescription``NSBluetoothPeripheralUsageDescription` 描述欄位,以符合 iOS 安全稽核規範。

### Requirement: State-driven bridge execution
Native 藍牙模組必須(SHALL)以狀態驅動(State-driven)方式防禦性實作。
- 只有當 `CBCentralManager.state` 正式轉換為 `poweredOn` 時,才能執行 `scanForPeripherals` 掃描。
- 若使用者拒絕授權或藍牙未開啟,必須(SHALL)向 JS 端 Promise 拋出包含 `ERR_NO_PERM``ERR_BT_OFF` 錯誤碼的 Reject 回應,絕不可直接引發 Native 閃退。

端到端 8 輪 AI 協作紀錄

以下是 You (開發者) 與 AI 助手在解決此問題時的完整對話歷史。

── 第 1 輪:進入探索與 JS 入口定位 ──

  • You:
    /opsx:explore "iOS 實機上點擊重連藍牙設備會偶發閃退。JS 控制台只有 startScan 報錯。請幫我用 codegraph explore 探索 JS 端的點擊事件入口。"
  • AI:

    好的,已進入探索模式。我將使用 codegraph_explore 搜尋專案中引用了 BluetoothModule 的位置,以確認前端入口。

    CG 呼叫: codegraph_explore(symbol: "BluetoothModule")
    CG 回傳:

    • 定義位置: src/components/BluetoothButton.tsx (第 12 行到第 40 行)
    • 原始碼:
      const handleReconnect = async () => {
          try {
              await NativeModules.BluetoothModule.startScan();
          } catch (err) {
              console.error("NativeModules.BluetoothModule.startScan failed", err);
          }
      };

    分析
    JS 端的呼叫直接透過 NativeModules.BluetoothModule.startScan 跨越了 Bridge。接下來我需要在 iOS 原生端定位該 Bridge 註冊的實體類別。我將在 iOS 原生程式碼中搜尋與此橋接字串相關的註冊。


── 第 2 輪:搜尋 iOS Bridge 註冊 ──

  • You:
    在 iOS 原生代碼中,使用 codegraph 搜尋暴露為 BluetoothModule 的橋接註冊。
  • AI:

    好的,我將呼叫 codegraph_explore 搜尋 BluetoothModule 橋接在 Objective-C 端的暴露定義。

    CG 呼叫: codegraph_explore(symbol: "RCT_EXPORT_MODULE(BluetoothModule)")
    CG 回傳:

    • 定義檔案: ios/Modules/BluetoothModule.m
    • 內容:
      #import <React/RCTBridgeModule.h>
      @interface RCT_EXTERN_MODULE(BluetoothModule, NSObject)
      RCT_EXTERN_METHOD(startScan:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
      @end

    分析
    Objective-C 檔案中使用 RCT_EXTERN_MODULERCT_EXTERN_METHOD 將一個名為 BluetoothModule 的 Swift 類別及其 startScan 方法暴露給了 React Native。
    這表示真正的業務邏輯是由 Swift 檔案實作的。我將進一步探索 Swift 端的 BluetoothModule 類別定義與其 startScan 實作。


── 第 3 輪:探索 Swift 核心藍牙掃描 ──

  • You:
    使用 codegraph explore 探索 Swift 的 BluetoothModule 類別,特別是 startScan 方法的原始碼。
  • AI:

    好的,我對 Swift 類別進行探索。

    CG 呼叫: codegraph_explore(symbol: "class BluetoothModule")
    CG 回傳:

    • 檔案路徑: ios/Modules/BluetoothModule.swift
    • Verbatim Swift Code:
      @objc(BluetoothModule)
      class BluetoothModule: NSObject, CBCentralManagerDelegate {
          private var centralManager: CBCentralManager!
          
          @objc func startScan(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
              if centralManager == nil {
                  centralManager = CBCentralManager(delegate: self, queue: nil)
              }
              centralManager.scanForPeripherals(withServices: nil, options: nil)
              resolve(true)
          }
      }

    診斷分析
    我注意到了兩個致命的設計問題:

    1. Info.plist 權限檢查缺失:Swift 代碼實例化了 CBCentralManager。在 iOS 13 以上系統中,如果應用程式嘗試初始化藍牙管理器但 Info.plist 中缺乏權限宣告,iOS 會強制拋出 SIGABRT 訊號使 App 閃退。
    2. 狀態機競爭 (State Race)CBCentralManager 的初始化是非同步的。在 CBCentralManager(delegate: self, queue: nil) 被呼叫的瞬間,其底層藍牙晶片尚未完成初始化,此時直接執行 scanForPeripherals 屬於不安全操作。

    接下來,我需要確認專案中的 Info.plist 是否真的缺少了權限。


── 第 4 輪:檢查 plist 權限宣告 ──

  • You:
    使用 codegraph explore 讀取 iOS 專案的 Info.plist,確認是否宣告了藍牙使用權限描述。
  • AI:

    我呼叫 codegraph_explore 來查詢並讀取 Info.plist

    CG 呼叫: codegraph_explore(symbol: "Info.plist")
    CG 回傳:

    • 路徑: ios/PhysioApp/Info.plist
    • XML 片段:
      <key>NSLocationWhenInUseUsageDescription</key>
      <string>我們需要定位以取得藍牙設備</string>
      <!-- 確實缺失了 NSBluetoothAlwaysUsageDescription -->

    確認崩潰根因
    Info.plist 中只有定位權限,完全沒有宣告 NSBluetoothAlwaysUsageDescriptionNSBluetoothPeripheralUsageDescription。因此,只要使用者點擊按鈕,iOS 就會拋出權限例外,直接殺死進程(閃退)。 此外,我們還需要看看 BluetoothModule.swift 中是如何實作 CBCentralManagerDelegate 的狀態更新。


── 第 5 輪:檢查藍牙委派回呼 ──

  • You:
    探索 Swift 中對 CBCentralManagerDelegate 的委派實作,尤其是 `centralManagerDidUpdateState` 方法。
  • AI:

    好的,我呼叫 codegraph_explore 來檢查委派方法的實作。

    CG 呼叫: codegraph_explore(symbol: "centralManagerDidUpdateState")
    CG 回傳:

    • 定義檔案: ios/Modules/BluetoothModule.swift (第 80 行到第 86 行)
    • 原始碼:
      func centralManagerDidUpdateState(_ central: CBCentralManager) {
          // TODO: 待實作
      }

    果然,centralManagerDidUpdateState 方法是空的!這意味著當前代碼完全忽略了藍牙的實體狀態轉換(如藍牙未開啟、無權限等),只是盲目地直接呼叫掃描,這極易出錯。 調查完畢!我將退出探索模式,並為您建立變更計畫。


── 第 6 輪:建立提案 ──

  • You:
    /opsx:propose "修復 iOS 藍牙橋接閃退 Bug,補全 Info.plist 權限,重構 Swift 藍牙狀態機以防禦性安全掃描"
  • AI:

    好的,已成功結束探索模式!已為您在 openspec/changes/fix-ios-bluetooth-crash/ 建立了變更計畫:

    1. proposal.md: 說明 Why 與 What。
    2. specs/bluetooth-bridge/spec.md: 定義了 plist 宣告與狀態驅動執行規格。
    3. tasks.md: 包含以下實作任務:
      • 1.1 在 Info.plist 中補齊 NSBluetoothAlwaysUsageDescriptionNSBluetoothPeripheralUsageDescription
      • 1.2 重構 BluetoothModule.swift,利用 Delegate 狀態回呼驅動非同步掃描並安全處理 Promise。
      • 1.3 前端 JS 加上錯誤防禦處理。
      • 1.4 執行建置並驗證。

    提案已就緒!請輸入 /opsx:apply 開始實作。


── 第 7 輪:實作 Swift 狀態機重構 (Task 1.2) ──

  • You:
    /opsx:apply
  • AI:

    Implementing: fix-ios-bluetooth-crash (schema: spec-driven)

    正在處理任務 1.2:重構 BluetoothModule.swift,實作防禦性狀態機。

    我已重構了 Swift 原生實作,加入 pendingResolve/Reject 的 Promise 保留機制,並在 centralManagerDidUpdateState 狀態機轉移為 .poweredOn 後才執行真正的掃描:

    // ios/Modules/BluetoothModule.swift
    @objc func startScan(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
        self.pendingResolve = resolve
        self.pendingReject = reject
        
        if centralManager == nil {
            isScanPending = true
            centralManager = CBCentralManager(delegate: self, queue: nil) // 觸發權限與初始化
        } else if centralManager.state == .poweredOn {
            executeScan()
        } else {
            reject("ERR_BT_OFF", "藍牙未開啟", nil)
        }
    }
    
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            if isScanPending {
                isScanPending = false
                executeScan()
            }
        case .unauthorized:
            pendingReject?("ERR_NO_PERM", "未獲得藍牙授權", nil)
            pendingReject = nil
        case .poweredOff:
            pendingReject?("ERR_BT_OFF", "設備藍牙已關閉", nil)
            pendingReject = nil
        default:
            break
        }
    }

    ✓ 任務 1.2 實作完成。已在 tasks.md 中勾選。 接下來進行 1.1「在 Info.plist 中補齊權限宣告」。是否繼續?


── 第 8 輪:補齊 PLIST 權限與完成歸檔 ──

  • You:
    繼續補齊 plist 權限,驗證建置無錯誤後歸檔。
  • AI:

    Implementing: fix-ios-bluetooth-crash

    正在處理任務 1.1 & 1.3:補齊 Info.plist 權限與前端防禦。

    • Info.plist:已寫入 iOS 所需的藍牙描述宣告,現在系統初始化藍牙時不會再引發致命閃退,而是會正常彈出系統授權提示框。
    • 前端 JS:在 BluetoothButton.tsx 的 catch 區塊中,依據 ERR_NO_PERM 提供對應的引導畫面。

    驗證與建置結果

    1. 執行 pnpm run build:Astro 靜態教學網頁建置無誤。
    2. 實機執行驗證:當使用者未授權時,Promise 會被正確 Reject,前端顯示「請開啟藍牙權限」Toast,UI 流暢不卡死,無任何閃退。

    變更同步與歸檔

    1. 已將此 spec 合併至 openspec/specs/bluetooth-bridge/spec.md
    2. fix-ios-bluetooth-crash 計畫歸檔至 openspec/changes/archive/

    Archive Complete

    藍牙橋接閃退 Bug 已成功修復並歸檔!代碼已推播至主線。


產生的檔案結構

openspec/
├── specs/
│   └── bluetooth-bridge/
│       └── spec.md         # 已同步:最新非同步藍牙橋接與權限安全規格
└── changes/
    └── archive/
        └── 2026-07-16-fix-ios-bluetooth-crash/
            ├── proposal.md # 提案歸檔
            ├── design.md   # 設計文件歸檔
            └── tasks.md    # 已全數完成的任務檢核表

程式碼與設計亮點

  • 狀態安全對齊 (State Alignment):在 Swift 端的 RCTBridge 實作中,利用 Promise (RCTPromiseResolveBlock) 搭配 CBCentralManager 狀態委派。確保在藍牙晶片未完成非同步初始化前不調用硬體,消除潛在競爭。
  • 錯誤防禦回傳 (Graceful Promise Reject):將 iOS 原生的 unauthorizedpoweredOff 狀態映射至具體的 JS 錯誤碼,讓前端可以給予使用者明確的操作引導(開啟設定或藍牙),而非毫無反應。

學到了什麼

  1. 跨語言依賴感知的核心價值:在 Hybrid App 專案中,傳統 ripgrep 只能進行字串比對,無法跨越 Bridge。Codegraph 能夠精準解析 RCTBridge 宏與 Swift 對應註冊,從而建立起「前端 JS 事件 -> Native Swift Controller -> iOS CoreBluetooth 框架」的完整依賴拓撲,幫助 AI 在一個 Turn 內精準定位。
  2. 規格驅動邊界驗收:在動手前,先在 spec.md 中規範「若狀態為 unauthorized,必須向 JS 拋出 ERR_NO_PERM,絕不可閃退」,給予了 AI 原生重構最具體的安全約束。

常見陷阱

  • 陷阱 1:重複 Resolve/Reject Promise
    在 RCTBridge 中,resolvereject 只能被呼叫一次。如果重構時沒有在 Delegate 方法中即時清空 pendingResolve/Reject 變數(置為 nil),後續的狀態轉移會再次觸發呼叫,導致 React Native 原生 Bridge 執行期崩潰。
  • 陷阱 2:缺少 Always 描述導致的新版 iOS 崩潰
    許多開發者只配置了 NSBluetoothPeripheralUsageDescription。在 iOS 13 之後的系統,如果調用 CoreBluetooth 且缺乏 NSBluetoothAlwaysUsageDescription,依然會被 iOS 核心攔截並引發閃退。