Files
kiosk/docs/工程业务与技术导读.md
T

23 KiB
Raw Blame History

KIOSK 工程业务与技术导读

面向第一次接手工程的开发者。本文基于 2026-08-04 的代码状态整理,目标是先建立完整的业务和技术心智模型,再进入具体模块开发。

1. 一句话理解这个工程

KIOSK 是部署在景区或线下门店 Android 自助终端上的照片售卖与打印客户端:游客可以上传手机照片,或通过人脸识别找到景区相机拍摄的照片,完成选片和支付后,由终端连接 DNP 照片打印机出片。

它同时还是一台长期在线的设备,因此还承担:

  • 设备身份认证和配置同步
  • WebSocket 实时消息接收与心跳上报
  • 微信扫码隐私取片任务
  • 打印机、纸张余量和任务状态管理
  • 待机海报、视频、直播和语音播放
  • 运营素材上传
  • APK 在线升级和开机自启动

应用名称是“自助照片打印”,Application ID 为 com.yzx.kiosk,最低支持 Android 8.0(API 26)。

2. 业务参与方

理解下面几个参与方后,工程中的接口和状态会清晰很多。

参与方 职责
游客 在终端上选业务、拍照识别人脸、选片、扫码支付和取走照片
手机端/微信页面 扫终端二维码、上传照片、支付或发起隐私取片
KIOSK Android 客户端 展示业务页面,维护设备状态,接收订单并驱动打印机
Oscar 业务后端 下发设备配置、二维码、价格、订单、Socket Token、版本信息等
照片盒子/人脸服务 保存景区相机照片,提供公网或局域网人脸检索及图片地址
OSS 保存照片、海报、视频、音频和升级包等文件
DNP 打印机 实际输出照片,当前主要适配 RX1 和 QW410
运维人员 配置设备密钥、局域网模式、纸张数、素材、打印机和应用版本

整体关系如下:

flowchart LR
    Visitor["游客"] --> Kiosk["Android KIOSK"]
    Phone["手机端 / 微信"] <-->|"上传、支付、取片"| Backend["Oscar 业务后端"]
    Kiosk <-->|"HTTPS API"| Backend
    Kiosk <-->|"WebSocket 消息与心跳"| Backend
    Kiosk <-->|"人脸检索、照片读取"| FaceBox["照片盒子 / 人脸服务"]
    Backend <--> OSS["阿里云 OSS"]
    Kiosk -->|"下载或上传素材"| OSS
    Kiosk -->|"DNP SDK 打印任务"| Printer["RX1 / QW410 打印机"]
    Operator["运维人员"] --> Kiosk

3. 用户能看到的主要业务

3.1 首页

首页是所有业务的入口,主要展示:

  • 人脸识别照片/打卡点照片打印入口
  • 上传手机照片打印入口
  • 微信扫码隐私取片二维码
  • 首页宣传视频
  • 当前打印任务状态
  • 剩余打印纸张数和客服电话

首页双击特定区域会弹出管理密码,校验后进入设置页。首页 60 秒无操作,且当前没有打印任务时,会进入待机海报或直播层;用户触摸后返回首页。

主要代码:

3.2 手机照片上传打印

这是 capture type = 1 的业务。

sequenceDiagram
    actor User as 游客
    participant K as KIOSK
    participant API as 业务后端
    participant WS as WebSocket
    participant P as DNP 打印机

    User->>K: 进入“上传手机照片打印”
    K->>API: 获取上传二维码和打印服务信息
    K-->>User: 展示二维码、价格和规格
    User->>API: 手机扫码并上传照片
    WS-->>K: code=1 扫码成功
    WS-->>K: code=2 下发 file_map 照片列表
    K-->>User: 展示照片并选择
    K->>API: verify-result 计算单价和总价
    K->>API: get-pay-url 获取支付二维码
    User->>API: 手机支付
    WS-->>K: code=5 支付成功及订单信息
    K-->>User: 支付成功,直接进入打印页
    K->>P: 逐张下载、处理并打印
    K->>API: print-notify 上报每张结果
    K->>API: print-complete 上报订单完成

