2020年1月31日 星期五

開發BLE數據透傳應用程序


cnblogs.com

手把手教你開發BLE數據透傳應用程序 - iini - 博客園

iini 關注 - 0 粉絲 - 93 +加關注

 如何開發BLE數據透傳應用程序?什麼是BLE service和characteristic?如何開發自己的service和characteristic?如何區分ATT和GATT?有沒有什麼工具可以對BLE設備進行壓力測試?如何提高BLE設備的數據上傳速度?本文將對以上問題進行解答。
在很多應用場合,BLE只是作為一個數據透傳模塊,即將設備端數據上傳給手機,同時接收手機端下發的數據。本文將和大家一起,一步一步演示如何開發一個BLE透傳應用程序。按照本文的說明,大家可以很快就實現一個BLE透傳應用,BLE透傳應用已經是BLE應用中比較複雜的一種,一旦大家掌握了BLE透傳應用,其他BLE應用開發就更不在話下了。本文還會以BLE透傳為例子,來解釋BLE service和characteristic等概念,以幫助大家理解如何定義和開發自己的BLE service和characteristic等,從而徹底理解BLE協議棧中的ATT和GATT的運行原理。然後,本文還將手把手教大家如何提高BLE數據傳輸速度(藍牙4.2的理論吞吐率大概為100kB/s,而我們實際達到了80kB/s,已經非常接近理論值)。最後,我們將告訴大家如何使用安卓版nRF Connect來對你的BLE設備進行壓力測試,以測試設備的穩定性和可靠性。當然,文章的最後也會告訴大家如何找到安卓和iOS手機app開發參考代碼。

1. 開發準備

1)     Nordic nRF52或者nRF51開發板1塊。請參考「Nordic nRF51/nRF52開發流程說明」,購買相應開發板(DK)。
2)     開發環境搭建。簡述如下(詳細說明請參考「Nordic nRF51/nRF52開發環境搭建」):
  1. 安裝Keil5 MDK
  2. 安裝SDK。如果你使用的是nRF52開發板,請安裝nRF5 SDK15.0.0,下載鏈接:https://www.nordicsemi.com/eng/nordic/download_resource/59012/70/52858981/116085。如果你手上是nRF51開發板,請下載nRF5 SDK12.3.0:https://www.nordicsemi.com/eng/nordic/download_resource/54280/56/38442131/32925nRF51最高SDK版本只能到12.3.0,後續SDK就不再支持nRF51
  3. 安裝ARM CMSIS4.5.0,下載鏈接:https://github.com/ARM-software/CMSIS/releases/download/v4.5.0/ARM.CMSIS.4.5.0.pack
  4. 安裝Keil5 Device Family Pack,下載鏈接:https://www.nordicsemi.com/eng/nordic/download_resource/58865/28/26535159/87790
  5. 安裝nRF5 Command Line Tools,下載鏈接(Windows版):https://www.nordicsemi.com/eng/nordic/download_resource/58850/47/60411125/53210
  6. 安裝安卓版或者iOS版nRF connect。iOS版nRF connect請到蘋果app store下載,搜索「nRF」即可以找到。安卓版nRF connect可以到Nordic Github官網上下載,下載鏈接為:https://github.com/NordicSemiconductor/Android-nRF-Connect/releases
  7. 安裝PC版nRF connect或者nRFgo studio,兩個選其一即可。PC版nRF connect下載鏈接(Windows版):https://www.nordicsemi.com/eng/nordic/download_resource/58847/15/21277021/108233
註:如果你使用的是Linux系統/Mac系統,或者你使用的不是Keil5-MDK,請參考「Nordic nRF51/nRF52開發環境搭建」來搭建你的開發環境。

2. 運行Nordic ble_app_uart應用程序

Nordic SDK已經提供了一個直接就可以編譯和運行的數據透傳應用程序:ble_app_uart,Nordic將BLE透傳服務稱為Nordic UART Service(NUS),所以在Nordic SDK中,NUS就是BLE透傳服務。請按照如下步驟運行SDK自帶的ble_app_uart程序:
1)     確認自己的芯片型號或者開發板。如果採用Nordic官方開發板的話,芯片型號和開發板編號對應關係如下:
  • nRF51系列對應開發板編號為PCA10028
  • nRF52832和nRF52810對應開發板編號為PCA10040。雖然52832和52810共用同一塊開發板,但是他們在SDK中的項目編號是不一樣的,52832對應PCA10040目錄,52810對應PCA10040e目錄,由於52810和52832 PIN to PIN兼容,軟件也是完全兼容的,因此SDK很多項目只有PCA10040的目錄,而沒有PCA10040e目錄,此時需要你自己來建立PCA10040e對應的目錄和工程,具體說明可參考:http://infocenter.nordicsemi.com/index.jsp?topic=%2Fcom.nordic.infocenter.sdk5.v15.0.0%2Fnrf52810_user_guide.html&cp=4_0_0_5_0
  • nRF52840對應開發板編號為PCA10056
  • nRF52840 dongle編號為PCA10059
這裡我會以nRF52832開發板PCA10040為例來闡述整個開發過程,其他開發板與之類似,大家自己可以舉一反三來開始自己的開發之旅。
2)     將開發板與PC機通過USB線相連,同時打開開發板電源(將左下角的撥位開關打到「ON」位置),打開桌面版nRF Connect,選擇啟動「Programmer」應用,由於驅動之前已經安裝好了,設備可以立即識別成功。執行「full erase」操作,以擦除芯片原始內容。
 

3)     打開SDK中的ble_app_uart程序。如果是52832開發板,請打開:nRF5_SDK_15.0.0_a53641a\examples\ble_peripheral\ble_app_uart\pca10040\s132\arm5_no_packs;如果是51822開發板,請打開:nRF5_SDK_12.3.0_d7731ad\examples\ble_peripheral\ble_app_uart\pca10028\s130\arm5_no_packs
後續將以52832開發板為例來闡述,51822與之類似就不再闡述了。
註:Nordic SDK例程目錄結構為:SDK版本/ examples /協議角色/例子名稱/開發板型號/協議棧型號/工具鏈類型/具體工程,比如下面例子:
 
Nordic每一個例子都支持5種工具鏈:Keil5/Keil4/IAR/GCC/SES,如下所示:
 
4)     編譯程序。如果你已經按照之前的說明配置好了開發環境,那麼這裡編譯是不會報任何錯的。(如果你遇到了編譯錯誤,請重新按照前面說明去搭建你的開發環境,不要懷疑SDK例子代碼有問題哦)
5)     下載程序。程序下載包括2步:一先下載softdevice,二再下載應用。Softdevice是Nordic藍牙協議棧的名稱,整個開發過程中只需下載一次。應用就是我們這裡的ble_app_uart程序。如果你的開發板已經下載了其他代碼,那麼最好先把開發板全擦一次,然後再下載softdevice和應用。
  • 芯片全擦(可選)。你可以使用nRFgo studio,或者nRF connect桌面版,或者nrfjprog,三者選其一來執行擦除操作。
    • 使用nRFgo studio執行全擦操作

  • 使用nRF connect桌面版執行全擦操作
  
  • 使用nrfjprog執行全擦操作
 
  • 藍牙協議棧下載(整個開發週期只需下載一次)。在Keil 『select target'下拉列表中,默認選擇的是Keil工程對應的Target,即『nrf52832_xxaa'。我們還可以選擇另一個target 『flash_s132_nrf52_6.0.0_softdevice',即softdevice對應的target,然後點擊「下載download」(不需要編譯哦!),此時會把softdevice下載到開發板中。

  • 應用下載。重新選擇Target:『nrf52832_xxaa',點擊「下載Download」,此時會把ble_app_uart應用程序下載到開發板中。此時開發板的LED1閃爍,表示程序運行正常。
6)     連接手機。打開手機藍牙和手機版nRF connect。在nRF connect中,你將看到一個廣播設備:Nordic_UART,這個就是開發板的廣播名字。點擊「CONNECT」,手機將與設備建立連接,並開始服務發現過程,連接成功後,LED1熄滅,LED2點亮,最後將得到如下界面。

上圖的Nordic UART Service(NUS)就是我們的數據透傳服務, NUS具體包括兩個characteristic:TX和RX,由於NUS是由設備提供的,所以TX表示設備發送數據給手機,RX表示設備接收手機發過來的數據。
7)     測試NUS服務。ble_app_uart使用串口與上位機交互,選擇一款串口助手軟件,比如Putty,打開該串口軟件,並做如下設置:
  • Baud rate: 115.200
  • 8 data bits
  • 1 stop bit
  • No parity
  • HW flow control: None
復位開發板,你會發現串口助手會打印如下信息:
 
按照第6)步,重新將開發板連上手機,然後點擊右上角的「Enable CCCDs」以使能notification,如下所示:

設備接收數據: 點擊RX characteristic旁邊的向上箭頭,通過手機藍牙往設備發送:12345678,如下所示:

     此時設備通過串口打印出剛才接收到的數據,如下所示:

