有赞零售智能硬件体系搭建历程

点击关注“ 有赞coder

获取更多技术干货哦~

作者:洪恩涛

部门:有赞零售-移动组

前言

有赞零售 App 上线至今,为了降低商家硬件迁移成本,同时提高商家硬件采购的选择多样性,陆陆续续对接了市面上 Top 20+ 的智能硬件,包括打印机、电子秤、扫码枪、摄像头、一体机等, 在硬件对接过程中团队投入了大量的人力进行支持,受限于硬件架构不成体系、硬件类目划分不清晰、通信协议多样性、多端重复适配造轮子等因素,导致硬件线上问题较多,且投入的开发成本很高,也影响了商家的正常经营。为了彻底解决这些问题,提高新设备对接效率,并确保硬件交互质量,有赞零售移动团队对硬件体系做了几次重构演进,目前一款新硬件的对接与适配成本已经控制在一到两个工作日内,相较2019年人力投入降低了50%。同时通过不断完善硬件 FAQ 文档,协助商家与硬件支持同学快速定位解决问题,硬件开发同学直接处理的线上问题数量相较2019下半年环比下降55%,技术支持同学对接的硬件问题也环比下降了33%,提效比较明显。

一、智能硬件矩阵

1.1 设备使用场景简介

硬件类型 使用场景 对接设备
一体机 线下门店都会在收银台配置一款收银机,方便商家与收银员进行门店经营开单操作 商米、天波、联迪、中科英泰等
打印机 订单正向与逆向环节需要打印小票,比如购车小票、退货小票等 365 、映美云、佳博、思普瑞特、易联云等
副屏 开单支付与会员结算流程中,订单信息对顾客足够透明,可通过副屏将购物车、会员、支付相关信息投影到副屏上 商米 T2、T1、D2、联迪等
客显 除了副屏可以投影订单数据之外,还提供客显这种低成本的外接设备进行数据投影 中崎等
人脸识别 通过摄像头采集顾客人脸信息,支持会员快速识别、快捷支付等 青蛙 Pro、蜻蜓、三方摄像头等
NFC & Rfid 通过对接磁条、nfc、Rfid 等外接设备,满足实体卡支付、Rfid 条码商品(一般应用在服装品类)等加购场景 cas、灵天智能等
电子秤 生鲜果蔬商家涉及到称重环节,通过适配电子秤满足称重的经营场景 凯士、大华、欧陆达、S2 等
POS机 部分商家不采购收银机,只需要使用 POS 进行订单结算,且需要支持刷卡功能 WANGPOS、SUNMI P 系列等

1.2 硬件矩阵图

1.3 体系搭建介绍

有赞零售对接的设备种类繁多,由于篇幅内容有限,接下来会着重讲解打印机、 POS 、电子秤、副屏相关技术的设计细节。

二、硬件库拆解重构

零售设备库 sdk 早期设计类似于全家桶,聚合了打印机、电子秤、POS 机等所有设备,扩展性比较差,随着新机器的适配接入,造成 sdk 频繁升级,稳定性无法保证。前期只接入几款设备勉强还能应付过来,随着业务迭代发展,设备接入种类与数量越来越多,当前的设备库架构设计显得非常臃肿,维护与适配成本比较高,开发对接效率也非常低。

为了彻底解决这些问题,组内经过多次讨论与论证,全家桶的方式需要被彻底推翻改造,首先要做的就是对设备进行分类,将通用的设备放到单独的 module 中进行维护,打成 aar 给业务方灵活调用,且需要下沉抽象出一些通用能力,降低新设备的接入成本,通过这次的架构设计迭代,新设备适配人力成本减少 2 倍以上,且硬件上线质量也得到了有效保证。

架构图

新设备库框架部分参考 Android 系统架构模型,分为 OEM、Core 、 Base 、Library 四层,OEM 为业务 Manager 层,业务方只需要感知 Manager 提供的 Api ,底层能力通过 Core、Base 支撑,同时 Library 层将硬件之间一些通用的三方sdk(比方说 WANGPOS SDK 既提供刷卡能力,又提供打印能力,聚合多个 OEM 的功能,可以共享)共享出来,供 OEM 层调用。

2.1 设备库架构介绍

2.1.1 OEM 层

提供 PrinterManager 、 PosManager 、 WeightManager 、 XXXManager 等设备管理类,供业务方调用,且每个设备单独打成 aar ,供业务方灵活依赖。

例如:

零售工程通过模块化进行开发管理,所有硬件能力通过 module_device 向外提供能力,业务 module 通过调用模块间定义的向外暴露接口(有赞零售模块间通信方式详细设计请参考这篇文章 Android -模块化-面向接口编程 )来访问 module_device 提供的能力,同时 module_device 会依赖设备能力各自的 aar ,其他业务模块只需要向设备模块要能力,不需要关心设备模块具体的实现,模块之间责任划分清晰。

调用示例图:

2.1.2 Core 层

提供设备通用能力,包括设备模型、连接能力、缓存能力、设备状态心跳检测、异常处理、线程管理、读写能力等。

1)设备模型

零售对接了如此多的的设备,设备模型的抽象尤为重要,包括设备连接类型、设备 id 、设备型号、设备状态、设备标签、是否需要缓存等,分类设备又可以基于设备模型进行接口扩展,比如 IPrinter 抽象出打印能力,IPos 抽象出刷卡、退款等能力,IWeight 抽象出称重、置零、去皮等能力,设备实体各自实现 IPrinter 、IPos 、 IWeight 接口,实现接口提供的相应方法,通过面向接口编程,业务划分与代码管理清晰很多。

UML :

2)设备状态心跳检测

有赞零售收银台右上角“收银中心”聚合了很多收银通用能力,其中就包括了外接设备的状态管理,该功能可以实时监测设备状态,在快速定位线上问题过程中发挥了非常重要的作用,且也能协助商家对设备进行健康自检。

  • 注册心跳,开启心跳检测

/**
 * 注册监听
 */
fun registerCheckState(listener: IDeviceStateListener) {
    if (!deviceStateListeners.contains(listener)){
        deviceStateListeners.add(listener)
    }
    checkHeart()
}


/**
 * 检查心跳
 */
private fun checkHeart() {
    if (cacheDevices.isNullOrEmpty()) {
        return
    }
    if (!isCheckingHeart) {
        isCheckingHeart = true
        DeviceThreadManager.threadPoolProxy.getHeartExecutor().execute(HeartTask())
    }
}
  • 心跳机制

单线程内开启 while 循环,每次心跳间隔 2 秒

inner class HeartTask : Runnable {
        override fun run() {
            while (true) {
                if (cacheDevices.isNullOrEmpty()) {
                    continue
                }
                for (entity in cacheDevices) {
                    val newState = entity.device.getState()
                    var countChange = false
                    if (deviceCount != cacheDevices.size){
                        countChange = true
                        deviceCount = cacheDevices.size
                    }
                    val shouldNotify = (entity.getState() != newState) || countChange
                    entity.setState(newState)
                    for (listener in deviceStateListeners){
                        if (listener is IDeviceStateAlwaysListener){
                            listener.onDeviceState(entity)
                        } else {
                            // 会与上一次心跳状态进行比较,状态不一样时,才会回调
                            if (shouldNotify){
                                listener.onDeviceState(entity)
                            }
                        }
                    }

                }

                try {
                    Thread.sleep(STATE_UPDATE_SUSPEND)
                } catch (e: Exception) {
                    e.printStackTrace()
                }
            }
        }
    }
}

3)读写能力

打印小票的前提是将 ESC / POS 协议字节数据输入到打印机驱动中,这里涉及到写的场景。而在生鲜果蔬行业涉及到称重场景中要用到电子秤,商品重量需要实时传输到收银机,这个又涉及到读的场景,底层抽象读写接口,业务方自己实现,这块底层做的比较轻。

/**
 * 读接口
 */
interface IRead {
    fun read(): T?
}

/**
 * 写接口
 */
interface IWrite {
    fun write(content: T?)
}

// 大华电子秤读取商品重量,业务方自己实现
class DahuaWeight: IWeight, IRead{
      
 
    override fun read(): String? {
        return DahuaWeightSdk.getWeight()
    }
}
4)缓存能力

有赞零售 app 为了满足设备连接多样性,支持同时连接多款设备,且针对每款设备提供手动断开、连接能力(比方说餐饮行业,前台与后厨都连接了打印机,退款小票只需要在前台打印机打印的话,后厨的打印机可以手动点击断开),且我们需要确保商家退出 app 、app 覆盖升级等场景,设备的状态可以恢复,基于这种场景必须要支持本地缓存能力,下次 app 进入读取本地缓存,绘制 UI 即可。

/**
 * 设备缓存管理
 * 缓存到本地文件
 */
class DeviceCacheManager {
    //添加设备
    fun addDevice(deviceInfo: DeviceInfo?) {
        if (addInner(deviceInfo)){//添加到内存
            memoryToCache()//刷到缓存
        }
    }
    //删除设备
    fun removeDevice(deviceInfo: DeviceInfo?) {
        if (removeInner(deviceInfo)){//从内存中删除
            memoryToCache()//刷到缓存
        }
    }
    //获取设备列表
    fun getCacheDevices(tag: String): List? {
        if (cacheDevices.isNullOrEmpty()){
            cacheToMemory()
        }
 
        return getDevicesByTag(tag)
    }
}