对应页面链路:

首页
  -> 上传照片二维码页 UploadPhotoScreen
  -> 照片选择页 PhotoSelectScreen
  -> 打印页 PrintingScreen
  -> 自动返回首页

核心类:

服务端通过 file_map 同时下发 OSS 和局域网地址。客户端根据设置中的“使用 LAN”开关选择 *_lan_url 或 *_oss_url。

3.3 人脸识别照片打印

这是 capture type = 2 的业务,适用于景区相机、无人机或打卡点预先拍摄的照片。

业务过程:

  1. 游客进入人脸识别页。
  2. CameraX 打开前置相机,支持 5 秒倒计时或手动拍照。
  3. 客户端把照片上传到照片盒子的 /{sn}/api/search 接口。
  4. 服务根据人脸相似度返回当天匹配的照片。
  5. 终端展示缩略图,游客选择照片。
  6. 客户端请求后端计算价格并生成支付二维码。
  7. WebSocket 或 HTTP 轮询确认支付成功后直接进入打印页。

主要代码:

人脸服务地址、盒子 SN 和 Token 来自设备配置。设置为 LAN 模式时走局域网地址,否则走公网地址。

3.4 微信扫码隐私取片

隐私取片不是通过当前页面一步步操作,而是后台 WebSocket 直接下发的打印任务。

处理过程:

  1. 首页显示服务端配置的微信隐私取片二维码。
  2. 用户在微信侧完成选片或确认。
  3. WebSocket 收到 code = 4 消息。
  4. PrintQueueManager 按订单去重并排队。
  5. PrivacyPrintService 依次下载照片并调用打印机。
  6. 首页顶部显示“手机尾号 xxxx 用户正在打印/打印完成”。
  7. 客户端上报单张结果和订单完成状态,并更新纸张余量。

主要代码:

这条链路允许游客在手机端操作打印时,终端屏幕继续服务其他用户,因此打印状态通过全局状态栏展示,而不是强制占用前台页面。

3.5 打印机处理

打印能力封装在 PrinterService.kt 中,底层使用 DNP 本地 AAR/JNI SDK。

当前处理步骤:

  1. 枚举打印机端口并保存打印机 ID。
  2. 根据 ID 区分 RX1 和 QW410。
  3. 按打印机方向旋转原图。
  4. 保持原比例缩放,居中绘制到白色背景画布。
  5. 保存为 300 DPI BMP 文件。
  6. 创建 PrintJob 并加入 DNP PrintQueue。
  7. 更新打印状态、打印记录和纸张余量。

尺寸配置位于 PRINTSIZE.kt:

打印机 画布尺寸 方向
DNP RX1 1840 × 1240 横向
DNP QW410 1266 × 1836 纵向

工程里的打印包目录名是历史拼写 priter,并不是文档笔误。

3.6 待机广告、直播和语音

设备配置接口会下发海报、背景音乐、首页视频及直播信息。

  • 图片/视频海报使用 Compose + Media3 展示
  • 首页底部视频通过 ExoPlayer 循环播放
  • 景区直播通过 RTMP 地址播放
  • WebSocket 绑定成功后发送 type = 304 订阅直播变化
  • 本地页面引导语音由 LocalAudioPlayService 管理
  • 网络 BGM 和其他音频由 AudioPlayService 管理
  • 打印通知还可通过阿里云语音合成播报

主要代码:

3.7 设备管理和运营配置

设置页用于现场运维,主要包括:

  • 切换公网/局域网照片地址
  • 设置剩余打印纸张数
  • 打开设备配置页和打印机管理页
  • 查看帮助中心、关于我们和协议页面
  • 手动检查应用更新

设备配置页还可以维护:

  • 设备密钥
  • 景区和客服电话
  • 隐私取片二维码开关
  • 首页直播和新版首页开关
  • 最多 5 个已选海报
  • 背景音乐
  • 上传自定义图片、视频和音频素材

