Aug 29

蓝牙热敏票据打印机编程整体优化思路

Lrdcq , 2020/08/29 12:09 , 程序 , 閱讀(1244) , Via 本站原創
背景

在我们电商企业应用开发过程中,不可避免的遇到了现在行业里常用的蓝牙热敏票据打印机,这类打印机不同于标准的ESC/P编程打印机,往往会自定义自己的简化操作指令并进行相对固定的动作,一方面是本事功能受限,另一方面也能让小票打印编程更简单。我们使用的一款打印机的指令设计就很直接,纯字符的:
//初始化打印区域
SIZE 58 mm,30 mm
GAP 2 mm
//清除图像缓冲区全部数据
CLS
//往图像缓冲区填写数据
TEXT 50,50,"4",0,1,1,"这是一行文字"
BITMAP 200,200,2,16,0,xxxxxxxxxxxxxxxxx //这是一张图片,图片是二值图二进制数据
BOX 100,100,200,200,5 //这是一个矩形框
QRCODE 20,20,L,4,A,0,"这是一个二维码"
//打印缓冲区图像1次
PRINT 1

看起来确实很简单,命令全部通过基本可编程的字符串传输并执行。但是需要注意的是,打印机机能并没有想象中的那么强:

1. 文字渲染其实只是根据字符从字体芯片中取出图像并且直接绘制,文字不具备非等比缩放功能。
2. 图像也是直接绘制0/1二进制二值数据,没有任何解码,转换相关工作在其中。
3. 二维码,当然是有一个专用的二维码芯片把文字转换为二维码数据。

因此整体上看,打印机本身并没有什么问题或者风险,简单来说,运行效率=指令长度。毕竟指令能力是相当固定的。但是对应的,在打印机能力上施加灵活性后,会产生巨大的性能瓶颈,需要在软件层面给予解决与优化。

點擊在新視窗中瀏覽此圖片
(一张实际的标签大概长这样)

需求与性能困境

因为打印机能力受限,简单来说,只能按照固定比例打印文字,图片,矩形,条码,和其他直接针对图像缓冲区的操作(and / or / xor),因此产生需求上的困难包括:

1. 自定义字体字号。因为实际的文字打印能力是字体芯片提供的,因此完成无法自定义字体字号,本来标签就相对狭小,这会导致标签排版布局上受限无法版面利用最大化。
2. 同时由于文字/图片等都不具备缩放能力,结果上导致打印不同尺寸的标签时需要单独进行设计与布局,大大增加了标签运营成本。

同时考虑到:基于对打印机芯片的分析,显然这个打印机的指令运行无论是文字二维码还是啥,均为通过特定芯片转换为二值化图像写入缓冲区的,因此,最简单的思路就是:

- 如果我们把硬件不具备绘制能力的东西,自己在软件上完成绘制,以图像的形式交给打印机,就可以完全绕过硬件能力的限制了

理想很美好,现实很骨感,实际实现一下就会发现,这个方法的打印速度确实太慢了。主要的瓶颈是:

1. 关键:图像数据量太大,在蓝牙上传输缓慢。一张200dpi 60x40mm的标签实际上480x320的像素,那么这么大一张二值图有多大呢:8个像素一个byte,就是19kb。由于蓝牙传输分段的特点和打印机硬件限制,实际这19kb需要传输3s-5s左右。想想一下如果我在连续打印标签的流水线生产过程,每打印一个标签要停顿3-5s,是完全不可以接受的。
2. 另一个麻烦是,准备数据的过程得动用前端的图像绘制能力,比如canvas或者别的啥,同时还得进行图像二值化才能递给打印机。

因此我们的优化为两个方向:首先是对指令与数据传输做尽可能的压缩与简化;在这基础之上,对发送端的预渲染与运算逻辑也做尽可能的性能优化。

解决方案-数据压缩

数据压缩方案是指的,尽量减小传输到打印机上的指令长度,从而缩短蓝牙指令传输时间,进而缩短标签打印间隔时间,提升打印效率。

P0: 分布局区域打印