5)线程管理

设备的状态监测、IO 读写、耗时逻辑处理都涉及到线程切换,目前底层提供配置线程池统一管理,避免线程随意创建,抢占系统资源,拖累收银机的性能(零售对接了很多低端设备,线程控制非常严格,且部分机型可能出现 p-thread 问题,线程创建数量超出一定数量后, app 将 crash )。

... ...
private val diskIOExecutor  = Executors.newSingleThreadExecutor(DeviceThreadFactory("diskIO"))
private val heartExecutor  = Executors.newSingleThreadExecutor(DeviceThreadFactory("heart"))
private val networkExecutor = Executors.newFixedThreadPool(3, DeviceThreadFactory("network"))
private val scheduleExecutor = ScheduledThreadPoolExecutor(5, DeviceThreadFactory("schedule"), ThreadPoolExecutor.AbortPolicy())
... ...

6)异常模型

硬件的异常管理在实际开发与交互提示流程中非常重要,比方说打印机是否缺纸了、电子秤是否断开了等场景,通过交互提示能协助快速定位排查问题。

{
    // 设备名称
    "deviceName":"sunmi",
    // 额外信息
    "extra":"",
    // 当前设备的连接状态
    "state":0,
    "error":{
        // 打印机异常状态码
        "code":1,
        // 打印机异常信息详情
        "message":"打印机缺纸/打印机离线/打印机断开"
    }
}

2.1.3 Library 层

部分设备连接需要依赖硬件厂商提供的 sdk , 且不同分类的设备可能共享该 sdk , 这类的sdk可以放到 Library 进行管理,避免设备重复依赖。

库简介:

library名称 功能介绍
woyou.aidlservice 商米打印与称重 aidl 接口
sprtprintersdk 思普瑞特打印能力
paymentService 商米 P1 刷卡 sdk
cloudpossdk、wangpossdk WANGPOS 刷卡打印能力

2.1.4 Base 层

提供最基础的能力,包括网络请求、log 埋点等。

2.2 硬件库实现细节

2.2.1 打印机

零售对接的打印设备非常多,包括蓝牙、usb 、http 等,原有的设计中打印机与 pos 、电子秤功能聚合在一起,功能耦合严重,不同的硬件开发人员都会改动设备库的代码,导致 sdk 频繁发版,违背开闭原则,设备库稳定性也无法得到保证。需要将通用能力抽出来,包括连接能力、打印能力、协议封装能力等,确保新的设备能够快速接入。

解决方案

UML :

技术细节描述:

PrinterManager 暴露相应的 api 给业务方调用,DeviceCoreManager 提供 Core 通用能力(包含缓存能力、连接能力、线程切换能力等)并作为 PrinterManager 的成员变量,所有的打印机实体继承 AbsPrinter 基类(实现一些基本信息,以及相关方法做了默认实现),AbsPrinter 又实现 IPrinter 接口,IPrinter 继而又继承 IDevice 接口,同时部分打印机又可以打开钱箱,需要实现 IMoneyBox 接口。

IPrinter :

interface IPrinter : IDevice {
    ... ...
    /**
     * 设备纸张类型
     *
     * @return
     */
    fun getPagerType(): PagerType

    /**
     * 获取设备协议
     *
     * @return
     */
    fun getProtocol(): Protocol


    /**
     * 打印内容
     *
     */
    fun print(content: ByteArray): PrinterResponse

    /**
     * 打印内容,附加一些信息
     *
     */
    fun print(content: ByteArray, extraInfo: String?): PrinterResponse

    /**
     * js DeviceName
     * @return
     */
    fun jsDeviceName(): String

    fun isSupportJSPrinter(): Boolean
    ... ...
}

2.2.2 POS 机

零售开发早期,开发了独立的 POS 收银台,直接访问第三方支付公司(通联等)提供的刷卡接口,且针对 8583 协议(8583协议)进行自定义封装,代码复杂度与维护成本很高,在线上运行一段时间后,发现接口不太稳定,商家经常出现刷卡不成功问题。后期与 POS 厂商沟通后,直接对接了 POS 厂商提供的刷卡 sdk, 刷卡稳定性得到了提升,但是从设备库设计来说还是要兼容自建收银台功能,目前还有部分商家在使用老的刷卡方式能力,不能贸然迁移。

零售 POS 对接现状:

交易模块、订单模块、储值模块、支付模块都有使用过刷卡能力,但是各自调用的 sdk 不尽相同,包括 ecosy、zanpay、pos_pay_sdk 等,开发与维护成本很高