运营配置既会调用 /api/oscar/config/set 保存到后端,也会同步一部分到本地 MMKV,供首页立即读取。

主要代码:

3.8 在线升级与设备自启动

VersionUpdateManager 请求 type = 8 的最新 Android 终端版本,下载 APK 后通过 FileProvider 拉起系统安装界面。Manifest 同时注册了开机和包更新广播,目标是让自助终端重启或升级后恢复运行。

主要代码:

4. 应用启动时发生什么

应用进程启动,App.onCreate 完成全局初始化
  -> 系统或桌面启动 SplashActivity
  -> 等待约 1.5 秒
  -> 进入 MainActivity
  -> 初始化无操作检测并注册 WebSocket 监听器
  -> MainActivity 获取 Socket Token
  -> WebSocket 连接并发送 type=1000 绑定设备
  -> Compose 创建 AppNavHost 并进入首页
  -> HomeViewModel 拉取设备配置
  -> 每 5 秒发送 type=1001 心跳
  -> 首页开始展示入口、二维码、视频和纸张信息
  -> 无操作 60 秒后展示待机海报或直播

Application 级初始化在 App.kt 中完成,包括 Hilt、MMKV、日志、Toast、Coil 缓存和全局状态初始化。DNP SDK 延迟到第一次使用打印机时初始化。

5. WebSocket 协议在客户端中的作用

WebSocket 是业务闭环的关键,不只是普通通知。当前客户端使用的主要消息如下:

方向 类型或 code 含义
客户端 -> 服务端 type = 1000 使用 Socket Token 绑定设备
客户端 -> 服务端 type = 1001 心跳,上报设备、打印机状态和剩余纸张
客户端 -> 服务端 type = 304 订阅景区直播状态
服务端 -> 客户端 code = 1 手机扫码成功
服务端 -> 客户端 code = 2 上传照片列表到达
服务端 -> 客户端 code = 4 隐私取片打印任务
服务端 -> 客户端 code = 5 支付成功,包含订单和照片信息
服务端 -> 客户端 code = 100000 绑定、心跳或直播动作成功
服务端 -> 客户端 code = 100099 Token 失效,需要刷新并重新绑定

WebSocketService 把上传/支付类消息转换为 SharedFlow<UploadPhotoEvent>,页面 ViewModel 各自订阅;隐私打印则直接进入后台打印队列。

6. 网络认证和环境

普通 HTTP 请求由 RequestInterceptor.kt 添加:

sn        = 设备 SN
timestamp = 当前秒级时间戳
token     = MD5(sn + deviceSecret + timestamp)

设备密钥需要先在设备配置页录入。没有密钥时客户端不会生成 token 请求头,首页会提示先配置设备密钥。

构建环境由 BuildConfig 区分:

构建类型 HTTP API WebSocket
Debug api-test.zhifly.cn 测试 WSS
Release api.zhifly.cn 正式 WSS

配置位置为 app/build.gradle.kts,网络组件装配位于 NetworkModule.kt。

7. 工程采用的技术

7.1 语言、构建和 UI

技术 用途
Kotlin 2.1.10 主要开发语言,JVM Target 11
Gradle 8.11.1 + AGP 8.10.0 Android 构建系统
Jetpack Compose + Material 3 主要页面 UI
ViewBinding + XML Splash 等少量传统页面
Navigation Compose 页面路由和回退栈
StateFlow / SharedFlow 页面状态、全局状态和实时事件
Coroutines 网络、打印、上传下载和倒计时等异步任务

整体采用接近单 Activity + MVVM 的结构:

flowchart TD
    UI["Compose Screen"] --> VM["Hilt ViewModel"]
    VM --> Repo["Repository / Manager"]
    Repo --> Api["Retrofit Service"]
    Repo --> Local["Room / MMKV"]
    VM --> Device["打印机、相机、播放器"]
    WS["WebSocketService"] --> Flow["SharedFlow / StateFlow"]
    Flow --> VM
    VM --> Nav["AppNavigator"]
    Nav --> UI