- 只要打印标签上的内容可以抽象,我们就算是绘制成一整张图,也不用真的绘制一张大的图,而是按布局区域,文本区,图像区,二维码区来进行绘制。每个区域分开传输指令并绘制。
- 这样首先避免了大图传输的问题,并且为我们提供了基础思路:渲染区域分块。整个后续优化方向均在这个基础上进行。

P1:区域differ重绘

- 前端渲染逃不掉的是数据驱动渲染,首先我们要知道,打印机的缓冲区,是可以清除部分,或者直接复写的:
//清除图像缓冲区全部数据
CLS
//清除图像缓冲区部分数据
ERASE x_start,y_start,x_width,y_height

- 因此显然在第一张打印完成之后,后续的打印指令,我只需要传输数据发生改变的区域,对每个区域执行ERASE并重现传输绘制指令,就ok了。
- 因此基于数据驱动打印的特点,我们至少需要非常简要的数据模型绑定与类型映射,比如:
//state数据json
{
  "title": "【标签】标签文本文本文本文本",
  "code": "P3128932179321",
  "qrcode": "https://lrdcq.com/?code=P3128932179321",
  "footerimage": "file://..../logo.png",
  "footertext": "驴肉有限公司荣誉出品"
}
//布局模型json
{
  "title": {
    "type": "text:imagerender",
    "x": 10,
    "y": 10,
    "width": 440,
    "height": 50,
    "font-size": 26
  },
  "code": {
    "type": "text:sourcerender",
    "x": 10,
    "y": 80,
    "width": 300,
    "height": 20,
    "font-size": 16
  },
  "qrcode": {
    "type": "qrcode:sourcerender",
    "x": 320,
    "y": 80
  },
  "footerimage": {
    "type": "imagerender",
    "x": 10,
    "y": 300
  },
  "footertext": {
    "type": "text:sourcerender",
    "x": 200,
    "y": 320,
    "width": 280,
    "height": 30,
    "font-size": 16
  }
}

點擊在新視窗中瀏覽此圖片

声明数据和render和关系,和render区域信息,我们应该能清晰的知道:a. 每个渲染区域的x, y ,width, height,这也是每个数据对应区域的重绘清理范围。b. 整体布局,区域之间的重叠关系,计算重绘区的时候需要联动处理。

- 因此结果上,整体逻辑会变为:对于同一布局(相同布局模型json),第一张进行全部重绘,第二张开始会计算数据上和第一张的differ,并且重绘对应的区域,如果区域之间存在遮挡关系,也需要重绘遮挡区域,递归的完成整个渲染,

可能存在的风险:

- 重绘指令,每一个区域都会插入ERASE指令去清除重绘区,因此如果布局划分区域极多并且重绘区域的块数也很多,确实有可能出现ERASE指令占用长度过多导致优化无效甚至负优化。因此需要在实际场景中关注第一张full打印和第二张differ打印的实际传输指令长度。
- 如果区域叠加采用了更复杂的叠加渲染逻辑(不是and,而是or或者xor),布局配置相对会复杂很多。

P2:区域最小重绘区

- 完成P1优化过后,数据量说不定已经能减小70%,但是整体还是很慢 ,主要集中在image传输上。比如如上的文字区域:
{
  "title": {
    "type": "text:imagerender",
    "x": 10,
    "y": 10,
    "width": 440,
    "height": 50,
    "font-size": 26
  }
}

字大区域也大,准备的区域足足有440x50,但是实际上文字渲染上去,a. 有大量空白区域。 b. 实际业务场景中文字改变并没有那么大。因此每次传输一张全尺寸的位图去刷新,着实不实惠。

- 因此对于渲染为图像传输的区域,需要进行更精细的重绘区计算。假设这么两个title区域图像产生differ:
--
點擊在新視窗中瀏覽此圖片
--
因为文字渲染是我们自己完成的排版,因此在这个场景下,我们逐行进行图像differ运算,可以找个每一行的最小重绘区如上。

