1 二奢仓店面临的问题
1.1 什么是二奢仓店
二奢仓店是转转新开业的一家线下实体店,主营二手奢侈品和其他品类,以仓店的形式营业。
所谓仓店,即是作为一个店铺,提供商品和收银台等常规店铺的购物功能,用户可以正常进店购物。同时作为仓库,管理进出库,陈列有大量货物,一方面用于存储,另一方面也方便用户挑选。
1.2 有什么问题
作为一个同时有店铺属性和仓库属性的实体店,同时面对这门店的商品管理和收银问题,也存在仓库的货物管理和收发货问题。在这种场景下,很多环节都会需要靠打印各种标记来区分和管理实物。同时由于可能存在一台电脑会参与多个场景,对打印管理也有一定要求。主要是如下的几个条件:
- 需要打印的内容为以下几类:收银小票、商品吊牌、不干胶吊牌、配件签,再根据商品的分类和货源区分
- 现场操作人员统一使用windows系统进行操作,用到的操作后台都是网页,调起打印机进行打印的触发也是通过网页上的按钮
- 为了减少操作人员的不必要操作,打印过程尽可能的简单
- 一台电脑可能连接着多个打印机,需要根据打印场景自动选择使用哪个打印机
2 调用打印机的限制
2.1 js的局限
由于操作后台都是网页,这里首先想到的是通过网页js来调起windows打印机进行打印。一般是通过js调用window.print()
来打印,而window.print()
打印的是整个网页的内容,实际需要打印的内容仅仅是条码或收银数据,所以可以创建一个临时的iframe,在iframe内创建打印内容,再调用window.print()
来打印。
但不论是否创建iframe来打印,这种做法都会有一个问题,就是会触发一个windows的打印预览弹窗,而在仓店的实际操作中这个弹窗是没有必要的,所以这属于2.1的场景中的第3点需要解决的问题,即需要实现静默打印。
2.2 想要静默打印
网上对于js实现静默打印的办法一般有两种:
第一种是在chrome下可以通过修改chrome://flags
下的参数来实现,但是操作复杂,而且不同版本的支持情况可能也不同,属于不稳定的解决方法,不适合放在线下由店员来操作。
第二种办法是使用一个代理程序,将js调起打印的操作转化为js通知代理程序,代理程序调用打印机,这样即可绕过浏览器和windows的一般流程,实现不弹预览窗口的静默打印。
这个打印代理程序在网上能搜到一些,但是对于转转二奢仓店也存在一些特例场景不一定能满足的情况,而且这个代理程序实现起来也不难,所以还是自己做一个方便定制化。
3 做一个定制化的打印代理
3.1 java调起windows打印机
在java中调起Windows打印机,可以通过java的打印服务API实现。首先需要引入javax.print
包,然后通过PrintServiceLookup
查找可用的打印服务。对于Windows系统,可以根据名称选择特定的打印机。

示例代码如下:
java
private PrintService findPrintService(String name) {
// 查找全部的打印服务
PrintService targetPrintService = null;
PrintService[] allPrintServices = PrintServiceLookup.lookupPrintServices(null, null);
if (null != allPrintServices) {
for (PrintService printService : allPrintServices) {
if (printService.getName().equals(name)) {
targetPrintService = printService;
break;
}
}
}
if (null == targetPrintService) {
if (null != allPrintServices && allPrintServices.length > 0) {
List<String> allPrintServiceNameList = Arrays.stream(allPrintServices)
// 过滤掉window自带的打印服务
.filter(s -> !s.getName().contains("OneNote") && !s.getName().contains("Microsoft"))
.map(PrintService::getName)
.collect(Collectors.toList());
log.info("没有找到目标打印机, 目标打印机:{}, 全部打印机:{}", name, GsonUtil.toJson(allPrintServiceNameList));
} else {
log.info("没有找到目标打印机, 目标打印机:{}", name);
}
return null;
}
return targetPrintService;
}
通过这种方式,可以灵活控制当前需要使用的打印机,如果同时连接了多个同一型号的打印机,可以靠修改windows中的打印机名称来做区分。同样的,如果更换了其他型号的打印机但是打印内容没有变化,可以靠修改打印机名称来匹配原有逻辑进行打印。
3.2 我该怎么生成打印数据
仓店打印的场景中,存在多种需要打印的内容,例如收银小票、商品吊牌、配件签等等。对于不同的打印内容,需要考虑对应的打印数据获取方式和打印方式。 一般而言可以分为几种方式: 一种是使用模板文件,提前将模板画好,在实际打印时传入需要打印的数据。这种做法适合长期不变的和比较复杂的内容,例如快递面单等。