7.2 依赖注入和架构组件

  • Hilt:Application、Activity、ViewModel、Repository 和 Service 的依赖注入
  • KSP:Hilt、Room 和 Glide 等代码生成
  • Lifecycle ViewModel:页面状态和协程生命周期
  • 自定义 AppNavigator:通过 SharedFlow 解耦 ViewModel 与 NavController
  • BaseViewModel:统一导航、Loading、网络结果和登录失效处理

7.3 网络与实时通信

  • Retrofit + Gson:业务 REST API
  • OkHttp:HTTP、APK 下载和 WebSocket
  • 自定义请求拦截器:设备 SN、时间戳和签名 Token
  • 自定义响应拦截器与结果层:统一解析业务 code
  • WebSocket:扫码、照片列表、支付成功、隐私打印、直播和设备心跳

7.4 数据存储

存储 保存内容
MMKV 设备密钥、设备配置、Socket Token、盒子地址、纸张数、打印记录和开关状态
Room AppDatabase 运营素材上传任务及进度
Room CloudDatabase OSS 上传、下载任务
应用私有文件目录 处理后的打印 BMP、缓存和下载文件
Coil 磁盘缓存 网络图片缓存,当前限制约 50 MB

MMKV 的统一访问入口是 AppStoreDataSource.kt,全局响应式状态集中在 AppState.kt。

7.5 图片、相机和扫码

  • CameraX:人脸识别页拍照
  • Coil / Glide:网络图片加载
  • uCrop:照片裁剪
  • PictureSelector:本地运营素材选择
  • ML Kit Barcode Scanning + ZXing:扫码识别和二维码生成
  • Android Bitmap/Canvas:打印前旋转、缩放和白底合成

7.6 音视频与云能力

  • AndroidX Media3 / ExoPlayer:视频、音频和 RTMP 直播
  • 阿里云 OSS SDK:素材上传下载
  • 阿里云语音 SDK:状态语音合成
  • 高德地图 SDK:定位相关能力
  • DNP PhotoPrint SDK:照片打印机控制

本地闭源 SDK 位于 app/libs/,这是工程能否完整构建和连接硬件的重要前提。

8. 代码目录怎么理解

app/src/main/java/com/yzx/kiosk/
├── App.kt                     # Application 初始化
├── SplashActivity.kt          # 启动页
├── MainActivity.kt            # 单 Activity 容器、Socket 和待机层
├── audio/                     # 在线/本地音频播放
├── base/                      # ViewModel 基类
├── component/                 # 通用 Compose 组件和全局 UI
├── datastore/                 # MMKV 封装和全局状态
├── navigation/                # 路由、导航事件和 NavHost
├── network/
│   ├── db/                    # 上传任务 Room 数据库
│   ├── di/                    # Hilt 网络与协程模块
│   ├── interceptor/           # 请求签名和响应处理
│   ├── manager/               # 素材上传任务调度
│   ├── model/                 # 请求、响应和实体
│   ├── repository/            # 数据仓库
│   └── service/               # Retrofit 接口
├── priter/                    # DNP 打印服务和打印状态
├── receiver/                  # 开机/更新广播
├── ui/
│   ├── device/                # 设备和运营素材配置
│   ├── face/                  # 人脸识别业务
│   ├── home/                  # 首页
│   ├── poster/                # 待机海报和直播
│   ├── printer/               # 打印机管理
│   ├── setting/               # 设置、协议和帮助
│   └── upload/                # 上传、选片、支付、打印
├── utils/                     # 更新、日志、二维码、超时等工具
│   └── cloud/                 # OSS 上传下载及第二套 Room 数据库
└── websocket/                 # 长连接、实时事件和隐私打印队列

9. 建议的代码阅读顺序