設備發送數據:在串口助手中輸入「abcdefgh」並輸入「\n」(註:在Putty中,先按「CTRL」再按「J」就會發出「\n」換行符)作為結束符,設備將把串口收到的數據通過藍牙發送給手機,手機的TX characteristic將顯示上述字符串,如下所示:
 
                註:如果你的串口助手發不出「\n」換行符,那麼你需要最少輸入MTU-3個字符,設備才會把收到的全部字符通過藍牙發出去
通過上面的測試,大家可以發現Nordic SDK已經把藍牙數據透傳服務做好了,大家可以直接拿過來使用,下面將對其工作原理進行闡述,最後在Nordic藍牙透傳例子ble_app_uart上進行二次開發,以增加一些其他有用功能。如果大家覺得Nordic ble_app_uart已經可以滿足自己的需求,而且也不想花時間去研究裡面的原理,那麼章節3/4/5/6/7.1可以略過不看。

3. BLE client/server(C/S) 架構

        BLE採用了client/server (C/S)架構來進行數據交互,C/S架構是一種非常常見的架構,在我們身邊隨處可見,比如我們經常用到的瀏覽器和服務器也是一種C/S架構,這其中瀏覽器是客戶端client,服務器是服務端server,server比如淘寶服務器,提供商品信息,廣告,社交等服務,而瀏覽器,比如微軟的IE,就可以用來請求這些服務,並使用server提供的服務。BLE與此類似,一般而言設備提供服務,因此設備是server,手機使用設備提供的服務,因此手機是client。比如藍牙體溫計,它可以提供「體溫」數據服務,因此是一個server,而手機則可以請求「體溫」數據以顯示在手機上,因此手機是一個client。
        服務是以數據為載體的,所以說server提供服務其實就是提供各種有價值的數據。
 
上圖所示的Request和Response其實就是我們經常說的ATT命令(ATT PDU),也就是說Client和Server之間通過ATT PDU進行交互。另外,一個數據「37」,有可能是說體溫「37度」,也有可能是說心率「37次」或者濕度「37%」,因此Server需要將數據進行包裝和分類,在BLE中,數據是通過characteristic進行包裝的,而且多個characteristic組成一個service,service是一個獨立的服務單元,或者說service是一個基本的BLE應用。因此我們可以把上圖細化為:

如果某個service是一個藍牙聯盟定義的標準服務,也可以稱其為profile,比如HID/心率計/體溫計/血糖儀等,都是標準藍牙服務,因此都有相應的profile規格書。

4. BLE service, characteristic以及CCCD

如文章「深入淺出低功耗藍牙(BLE)協議棧」所講,BLE協議棧架構如下所示:

        如上圖所示,用戶開發應用程序或者說service的時候,調用的都是GATT API,而GATT又調用了ATT API,前面也講過,BLE數據最終都是通過ATT PDU來傳輸的,那麼為什麼還需要GATT層?直接操作ATT層不也可以達到同樣的目的嗎?
        前面也提過,Server是通過characteristic來表示數據的,雖然一條數據最有價值的部分是它的值(value),但是僅有value是不夠,比如27,到底是表示27°溫度還是27%濕度;如果表示的是溫度,那麼它的單位是攝氏度還是華氏度。同時每個value還有相應的讀寫屬性以及權限屬性,因此一個characteristic包含三種條目:characteristic聲明,characteristic的值以及characteristic的描述符(可以有多個描述符),如下所示:
 
        由於一個service可以包含多個characteristic,characteristic declaration就是每個characteristic的分界符,解析時一旦遇到characteristic declaration,就可以認為接下來又是一個新的characteristic了,同時characteristic declaration還將包含value的讀寫屬性等。Characteristic value就是數據的值了,這個比較好理解就不再說了。Characteristic descriptor就是數據的額外信息,比如溫度的單位是什麼,數據是用小數表示還是百分比表示等之類的數據描述信息。CCCD是一種特殊的characteristic descriptor,一般而言,都是client來訪問server的characteristic,我們把這種操作稱為讀或者寫。另外,server可以直接把自己的characteristic的值告訴client,我們稱其為notify或者indicate,跟read操作相比,只有需要傳輸數據的時候或者說只有當數據有效時,server才開始notify或者indicate數據到client,因此這種操作方式可以大大節省server的功耗。有時候client不想監聽characteristic notify或者indicate過來的數據,那麼就可以使用CCCD來關閉characteristic的notify或者indicate功能;如果client又需要監聽characteristic的notify或者indicate,那麼它可以重新使能CCCD來打開相關操作。總結一下,當characteristic具有notify或者indicate操作功能時,那麼必須為其添加相應CCCD,以方便client來使能或者禁止notify或者indicate功能。
        不管是characteristic declaration,characteristic value還是characteristic descriptor,實現的時候,我們都是用attribute來表達的,也就是說,他們每一個都是一個attribute,attribute可以用下圖來表示:
 
  • Attribute handle,Attribute句柄,16-bit長度。Client要訪問Server的Attribute,都是通過這個句柄來訪問的,也就是說ATT PDU一般都包含handle的值。用戶在軟件代碼添加characteristic的時候,系統會自動按順序地為相關attribute生成句柄。
  • Attribute type,Attribute類型,2字節或者16字節長。在BLE中我們使用UUID來定義數據的類型,UUID是128 bit的,所以我們有足夠的UUID來表達萬事萬物。其中有一個UUID非常特殊,它被藍牙聯盟採用為官方UUID,這個UUID如下所示:

 由於這個UUID眾所周知,藍牙聯盟將自己定義的attribute或者數據只用16bit UUID來表示,比如0x1234,其實它也是128bit,完整表示為:
 
Attribute type一般是由service和characteristic規格來定義,站在藍牙協議棧角度來看,ATT層定義了一個通信的基本框架,數據的基本結構,以及通信的指令,而GATT層就是前文所述的service和characteristic,GATT層用來賦予每個數據一個具體的內涵,讓數據變得有結構和意義。換句話說,沒有GATT層,低功耗藍牙也可以通信起來,但會產生兼容性問題以及通信的低效率。
  • Attribute value,就是數據真正的值,0到512字節長。
  • Attribute permissions,Attribute的權限屬性,權限屬性不會直接在空中包中體現,而是隱含在ATT命令的操作結果中。假設一個attribute read屬性設為open(即讀操作不需要任何權限),那麼client去讀這個attribute時server將直接返回attribute的值;如果這個attribute read屬性設為authentication(即需要配對才能訪問),如果client沒有與server配對而直接去訪問這個attribute,那麼server會返回一個錯誤碼:告訴client你的權限不夠,此時client會對server發起配對請求,以滿足這個attribute的讀屬性要求。目前主要有如下四種權限屬性:
    • Open,直接可以讀或者寫
    • No Access,禁止讀或者寫
    •  Authentication,需要配對才能讀或者寫,由於配對有多種類型,因此authentication又衍生多種子類型,比如帶不帶MITM,有沒有LESC
    • Authorization,跟open一樣,不過server返回attribute的值之前需要應用先授權,也就是說應用可以在回調函數裡面去修改讀或者寫的原始值。
    • Signed,簽名後才能讀或者寫,這個用得比較少。
         大家還記不記得設備與手機nRF connect連接成功後呈現的界面,我這裡再貼一下:
 
         可以看到手機呈現的就是上文講的service和characteristic,nRF Connect為了讓整個界面變得更美觀,將訪問屬性,UUID,handle都分列來表示了,以致於很多初學者會把理論和現實二者對應不起來。Nordic之前推出過一款Master Control Panel(MCP),MCP現在已經不推薦使用了,不過MCP有一個好處,它對service和characteristic的組織方式更接近底層實現方式,對大家理解service和characteristic是非常有幫助的。還是這個設備,我用MCP跟它連接並進行服務發現,你會發現它呈現的界面如下所示:

這個圖就跟上面講的理論知識可以一一對應起來了,NUS包含2個characteristic:RX和TX,每一個條目都是一個attribute,NUS服務本身就是一個attribute,而RX characteristic本身又包含2條attribute:一條是declaration attribute,一條是value本身attribute。由於TX支持notify,所以它包含3條attribute,另外一條attribute是CCCD。每個attribute都有一個handle和UUID,handle用來訪問該attribute,UUID用來指明該attribute的類型。可以說,server提供數據,而數據是由attribute來表達,所有attribute組成一個attribute table,設備支持的服務不同,attribute table就不同。這裡說明一下,當你在Nordic已有例程基礎上再去添加新的服務或者刪除已有的服務,記得一定要去修改ATTR_TAB_SIZE那個宏,否則協議棧初始化會有問題。