另一种是利用java的java.awt.print.Book
类来控制打印文字和坐标,交由打印机进行打印。这种做法适合样式简单文字量大的内容,例如收银小票等。
java
Book book = new Book();
book.append((graphics, pageFormat, pageIndex) -> {
try {
// 绘制一段字符串
graphics.setFont(new Font("Default", Font.BOLD, 12));
graphics.drawString("十二号字测试行", 0, 30);
graphics.setFont(new Font("Default", Font.BOLD, 14));
graphics.drawString("十四号字测试行", 0, 60);
graphics.setFont(new Font("Default", Font.BOLD, 10));
graphics.drawString("十号字测试行", 0, 90);
graphics.setFont(new Font("Default", Font.BOLD, 8));
graphics.drawString("八号字测试行", 0, 120);
graphics.setFont(new Font("Default", Font.BOLD, 6));
graphics.drawString("六号字测试行", 0, 150);
// 绘制一个条线条
graphics.drawLine(20, 155, 185, 155);
} catch (Exception e) {
log.error("绘制打印内容异常", e);
}
return PAGE_EXISTS;
}, pf);
还有一种做法是把需要打印的内容生成图片,调打印机的时候直接打印图片。这种做法相对灵活,但是对打印机的打印精度有要求,精度太低的在打印复杂图片的时候可能会丢失细节导致打印内容不清晰,尤其是涉及到文字相关的时候。
java
public PageFormat getPageFormat(int imageHeight, int imageWidth) {
// 传入图片的长宽比
double aspectRatio = getAspectRatio(imageHeight, imageWidth);
Paper paper = new Paper();
// 打印纸的页面大小
paper.setSize(130.4D, 225.4D);
// 可打印区域大小
int areaWidth = 91 - 6;
// 设置打印坐标,固定宽度,根据长宽比计算打印长度
paper.setImageableArea(17D + 7, 168D + 2, areaWidth, areaWidth * aspectRatio);
PageFormat pageFormat = new PageFormat();
pageFormat.setPaper(paper);
pageFormat.setOrientation(PageFormat.PORTRAIT);
return pageFormat;
}
仓店打印最后选择的传入图片打印的方式。之所以选择传图打印,主要有以下几点考虑:
第一是打印内容多以条码为主,这种情况下传图比较方便。
第二是更改需要打印的内容时不需要更新打印程序,只需要调整传入的图片即可。这样来说对于仓店打印这种通过网页操作的场景,可以在不需要实际操作人员介入的情况下完成更新,免去更新打印程序的步骤,更灵活,也减少实际操作人员的工作。
3.3 多场景的自助代理打印
为了能够简单的和操作后台对接,这个打印代理程序使用web的形式实现,即在仓店操作人员的电脑上启动一个web服务器,由操作后台的页面js调用http接口的形式来传入打印图片触发打印。
所以,首先需要创建web程序。java创建web程序很简单,这里选用spring boot
创建一个仅有单个接口的web程序,这个接口用来接收传入的打印图片和场景
java
@RequestMapping("/print")
@ResponseBody
public WebResponse<Void> print(@RequestParam("type") String type, @RequestParam("file") MultipartFile file)
不同的打印场景对应不同的打印参数配置
java
// 注入不同的打印服务类,key为接口入参的type
@Resource
private Map<String, IPrintService> printServiceMap;

