有贊零售小票打印跨平臺解決方案

做者:王前、林昊(魚乾)

1、背景

零售商家的平常經營中,小票打印的場景無處不在,顧客的每筆消費都會收到商家打印出的消費小票,這個是顧客的消費憑證,因此小票的內容對顧客和商家都尤其重要。對於有贊零售應用軟件來講,小票打印功能也是必不可少的,諸多業務場景都須要提供相應的小票打印能力。javascript

  • 打印需求端

圖片描述

  • 小票業務場景

圖片描述

  • 小票打印機設備類型

圖片描述

過去咱們存在的痛點:前端

  1. 每一個端各自實現一套打印流程,方案不統一。致使每次修改都會三端修改,並且 iOS 和 Android 必須依賴發版纔可上線,不具備動態性,並且研發效率比較低。
  2. 打印小票的業務場景比較多,每一個業務都本身實現模板封裝及打印邏輯,模板及邏輯不統一,維護成本大。
  3. 多種小票設備的適配,對於每一個端來講都要適配一遍。

<font color=red>其中最主要的痛點仍是在於第一點,多端的不統一問題。因爲不統一,致使開發和維護的成本成倍級增加。</font>java

針對以上痛點,小票打印技術方案須要解決的三個主要問題:git

  1. iOS 、安卓和網頁端的零售軟件都須要提供小票樣式設置和打印的能力,如何下降小票打印代碼的維護和更新成本。
  2. 如何定製顯示不一樣業務場景的小票內容:不一樣業務場景下的小票信息都不盡相同,好比購物小票和退款小票,商品信息的樣式是同樣的,可是支付信息是不同的,購物小票應當顯示顧客的支付信息,退款小票顯示商家退款信息。
  3. 如何更靈活的適配多種多樣的小票打印機,從鏈接方式上分爲藍牙鏈接和 WIFI 鏈接,從紙張樣式分爲 80mm 和 58mm 兩種寬度。

2、總體解決方案

針對以上三個問題,咱們提出了一個涉及前端、移動端和服務端的跨平臺解決方案,github

  • 架構圖

圖片描述

架構設計的核心在於經過 JS 實現支持跨平臺的小票解析腳本,並具備動態更新的優點;經過服務端下發可編輯的樣式模板實現小票內容的靈活定製;客戶端啓動 JS 執行器執行 JS 小票腳本引擎(如下簡稱:JS 引擎)並負責打印機設備的鏈接管理。json

1 、JS 引擎設計

JS 引擎主要能力就是處理小票模版和業務數據,將業務數據整合到模版中(處理不了的交給移動端處理,好比圖片),而後將整合模版數據轉換成打印指令返給移動端。後端

  • 總體處理流程圖

圖片描述

  • 結構設計

圖片描述

* 小票格式中,打印機是一行一行的輸出。那麼基本輸出佈局單位,咱們定義爲 layout
* 默認一行有一個內容塊,即一個 layout 裏面有一個 content object
* 當一行有多列內容的時候,即一個 layout 裏面包含 N 個 content object 。 各自內容塊有 pagerWeight 表明每一個內容的寬度佔比
* 每一行的後面的是一個佔位符,用數據模型的 key 作佔位

小票 layout 樣式描述:緩存

圖片描述

content block 內容塊:架構

圖片描述

不一樣類型內容所支持的能力:app

圖片描述

  • 模版編譯

這裏使用了 HandleBars.js 做爲模板編譯的庫。此外,目前還額外提供了部分能力支持。

自定義能力:

圖片描述

  • 打印機設備適配

主要進行適配指令集解析適配,根據鏈接不一樣設備進行不一樣指令解析。目前已適配設備:365wifi 、 sunmi 、 sprt80 、 sprt58 、 wangpos 、 aclas 、 xprinter 。若是鏈接未適配的設備拋出找不到相應打印機解析器 error。

  • 調用對應打印機的 parser 指令解析流程

圖片描述

  • 兼容性問題

    • 切紙:支持外部傳入是否須要切紙,防止外部發送打印指令時加入切紙指令後重復切紙問題,默認加切紙指令。
    • 一機多尺寸打印:存在一臺打印機支持兩種紙張打印( 80mm 、 58mm ),這時須要從外部傳入打印尺寸,默認 80mm 。好比,sunmiT1 支持 80mm 和 58mm 打印,默認是 80mm 。
  • 容錯處理

    • 因爲模版解析有必定格式要求,因此一些特殊字符及轉移字符存在數據中會存在解析錯誤。因此 JS 在傳入數據時,作了一層過濾,將 "\\" 、 "n" 、 "b" ... 等字符去掉或替換,保證打印。
    • 若是在解析過程當中存在錯誤,將拋出異常給移動端捕獲。

2 、模板管理服務

小票模板的動態編輯和下發,模版動態配置信息存儲和各業務全量模版存儲,提供移動端動態配置信息接口,拉取業務小票模版接口,各業務方業務數據接口。

  • 總體處理流程圖