第一次接手时,不建议从工具类或所有依赖开始看。按下面顺序更容易建立上下文:

  1. README.md:先把工程跑起来,理解环境和硬件要求。
  2. AppRoutes.kt 和 AppNavHost.kt:知道有哪些页面和用户动线。
  3. HomeScreen.kt 和 HomeViewModel.kt:理解首页配置如何驱动业务入口。
  4. ui/upload/viewmodel/:完整看一遍上传、选片、支付、打印主链路。
  5. ui/face/viewmodel/:理解第二条照片来源链路。
  6. WebSocketService.kt:理解页面为什么会因服务端消息自动跳转。
  7. PrinterService.kt 和 PrivacyPrintService.kt:理解硬件和后台任务。
  8. NetworkService.kt 与 AppStoreDataSource.kt:梳理接口和持久化字段。
  9. ui/device/、utils/cloud/ 和 VersionUpdateManager:最后看运营配置、素材和升级能力。
  10. 工程代码审查报告.md:了解当前缺陷和发布风险。

10. 接手时最容易混淆的几个概念

capture type

  • 1:用户从手机上传的照片
  • 2:通过人脸搜索得到的景区照片

它会贯穿计价、支付、打印通知和订单完成接口。

file_map

一张照片不只有一个 URL,而是同时包含图片 ID、OSS 原图/缩略图地址和 LAN 原图/缩略图地址。打印必须保留图片 ID,服务端才能正确记录每张照片的状态。

设备 SN、设备密钥和 Socket Token

  • 设备 SN:当前主要取 Android ID,标识终端
  • 设备密钥:运维录入,用于普通 HTTP 请求签名
  • Socket Token:后端接口动态获取,用于 WebSocket 绑定

这三个值用途不同,排查鉴权问题时不要混在一起。

公网模式和 LAN 模式

切换的是照片盒子服务和照片 URL 的访问方式,不是 Debug/Release 环境切换。Debug/Release 由构建类型决定,LAN/公网由设备设置决定。

两类打印

  • 前台打印:游客在终端完成上传/人脸业务后进入 PrintingScreen
  • 后台隐私打印:WebSocket code = 4 直接进入 PrivacyPrintService

两条链路最终都调用 PrinterService,但任务队列、UI 展示和状态上报入口不同。

11. 接手后的优先验证清单

在开始改业务前,建议用测试环境完成以下闭环:

  1. 配置设备密钥,确认设备配置接口成功。
  2. 确认 WebSocket 绑定成功且心跳包含正确纸张和打印机状态。
  3. 手机扫码上传一张照片,走完选片、计价和支付事件。
  4. 使用相机完成一次人脸搜索,分别测试公网和 LAN 地址。
  5. 连接 RX1 或 QW410,各打印一张横图和竖图。
  6. 验证单张 print-notify 和最终 print-complete 到达服务端。
  7. 发送一条隐私打印任务,确认不会阻断前台用户操作。
  8. 验证海报、视频、直播、BGM 和无操作返回逻辑。
  9. 验证重启后开机自启动、配置恢复和 Socket 重连。
  10. 在准备发布前阅读代码审查报告并处理签名、隐私、打印结果和并发问题。

12. 当前需要特别注意的技术债

本文重点是业务和技术导读,不展开缺陷细节;但以下问题会直接影响你对现有实现的判断:

  • Release 当前实际使用 Debug 签名,且签名信息直接写在 Gradle 配置中。
  • 打印任务加入 SDK 队列后不等于物理打印成功,当前成功判定不够可靠。
  • 部分 Token、设备密钥和业务配置保存在未加密 MMKV 中。
  • WebSocket、打印通知及上传下载存在并发和恢复方面的风险。
  • APK 更新缺少足够的完整性与签名校验。
  • 自动化测试覆盖少,Lint 当前仍有待处理项。

具体证据、文件位置和修复建议见 工程代码审查报告.md。


如果要修改某个业务,优先从对应页面的 ViewModel 顺着 Repository、WebSocket 或 PrinterService 向下追踪;如果问题表现为“页面没有自动跳转”,通常还要同时检查 WebSocket 消息是否到达以及 SharedFlow 订阅是否仍处于活跃生命周期。