← 返回首頁

用 asyncio 解決 BLE 掃描卡住 UI 的問題

2026.09.21 · Python · 4 min read

在同步程式裡呼叫 bleak 的 BLE 掃描,UI 會整個卡住;用 asyncio 的 event loop 橋接之後,掃描與連線期間 UI 可以正常回應。

問題:BLE 掃描時 UI 凍結

我做的 BLE dashboard 需要跟裝置連線、讀寫 GATT。bleak 是 Python 常見的 BLE 模組,但它從設計上就是非同步的:

BleakScanner.discover()   # 協程(coroutine)
BleakClient.connect()     # 協程(coroutine)

這些函式回傳的是「協程物件」,不是直接執行結果。如果照一般同步寫法直接呼叫,會遇到兩個狀況:要嘛沒有 event loop 可以跑,要嘛整個執行流程停在那裡等,UI 就沒有回應了。

原因不難理解:BLE 掃描本質上是「等待」— 等裝置回廣播、等連線建立、等 GATT 回應。同步程式在等待期間什麼都不能做,畫面自然就凍住。

解法:把 event loop 接起來

asyncio 適合 IO 密集的場景(等待網路、檔案、藍牙),它的做法是用一個 event loop 來調度任務:當某個任務在等 IO 時,loop 就去處理其他事情。

同步程式要執行協程,只要三個步驟:

import asyncio
from bleak import BleakScanner

# ① 建立新的 event loop
loop = asyncio.new_event_loop()

# ② 設定為當前執行緒的 event loop
asyncio.set_event_loop(loop)

# ③ 執行協程,直到它完成
devices = loop.run_until_complete(BleakScanner.discover(timeout=5.0))

包成函式,加上資源清理:

def scan_ble_devices():
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    try:
        return loop.run_until_complete(
            BleakScanner.discover(timeout=5.0)
        )
    finally:
        loop.close()

連線並讀取 GATT 特徵值也是同樣的模式:

async def read_gatt(address, char_uuid):
    async with BleakClient(address) as client:
        if client.is_connected:
            return await client.read_gatt_char(char_uuid)

def do_read(address, char_uuid):
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    try:
        return loop.run_until_complete(read_gatt(address, char_uuid))
    finally:
        loop.close()

三個函式各自負責什麼

函式作用
new_event_loop()建立一個新的事件迴圈(非同步任務的調度器)
set_event_loop()設定為當前執行緒的預設迴圈
run_until_complete()執行協程直到完成,是同步與非同步之間的橋樑

踩坑紀錄

GUI 情境的寫法

import threading

def run_async_in_thread(coro):
    def worker():
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        try:
            loop.run_until_complete(coro)
        finally:
            loop.close()

    threading.Thread(target=worker, daemon=True).start()

成果

改用 event loop 橋接之後,BLE 掃描與連線期間 UI 可以正常操作,掃描結果也能正確取回。Flask 的請求各自有執行緒,所以直接在 route 裡用這個模式就能運作。

可以再改進的地方

重點整理