圖片描述

  • 小票基礎模版庫存儲示例

圖片描述

shopId:店鋪 ID

business:業務方

type:打印內容類型

content:layout 中 content 內容

sortWeight:排序比重,用於輸出模板 layout 順序

  • 動態設置數據存儲示例

圖片描述

shopId:店鋪 ID

business:業務方

type:打印內容類型

params:須要替換填充的內容

  • 接口返回整合後的小票模版 json
{
    "business": "shopping",
    "shopId": 111111,
    "id": 321,
    "version": 0,
    "layouts": [{
                "name": "LOGO",
                "content": "[{\"content\":\"http://www.test.com/test.jpg\",\"contentType\":\"image\",\"textAlign\":\"center\",\"width\":45}]"
                },{
                "name": "電話",
                "content": "[{\"content\":\"電話:{{mobile}}\",\"contentType\":\"text\",\"textAlign\":\"left\",\"fontSize\":\"default\",\"pagerWeight\":1}]"
                },...]
}

其中相關動態數據後端已經作過整合替換,須要替換的業務數據保留在模板 json 中,等獲取業務數據後由 JS 引擎進行替換。
上面 json 中 http://www.test.com/test.jpg 就是動態整合替換數據,{{mobile}} 是一個須要替換的業務數據。

3 、移動端

移動端除了動態模版配置以外,主要的就是打印流程。移動端只須要關心須要打印什麼業務小票,而後去後端拉取業務小票模版和業務數據,將拉取到的數據傳給 JS 引擎進行預處理,返回模版中處理不了的圖片 url 信息,而後移動端進行下載圖片,進行二值轉換,輸出像素的 16 進制字符串,替換原來模版中的 url ,最後將鏈接的打印機類型和處理後的模版傳給 JS 引擎進行打印指令轉換返回給打印機打印。

  • 動態模版配置

圖片描述

動態配置小票內容,支持 LOGO 、店鋪數據、營銷活動配置等。左側爲在 80mm 和 58mm 上預覽樣式。經過動態配置模版,實現後端接口模版更新,而後能夠實時同步修改打印內容。網頁零售軟件上動態配置內容和移動端同樣。

  • 打印業務流程

圖片描述

該業務流程,移動端徹底脫離數據,只須要作一些額外能力以及傳輸功能,有效解決了業務數據修改依賴移動端發版的問題。 Android 和 iOS 流程統一。

3、移動端功能設計

1 、動態化

動態化在本解決方案裏是必不可少的一環,實時更新業務數據模板依賴於後端,可是 JS 解析引擎的下發要依靠移動端來實現,爲了及時修復發現的 JS 問題或者快速適配新設備等功能。更新流程圖以下:

圖片描述

這裏說明一下,由於可能會出現執行 JS 的過程當中,正在執行本地 JS 文件更新,致使執行 JS 出錯。因此在完成本地更新後會發送一個通知,告知業務方 JS 已更新完成,這時業務方可根據自身需求作邏輯處理,好比從新加載 JS 進行處理業務。

2 、JS 執行器

iOS 使用 JavaScriptCore 框架,Android 使用 J2V8 框架,具體框架的介紹這裏就不說明了。JS 執行器設計包含加載指定 JS 文件,調用 JS 方法,獲取 JS 屬性,JS 異常捕獲。

/**
     初始化 JSExecutor

     @param fileName JS 文件名
     @return JSExecutor
     */
    - (instancetype)initWithScriptFile:(NSString *)fileName;

    /**
     加載 JS 文件

     @param fileName JS 文件名
     */
    - (void)loadSriptFile:(NSString *)fileName;

    /**
     執行 JS 方法

     @param functionName 方法名
     @param args 入參
     @return 方法返回值
     */
    - (JSValue *)runJSFunction:(NSString *)functionName args:(NSArray *)args;

    /**
     獲取 JS 屬性

     @param propertyName 屬性名
     @return 屬性值
     */
    - (JSValue *)getJSProperty:(NSString *)propertyName;

    /**
     JS 異常捕獲

     @param handler 異常捕獲回調
     */
    - (void)catchExceptionWithHandler:(JSExceptionHandler)handler;

加載 JS 文件方法,能夠加載動態下發的 JS 。邏輯是先判斷本地下發的文件是否存在,若是存在就加載下發 JS ,不然加載 app 中 bundle 裏面的 JS 文件。