5. 常用ATT命令

        Client和Server之間是通過ATT PDU來通信的,ATT PDU主要包括4類:讀,寫,notify和indicate。如果一個命令需要response,那麼會在相應命令後面加上request;如果一個命令只需要ACK而不需要response,那麼它的後面就不會帶request。這裡要特別強調一點,BLE所有命令都是「必達」的,也就是說每個命令發出去之後,會立馬等ACK信息,如果收到了ACK包,發起方認為命令完成;否則發起方會一直重傳該命令直到超時導致BLE連接斷開。換句話說,只要你的BLE沒有斷開,那麼你之前發送的數據包,不管它是用什麼ATT PDU來發送的,它肯定被對方收到了。我估計很多人對此會產生疑問,因為他們經常碰到丟包的情況,其實大家經常碰到的「丟包」,不是空中把包丟了或者包在空中被干擾了,而是大家發送的代碼寫得有問題,導致你要發送的包沒有被安全送達到協議棧射頻FIFO中,所以以後大家碰到丟包情況,請先檢查你的代碼,保證你的數據包正確完整安全地送達到協議棧射頻FIFO中,只要數據包放到了協議棧射頻FIFO中,藍牙協議棧就能保證該數據包「必達」對方。既然每個ATT命令都必達對方,那麼還需要request做什麼?如果一個命令帶有request後綴,那麼發起方就可以收到命令的response包,這個response包在應用層是有回調事件的,而前述的ACK包在應用層是沒有回調事件的。所以採用request/response方式,應用層可以按順序地發送一些數據包,這個在很多應用場合是非常有用的。相反,如果你對應用層數據包的順序沒有要求,那麼就可以不使用request/response形式。另外Request/response有一個副作用:大大降低通信的吞吐率,因為request/response必須在不同的連接間隔中出現,也就是說,你在間隔1中發送了一個request命令,那麼response包必須在間隔2或者稍後間隔中回覆,而不能在間隔1中回覆,這就導致兩個連接間隔最多只能發一個數據包,而不帶request後綴的ATT命令就沒有這個問題,在同一個連接間隔中,你可以同時發多個數據包,這樣將大大提高數據的吞吐率。大家可以參考下圖來理解request和非request命令的區別:
 

常用的帶request的命令:所有read命令,write request,indication等,而常用的不帶request的命令有write command,notification等,完整的ATT命令列表如下所示:


6. 設備端固件代碼一覽

現在我們一起來看一下ble_app_uart的源代碼,看看它是怎麼工作起來的。首先我們來看main函數:
 
如上所述,ble_stack_init用於初始化配置和使能藍牙協議棧,其代碼如下所示:

其中,nrf_sdh_enable_request需要選擇藍牙協議棧的低頻時鐘(由於藍牙協議棧的高頻時鐘必須為外部32M晶振,所以高頻時鐘無需配置;而低頻時鐘可以選擇為內部32K RC或者外部32K晶振,所以低頻時鐘需要人工配置),因此如下宏需要根據實際情況進行調整:

    nrf_clock_lf_cfg_t const clock_lf_cfg =

    {

        .source       = NRF_SDH_CLOCK_LF_SRC,

        .rc_ctiv      = NRF_SDH_CLOCK_LF_RC_CTIV,

        .rc_temp_ctiv = NRF_SDH_CLOCK_LF_RC_TEMP_CTIV,

        .accuracy     = NRF_SDH_CLOCK_LF_ACCURACY

};
通過sdk_config.h文件可以看到,默認是選擇外部32K晶振作為低頻時鐘的,如果你想選擇內部32K RC作為低頻時鐘,那麼需要做如下修改

NRF_SDH_CLOCK_LF_SRC = 0

NRF_SDH_CLOCK_LF_RC_CTIV = 16    //每4s啟動一次校準

NRF_SDH_CLOCK_LF_RC_TEMP_CTIV = 2

NRF_SDH_CLOCK_LF_ACCURACY = 1  //500ppm
nrf_sdh_ble_default_cfg_set用來配置softdevice協議棧,如下宏是經常需要修改的:

NRF_SDH_BLE_TOTAL_LINK_COUNT  //一共同時可以支持多少個連接

NRF_SDH_BLE_PERIPHERAL_LINK_COUNT  //作為從模式的連接同時能有幾個

NRF_SDH_BLE_CENTRAL_LINK_COUNT  //作為主模式的連接同時能有幾個

NRF_SDH_BLE_GATT_MAX_MTU_SIZE //MTU size為多大

NRF_SDH_BLE_VS_UUID_COUNT  //用戶自定義的base UUID有幾個

NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE  //Attribute table總共佔多少協議棧RAM空間

NRF_SDH_BLE_SERVICE_CHANGED  //要不要包含service change characteristic
nrf_sdh_ble_enable真正使能BLE功能,它的參數ram_start既是一個輸入參數又是一個輸出參數,作為輸入參數,系統自動會把如下的RAM起始地址傳入:

同時nrf_sdh_ble_enable會把當前softdevice配置情況下,它實際需要佔用的RAM空間通過ram_start返回,如果這個返回值不等於輸入值,那麼用戶需要把上圖的IRAM1起始地址修改成它的返回值。其中NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE這個宏的取值是需要用戶不斷去試錯的,因此每當你添加了或者刪除了BLE service,都需要去調整NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE這個宏的值,然後去查看nrf_sdh_ble_enable的返回值,看看這個參數的取值是否合理
NRF_SDH_BLE_OBSERVER用來為本地文件(此處為main.c)註冊一個BLE回調函數(此處為ble_evt_handler),NRF_SDH_BLE_OBSERVER這個宏執行成功後,所有的BLE事件都會被ble_evt_handler捕獲。進入ble_evt_handler,你會發現BLE有上百個回調事件,你不需要每個都處理,你只需要處理你關心的事件即可,比如連接成功事件BLE_GAP_EVT_CONNECTED或者連接斷開事件BLE_GAP_EVT_DISCONNECTED,如下所示:

NRF_SDH_BLE_OBSERVER有一個很大的好處:某個模塊如果需要捕獲BLE事件,那麼它自己調用NRF_SDH_BLE_OBSERVER這個宏註冊相應回調函數即可,而不再需要在其它文件中去註冊這個回調函數,將模塊的耦合性降到最低,符合模塊化編程思想。
gap_params_init用來修改廣播名字和連接間隔的。gatt_init用來修改底層數據包長度的。advertising_init用來修改廣播包內容,廣播間隔以及廣播超時時間。conn_params_init用來請求更新連接間隔的。
我們來重點講一下services_init,services_init用來添加服務和characteristic,前面講了那麼多的概念和理論,現在我們就來看看services_init是如何做到跟理論一致的。services_init通過ble_nus_init添加了一個藍牙數據透傳服務:NUS,那ble_nus_init是怎麼將NUS服務添加成功的呢?查看ble_nus_init函數體,你會發現它是分三步來做的:
  1. 添加服務的UUID。如果是藍牙標準服務,這步可以省略。由於NUS不是藍牙聯盟定義的,所以需要調用sd_ble_uuid_vs_add以增加一個供應商自定義的UUID。
  2. 添加服務本身。直接調用sd_ble_gatts_service_add就可以完成。
  3. 添加服務下面的characteristics。server的characteristic一般都是通過sd_ble_gatts_characteristic_add來添加的。以NUS的RX characteristic為例,可以看到:
sd_ble_gatts_characteristic_add(p_nus->service_handle,  &char_md,  &attr_char_value, &p_nus->rx_handles);
其中,p_nus->service_handle表示該characteristic屬於那個service,p_nus->rx_handles是輸出值,由協議棧返回,以後訪問該characteristic都是通過這個句柄來完成,attr_char_value這個是characteristic的value,char_md這個是characteristic的元數據(metadata),前面第4章也講過,一個數據除了有value這個characteristic之外,它還包含其他attribute,而這些attribute全部都用char_md來表示,比如這個characteristic value能支持的ATT命令類型,CCCD信息,descriptor信息等,這裡要特別指出的是,只有當支持notify或者indicate時,才需要提供cccd_md信息,其他ATT命令不需要cccd_md信息,所以RX characteristic的char_md如下所示,它同時支持write和write request兩種寫命令,由於它不支持notify或者indicate,所以cccd_md為NULL。
 
attr_char_value是一個attribute,所以它包含attribute metadata,如下:

attr_char_value具體包含的value信息由以下成員表示:
 
由於這裡把characteristic value放在了協議棧RAM中,所以協議棧會自動為這個value創建一個buffer。如果你想把characteristic value放在用戶RAM中,即vloc = BLE_GATTS_VLOC_USER,那麼這裡你還需要把一個全局數組變量賦給attr_char_value. p_value。
TX characteristic與之類似,就不再額外解讀了。
這裡需要特別提醒大家的是,雖然Nordic API結構體參數設計得很複雜,但是大部分成員變量直接就可以使用它的默認值0,你只需對你感興趣的成員變量進行賦值即可,所以大家經常看到如下場合,即先用memset將該結構體變量初始化為0,讓其所有成員變量都採用默認值,然後再對某些需要修改的成員變量進行二次賦值。大家一定不要忘了將結構體變量清零這一步操作!
 