- 运算differ的逻辑其实很简单,因为二值化图像其实就是一个全是0/1的二维数组(bool[w][h]或者实际为uint[w/8][h]),因此实际differ算法是: a. 对两个图像的所有数据进行xor操作,得到differ数组。b. 对这个二维数组从xy两个方向寻找min/max的位置,逼近得到的矩形区域即单个最小重绘区。
- 因此结论上,对于所有图像绘制进行differ的时候,进行最小重绘区计算,进而继续缩小传输数据大小。针对对于文字渲染图像计算时,以排版行为单位分区域计算,能更有效的缩小重绘区。

可能存在的风险:

- 在P1的基础上,这个优化一定是一个正向优化,但是重绘区计算有可能效果很差,默认的算法,比如0,0一个differ,100,100一个differ,就会算出100x100的重绘区。
- 当然,类似于文字渲染,我们可以通过一定算法对特定数据/通用数据继续进行拆分,拆分出更小更合理的重绘区,当然重绘区数量也会上升。那么这就会遇到大量ERASE和BITMAP指定带来的传输成本的权衡问题了,和P1的情况一样。如果要做这个优化,无比进行精密的测算。

P3:字符表绘制

- 对于文字的图像绘制的场景——其实是我们目前遇到阻碍最大的场景,进行P2的图像differ绘制确实可以解决绝大部分问题。但是还是觉得很大怎么破。注意到对于一个用户绘制的文字,绝大部分都是重复的——就算中文不是绝大部分是重复的,a-zA-Z0-9与常见标点符号至少是重复的。
- 考虑到打印机本身的文字绘制能力,模拟一下,即可得到路径:a. 在初始化阶段把用户常用字符绘制的图像数据推到打印机上(后续也可以补充)。b. 文字绘制时调用对应字符图像进行绘制。c. 保留P2优化,differ绘制也是在区域内按字符进行覆盖绘制。

实际实现上,考虑“把用户常用字符绘制的图像数据推到打印机上”,真的有这个能力么?检查过后,分两种情况:

1. 高级打印机,还真有把image文件推送到打印机的ram上的能力。因此打印P11223344的时候只需要调用类似于:
PUTBMP 10,10,"char_20_P.bmp"
PUTBMP 30,10,"char_20_1.bmp"
PUTBMP 50,10,"char_20_1.bmp"
PUTBMP 70,10,"char_20_2.bmp"
PUTBMP 90,10,"char_20_2.bmp"
PUTBMP 110,10,"char_20_3.bmp"
PUTBMP 130,10,"char_20_3.bmp"
PUTBMP 150,10,"char_20_4.bmp"
PUTBMP 170,10,"char_20_4.bmp"

就可以解决问题了。当然按字符排版得自己计算(如果涉及到非等宽字体,确实相对麻烦)。另外由于注意到储备的是字符的图像,所以其实包含的是字体/字号两个信息,同一个字体不同字号的图像肯定不同。

2. 对于更多打印机,并不具备推送文件的能力,不过简要的指令设计上居然包含“变量”与“函数”,那就简单的毕竟指令即程序内存本身也包含数据的,我们推送单个字符函数到设备上,再按需调用即可。类似于:
X=0
Y=0
:DARW_20_1
  BITMAP X,Y,2,16,0,xxxxxxxxxxxxxxxxx
RETURN
:DARW_20_2
  BITMAP X,Y,2,16,0,xxxxxxxxxxxxxxxxx
RETURN
:DARW_20_3
  BITMAP X,Y,2,16,0,xxxxxxxxxxxxxxxxx
RETURN
:DARW_20_4
  BITMAP X,Y,2,16,0,xxxxxxxxxxxxxxxxx
RETURN
:DARW_20_P
  BITMAP X,Y,2,16,0,xxxxxxxxxxxxxxxxx
RETURN

//使用
X=10
Y=10
GOSUB DARW_20_P
X=30
Y=10
GOSUB DARW_20_1
X=50
Y=10
GOSUB DARW_20_1
X=70
Y=10
GOSUB DARW_20_2
X=90
Y=10
GOSUB DARW_20_2
//...

可能存在的风险:

- 如果字符重复率不高,这个写法的指令成本其实是相当大的,因此保守的做法是只用该方案解决0-9和选定的常用字母/标点,中文可能得考虑考虑。
- 再叠加上重绘区方案,重绘区计算会从像素重绘区比较变成字符重绘区比较,更复杂。
- 软件部分需要自己管理字符/字体渲染与文本字符级排版,逻辑也更加复杂。