- (void)loadSriptFile:(NSString *)fileName{
        NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
        if (paths.count > 0) {
            NSString *docDir = [paths objectAtIndex:0];
            NSString *docSourcePath = [docDir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.js", fileName]];
            NSFileManager *fm = [NSFileManager defaultManager];
            if ([fm fileExistsAtPath:docSourcePath]) {
                NSString *jsString = [NSString stringWithContentsOfFile:docSourcePath encoding:NSUTF8StringEncoding error:nil];
                [self.content evaluateScript:jsString];
                return;
            }
        }
        NSString *sourcePath = [[YZCommonBundle bundle] pathForResource:fileName ofType:@"js"];
        NSAssert(sourcePath, @"can't find jscript file");
        NSString *jsString = [NSString stringWithContentsOfFile:sourcePath encoding:NSUTF8StringEncoding error:nil];
        [self.content evaluateScript:jsString];
    }

這時候可能會有人疑問,爲何這裏是直接強制加載本地下發 JS ,而不是對比版本優先加載。這裏主要有兩點緣由:

  • 動態下發 JS 文件,就是爲了補丁或者優化更新,因此通常新版本下發配置不會存在
  • 爲了支持 JS 版本回滾

JS 異常捕獲功能,將異常拋出給業務方,可讓調用者各自實現邏輯處理。

3 、緩存優化

因爲模板和數據都在後端,須要拉取兩次接口進行打印,因此須要提供一套緩存機制來提升打印體驗。因爲業務數據須要實時拉取,因此必須走接口,模板相對於業務數據來講,能夠容許必定的延遲。因此,模板採用本地文件緩存,業務數據採用和業務打印頁面掛鉤的內存緩存,業務數據只須要第一次打印是請求接口,從新打印直接使用。

流程圖:
圖片描述

本緩方案存會存在偶現的模板不一樣步問題,在即將打印時,若是網頁後臺修改了模板,就會出現本次打印模板不是最新的,可是在下一次打印時就會是最新的了。因爲出現的概率比較低,模板也容許有一點延遲,因此不會影響總體流程。

對於離線場景,咱們在 app 中存放一個最小可用模板,專門用於離線下小票打印使用。爲何是最小可用模板,由於離線下,業務數據及一些其餘數據有可能不全,因此最小可用模板能夠保證打印出來的數據準確性。

4 、圖片處理

因爲 JS 引擎是不能解析圖片文件的,因此在最初模板中存在圖片連接時,所有由移動端進行處理,而後進行替換。圖片處理主要就是下載圖片,圖片壓縮,二值圖處理,圖片像素點壓縮(打印指令要求),每一個字節轉換成 16 進制,拼接 16 進制字符串。

  • 下載圖片

採用 SDWebImage 進行下載緩存,建立並行隊列進行多圖片下載,每下載成功一張後回到主線程進行後續的相關處理。全部圖片都處理完成或,回調給 JS 引擎進行指令解析。

  • 圖片壓縮

根據 JS 引擎模板要求的 width(必須是 8 的倍數,後續說明),進行等比例壓縮,轉換成 jpg 格式,過濾掉 alpha 通道。

  • 二值圖處理

遍歷每個像素點,進行 RGB 取值,而後算出 RGB 均值與 255 的比值,根據比值進行取值 0 或 255 。這裏沒有使用直方圖尋找閾值 T 的方式進行處理,是出於性能和時間考慮。

  • 像素點壓縮

因爲打印機指令要求,須要對轉換成二值後的每一個點進行 width 上壓縮,須要將 8 個字節壓縮到 1 個字節,這裏也是爲何圖片壓縮時 width 必須是 8 的倍數的緣由,不然打印出來的圖片會錯位。

圖片描述

  • 16 進制字符串

由於打印機打印圖片接收的是 16 進制字符串,因此須要將處理後的每一個字節轉換成 16 進制字符,而後拼成一個字符串。

5 、實現屢次打印

因爲業務場景須要,須要自動打印多張小票,因此設計了屢次打印邏輯。因爲每次打印都是異步線程中,因此不能夠直接循環打印,這裏使用信號量 dispatch_semaphore_t ,在異步線程中建立和 wait 信號量,每次打印完成回調線程中 signal 信號量,實現屢次打印,保證每次打印依次進行。若是中途打印出錯,則終止後續打印。

dispatch_async(dispatch_get_global_queue(0, 0), ^{
        dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
        for (int i = 1; i <= printCount; i++) {
            if (stop) {
                break;
            }
            [self print:template andCompletionBlock:^(State state, NSString *errorStr) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    if (errorStr.length > 0 || i == printCount) {
                        if (completion) {
                            completion(state, errorStr);
                        }
                        stop = YES;
                    }
                    dispatch_semaphore_signal(semaphore);
                });
            }];
            dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 15*NSEC_PER_SEC));
        }
    });

4、總結與展望

本方案已經實施,在零售 app 中使用來看,已經知足目前大部分業務場景及需求,後續的開發及維護成本也會大幅度下降,提升了研發效率,接入新業務小票也比較方便。客戶使用上來講,使用體驗和之前沒有較大差異,同時在處理客戶反映的問題來講,也能夠作到快速修改,實時下發等。不過目前還存在一些不足點,好比說圖片打印的功能,還不能徹底知足全部圖片都作到完美打印,畢竟圖片處理考慮到性能體驗方面;還有模板後續能夠增長版本號,這樣在模板存在異常時也能夠回滾或兼容處理等;再者就是緩存優化能夠後續進一步優化體驗,好比加入模板推送,本地緩存優化等。

參考連接

圖片描述

相關文章
相關標籤/搜索