ble_nus_init同時註冊了nus_data_handler回調函數,當設備收到手機發過來的數據時,就會觸發nus_data_handler,用戶可以在nus_data_handler中對接收到的數據進行處理,本例程中nus_data_handler直接將ble收到的數據通過uart口轉發出去。如果用戶需要發送數據給手機,在連接成功和notify使能的情況下,直接調用ble_nus_data_send即可,而ble_nus_data_send又是通過調用協議棧API:sd_ble_gatts_hvx來實現數據發送功能的。那麼什麼時候需要發送數據給手機?本例程的做法是,當串口有數據過來並滿足如下條件時調用ble_nus_data_send:
if ((data_array[index - 1] == '\n') || (index >= (m_ble_nus_max_data_len)))
main函數最後將調用API讓協議棧跑起來,如果你的設備將來是一個從設備(peripheral),那麼請調用ble_advertising_start,ble_advertising_start將開啟可連接的廣播,從而讓你的設備連接成功之後成為從設備。如果你的設備將來是一個主設備(central),那麼請調用sd_ble_gap_scan_start,sd_ble_gap_scan_start將開啟設備的掃瞄功能,從而讓你的設備連接成功之後變為主設備。
最後我們來看main循環,它只有一個函數: idle_state_handle,idle_state_handle先把需要打印的日誌打印完,然後讓系統進入idle狀態(Nordic SoC spec稱其為System ON狀態),一旦有協議棧事件或者中斷事件發生,系統將喚醒,以處理相關事件回調函數,然後再執行一遍idle_state_handle。注意:idle狀態下,藍牙連接或者廣播可以正常進行而不受影響,藍牙連接或者廣播都是週期性的,在一個週期中,藍牙連接或者廣播只持續很短一段時間(這段時間CPU有可能會退出idle狀態),其餘時間系統都是處於idle狀態的,從而大大節省系統功耗。

7. 定製你的BLE數據透傳應用程序

7.1 BLE數據上傳吞吐率

如何快速的把大量數據上傳給手機?這是一個很常見的應用場合,現在我們嘗試去修改一下Nordic的原生例程,以實現最高的數據吞吐率。下面我們通過幾種不同的方法來看看每種方法下它的吞吐率能到多少。

方法1:(通過宏METHOD1來開關)

藍牙spec規定,藍牙連接間隔最小只能為7.5m,為了達到最高的吞吐率,我們創建一個timer,讓其每7ms發一次數據,看一看此時吞吐率能達到多少。7ms中斷服務函數代碼如下所示:

static void throughput_timer_handler(void * p_context)

{

    UNUSED_PARAMETER(p_context);

    ret_code_t err_code;

    uint16_t length;

    m_cnt_7ms++;

    length = m_ble_nus_max_data_len;

    if (m_conn_handle != BLE_CONN_HANDLE_INVALID)

    {

        err_code = ble_nus_data_send(&m_nus, m_data_array, &length, m_conn_handle);

//      if ( (err_code != NRF_ERROR_INVALID_STATE) && (err_code != NRF_ERROR_RESOURCES) &&

//          (err_code != NRF_ERROR_NOT_FOUND) )

//      {

//                APP_ERROR_CHECK(err_code);

//      }             

        m_len_sent += length;           

        m_data_array[0]++;

        m_data_array[length-1]++;         

     }

     NRF_LOG_INFO("time: %d *7ms == bytes send: %d Bytes == avg speed: %d B/s",m_cnt_7ms,m_len_sent,m_len_sent/(m_cnt_7ms*7));   

}
這種做法會導致ble_nus_data_send報「NRF_ERROR_RESOURCES」錯誤,這個錯誤表示協議棧無資源應付這麼快的調用速度。為此我們對ble_nus_data_send返回的錯誤值一概不進行處理,看看會發生什麼?我們發現程序可以正常運行,RTT viewer打印的日誌如下所示:
 
由上圖可知,數據上傳吞吐率達到了34.8kB/s,其實這個吞吐率是假的,因為中間丟了很多包,但計算吞吐率的時候把丟的包也算進去了。如下圖所示,0x6E之後應該為0x6F,但實際發送的數據包編號為0x83,丟包非常嚴重。
 
   為了防止所謂的「丟包」(前面也提過,這裡的丟包不是數據包在空中丟掉了,而是數據包沒有安全送到協議棧的buffer中,從而導致丟包),我們加上如下if語句,只有ble_nus_data_send返回正確時,才認為數據包正確發送,然後才能算入到throughput中:

  if (err_code == NRF_SUCCESS)

  {

             m_len_sent += length;  

             m_data_array[0]++;

             m_data_array[length-1]++;                                                      

    }
通過查看nRF connect日誌,你會發現此時不會發生丟包了,但吞吐率直接降到了1.6kB/s左右。

方法1+:(通過宏METHOD1_PLUS來開關)

我們對方法一稍作調整,首先我們持續往發送buffer寫數據,直到返回值不是NRF_SUCCESS

do
{
    err_code = ble_nus_data_send(&m_nus, m_data_array, &length, m_conn_handle);
    if ( (err_code != NRF_ERROR_INVALID_STATE) && (err_code != NRF_ERROR_RESOURCES) &&
      (err_code != NRF_ERROR_NOT_FOUND) )
    {
        APP_ERROR_CHECK(err_code);
    }
    if (err_code == NRF_SUCCESS)
    {
        m_len_sent += length;
        m_data_array[0]++;
        m_data_array[length-1]++;
    }
} while (err_code == NRF_SUCCESS);
然後我們把連接間隔設為儘可能小,以期提高吞吐率,如下:

#ifdef CONN_INTERVAL_OPTIMIZE

#define MIN_CONN_INTERVAL               MSEC_TO_UNITS(8, UNIT_1_25_MS)   

#define MAX_CONN_INTERVAL               MSEC_TO_UNITS(12, UNIT_1_25_MS)

#endif
這種方法吞吐率能達到10kB/s,但離我們的目標還是很遠。
最後我們把connection event length extension和data length extension都打開(我們將在方法2+中詳細闡述這2個有效提高吞吐率的利器),即定義如下宏:

可以看到吞吐率將達到70kB/s,這個吞吐率還是不錯的。但仔細查看nRF connect日誌,你會發現這種模式下還是有小概率事件會導致「丟包」發生,而且整個發送邏輯也不是很優化,為此我們想到了METHOD2.

方法2:(通過宏METHOD2來開關)