解决方案

UML :

技术细节描述:

PosManager 暴露相应的 api 给业务方调用,DeviceCoreManager 提供 Core 通用能力(连接能力、线程切换能力等)并作为 PosManager 的成员变量,所有的 POS 机实体继承 AbsCashier 基类(实现一些基本信息,以及相关方法做了默认实现),AbsCashier 又实现 IPos 接口,同时 IPos 继承 IDevice 接口。AbsPrinter 会维护 PosChainTaskList 队列,分别对应 POS 中签到、收单、支付、上报流程。这些 Task 业务方需要注入并做接口实现,底层只会维护调用链路,不关心业务 Task 的执行内容。

IPos :

interface IPos : IDevice {

    /**
     * 刷卡支付
     *
     */
    fun payByPos(entity: PhoinexPosPayResult): Observable

    /**
     * 取消支付
     *
     * @param orderNo
     * @param voucherNo
     */
    fun revoke(orderNo: String, voucherNo: String): Observable

    /**
     * 退款
     *
     * @param orderNo
     */
    fun refund(orderNo: String): Observable
}

2.2.3 电子秤

电子秤提供的能力比较简单,IWeight 提供去皮、置零等能力,电子秤的读取通过 CallableData (类似参考LiveData实现)进行 postValue 分发,同时 WeightManager 提供了设备基本的增删改查能力。

解决方案

UML :

技术细节描述:

WeightManager 暴露相应的 api 给业务方调用,Weight 相对比较简单,所有的电子秤都实现 IWeight 接口,IWeight 集成 IDevice 接口,同时 DeviceCoreManager 为电子秤提供底层能力(读写能力、连接能力、缓存能力等)支持,电子秤部分是串口通信,需要实现 UsbReceiver 广播监听 usb 线的插拔状态。

IWeight :

interface IWeight: IDevice {

    // 去皮
    fun doTare(): Pair

    // 置零
    fun fun doZero(): Pair

}

2.3 灰度上线方案

硬件重构相当于推倒重来,如此大的改动上线必须要稳,故此采用 AB Test 进行灰度,一部分商家继续使用老 sdk ,一部分商家使用新 sdk ,新 sdk 进行数据异常埋点,当检测到新的设备库出现问题后,配置中心操作,使用新 sdk 的商家收银机会立即回滚到老设备库。

方案图

灰度工具:AB-Test

三、打印机协议统一

移动团队配合硬件支持同学根据商家需求适配对接了十几款市面上口碑与稳定性较高的打印机设备,包括 365 、佳博、映美云、思普瑞特、飞蛾等品牌,且技术上适配了 usb 、蓝牙、 wifi 等多种连接方式,为商家硬件选配提供了多样性选择。团队对接打印机的过程中投入了大量的人力支持,也踩了不少坑,同时新设备的对接效率始终比较低,且稳定性不够,商家经常反馈一些连接与打印问题,开发人员的自我成就不高,且对商家的经营场景造成了影响。在技术侧特别是打印机协议适配涉及到多端参与( Android 、iOS 、前端等),重复造轮子的同时,也很难保证协议解析的稳定性与统一性,为了降低多端打印协议适配成本,痛定思痛,技术上利用 js 作为桥接层对打印协议进行统一解析预处理,业务方只需要根据一定格式(类似于 html )输入打印内容,js 层会针对打印内容映射为打印协议,且该方案支持跨平台与动态化,目前零售所有的打印业务都是通过这种方式进行适配,稳定性得到了保障,且维护成本也被极大的降低,详细技术方案请看这两篇文章。( 有赞零售小票打印跨平台解决方案 有赞零售跨平台打印库方案

架构图

PC、Android、iOS 将打印内容输入到 JsCore , JsCore 解析匹配打印数据,适配成特定的打印协议( ESC / POS 等),端获取到打印协议后,将打印协议输入给打印机,打印机读取到协议数据后进行打印,且 JsCore 可通过后端配置中心进行动态下发,实时修复问题,无需重发版。

3.1 举例:打印电子发票

3.1.1 小票模板编辑

每个小票都可在后台配置小票模板,对小票的基本信息、商品信息、支付信息、买家信息、其他信息进行编辑,且编辑后之后可以实时预览,小票模板编辑完成后,有赞零售 app 启动后会拉取小票模板数据,存在本地,当下次触发小票打印任务时,会将本地模板数据与打印数据进行结合,传入到 JsCore 中,输出打印协议,传输到打印机中进行打印。

  • 小票模板配置样式

  • 小票模板预览样式

  • 小票模板配置源代码