去年一直做物聯(lián)網(wǎng)這一塊。開始接手了一個Wifi的項目,之后從零開始負(fù)責(zé)了一個BLE項目。這里有一個簡單的DemoGithub - BLE Demo可供參考。運行起來可以搜索附近的藍牙設(shè)備并展示獲取的一些基本信息,點擊鏈接查看各項服務(wù)以及字段。在實際的開發(fā)當(dāng)中,確實遇到了不少的問題。這篇文章我想結(jié)合之前Wifi項目的經(jīng)驗,總結(jié)一下。
BLE項目中使用的mesh網(wǎng)絡(luò)是網(wǎng)狀自組網(wǎng)絡(luò)。移動端嘗試掃描周圍的設(shè)備,嘗試和這個網(wǎng)絡(luò)中的任意一臺藍牙設(shè)備進行連接,直連的藍牙設(shè)備類似于一個路由。之后移動端和網(wǎng)絡(luò)中的每一臺設(shè)備的通訊都需要通過這臺直連的設(shè)備。

我們?nèi)粘5膽?yīng)用開發(fā)是站在應(yīng)用層的層面,在iOS的應(yīng)用里調(diào)用系統(tǒng)API或者使用網(wǎng)絡(luò)庫進行通訊,這些都是已經(jīng)高度封裝好的,無需過多的操心底層傳輸?shù)膯栴}。應(yīng)用可以從回調(diào)里收集到足夠的信息來處理和應(yīng)付當(dāng)前的網(wǎng)絡(luò)情況。而mesh網(wǎng)絡(luò)的情況類似于OSI模型里的網(wǎng)絡(luò)層,在沒有上層協(xié)議的支持下,mesh網(wǎng)絡(luò)的傳輸是不可靠的。數(shù)據(jù)的傳輸量也非常的有限。頻繁的數(shù)據(jù)傳輸(比如從設(shè)備獲取一些數(shù)據(jù)量比較大的信息需要多次連續(xù)的傳輸)很可能造成mesh網(wǎng)絡(luò)的網(wǎng)絡(luò)風(fēng)暴。這些問題都需要在我們的應(yīng)用里處理。
在BLE項目中,整個項目被分成三層。底層是網(wǎng)絡(luò)層,負(fù)責(zé)和mesh網(wǎng)絡(luò)的通訊;上層是我們的業(yè)務(wù)邏輯代碼;中間層是Model層負(fù)責(zé)數(shù)據(jù)的處理以及轉(zhuǎn)發(fā)。開始并沒有想到設(shè)計中間層,只是希望網(wǎng)絡(luò)層在收發(fā)消息以后通過回調(diào)直接和業(yè)務(wù)邏輯代碼進行交互,但隨著功能的增多,開始出現(xiàn)了問題。
網(wǎng)絡(luò)層承擔(dān)的任務(wù)太重。
首先網(wǎng)絡(luò)層需要和藍牙設(shè)備進行通訊。在與藍牙設(shè)備建立連接以及通訊的各個回調(diào)方法中,我們需要執(zhí)行檢查設(shè)備,自動連接,設(shè)備驗證以及等操作。發(fā)現(xiàn)設(shè)備之后還需要網(wǎng)絡(luò)層查詢設(shè)備信息進行本地緩存。在通訊的過程中,網(wǎng)絡(luò)層還需要處理數(shù)據(jù)進行分發(fā)起到一個路由的作用。自上而下看,上層下發(fā)的每一類命令都需要網(wǎng)絡(luò)層拼接發(fā)送。網(wǎng)絡(luò)層慢慢變的非常臃腫,過多的任務(wù)也讓原先網(wǎng)絡(luò)層通訊的職能顯得不明確。數(shù)據(jù)未經(jīng)處理直接轉(zhuǎn)發(fā)
當(dāng)我們接收到設(shè)備返回給我們的數(shù)據(jù)之后,我們通過回調(diào)傳給業(yè)務(wù)邏輯代碼進行處理。網(wǎng)絡(luò)層不知道業(yè)務(wù)邏輯層如何處理,只能每次把數(shù)據(jù)直接丟給上層。
在項目中,有一些界面會顯示一個設(shè)備的列表,會根據(jù)我們接收的信息來即時刷新設(shè)備的狀態(tài)。正常情況下沒有任何問題,但在一些批量操作或者特定情況下,我們會收到大量的設(shè)備狀態(tài)信息。這時前端的tableView會不停的調(diào)用刷新操作,造成界面的卡頓。實際上很多冗余的信息不需要刷新,我們可以嘗試進行過濾并且做一些緩沖的操作來避免波峰對我們項目的沖擊。
- 代碼難以復(fù)用
在BLE項目中,因為我們需要顯示給客戶的是當(dāng)前環(huán)境下可操作的藍牙設(shè)備。也就是說只支持本地不支持遠程,所以我們本身不需要緩存運行時搜索到的設(shè)備。(實際我們還是有做緩存,將搜索到的設(shè)備的mac地址以及設(shè)備類型等固定不變的信息緩存到本地,在搜索到設(shè)備的時候先匹配本地,避免一些不必要的請求操作。)設(shè)備模型的生命周期是和App的生命周期保持一致的。項目中多處都需要使用當(dāng)前環(huán)境的藍牙設(shè)備列表,我們需要在每一個地方都處理網(wǎng)絡(luò)層返回的設(shè)備信息并進行處理。隨著項目的發(fā)展,重復(fù)代碼越來越多,并且我們難以保證各個地方藍牙設(shè)備列表的一致性。
為了解決這些問題并明確每一層的任務(wù),我嘗試在網(wǎng)絡(luò)層和業(yè)務(wù)邏輯層中間添加一個中間件。對于網(wǎng)絡(luò)層它是上層處理業(yè)務(wù)邏輯的對象,對于業(yè)務(wù)邏輯代碼它是底層數(shù)據(jù)通訊的管理者。網(wǎng)絡(luò)層只關(guān)心和藍牙設(shè)備之間的驗證以及通訊,業(yè)務(wù)邏輯層只關(guān)心事件的流程處理。我們將之前網(wǎng)絡(luò)層數(shù)據(jù)的處理轉(zhuǎn)發(fā),業(yè)務(wù)邏輯層的設(shè)備模型管理剝離出來,交給中間層處理。
分層結(jié)束,考慮每一層之間的通訊。自上向下來看:

網(wǎng)絡(luò)層暴露命令發(fā)送接口給Model層,只接收拼接好的命令而不關(guān)心命令的內(nèi)容。具體的命令由Model層拼接,單個設(shè)備的操作命令綁定到模型對象上作為實例方法,關(guān)于mesh網(wǎng)絡(luò)所有設(shè)備的操作我們綁定到Model層的管理類上作為類方法。業(yè)務(wù)邏輯層根據(jù)需要調(diào)用Model層的方法即可。
自下向上,網(wǎng)絡(luò)層接收mesh網(wǎng)絡(luò)的消息,通過回調(diào)層層上拋。在iOS當(dāng)中,有三種回調(diào)的方式:block,delegate以及notification。在這個項目當(dāng)中,網(wǎng)絡(luò)層和Model層之間采用的是delegate,Model層和業(yè)務(wù)邏輯層采用的是notification。
日常開發(fā)當(dāng)中,我最喜歡也是最常用的是block來處理回調(diào),因為它非常的輕,非常簡單的就嵌入了邏輯代碼當(dāng)中,便于閱讀。非常簡單的就可以訪問上下文。作為一個匿名函數(shù),獨立的詞法作用域可以保存現(xiàn)場便于處理。但非常遺憾在這個項目里它并不合適。
我們說了非常多的block的優(yōu)點,但它的缺點也非常的明顯。因為block太輕了,所以稍微復(fù)雜一點的情況就會顯得力不從心。在業(yè)務(wù)邏輯層和Model層之間有非常多的交互場景,如果為每一種場景都去設(shè)置一個block就會顯得非?,嵥楹碗y以管理。Model層和網(wǎng)絡(luò)層之間回調(diào)的處理比較簡單,主要就是將收到的消息簡單處理上拋給Model層處理,但是我們要考慮到一點的是,block非常容易造成retain cycle。除了非常常見的strong/weak self的處理,還有一點需要注意的是,因為我們長期需要這個block所以它是一直被持有不被釋放的。一旦造成內(nèi)存泄漏,非常難以察覺出來。
我們可以參考蘋果的做法,block和delegate在Cocoa中都有大量實例,比如簡單的UIView的animation類方法,一些界面跳轉(zhuǎn)的completion使用的是block,UITableView卻是使用的delegate。對比一下,臨時性一次性的,或者說長期持有但有一個明確的完成時間的,我們使用block;但是需要頻繁調(diào)用的我們最好還是采用delegate。
在剩下的兩種回調(diào)的方式里,比起delegate,notification的使用太過隨意,你可以在任意位置收發(fā)notification。而且注冊通知使用完以后你還必須手動去取消注冊。但是notification有一點比delegate強那就是它具有類似廣播的特性能夠一對多。
實際上block也可以實現(xiàn)一對多,只是會比較的麻煩。在SDWebImage中用一個字典按照url生成的標(biāo)識符來保存block的數(shù)組,執(zhí)行完某個任務(wù)后根據(jù)標(biāo)識符取出數(shù)組依次執(zhí)行block即可。
回到項目中,在網(wǎng)絡(luò)層和Model層之間這是一對一的關(guān)系。非常適合delegate的發(fā)揮。我們通過Protocol來定義通訊過程中的各個部分,處理起來非常的有條理。還有一個很重要的原因就是網(wǎng)絡(luò)層和Model層之間處理的數(shù)據(jù)都是基本類型,而notification只能傳遞ObjectType類型。但是在Model層和業(yè)務(wù)邏輯層之間,常常一個模型參數(shù)的變化需要多個地方去響應(yīng),這個時候我們使用nitofication就會比較的合適了。
當(dāng)然,notification本身的一些缺點我們可以通過規(guī)范代碼來改善。在本項目中:
- 所有notificationName都統(tǒng)一在一個文件中進行宏定義。
- 一個notificationName為get和post定義成兩套宏,按照get/post+事件名稱的規(guī)則來命名。
- 相同事件的響應(yīng)函數(shù)名稱保持一致。按照get+事件名稱+notification的規(guī)則命名
這樣規(guī)范以后,notification相關(guān)的內(nèi)容管理起來就會方便很多。
在BLE的項目中,app一直和藍牙設(shè)備保持著密切的通訊。除了app向藍牙設(shè)備請求之外,藍牙設(shè)備自身在狀態(tài)變化的時候也會主動推給app消息。網(wǎng)絡(luò)層實際只承擔(dān)簡單的收發(fā)作用,至于具體的什么內(nèi)容,不關(guān)心也不知道。這一切都丟給了Model層來打理。
之前接手的Wifi項目,出現(xiàn)了一個什么問題呢。在Wifi項目中所有收發(fā)的消息都通過notification發(fā)出去了,在notification的信息里只是簡單的區(qū)分了是哪種協(xié)議收到的消息,但不關(guān)心消息的類型,全部廣播出去。需要接收消息的地方注冊notification接收了消息以后根據(jù)消息的內(nèi)容(字典)再去判斷是不是我需要的內(nèi)容。
問題很大:
通訊的消息很多,消息類型有很多種。很多時候某個界面只是關(guān)心特定的消息類型,但是卻被迫要去處理每一條返回的消息。 這里的處理不僅僅是簡單的判斷,需要你取出字典里的內(nèi)容進行比對,每一條關(guān)鍵的數(shù)據(jù)都需要進行合法判定。消息類型判定的邏輯如果發(fā)生了變化你需要在每一個notification里去修改。
無法過濾這些消息。比如設(shè)備會定時上報自己的狀態(tài),假設(shè)我的界面顯示設(shè)備是打開的狀態(tài),如果最新設(shè)備上報的狀態(tài)仍然是開。我們實際可以直接處理掉這條消息,只在設(shè)備狀態(tài)變化的時候才去通知我們的業(yè)務(wù)邏輯做處理
可能會影響未來業(yè)務(wù)的邏輯判斷。整個項目實際上是不斷在增加新的業(yè)務(wù)需求?;卣{(diào)里的一些操作可能會因為新業(yè)務(wù)的增加而在開發(fā)者不明了的情況下被觸發(fā),造成一些稀奇古怪的問題。比如重連,登錄名和密碼被切換。
除了這幾點BLE項目還有一個比較麻煩的地方是,mesh網(wǎng)絡(luò)內(nèi)各個設(shè)備之間會定期進行通信確認(rèn)設(shè)備的在線狀態(tài),但是外部如果正在請求數(shù)據(jù)會影響到內(nèi)部的通訊,超過一定時間會判定某個設(shè)備下線,但是下線狀態(tài)會立刻被刷新回來。反映到app里就是顯示設(shè)備開關(guān)的按鈕會出現(xiàn)閃爍的情況。我們希望能夠在應(yīng)用內(nèi)添加判斷把這種情況給處理掉。
網(wǎng)絡(luò)層在接收消息之后,會將數(shù)據(jù)通過回調(diào)傳遞給Model層。大概的流程是:Model層會先根據(jù)消息里的標(biāo)志位區(qū)分消息的任務(wù)類型。之后對消息進行清洗。在這一步所有不合法的消息都會被丟棄,合法的信息將被解析處理成模型(信息是一個char類型的數(shù)組),確保各項數(shù)據(jù)無誤以后再通過notification分發(fā)出去。
Model層在消息接收的地方添加了一個時延,類似TCP協(xié)議里的ack捎帶操作。一些狀態(tài)類型的消息在接收以后我們會延遲一段時間等待是否有其他的狀態(tài)信息上報,確認(rèn)沒有之后再進行下一步操作,如果有新的消息則重置延遲時間。上報狀態(tài)消息的設(shè)備如果相同則保存最后一條狀態(tài)消息,設(shè)備不同就打包這些設(shè)備模型等到延遲結(jié)束一起丟給業(yè)務(wù)邏輯層處理,這樣做可以有效避免mesh網(wǎng)絡(luò)自身通訊不暢引起的問題以及減輕前端UI部分的壓力。
如果同時有多個設(shè)備的狀態(tài)信息上報過來可以一次刷新表格處理完而不用短時間內(nèi)多次調(diào)用單行刷新的操作。類似微信的收消息,如果單條短訊就一條一條提示,但是如果同時來了多條短訊比如刷表情就會一起提示有‘xx'個未讀消息。
合法的消息被處理成模型之后進行分發(fā),Model層在這一塊做了一些處理。首先,我們將返回類型大致分為幾種。通知分發(fā)的時候走不同的notificationName。這里區(qū)分的標(biāo)準(zhǔn)是各種模型在項目中涉及的界面和服務(wù)層級。服務(wù)層級可能描述的不是準(zhǔn)確。做一個簡單的類比,在Python中l(wèi)ogger模塊有一個level的參數(shù),你可以設(shè)定不同的level來區(qū)分log的級別也就是重要程度。同樣在BLE項目中不同的消息我們關(guān)心的程度會不一樣。開關(guān)之類的是基礎(chǔ)消息頻繁而簡單;設(shè)備類型之類的查詢消息簡單且相關(guān)的查詢需求基本都是集中使用;定時器的查詢信息查詢量大且服務(wù)比較重要獨占一個模塊。我們依據(jù)各種消息的特點進行劃分層級區(qū)別分發(fā),但是并沒有區(qū)分的太細(xì)。這樣做的目的是為了讓各個notification能夠盡可能獨立不會打擾到不相關(guān)的部分,又不會分的太散導(dǎo)致notification過多難以管理,某些地方可能會出現(xiàn)需要注冊多個通知去接收所需的消息。在notification分完層級之后,在每一個notification中添加一個int類型的掩碼來具體指明每一個notification所傳遞的類型。每一位標(biāo)示一個消息類型,注冊了notification的地方通過和掩碼的位運算就可以快速確定每條notification中的模型所代表的內(nèi)容。

mesh網(wǎng)絡(luò)中有多個通道,為了保證開關(guān)之類的基礎(chǔ)消息能夠即時傳遞,留給其他服務(wù)的資源是非常有限的。在應(yīng)用里我們?nèi)绻矔r交互大量的數(shù)據(jù)會造成非常嚴(yán)重的丟包和網(wǎng)絡(luò)狀況的不穩(wěn)定。在硬件條件無法改善的情況下,我們必須在應(yīng)用里做相關(guān)的處理來改善情況。并且涉及到一些關(guān)鍵消息的傳輸,可靠性的支持必須由我們應(yīng)用來實現(xiàn)。
在BLE項目中發(fā)送的消息可以分為兩類。一類是發(fā)送出去不關(guān)心結(jié)果沒有回調(diào)的消息,比如開關(guān)之類;另一類是需要從BLE設(shè)備處獲取信息的查詢命令,比如獲取鬧鐘,查詢BLE設(shè)備信息之類的命令。
有關(guān)設(shè)備的基礎(chǔ)命令我們直接發(fā)送,指定好設(shè)備和消息類型由Model層打包命令丟給網(wǎng)絡(luò)層發(fā)送就好。因為之前已經(jīng)說過mesh網(wǎng)絡(luò)已經(jīng)為這些服務(wù)預(yù)留了足夠的資源,那么我們可以直接發(fā)送不做任何延時之類的處理。組的開關(guān)也無需擔(dān)心,因為和一般項目的不同是,BLE組功能是將地址位設(shè)為0xffff其余部分和單條開關(guān)消息一致。消息在mesh網(wǎng)絡(luò)中傳遞,設(shè)備對地址進行匹配判斷自己是否在組內(nèi)來進行響應(yīng)即可,而不是逐個通知增大了消息量。而后一種的查詢命令,就復(fù)雜了很多。因為mesh網(wǎng)絡(luò)預(yù)留的資源不多而且需要使用大量的數(shù)據(jù)交互,同時我們還要求BLE設(shè)備返回消息給我們。文章的開始提到過mesh網(wǎng)絡(luò)類似網(wǎng)絡(luò)層,對應(yīng)的協(xié)議是IP協(xié)議。而我們?nèi)粘i_發(fā)所接觸的TCP/IP協(xié)議簇以及基于此的HTTP協(xié)議的可靠性支持都是由IP協(xié)議的上層協(xié)議TCP協(xié)議實現(xiàn)的。針對BLE項目中需要明確結(jié)果的查詢需求如何處理?
先簡單列舉一下實際遇到的麻煩。
丟包率高,數(shù)據(jù)傳輸不可靠。簡單來說就是通信壓力過大mesh網(wǎng)絡(luò)無法負(fù)擔(dān),一般來說如果是非直連的BLE設(shè)備都會丟百分之十至三十的包,甚至BLE設(shè)備癱瘓造成mesh網(wǎng)絡(luò)短時間無法通信的情況
數(shù)據(jù)量如果比較大,無法一個包發(fā)完,需要移動端依次請求讓BLE設(shè)備逐個發(fā)送(類似分片)。我們的設(shè)備需要處理發(fā)送和接收的邏輯來保證數(shù)據(jù)的正確性。
BLE設(shè)備會有多個版本,對于部分消息類型協(xié)議有所不同。
解決問題之前需要明確的一點是,什么是可靠性。查詢的結(jié)果每一條都有明確的回復(fù),這個回復(fù)可以是BLE設(shè)備正確的返回,也可以是出現(xiàn)錯誤我們得到的狀態(tài)碼。大概類似于所謂的生要見人死要見尸,我們必須知道每一條命令發(fā)送出去的結(jié)果。
對比真實的網(wǎng)絡(luò),mesh網(wǎng)絡(luò)的情況還是簡單的很多。在HTTP請求中,response的status code可以提供給client本次request的結(jié)果。status code種類非常多,涵蓋了幾乎所有網(wǎng)絡(luò)中可能出現(xiàn)的情況,情況非常的多和復(fù)雜。但實際在BLE項目中,出現(xiàn)問題的時候比如超時,權(quán)限不夠被拒絕,數(shù)據(jù)包過大之類,BLE設(shè)備和mesh網(wǎng)絡(luò)不會通知移動端。我們可能遇到的情況實際只有發(fā)送成功正確收到返回消息和等待超時沒有返回兩種。
實際還有一種可能是我們多個消息發(fā)送出去同時返回多個結(jié)果,造成接收的錯亂。但后面我們會提到這種情況不會發(fā)生,因為考慮到mesh網(wǎng)絡(luò)的承擔(dān)能力以及本身設(shè)備不支持我們查詢一組數(shù)據(jù)中特定位置的數(shù)據(jù),我們必須使用串行隊列依次發(fā)送消息。實現(xiàn)類似TCP協(xié)議中的Negla算法。
為了避免出現(xiàn)丟包的情況,我們應(yīng)當(dāng)避免短時間內(nèi)較大數(shù)據(jù)量的傳輸。但是如果需求確實要進行大量的數(shù)據(jù)交互,在硬件條件無法改善的環(huán)境下,我們考慮的是限制傳輸?shù)乃俾蕘頊p輕mesh網(wǎng)絡(luò)的壓力。
具體的實現(xiàn)是一個NSOperationQueue來做一個串行隊列,它的最大允許并行數(shù)設(shè)置為1。必須等待上一挑命令執(zhí)行完成才會執(zhí)行下一條的命令。這樣做的目的一來是為了減輕通訊的壓力;二來如果同時返回多條消息可能出現(xiàn)亂序(一條消息返回的路徑并不確定。后發(fā)出的消息可能通過較短的路徑先到達目的端。而且我們的設(shè)備不支持查詢特定index的數(shù)據(jù)只能一次查詢?nèi)浚?,移動端無法正確識別。
關(guān)于傳輸可靠性的實現(xiàn)也是基于這個串行隊列的方法:每一條命令發(fā)送出去的時候都是可以明確返回數(shù)據(jù)的格式,移動端在發(fā)送一條命令之后只有接收到對應(yīng)的消息才會確認(rèn)當(dāng)前命令已經(jīng)完成。第一次發(fā)出查詢命令以后會等待1s,如果沒有返回則2S后發(fā)送第二條命令;等待1s,接收到數(shù)據(jù)繼續(xù)查詢,沒有返回則5s后嘗試查詢最后一次,還沒有正確接收認(rèn)定當(dāng)前通訊狀況不佳,這條命令沒有成功返回我們有理由相信其他命令在當(dāng)前網(wǎng)絡(luò)環(huán)境下也是無法正確收到返回的,所以程序返回超時錯誤,提示用戶當(dāng)前網(wǎng)絡(luò)狀況不佳。每一次間隔時間的延長是考慮到如果沒有成功接收到返回消息,程序認(rèn)為當(dāng)前網(wǎng)絡(luò)環(huán)境不佳,多等待一些時間允許mesh網(wǎng)絡(luò)進行調(diào)整和處理擁塞的消息。