触发打印的流程放到AbstractPrintService里统一处理
java
@Override
public boolean print(InputStream inputStream) {
PrintService targetPrintService = findPrintService(getName());
if (null == targetPrintService) {
return false;
}
BufferedImage image = null;
try {
ImageIO.setUseCache(false);
// 转换为图片类
image = ImageIO.read(inputStream);
if (null == image) {
log.error("打印错误,无法获取需要打印的内容,打印机={}", getName());
return false;
}
// 图片尺寸
int oriHeight = image.getHeight();
int oriWidth = image.getWidth();
log.debug("name={} oriHeight={} oriWidth={}", getName(), oriHeight, oriWidth);
// 创建打印任务
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(targetPrintService);
// 设置打印页面配置
PageFormat pageFormat = getPageFormat(oriHeight, oriWidth);
// 缩放比例
double scale = getScale(pageFormat, oriHeight, oriWidth);
// 设置打印内容
job.setPrintable(new ImagePrintable(image, scale), pageFormat);
PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet();
// 打印1份
attributes.add(new Copies(1));
// 发送打印任务
job.print(attributes);
log.info("打印任务已发送, 打印机={}", getName());
} catch (Exception e) {
log.error("打印过程异常, 打印机={}", getName(), e);
return false;
} finally {
if (null != image) {
image.flush();
}
System.gc();
}
return true;
}
3.4 可执行文件的独立部署
为了便于在仓店的Windows电脑上运行打印代理程序,我们选择将java程序打包为一个独立的可执行文件,来简化仓店操作人员的操作。
将java代码打包成exe文件有多种方式,仓店选择的是Launch4j
,Launch4j
可以在代码编译时生成exe文件,免去额外操作的步骤
首先这个打印程序毕竟是个基于java的程序,运行时需要依赖很多依赖包,为了简化部署的复杂度,需要添加assembly的编译插件,用来将所有的依赖包打成一个肥包,来保证单文件可运行:
xml
<!-- 编译时把依赖的jar全都打包到一个jar文件内的插件,用来保证编译出的exe文件可以独立运行 -->
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<executions>
<execution>
<id>assembly</id>
<phase>package</phase>
<goals><goal>single</goal></goals>
<configuration>
<descriptors>
<descriptor>src/main/assembly/assembly.xml</descriptor>
</descriptors>
</configuration>
</execution>
</executions>
</plugin>
然后添加launch4j的编译插件,给编译生成的肥包加exe的外壳:
xml
<!-- Launch4j插件,用来生成exe文件 -->
<plugin>
<groupId>com.akathist.maven.plugins.launch4j</groupId>
<artifactId>launch4j-maven-plugin</artifactId>
<version>${launch4j-maven-plugin.version}</version>
<executions>
<execution>
<id>l4j-clui</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<!-- 控制生成的exe文件是命令行显示还是GUI界面显示,GUI的话需要自己做一个界面 -->
<headerType>console</headerType>
<!-- 生成的exe需要包含的jar -->
<jar>
${project.build.directory}/${project.build.finalName}.${project.packaging}
</jar>
<!-- 生成的exe的输出位置 -->
<outfile>${project.build.directory}/printWebserver_1.0.exe</outfile>
<!-- exe程序的图标,会显示在例如窗口、任务管理器等地方 -->
<icon>src/main/resources/image/zzPrinter.ico</icon>
<!-- 控制程序以单实例的方式运行 -->
<singleInstance>
<mutexName>warestorePrintWebserver</mutexName>
</singleInstance>
<!-- 启动的入口类 -->
<classPath>
<mainClass>org.springframework.boot.loader.JarLauncher</mainClass>
<addDependencies>true</addDependencies>
<jarLocation>lib/</jarLocation>
</classPath>
<!-- 执行环境的一些控制参数 -->
<jre>
<minVersion>1.8.0</minVersion>
<initialHeapSize>1024</initialHeapSize>
<maxHeapSize>2048</maxHeapSize>
</jre>
<!-- exe文件信息的一些控制参数,在windows右键-属性-详细信息里显示 -->
<versionInfo>
<fileVersion>1.0.0.0</fileVersion>
<txtFileVersion>1.0.0.0</txtFileVersion>
<fileDescription>zhuanzhuan warestore universal printing program</fileDescription>
<copyright>zz_warestore</copyright>
<productVersion>1.0.0.0</productVersion>
<txtProductVersion>1.0.0.0</txtProductVersion>
<productName>printWebserver</productName>
<internalName>printWebserver</internalName>
<originalFilename>printWebserver.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
</executions>
</plugin>
配置正常的话,编译完成之后,在target目录下会有一个生成的exe文件