PX:其他考虑

- 上面提到的方案是渐进过程,实际实施过程可以逐步实施,先完成P0,P1再考虑后续优化。
- 整体上看,以上优化完成后,都有优化效果曲线受参数控制的问题,因此在实际运行时,肯定有功能/参数决策问题,可行的做法包括:

1. 在项目实施过程中人工测试找出最合适的配置。落到项目实施成本上。
2. 在运行时进行多重计算,用不同配置/参数生成多组指令,选出最优的一项。这个对实际运行的算力消耗较差,肯定会导致如下的运算瓶颈。
3. 结合端智能应用,建立用户使用情况(比如常用字符,常用字段变化情况)的模型,结合模型在运行时给出最优解。

解决方案-运算压缩

完成数据压缩部分的优化后,蓝牙传输部分的问题大大缓解,但是对应的发送端前端的运算压力激增。实际可能出现的运算瓶颈包括:

1. 图像绘制,利用canvas或者啥啥绘制文字/图像缩放,并转换为二值图的这个过程,像素处理其实很快,但是canvas这张系统对象操作成本相当高。这部分的性能开销实际主要是在画板的高频率创建销毁与数据导出上。
2. 像素differ和文字排版differ运算(涉及到文字测量)。这部分的开销主要是字符处理上。

减小运算压力的路径其实很明确——缓存,空间换时间。因此具体做法包括:

1. 画布复用:canvas等系统画布能力就不能频繁创建销毁,而是复用同一块画布,并通过实际预期的绘制区域,按区域取到渲染数据。
2. 字符图像缓存:我们动态绘制的到的单个字符图像,按理说都是常用字符,都应该做内存/储存的双从缓存,储存图像本身与关联其字体字号,然后利用LRU缓存策略最短路径取到合适的资源。
3. 普通图像缓存:当然对于普通的图像,不管是本地图片资源还是http下载的图片,也应该做相同的事情,对图片不同缩放x—y的运算二值图做持久化缓存。
4. 构建时预运算:类似于本地图像,固定资源等,在布局资源包准备时就应该完成预运算,包括可能的布局结果,字体信息等。比如我某个布局某一个文本区域,已经确定字体字号,那我下发的布局文件中完全可以以资源包的形式,把字体图像数据,布局使用的测量参数,提前运算好直接下发到客户端上。
5. 像素运算优化:像素储存合理化——密集二值图应使用uint数组储存,内存使用能合理,访问速度更快;像素操作合理化——因为我们的像素操作基本上是模拟打印机缓冲区操作,因此尽量使用位运算(and, or, xor)来完成。

完成以上事项,唯一的瓶颈应该就是客户端的内存和io问题了。这个事情只能根据机能在运行时进行平衡。

其他讨论

- 整个优化的核心是优先传输过程进行的,从实际出发,目前打印发送端,不管是pc还是移动设备,运算机能基本上没什么问题。但是如果真的遇到超低配置的设备(比如低端安卓机)或者运行压力很大的设备(多个运算程序并行抢占资源),我们整个程序应有能力降级到纯传输模式,取消一些cpu/io等高硬件依赖的优化项目。因此整个打印客户端的运行状态监控,降级开关切换等方案需要再议。

- 打印机的指令集一般还会有一些别的限制,包括:1. 程序ram毕竟也是有限的,每次推送到打印机的指令对应每一条打印条码的限制也是有限的。因此在图片推送过程中需要注意指令是否超过max。不过理论上如果原本原始命令可以打印,优化后一定可以打印。对应的,因为内存足够,其实可以一口气推送多条打印指令到打印机上连续执行与打印。 2. 一些指令并不能随心所欲的使用,比如BITMAP打印的图像实际必须是byte的宽度,也就是图像宽度必须是8的倍数,这在算differ生成重写数据时有一定影响。

- 目前的整个优化方案其实是考虑到没一张标签有区别这一个特点,打印机本身的设计上如果标签没有区别的话,直接连续打印就可以了,也可以利用这个特点做组合打印方案。
logo