關(guān)于和mesh網(wǎng)絡(luò)通訊不暢的情況,還有一種可能是用戶在使用程序的同時位置發(fā)生了改變,離開始連接的設(shè)備相隔了一定的距離從而出現(xiàn)了假死。實際網(wǎng)絡(luò)層會有一個計數(shù)器,出現(xiàn)超時情況之后計數(shù)器會累加。計數(shù)到上限值時做一個檢查操作,重新掃描附近的設(shè)備對比RSSI。如果當(dāng)前連接的設(shè)備信號強度較弱,則嘗試切換到信號良好的設(shè)備上。
在拿到數(shù)據(jù)之后,記錄型的數(shù)據(jù)我們需要做好數(shù)據(jù)的本地化存儲。之后每次查詢這類數(shù)據(jù)先嘗試從本地獲取,如果沒有再向設(shè)備請求。以此減少查詢的數(shù)據(jù)量。
上面是從整體的概念上去解釋了整個項目的規(guī)劃設(shè)計。實際項目中,使用了Category來將一些管理類劃分成多個部分。組合優(yōu)于繼承,特別是在BLE項目中新業(yè)務(wù)新功能不斷增加的環(huán)境下。我們在主支上實現(xiàn)基礎(chǔ)功能,根據(jù)不同業(yè)務(wù)再將相關(guān)業(yè)務(wù)代碼剝離出去。方便我們后續(xù)的維護以及拓展。