运行配置文件,程序启动后会开启web服务,通过js调用本地的web服务端口即可实现静默打印

4 打印偏移的通用解决方案
一般而言收银小票、纸质吊牌、标签纸这种打印纸都是以卷状销售的,对应的打印机也是按照装入打印纸卷设计的。在实际应用的场景中,难免会因为各种各样的原因导致出现与预期不一致的情况,例如:
- 纸质吊牌是定制的,出厂有预印刷的图案在上面,而这个图案因为公差等等原因导致不同批次会有一定的偏差,导致按照既定打印参数打印出来的内容位置与预期不一致
- 一卷纸打完了换另外一卷,但是因为公差、装入的错误、更换了供货品牌等等原因导致存在尺寸差异,会导致打印出来的内容位置发生偏移
这种情况轻度可能仅仅是看起来观感差一点,严重的可能会影响实际效果。例如仓店吊牌打印的是条码,如果歪了可能导致条码与预留的打印区域边框过紧,无法正常的扫描等等。


针对这种情况,一般功能比较完善的打印机驱动会提供微调的功能

但是不是所有的打印驱动都有这个功能,而且不同品牌的打印机因为驱动不同,修改偏移量的位置也可能不同。为了避免实操人员需要记住各种不同品牌的打印机的调整方法,同时能够让微调简单且通用,仓店用了一个简易的办法来实现这个功能:
首先在电脑的固定位置创建一个固定名称的txt文件,里面填入横纵坐标的偏移量,作为偏移文件

代码增加一个工具类,在程序启动时从偏移文件中读取横纵的偏移量,将偏移量加到创建打印区域时的参数上,从而实现可以简单直观的控制打印位置的功能
java
@Getter
private static LocalOffsetEntity localOffsetEntity = new LocalOffsetEntity(0.0, 0.0);
// 启动时加载偏移文件的内容
static {
try {
// 获取桌面路径
String desktopPath = System.getProperty("user.home") + "/Desktop";
// 对于Windows系统需要转义反斜杠
if (System.getProperty("os.name").toLowerCase().contains("win")) {
desktopPath = System.getProperty("user.home") + "\\Desktop";
}
File filePath = new File(desktopPath, "\\打印机偏移参数.txt");
// 读取文件内容
final String content = FileUtils.readFileToString(filePath, StandardCharsets.UTF_8);
log.info("本地偏移文件内容:" + content);
final String[] offsetArr = content.split(",");
if (offsetArr.length > 1) {
Double xOffset = Double.valueOf(offsetArr[0]);
Double yOffset = Double.valueOf(offsetArr[1]);
localOffsetEntity.setxOffset(xOffset);
localOffsetEntity.setyOffset(yOffset);
}
} catch (IOException e) {
System.err.println("读取文件失败:");
}
}
java
// 在生成打印区域的时候把偏移量加进去,来实现调整打印位置的效果
final CacheUtil.LocalOffsetEntity localOffsetEntity = CacheUtil.getLocalOffsetEntity();
paper.setImageableArea(14.2D + localOffsetEntity.getxOffset(), 121.9D + localOffsetEntity.getyOffset(), areaWidth, areaWidth * aspectRatio);
5 总结
这个代理打印程序的实现整体功能不复杂,在设计过程主要需要考虑易用性和稳定性。
- 仓店场景因为操作人员使用的后台都是网页形式,所以使用web服务来实现代理,简化和前端交互的逻辑。
- 操作人员需要长时间面对各种操作场景,需要尽量减少的操作步骤和预期外问题
- 应对不可控的场景尽量提供简单易操作的解决方案
程序员中普遍流传着一句话,"脱离了业务的程序都是耍流氓",从实际场景出发,尽可能的符合需求,是程序设计的首要目标。
关于作者:项赢,转转java开发工程师
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~