作者:王前、林昊(魚乾)
一、背景
零售商家的日常經營中,小票列印的場景無處不在,顧客的每筆消費都會收到商家列印出的消費小票,這個是顧客的消費憑證,所以小票的內容對顧客和商家都尤為重要。對於有贊零售應用軟體來說,小票列印功能也是必不可少的,諸多業務場景都需要提供相應的小票列印能力。
- 列印需求端
- 小票業務場景
- 小票印表機裝置型別
過去我們存在的痛點:
- 每個端各自實現一套列印流程,方案不統一。導致每次修改都會三端修改,而且 iOS 和 Android 必須依賴發版才可上線,不具有動態性,而且研發效率比較低。
- 列印小票的業務場景比較多,每個業務都自己實現模板封裝及列印邏輯,模板及邏輯不統一,維護成本大。
- 多種小票裝置的適配,對於每個端來說都要適配一遍。
其中最主要的痛點還是在於第一點,多端的不統一問題。由於不統一,導致開發和維護的成本成倍級增長。
針對以上痛點,小票列印技術方案需要解決的三個主要問題:
- iOS 、安卓和網頁端的零售軟體都需要提供小票樣式設定和列印的能力,如何降低小票列印程式碼的維護和更新成本。
- 如何定製顯示不同業務場景的小票內容:不同業務場景下的小票資訊都不盡相同,比如購物小票和退款小票,商品資訊的樣式是一樣的,但是支付資訊是不一樣的,購物小票應當顯示顧客的支付資訊,退款小票顯示商家退款資訊。
- 如何更靈活的適配多種多樣的小票印表機,從連線方式上分為藍芽連線和 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 中使用來看,已經滿足目前大部分業務場景及需求,後續的開發及維護成本也會大幅度降低,提高了研發效率,接入新業務小票也比較方便。客戶使用上來說,使用體驗和以前沒有較大差別,同時在處理客戶反映的問題來說,也可以做到快速修改,實時下發等。不過目前還存在一些不足點,比如說圖片列印的功能,還不能完全滿足所有圖片都做到完美列印,畢竟圖片處理考慮到效能體驗方面;還有模板後續可以增加版本號,這樣在模板存在異常時也可以回滾或相容處理等;再者就是快取優化可以後續進一步優化體驗,比如加入模板推送,本地快取優化等。
參考連結
- HandleBars, by wycats
- date-fns, by date-fns
- lodash, by Lodash
- JavaScriptCore, by Apple
- J2V8, by eclipsesource