ble_nus_data_send每次成功發送數據包,都會產生一個BLE_NUS_EVT_TX_RDY事件,收到這個事件後,再去調用ble_nus_data_send,丟包的情況就不會再發生了,核心代碼如下所示:

 
if (p_evt->type == BLE_NUS_EVT_TX_RDY)
{
#ifdef METHOD2
    err_code = ble_nus_data_send(&m_nus, m_data_array, &length, m_conn_handle);
    if ( (err_code != NRF_ERROR_INVALID_STATE) && (err_code != NRF_ERROR_RESOURCES) &&
      (err_code != NRF_ERROR_NOT_FOUND) )
    {
          APP_ERROR_CHECK(err_code);
    }
    if (err_code == NRF_SUCCESS)
    {
         m_len_sent += length;
         m_data_array[0]++;
         m_data_array[length-1]++;
     }
     NRF_LOG_INFO("time: %d *10ms == bytes send: %d Bytes == avg speed: %d B/s",m_cnt_10ms,m_len_sent,m_len_sent * 100/m_cnt_10ms);
#endif
大家可以自己去查看一下nRF  Connect的數據log,這種方式是沒有丟包的,但是打開RTT viewer,你會發現他的吞吐率低得可憐,只有1kB/s。

方法2+:(通過宏METHOD2_PLUS來開關)

與方法1+類似,我們在方法2基礎上,持續往發送buffer送數據直到返回值不為0,如下:

#ifdef METHOD2_PLUS
//queue multiple tx array
do
{
    err_code = ble_nus_data_send(&m_nus, m_data_array, &length, m_conn_handle);
    if ( (err_code != NRF_ERROR_INVALID_STATE) && (err_code != NRF_ERROR_RESOURCES) &&
     (err_code != NRF_ERROR_NOT_FOUND) )
    {
         APP_ERROR_CHECK(err_code);
    }
    if (err_code == NRF_SUCCESS)
    {
        m_len_sent += length;
        m_data_array[0]++;
        m_data_array[length-1]++;
    }
} while (err_code == NRF_SUCCESS);
NRF_LOG_INFO("time: %d *10ms == bytes send: %d Bytes == avg speed: %d B/s",m_cnt_10ms,m_len_sent,m_len_sent * 100/m_cnt_10ms);
#endif
如果需要支持長的MTU的話,那麼需要修改gap event length。gap event length是指一個連接間隔中最多能給某一個設備數據交互的時間長度,如果gap event length設為1ms,而MTU設為247的話,那麼協議棧會報配置錯誤。如果gap event length遠大於一個數據長包的長度,那麼在一個連接間隔中就可以傳送多個長包。有的人為了省事,就把gap event length設的很大,比如大於或者等於連接間隔,這個配置本身是沒什麼問題的,但是如果一個設備要跟多個設備相連,那麼這種配置就會使得設備連接數有限或者其他設備帶寬有限,比如不能同時連20個設備,比如其他設備傳輸速度很慢。由於我們現在是一對一的連接,偷點懶,咱們把gap event length修改成一個合適的值,以使其儘可能佔滿整個連接間隔,如下將gap event length修改為30ms
#define NRF_SDH_BLE_GAP_EVENT_LENGTH 24
注意:為了兼容多連接以及保證其他設備的帶寬,我們一般建議gap event length就使用SDK默認配置:6,這個默認配置已經可以發送一個241字節的MTU長包了,但只能發送一個,為了在一個連接間隔中發送多個長包,可以在不修改gap event length的情況下,通過使能connection event length的做法,以達到同樣的目的,如下面代碼所示:

#ifdef EVT_LEN_EXT_ON

    ble_opt_t  opt;

    memset(&opt, 0x00, sizeof(opt));

    opt.common_opt.conn_evt_ext.enable = true;

    err_code = sd_ble_opt_set(BLE_COMMON_OPT_CONN_EVT_EXT, &opt);

    APP_ERROR_CHECK(err_code);

#endif
然後我們再將連接間隔設為一個合適的值,以保證上述connection event可以佔據整個連接間隔。注意:不是連接間隔越短越好,而是整個連接間隔中空閒時間越短越好,也就是說,哪怕連接間隔比較長,如果能保證connection event/connection interval最大,那麼就有可能達到最大的吞吐率。

#ifdef CONN_INTERVAL_OPTIMIZE

#define MIN_CONN_INTERVAL               MSEC_TO_UNITS(8, UNIT_1_25_MS)   

#define MAX_CONN_INTERVAL               MSEC_TO_UNITS(12, UNIT_1_25_MS)

#endif
我現在使用的是華為P9手機,它將把MTU設為241,在DLE不開的情況下(此時鏈路層每個數據包的長度還是只有27個字節!),我們可以看到throughput可以達到10kB以上,如下:


然後我們再打開DLE功能,此時鏈路層每個數據包的長度將變成251字節,如下:

#ifdef DLE_ON

        case BLE_GAP_EVT_DATA_LENGTH_UPDATE_REQUEST:

        {

            NRF_LOG_DEBUG("DLE update request.");

            ble_gap_data_length_params_t dle_param;

            memset(&dle_param, 0, sizeof(ble_gap_data_length_params_t));   //0 means auto select DLE                                                                    

            err_code = sd_ble_gap_data_length_update(p_ble_evt->evt.gap_evt.conn_handle, &dle_param, NULL);

            APP_ERROR_CHECK(err_code);

        } break;

#endif
此時我們可以看到throughput可以達到77kB/s,離藍牙4.2的理論throughput已經很接近了。這裡特別需要指出的是,當DLE使能情況下,connection interval不是越小吞吐率越高,我這裡使用的connection interval大概為10ms,如果大家把這個connection interval提高到30ms,有可能吞吐率更高,這裡就不再演示了。
 

 
上述代碼工程已經上傳到百度雲盤中,有需要的同學可以到如下鏈接下載:
下載「tutorial_ble_app_uart_SDK15_0_0.rar」,然後解壓縮到SDK15.0.0如下目錄下:nRF5_SDK_15.0.0_a53641a\examples\ble_peripheral,即可成功編譯運行。

7.2使用安卓版nRF connect測試BLE設備的穩定性和可靠性

先說明一下,以下內容只能通過安卓版nRF Connect來實現,iOS版nRF Connect不支持如下特性。

手機端宏錄製方式

相信到現在大家對BLE數據上傳機理和實踐有個大概的瞭解,那如何測試BLE數據下行性能,即怎麼測試數據從手機傳到設備的穩定性和可靠性?我們是不是必須開發一款手機app來進行相關測試嗎?答案是否定的,感謝Nordic給我們帶來了nRF connect,nRF connect支持宏錄製,我們可以通過nRF connect來對我們的設備進行壓力測試。下面我們來講講宏錄製是怎麼工作的。
所謂宏錄製,就是把你對nRF connect的操作錄製下來,然後通過宏播放實現自動化操作。由於nRF connect是一個容器,並支持JavaScript和HTML語法,宏其實就是一個XML腳本,nRF connect定義了自己的一套XML標籤操作,遵守這套XML標籤操作,就可以對nRF connect進行自動化操作。nRF connect支持的所有XML語法都在手機安裝目錄\Nordic Semiconductor中的示例中體現,只要示例中出現過的標籤就支持,相反示例中沒有的標籤就不支持。下面具體講一下宏錄製的操作過程。
當nRF connect連接設備成功後,你會發現右下角有一個紅點,那個就是宏錄製菜單。
 
點擊下面的紅點,我們開始宏錄製操作
 
然後我們按照普通操作來操作nRF connect,這些操作最終對應的BLE指令會被錄製下來,以便後續重複播放。我們先把「1234」發送給設備,如下:

發送完上述指令後,我們加一個300ms的延時,如下:

然後我們點擊完成按鈕,保存該宏,可以看出這個宏包括兩條操作:發送「1234」到設備,然後睡眠300ms。
 
將宏命名為「test」並保存:
 
到此宏已經錄製成功了,現在我們開始展示宏的神奇功能。如下,選擇循環播放模式,然後點擊「開始」按鈕開始循環播放該錄製宏。
 
大家可以看到,nRF connect先執行「Write 0x31323334 to RX characteristic」,然後睡眠300ms,然後又執行「Write 0x31323334 to RX characteristic」,如此循環往復。打開串口助手,你會發現設備已經收到了手機發過來的一連串「1234」,如下。

我們把剛才的test宏導出為XML,看一看它到底長什麼樣:

<macro name="test" icon="PLAY">

   <assert-service description="Ensure Nordic UART Service" uuid="6e400001-b5a3-f393-e0a9-e50e24dcca9e">

      <assert-characteristic description="Ensure RX Characteristic" uuid="6e400002-b5a3-f393-e0a9-e50e24dcca9e">

         <property name="WRITE" requirement="MANDATORY"/>

      </assert-characteristic>

   </assert-service>

   <write description="Write 0x31323334 to RX Characteristic" characteristic-uuid="6e400002-b5a3-f393-e0a9-e50e24dcca9e" service-uuid="6e400001-b5a3-f393-e0a9-e50e24dcca9e" value="31323334" type="WRITE_REQUEST"/>

   <sleep description="Sleep 300 ms" timeout="300"/>

</macro>
大家可以看到,宏就是一些XML標記,大家也可以在此基礎上,去修改該XML文件,以實現更複雜的自動化測試,然後通過nRF connect把最新的XML文件裝載進來,就可以自動播放了。
如果你還想瞭解宏更多的用法信息,請參考:https://github.com/NordicSemiconductor/Android-nRF-Connect/blob/master/documentation/Macros/README.md

電腦端XML方式

前面的宏錄製方式,功能還是比較單一,如果要實現更複雜的自動化測試,可以通過在PC端執行XML腳本方式來實現。通過安卓調試工具ADB,我們可以直接通過PC來操作nRF connect,而nRF connect又能識別XML腳本,這樣就可以讓nRF connect按照XML腳本意圖去執行相關自動化操作。nRF connect支持的所有XML語法都在手機安裝目錄中(手機內部存儲/ Nordic Semiconductor目錄)的示例中體現,只要示例中出現過的標籤就支持,相反示例中沒有的標籤就不支持。
欲瞭解更多信息請參考:https://github.com/NordicSemiconductor/Android-nRF-Connect/blob/master/documentation/Automated%20tests/README.md

8. 開發手機端app代碼

Nordic提供很多手機端開源app供大家參考,用得最多的就是nRF Toolbox和nRF Blinky(註:nRF connect代碼不開源),在nRF Toolbox和nRF Blinky中都有相關的BLE操作庫,尤其是nRF Toolbox包含了很多BLE庫,比如BLE管理,DFU,數據透傳,藍牙Mesh等等,大家可以參考他們來開發自己的手機端app。
nRF Toolbox軟件界面如下所示:
 
UART就是前文說到的NUS服務,除了nRF connect,其實大家也可以通過nRF Toolbox UART模塊來完成第2章所述的操作。nRF Toolbox另一個用的比較多的功能就是DFU,如果你需要通過手機BLE來實現設備固件的空中升級(OTA),那麼可以參考nRF Toolbox DFU模塊來編寫你的手機端軟件。

2020年1月16日 星期四

PWA 實戰經驗分享

https://blog.techbridge.cc/2018/10/13/pwa-in-action/


blog.techbridge.cc

PWA 實戰經驗分享

aszx87410


前些日子在忙公司的產品改版,從原本的 PHP 換成後端 Go + 前端 React SPA,分成桌面版跟手機版兩個不同的 Project,而既然都改版了,自然要把最新最潮的 PWA 也放在目標裡面,之前耳聞 PWA 很久但卻沒有實作過的我也有了機會來嘗試這個東西。
如今產品已經改版完畢且上線了兩三個月,慢慢穩定下來,在優化 PWA 的過程中也讓我有了一些心得可以讓大家分享。
在舉一些實際案例之前,先讓我們來談談到底怎樣才算是 PWA。
從 Google 官方的文件:你的首個 Progressive Web App 中可以看出 PWA 的一些詳細定義,但我不太喜歡這種制式的規則,對我來說,PWA 就是一個很像 Native App 的 Web App,而其中瀏覽器的支援也佔了很大的一部分。
在以往儘管你的網站做得再怎麼像 Native App,你還是有兩個難關沒辦法克服:離線的時候就 GG 以及沒辦法安裝在手機上,所以不管怎麼看,人家都知道你就是個 Web App,永遠都不會長得像 Native。
可是自從瀏覽器開始支援 Service Worker 以及 manifest 之後,上面這兩點就被克服了!得益於 Service Worker,讓網頁離線的時候也能夠運作,可以自己寫 code 來決定要渲染什麼畫面;而瀏覽器的「新增到主畫面」的功能更是讓安裝 Web App 成為可能,開發者也可以用 manifest.json 來自訂一些內容,像是啟動畫面以及安裝在主畫面上的名稱等等。
對我來說,如果你能夠利用上面這兩項技術,讓你的 Web App 成功安裝在手機上並且看起來跟 Native App 沒兩樣,我覺得就能稱作是 PWA。
我在之前的文章(原來 CORS 沒有我想像中的簡單)已經有分享過 PWA 在手機上面的樣子了,這邊就不再贅述。還記得我第一次體驗安裝 PWA 的時候也被嚇到了,因為看起來就跟 Native App 沒兩樣,如果真的做得好,應該是很難區分出來的。明明是個網頁可是看起來卻跟 Native App 一樣,這就是 PWA。
接著來介紹幾個 PWA 的重要因素,你要做 PWA 就一定要有下面幾個東西。
首先先來談manifest.json,有寫過 Android 的都知道有個東西叫做AndroidManifest.xml,其實兩個本質上是一樣的東西,就是去描述這個 App 的一些特性。
我們先來看看 Google 官方文件:The Web App Manifest裡面給的範例:
{
  "short_name": "Maps",
  "name": "Google Maps",
  "icons": [
    {
      "src": "/images/icons-192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/images/icons-512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "/maps/?source=pwa",
  "background_color": "#3367D6",
  "display": "standalone",
  "scope": "/maps/",
  "theme_color": "#3367D6"
}
裡面給的資訊很簡單,然後會跟你把 PWA 新增到主畫面時出現的東西息息相關。name的話就是你 App 的名稱,他在主畫面上面就會顯示這個name,但如果你也有提供short_name的話會優先使用short_name
再來icons就是主畫面上面會出現的 logo 囉,這沒什麼好多談的。start_url則是你從主畫面上開啟時會連線到的地方,很多人會加個?source=pwa之類的,這樣就可以知道這個使用者是使用 PWA,方便做一些統計。
這邊有個小地方要注意,那就是在某一版的 iOS Safari(抱歉我忘記是哪一版,但總之最新的已經沒有這個問題了),它是不會遵守start_url的!他會根據的是你在安裝 PWA 時的網址,例如說你在https://example.com/test/123的時候按下「新增到主畫面」,你在主畫面開啟 PWA 時就會連線到這個畫面。
這部分其實滿困擾的,但幸好最新的 iOS Safari 已經沒有這個問題了,大家可以不用擔心。
還有一個要特別提的就是namebackground_coloricon會自動組成Splash screens,就是你在打開 PWA 的時候會看到的一個畫面,是由這三個資訊自動被 Chrome 所組成的,意思就是你沒辦法客製化這個啟動畫面。
它就是會顯示你指定的背景顏色、然後中間放一個 icon 下面放你 App 的名稱,沒有其他東西可以調了,至少現在是這樣。
在這點上面 iOS 就不一樣,iOS 是不支援這種啟動畫面的,但好處就是你可以自己透過 html 的 tag 來設定!
<link
    rel='apple-touch-startup-image'
    href='/assets/splash/splash-1125x2436.png'
    media='(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'
/>
會有一些尺寸相關的設定你因為要幫每一種不同的 device 都準備一張圖片,詳情可參考:Progressive Web App Splash Screens 或是 Few Tips That Will Make Your PWA on iOS Feel Like Native
iOS 跟 Android 的差別在於 iOS 的啟動畫面你可以放一張圖片,因此可以完全客製化,你想放什麼就放什麼,自由度比 Android 來得高。
還有就是 icon 的部分,iOS 也不會看你mainfest.json的設定,而是會看自己的 html tag,所以你必須額外設置給 iOS 使用的 icon:
<link
    rel='apple-touch-icon'
    sizes='192x192'
    href='/assets/favicons/iOS192x192.png'
/>
對於manifest.json,該注意的點差不多就這些。其實最大的問題還是支援度,所以 Google 出了一個PWACompat,可以自動幫你針對舊的瀏覽器調整你的檔案以及 html 的 tag,不過也有人寫了一篇:You shouldn’t use Chrome’s PWACompat library in your Progressive Web Apps 來告訴大家不要用,論點大概是不能這樣一概論之,你必須針對每種不同的平台跟瀏覽器去了解他的差異再來做適配,才能得到最好的使用者體驗,這種統一調整的做法會在很多地方看起來 ok 但是怪怪的。
既然上面都提到 iOS 了,就來講講 iOS 的一些不同之處。其實 iOS 開始提供 PWA 的支援是今年(2018 年)的事情而已,而且剛推出的時候支援度滿差的,不過有在慢慢改善就是了。
關於那些 iOS 不同的地方,這兩篇文章都講得很清楚了:PWAs are coming to iOS 11.3: Cupertino, we have a problemProgressive Web Apps on iOS are here 🚀
最大的差異之一大概就是很多時候都不看manifest.json,你要自己額外設置一些相對應的 html tag 才有用,這點是要特別注意的。
再來就是<meta name=”apple-mobile-web-app-capable” content=”yes”>這個 tag 也很重要,主要是告訴瀏覽器説:「我準備好提供全螢幕的體驗了,就算隱藏瀏覽器的 UI 也沒關係」,而這篇:Don’t use iOS meta tags irresponsibly in your Progressive Web Apps 則告訴你千萬不要濫用這個 tag,不然你的 Web App 在 Safari 上的體驗會變得很差,因為很多東西都不支援。
至於 Safari 最大的一點問題我直接引用上面 PWAs are coming to iOS 11.3: Cupertino, we have a problem 的其中一段:
Also, it’s a massive problem for apps with two-factor authentication, such as Twitter. If you need to go to another app to get a token or to open a text message or an email, you will get out of the PWA. When you go back to paste the code, you are out of context and, you need to start the login process again losing the validity of that code. It happened to me on Twitter! Which means, the Twitter PWA on iOS is completely unusable for me.
這是什麼意思呢?我直接舉一個實際範例,假如你的 PWA 有提供 Facebook 登入的功能而且是用重新導向的方式,你一點下去他就會開一個新的 Safari 視窗連到 Facebook 讓你授權,可是當你授權完之後回到 PWA,你會發現什麼事情都沒發生。
這一點真的超傷,而且現在應該都還沒修好,只能期待 Safari 之後會把問題修掉了。而 Android Chrome 則是會在同一個視窗底下打開 Facebook,因此結束之後能夠順利完成登入流程。
有關於 iOS 的問題跟manifest.json的注意事項差不多就到這邊,再來我們談談 PWA 的第二個重點:Service Worker。
加入 Service Worker 的目的就只有一個,那就是快取。透過 Service Worker(以下簡稱 SW),可以幫助我們在發送 request 之前就先攔截到並且做處理,而離線運行的原理也是這樣的,我們先在第一次開啟時註冊 SW,並且利用 SW 下載靜態檔案並快取住,之後若使用者離線,我們再用已經快取住的檔案來回覆,就不會發送真的 request,自然也不會發生無法連線的情況。
而 Google 有提供了一個方便的工具:Workbox 來幫助我們自動產生出 SW 以及利用更方便的語法來攔截 request。
舉例來說,我自己用的是 Webpack 的 plugin:
new workboxPlugin.InjectManifest({
    swSrc: path.join(__dirname, '..', SRC_DIR, 'sw.js'),
    swDest: path.join(__dirname, '..', DIST_DIR, 'sw.js'),
    globDirectory: path.join(__dirname, '..', DIST_DIR),
    globPatterns: ['**/*.{js,css}']
}),


let precacheList = self.__precacheManifest || []
workbox.precaching.precacheAndRoute(precacheList)
只要這樣一寫,就會自動去找符合規則的檔案並且加入快取清單裡面,你只要一註冊 SW 的時候就會把那些檔案給快取起來。
除此之外呢,Workbox 也可以針對 URL 來監聽:

workbox.routing.registerRoute(/(https?:\/\/)(.*)\/api\/(.*)/, args =>
    workbox.strategies
        .networkFirst({
            cacheName: 'data-cache',
            plugins: [
                new workbox.expiration.Plugin({
                    maxEntries: 100,
                    maxAgeSeconds: 2592000
                })
            ]
        })
        .handle(args)
        .then(response => {
            return response
        })
        .catch(err => {
            console.log('err:', err)
        })
)
像上面的程式碼就是針對路徑中含有api的 request 做快取,這樣在離線時也可以利用以前快取住的 API response。
Workbox 針對這種動態的快取提供幾種策略,分別是:staleWhileRevalidatecacheFirstnetworkFirstnetworkOnlycacheOnly,其實看名字就可以大概理解策略是什麼了,想知道詳細的內容可以參考官方文件:Workbox Strategies
總之自從有了 Workbox 之後,基本上就不用自己手寫 SW 了,都靠著它提供的 API 以及功能就行了,就可以自動產生出符合需求的 SW。
最後要來談的是「安裝 PWA」這一塊,在 iOS Safari 上面別無他法,就只能自己叫出選單然後選取「Add to home screen」,可是在 Android Chrome 上面,如果你符合一定的條件(有設置mainfest.json以及有註冊 Service Worker),就會自動幫你跳出一個可愛的 Install banner。

(圖片來自:Changes to Add to Home Screen Behavior
根據 Chrome 版本的不同,行為也有所不同。
在 Chrome 67(含)以前的版本,如果你在beforeinstallprompt事件裡面沒有特別用preventDefault(),或是顯式的呼叫了prompt(),就會出現最左邊那個頗大的 A2HS banner。
然後在 Chrome 68(含)之後的版本,無論你做了什麼,系統都會自動出現那個 Mini-infobar,但如果使用者關掉的話,要隔三個月才會再出現一次,實在是有夠久。
接著呢,上面這兩個 A2HS banner 跟 Mini-infobar,使用者點擊之後都會出現最右邊的 A2HS Dialog,提示使用者要不要安裝 PWA。
但是在 Chrome 68 以後,你也可以利用程式去呼叫beforeinstallprompt裡面拿到的event.prompt()把這個 dialog 顯示出來。
聽起來有點複雜對吧?
先來介紹beforeinstallprompt這個 event 好了,這個 event 在一切都準備就緒,確認你滿足條件可以顯示 prompt 的時候會被觸發,會傳來一個 event,你可以阻止顯示 prompt,把這個 event 存起來:

let installPromptEvent;

window.addEventListener('beforeinstallprompt', (event) => {
  
  event.preventDefault();
  
  installPromptEvent = event;
  
  document.querySelector('#install-button').disabled = false;
});
為什麼要存起來呢?因為使用者可能不想一打開網站就看到這個彈窗,或者他可能正在結帳結果你跳這個東西來干擾他,所以先把它存起來,等適當的時機再呼叫installPromptEvent.prompt()來跳出 Dialog。
但要注意的事情是你直接呼叫installPromptEvent.prompt()是沒用的,你必須要within a user gesture,意思就是你要放在按鈕的 click 事件(或其他由使用者觸發的事件)裡才有效,直接呼叫是沒有用的,而且會看到 console 跳出錯誤訊息。
我之前一度很好奇它是怎麼做判斷的,後來發現原來有event.isTrusted可以用,可以判斷一個事件是不是被使用者主動觸發的,參考資料:MDN - Event.isTrusted
總之呢,因為在不同版本上的 Chrome 有不同行為,所以最後我們決定用下面的程式碼針對不同版本有不同的反應:

var installPromptEvent


var showTime = 30 * 1000

window.addEventListener('beforeinstallprompt', function (e) {
  e.preventDefault()
  installPromptEvent = e
  var data = navigator.userAgent.match(/Chrom(e|ium)\\/([0-9]+)\\./)
  var version = (data && data.length >= 2) ? parseInt(data[2], 10) : null
  if (version && installPromptEvent.prompt) {

    
    setTimeout(function() {
        
        if (version <= 67) {
            installPromptEvent.prompt()
            return
        }

        
        
        document.querySelector('#root').addEventListener('click', addToHomeScreen)    
    }, showTime)
  }
});

function addToHomeScreen(e) {
    if (installPromptEvent) {
        installPromptEvent.prompt()
        installPromptEvent = null
        document.querySelector('#root').removeEventListener('click', addToHomeScreen) 
    }
}
如果是 67 以下,直接呼叫就可以顯示 prompt,否則的話還要再一步,要加個 event listener 才行,而我們也選擇延遲 30 秒才顯示。
出乎意料地,這樣一個小改動帶來驚人的成長,原本一天大概才 20、30 個人安裝 PWA,經過這樣調整之後瞬間變成八到十倍,看到 GA 的那個統計圖我也嚇了一跳,沒想到效果這麼好。
與其一直積極地要別人快點安裝 PWA,還不如只要求真的對你產品有興趣(停留超過 30 秒鐘)的人。
最後我們來看看幾個知名的 PWA 都是怎麼寫他們的manifest.json
第一個是 PWA 界中很有名的 flipkart
{
    "name": "Flipkart Lite",
    "short_name": "Flipkart Lite",
    "icons": [
        {
            "src": "https:/https://static.coderbridge.com/img/techbridge/images1a.flixcart.com/www/linchpin/batman-returns/logo_lite-cbb3574d.png",
            "sizes": "192x192",
            "type": "image/png"
        }
    ],
    "gcm_sender_id": "656085505957",
    "gcm_user_visible_only": true,
    "start_url": "/?start_url=homescreenicon",
    "permissions": [
        "gcm"
    ],
    "orientation": "portrait",
    "display": "standalone",
    "theme_color": "#2874f0",
    "background_color": "#2874f0"
}
再來是鼎鼎大名的 twitter
{
  "background_color": "#ffffff",
  "description": "It's what's happening. From breaking news and entertainment, sports and politics, to big events and everyday interests.",
  "display": "standalone",
  "gcm_sender_id": "49625052041",
  "gcm_user_visible_only": true,
  "icons": [
    {
      "src": "https://abs.twimg.com/responsive-web/web/ltr/icon-default.604e2486a34a2f6e.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "https://abs.twimg.com/responsive-web/web/ltr/icon-default.604e2486a34a2f6e.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "name": "Twitter",
  "share_target": {
    "action": "compose/tweet",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url"
    }
  },
  "short_name": "Twitter",
  "start_url": "/",
  "theme_color": "#ffffff",
  "scope": "/"
}
最後則是 Google I/O 2018
{
  "name": "Google I/O 2018",
  "short_name": "I/O 2018",
  "start_url": "./?utm_source=web_app_manifest",
  "display": "standalone",
  "theme_color": "#6284F3",
  "background_color": "#6284F3",
  "icons": [{
    "src": "static/images/homescreen/homescreen57.png",
    "sizes": "57x57",
    "type": "image/png"
  }, {
    "src": "static/images/homescreen/homescreen114.png",
    "sizes": "114x114",
    "type": "image/png"
  }, {
    "src": "static/images/homescreen/homescreen128.png",
    "sizes": "128x128",
    "type": "image/png"
  }, {
    "src": "static/images/homescreen/homescreen144.png",
    "sizes": "144x144",
    "type": "image/png"
  }, {
    "src": "static/images/homescreen/homescreen192.png",
    "sizes": "192x192",
    "type": "image/png"
  }, {
    "src": "static/images/homescreen/homescreen512.png",
    "sizes": "512x512",
    "type": "image/png"
  }],
  "prefer_related_applications": false,
  "related_applications": [{
    "platform": "play",
    "id": "com.google.samples.apps.iosched"
  }],
  "gcm_sender_id": "103953800507"
}
我滿喜歡觀察別人家的這些東西,因為你會發現很多你查資料時遺漏或是根本找不到的資訊,而且這些看久了你也會有個概念,知道哪些屬性特別常用,除了manifest.json以外,也可以參考 html 裡面的 tag,一樣能學習到很多。
前陣子在與 PWA 奮戰以及被 PM 的夾擊之下,搜集了很多跟 PWA 有關的資料,也參考了許多很有用的文章,真心感謝那些前輩們的分享,才能避免後人踩一大堆坑。
雖然在 iOS 上的體驗差了點,但整體來說我還是很看好 PWA 的發展,第一個是 Google 強力推動,第二個是瀏覽器的支援度愈來愈高,就像我上面說的,iOS Safari 已經有慢慢把 Bug 給修掉了,之後的功能會比較完整一些。
再者,PWA 的使用者體驗是很不錯的,有可以接受的速度以及 Web 的彈性,重點是不用去 Google Play 特地下載就少了一道轉換的門檻(雖然還是有安裝 PWA 的門檻就是了,但我覺得比較容易一些),而 Chrome 也提供了許多機制給 PWA,希望使用者能安裝 PWA 在手機上。
總之呢,這篇主要是跟大家分享我在做 PWA 時候的一些小小心得,如果你也有什麼心得歡迎在底下留言跟我分享,感謝。
延伸閱讀與參考資料:
  1. Changes to Add to Home Screen Behavior
  2. Progressive Web App Splash Screens
  3. Few Tips That Will Make Your PWA on iOS Feel Like Native
  4. PWAs are coming to iOS 11.3: Cupertino, we have a problem
  5. Progressive Web App 會是未來趨勢嗎?
  6. PWA case studies
  7. A Pinterest Progressive Web App Performance Case Study
關於作者:
@huli 野生工程師,相信分享與交流能讓世界變得更美好

2020年1月14日 星期二

PWA 偽裝術:manifest.json

https://jonny-huang.github.io/angular/training/19_pwa/


jonny-huang.github.io

PWA 偽裝術:manifest.json

Jonny Huang

什麼是 漸進式網頁應用程式(PWA) 其在網路上已經有很多篇文章了,筆者看完得到的結論就是-讓以前只有 APP 做得到的事情,現在在 Web 上也可以做到,而現在之所以能實現是因為新版的瀏覽器增加了相關功能,言下之意就是瀏覽器的版本很重要,後續測試會以 Google Chrome 為主。
我們可以看一下 W3C 最新的 Web App Manifest 草案,可以看到編輯者主要來自 Mozilla、Intel、Google、Microsotf,這意味著主流瀏覽器都將支援。

雖然 Apple 並沒有在名單內,不過 查看 WebKit Feature Status 可以看到 Web App Manifest 已經列入考慮選項內,這也表示如果沒有意外 Safari 未來也將支援。

參考文件:你的首個 Progressive Web App

manifest.json

manifest.jsonService Work 可以說是 PWA 最核心的功能,比起較複雜的 Service Work,本篇先練習如何透過 manifest.json 就可以讓 Web APP 看起來跟一般的 APP 一樣。
這邊我們拿之前練習到 Angular 服務 的程式來當範例。
first-app_2017-09-14.zip
若要下載請記得先透過指令 npm install 來重新安裝 package。
我們在專案目錄下的 src 資料夾內建立一個 manifest.json,並添加相關設定值,以及應用程式圖示,下面是比較常用的設定值:
若是 Angular 專案,別忘了將 manifest.json 加到 .angular-cli.jsonassets,這樣建置時才會將 manifest.json 一併複製。
欄位 說明
name 應用程式名稱
short_name 應用程式簡稱
display 顯示模式:fullscreenstandaloneminimal-uibrowser
start_url 設定起始頁面
description 應用程式描述
scope 相關設定影響範圍
background_color 啟動畫面(Splashscreen)背景顏色
theme_color 主題顏色
dir 排版方式:ltr(由左到右)、rtl(由右到左)、auto
lang 應用程式語系
orientation 螢幕方向:anynaturallandscapeportraitportrait-primaryportrait-secondarylandscape-primarylandscape-secondary
icons 應用程式圖示
其他設定可參考
W3C - Web App Manifest
MDN - WebExtensions:manifest.json
我們先添加應用程式名稱與圖示:

  1. {
  2. "name": "WebApp - First App",
  3. "short_name": "FirstApp",
  4. "description": "My First APP",
  5. "icons": [
  6. {
  7. "src": "./assets/images/android_048.png",
  8. "sizes": "48x48",
  9. "type": "image/png"
  10. },
  11. {
  12. "src": "./assets/images/android_096.png",
  13. "sizes": "96x96",
  14. "type": "image/png"
  15. },
  16. {
  17. "src": "./assets/images/android_144.png",
  18. "sizes": "144x144",
  19. "type": "image/png"
  20. },
  21. {
  22. "src": "./assets/images/android_192.png",
  23. "sizes": "192x192",
  24. "type": "image/png"
  25. },
  26. {
  27. "src": "./assets/images/android_512.png",
  28. "sizes": "512x512",
  29. "type": "image/png"
  30. }
  31. ]
  32. }

找不到圖示的,可以像筆者一樣去下載 Metro Studio,註冊帳號後就會收到一組免費序號,目前版本累積圖示有 7000 多個,可以自己調整圖示大小、顏色、旋轉、外框,也提供多種匯出格式,不過最重要的是可以用於商業用途
接著就是將 manifest.json 關連到網頁上,開啟 src\index.html,將下列語法加入 head tag 內。
<link rel="manifest" href="manifest.json">
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <title>FirstApp</title>
  6. <base href="./">
  7. <meta name="viewport" content="width=device-width, initial-scale=1">
  8. <link rel="icon" type="image/x-icon" href="favicon.ico">
  9. <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
  10. <link rel="manifest" href="manifest.json">
  11. </head>
  12. <body oncontextmenu="return false">
  13. <app-root></app-root>
  14. </body>
  15. </html>

Safari

目前針對 Safari 瀏覽器我們需要在網頁內的 head tag 加入額外設定,尤其需要針對不同 iOS 裝置提供對應尺寸的圖示,當 Safari 正是支援 W3C 標準後應該就可以省略,修改設定如下:
<meta name="apple-mobile-web-app-capable" content="yes">
  1. ...
  2. <head>
  3. ...
  4. <link rel="manifest" href="manifest.json">
  5. <!-- Apple Safari -->
  6. <meta name="apple-mobile-web-app-capable" content="yes">
  7. <meta name="apple-mobile-web-app-status-bar-style" content="black">
  8. <meta name="apple-mobile-web-app-title" content="WebApp - First App">
  9. <link rel="apple-touch-icon" href="./assets/images/android_057.png" sizes="57x57">
  10. <link rel="apple-touch-icon" href="./assets/images/android_060.png" sizes="60x60">
  11. <link rel="apple-touch-icon" href="./assets/images/android_072.png" sizes="72x72">
  12. <link rel="apple-touch-icon" href="./assets/images/android_076.png" sizes="76x76">
  13. <link rel="apple-touch-icon" href="./assets/images/android_114.png" sizes="114x114">
  14. <link rel="apple-touch-icon" href="./assets/images/android_120.png" sizes="120x120">
  15. <link rel="apple-touch-icon" href="./assets/images/android_144.png" sizes="144x144">
  16. <link rel="apple-touch-icon" href="./assets/images/android_152.png" sizes="152x152">
  17. <link rel="apple-touch-icon" href="./assets/images/android_167.png" sizes="167x167">
  18. <link rel="apple-touch-icon" href="./assets/images/android_180.png" sizes="180x180">
  19. </head>
  20. ...
參考文件:Configuring Web Applications

Windows:加到桌面

執行指令 ng serve 來啟動 Angular 專案,點選 Chrome 瀏覽器 加到桌面 功能,從出現的對話視窗可以看到,圖示跟應用程式名稱都是來自 manifest.json,確認後可以發現桌面多一個類似應用程式的捷徑


點選捷徑啟動應用程式,可以發現透過 Chrome 預設開啟時就我們隱藏網址列與工具列,若不是透過滑鼠右鍵的功能選單,還真的很難發現這是 Web App,跟我們在 Electron:跨平台的視窗應用程式 所做的步驟比起來是否更加簡單。

如果確定網站不會使用到滑鼠右鍵,那可以更進一步把右鍵隱藏,讓一般使用者幾乎無法察覺。

start_url

接下來我們安裝 Chrome 擴充功能-Web Server for Chrome,透過它可以快速建立一個網頁伺服器,我們只要將資料夾設定為 Angular 輸出資料夾 dist,接著啟動 Web Server 就可以開始使用,在這邊筆者特別將連接埠改為 8081,透過瀏覽器檢視 http://10.0.1.107:8081。


再將 Web 程式加到桌面,我們可以注意到因為 Angular 路由規則的關係預設會被導到待辦事項,所以網址會變成 http://10.0.1.107:8081/home/to-do-list。

接著透過桌面捷徑啟動,可以發現找不到 /home/to-do-list 的錯誤訊息,這是因為 Angular 是一個 SPA 網頁應用程式,所以除了首頁(index.html)之外就沒有任何實體網頁,網址列上的網址都是 Angular 路由模組產生的路由路徑,實際上並不存在。


我們可以透過設定 manifest.jsonstart_url 來解決這個問題,將 start_url 設定為首頁-index.html,透過添加此屬性,可以強制改由 start_url 所指定的網址來開啟。
  1. {
  2. "name": "WebApp - First App",
  3. "short_name": "FirstApp",
  4. "start_url": "./index.html",
  5. "description": "My First APP",
  6. "icons": [...]
  7. }
重新建立潔淨後再執行應該就能正常顯示了。

Android:加到主畫面

接著我們改用 Android 手機的 Chrome 瀏覽器連結網站 http://10.0.1.107:8081。

透過 Chrome 加到主畫面 功能,我們一樣可以將網站釘選到手機桌面,只是預設名稱會改用 應用程式簡稱(short_name),點選新增後就會在手機桌面建立捷徑。



點選捷徑來啟動 Web App 可以發現在網頁載入的等待過程會有 啟動畫面(Splashscreen),開啟後可以發現一樣是沒有網址列的滿版網頁。


最後我們在 manifest.json 內在透過 background_color 來設定啟動畫面的背景顏色,透過 theme_color 來設定手機的主題顏色,修改如下:
  1. {
  2. "name": "WebApp - First App",
  3. "short_name": "FirstApp",
  4. "start_url": "./index.html",
  5. "background_color": "#5490CC",
  6. "theme_color": "#F4981F",
  7. "description": "My First APP",
  8. "icons": [...]
  9. }
重新建立捷徑後再啟動,可以看到啟動畫面的背景顏色與手機上方的狀態列顏色都改變了。


first-app_2017-09-25.zip

2019年10月29日 星期二

tesseract ocr 圖像辨識安裝

安裝

  1. 連到 https://github.com/UB-Mannheim/tesseract/wiki
  2. 下載 tesseract-ocr-w32-setup-v4.0.0-beta.1.20180608.exe
  3. 安裝完後需要把安裝路徑加入到 path 裡面,例如 C:\Program Files (x86)\Tesseract-OCR
  4. 添加 TESSDATA_PREFIX 環境變數,內容為 C:\Program Files (x86)\Tesseract-OCR\tessdata
  5. 開啟 console, 輸入 tesseract --version 驗證是否有成功安裝

簡單使用教學

tesseract.exe test.png out.txt
Reference:
https://github.com/tesseract-ocr/tesseract/wiki