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

有贊技術發表於2018-12-10

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

一、背景

零售商家的日常經營中,小票列印的場景無處不在,顧客的每筆消費都會收到商家列印出的消費小票,這個是顧客的消費憑證,所以小票的內容對顧客和商家都尤為重要。對於有贊零售應用軟體來說,小票列印功能也是必不可少的,諸多業務場景都需要提供相應的小票列印能力。

  • 列印需求端

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

  • 小票業務場景

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

  • 小票印表機裝置型別

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

過去我們存在的痛點:

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

其中最主要的痛點還是在於第一點,多端的不統一問題。由於不統一,導致開發和維護的成本成倍級增長。

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

  1. iOS 、安卓和網頁端的零售軟體都需要提供小票樣式設定和列印的能力,如何降低小票列印程式碼的維護和更新成本。
  2. 如何定製顯示不同業務場景的小票內容:不同業務場景下的小票資訊都不盡相同,比如購物小票和退款小票,商品資訊的樣式是一樣的,但是支付資訊是不一樣的,購物小票應當顯示顧客的支付資訊,退款小票顯示商家退款資訊。
  3. 如何更靈活的適配多種多樣的小票印表機,從連線方式上分為藍芽連線和 WIFI 連線,從紙張樣式分為 80mm 和 58mm 兩種寬度。

二、整體解決方案

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

  • 架構圖

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

架構設計的核心在於通過 JS 實現支援跨平臺的小票解析指令碼,並具有動態更新的優勢;通過服務端下發可編輯的樣式模板實現小票內容的靈活定製;客戶端啟動 JS 執行器執行 JS 小票指令碼引擎(以下簡稱:JS 引擎)並負責印表機裝置的連線管理。

1 、JS 引擎設計

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

  • 整體處理流程圖

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

  • 結構設計

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

* 小票格式中,印表機是一行一行的輸出。那麼基本輸出佈局單位,我們定義為 layout
* 預設一行有一個內容塊,即一個 layout 裡面有一個 content object
* 當一行有多列內容的時候,即一個 layout 裡面包含 N 個 content object 。 各自內容塊有 pagerWeight 代表每個內容的寬度佔比
* 每一行的後面的是一個佔位符,用資料模型的 key 做佔位
複製程式碼

小票 layout 樣式描述:

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

content block 內容塊:

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

不同型別內容所支援的能力:

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

  • 模版編譯

這裡使用了 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 流程統一。

三、移動端功能設計

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));
        }
    });
複製程式碼

四、總結與展望

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

參考連結

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

相關文章