Files
kiosk/docs/工程代码优化审查报告-2026-09-20.md

26 KiB
Raw Permalink Blame History

KIOSK 工程代码优化审查报告

审查日期:2026-09-20
代码基线:6d81f1a,以本次工作区实际内容为准。
工程:Android / Kotlin / Jetpack Compose,包名 com.yzx.kiosk。

1. 结论与范围

建议先修复打印结果、订单通知和连接生命周期,再处理安全配置、文件资源与工程质量。当前主要风险并非页面代码写法,而是设备实际状态、本地记录和服务端订单状态缺少可靠的一致性保障。

本次完成源码清单扫描,并重点阅读应用入口、构建配置、打印服务、两条打印流程、WebSocket、网络结果封装、上传下载、数据库、升级、人脸识别与购片、资源管理及测试。主源码目录共 195 个 Kotlin/Java 文件,约 28,254 行;这不是逐行穷尽审计,也不包含本地闭源 AAR/JAR 的内部实现。

本次仅新增审查文档与验证记录,未修改业务代码。原有 gradlew 修改和未跟踪的 UI 预览文件予以保留。

证据分为三类:源码确认表示代码行为可直接定位;测试确认表示本次实际执行得到结果;待实机验证表示发生概率、设备表现或外部协议仍需验证。源码确认的风险不等同于已经发生过线上故障。

优先级定义:P1 为影响业务正确性、设备持续工作或敏感配置的问题;P2 为可靠性与工程质量问题;P3 为需要测量后推进的结构和性能改善。

编号 优先级 优化项 证据
01 P1 打印成功应等待真实终态 源码确认,设备行为待实机验证
02 P1 打印明细通知与订单完成应持久化、顺序提交 源码确认
03 P1 打印机初始状态和初始化结果判定 源码确认
04 P1 隐私取片队列幂等与单消费者 源码确认,重投协议待确认
05 P1 WebSocket 主动停止和旧连接回调隔离 源码确认
06 P1 签名材料和固定管理密码 源码确认
07 P1 打印中间文件清理和磁盘上限 源码确认
08 P2 隐私取片使用原图、统一图片处理 源码确认,画质待实机对比
09 P2 图片下载在读取阶段限制内存 源码确认
10 P2 网络错误结果保持类型安全 源码确认
11 P2 APK 下载校验与缓存复用 源码确认
12 P2 数据库迁移与 schema 管理 源码确认
13 P2 凭证存储、备份和授权状态 源码确认
14 P2 Wrapper、Lint 与核心回归测试 测试及源码确认
15 P2(启用前) 云传输并发与取消句柄 源码确认,当前未找到业务入口
16 P3 包体、诊断能力和大类拆分 优化建议,收益待测量

2. 优先修复项

01|打印入队被当成打印完成(P1)

位置:PrinterService.kt:319。

现象与影响: printQueue.addJob(job) 后,RX1 分支立即设置空闲并回调成功;QW410 分支固定等待 18 秒后回调成功。业务层没有等待 SDK 的成功/失败终态。任务提交后缺纸、断连、卡纸或排队超过预估时间时,本地记录、纸张扣减和订单通知可能提前认定成功。现有 printMutex 只能保证该方法串行执行;RX1 方法提前返回后,它不能保证物理打印已经完成。

建议: 将“已提交”“设备执行中”“成功”“失败”“结果未知”分开。根据实际 SDK 支持,接入任务回调或按任务标识查询终态;等待超时进入待核对状态。业务成功、耗材记账与对外回执均由同一个终态驱动。先确认厂商 SDK 的终态语义,再实现适配层。

验收: 分别在 RX1/QW410 上覆盖正常出片、入队后拔线、缺纸、卡纸、长队列和超时;无可靠成功结果时不得显示完成或发送成功回执。

02|通知队列未等待网络完成,且没有持久化补偿(P1)

位置:PrintingViewModel.kt:116、PrintingViewModel.kt:373、PrintingViewModel.kt:457、ResultHandler.kt:27。

现象与影响: Channel 消费者调用 executePrintNotify(),但底层 handleResultWithData() 再次 scope.launch,没有等待请求结束。print-complete 又在另一条路径直接发起,没有等待明细通知全部确认。慢网情况下完成通知可能先到;退出页面或进程终止会丢失内存中的待通知状态。失败回调仅记录日志。

建议: 核心业务接口改为可等待的挂起调用;把打印事实和待发送回执写入 Room,在同一订单内按明细确认、订单完成的顺序推进。为每张照片/每次授权打印建立幂等标识,重试采用退避并能跨进程恢复。

纸张扣减应依据可靠的物理打印成功事实,并保证仅记账一次。网络通知失败时应重试同步,不应简单把已实际消耗的纸张加回;否则会引入新的库存偏差。

验收: 延迟首条明细响应、断网、页面退出、进程重启后,回执不丢失且顺序符合服务端约定;重试不会重复扣纸。

03|默认在线,初始化又未检查端口查询结果(P1)

位置:PrintStatusManager.kt:24、PrinterService.kt:59。

现象与影响: _isOnline 默认是 true。initDnpPrint() 虽取得 portNum,却没有判定返回值和端口有效性,就读取预分配数组的第一项,保存 ID 并设置在线。无打印机但查询未抛异常时,也可能报告连接成功;后续打印才因 ID 为 0 失败。

建议: 初始状态设为未知或离线;结合 SDK 返回语义校验端口数量、设备 ID 和实际可用状态。重连、USB 拔插、忙碌状态应有一致的状态转换,避免初始化把正在执行的任务覆盖为空闲。

验收: 不接设备冷启动、接入/拔出设备、异常返回值和打印中重新探测时,界面与心跳均不误报可接单。

04|隐私取片缺少持久化去重,消费者启动存在竞争(P1)

位置:PrintQueueManager.kt:34、PrintQueueManager.kt:104、PrivacyPrintService.kt:87、WebSocketService.kt:890。

现象与影响: 完成任务直接从内存列表移除,相同订单消息再次到达时可以重新入队;进程重启也没有去重记录。processNextTask() 的 isProcessing 检查和置位不在一个原子操作中,多个 IO 协程可能同时通过检查;队列自身加锁不能保护外部消费者状态及单个 currentPrintTask。

建议: 使用一个受管理的消费者串行领取任务;持久化任务状态与去重标识。和服务端明确“重复投递”与“用户授权补打”的区别,以消息 ID 或打印批次 ID 区分,避免把合法补打一律拒绝。异常终止后,已经提交硬件但终态未知的任务先核对,不能盲目再次打印。

验收: 同一消息连续投递、并发投递、完成后重投和重启后重投均只执行一次;独立授权补打仍可执行。

05|主动停止 WebSocket 后可能自动重连(P1)

位置:WebSocketService.kt:185、WebSocketService.kt:695、WebSocketService.kt:717。

现象与影响: stop() 取消已有重连并关闭连接,但关闭后到达的 onClosed() 无条件调用 scheduleReconnect(),代码没有停止标志。旧连接的 onClosed/onFailure 还会无条件将共享 webSocket 清空,可能干扰已经建立的新连接。多处临时创建的 CoroutineScope 使停止时难以完整取消工作。

建议: 建立统一作用域和连接状态机,持久保留本次运行是否允许连接的标志;回调携带连接代次,只允许当前连接修改状态。将 start/stop、心跳和重连事件集中到同一消费者或互斥保护范围内。

验收: 主动停止后等待多个重连周期也不恢复连接;连续 start/stop、旧连接延迟失败以及网络切换时,始终只有一个有效连接与心跳任务。

06|签名材料受版本管理,管理密码固定(P1)

位置:app/build.gradle.kts:112、app/build.gradle.kts:151、HomeViewModel.kt:162、HomeViewModel.kt:323。

确认事实: 签名密码写在 Gradle 配置中,Release 选择名为 debug 的签名配置;git ls-files 确认两个 .jks 文件仍受版本管理。虽然 .gitignore 已排除此扩展名,但不能移除已经跟踪的文件。设置入口仍将输入与固定常量比较。本文不复制任何口令。

建议: 签名凭证转移至受控的本机配置或 CI Secret,并处理仓库中已跟踪的材料及历史泄露范围;先核实在用设备的签名证书和升级路径,再迁移签名,避免直接替换导致无法覆盖安装。管理入口采用设备独立认证或短期授权,并限制连续失败。

验收: 工作树及发布配置没有明文签名密码;发布包证书符合既定升级策略;仅拿到 APK 不能获得所有设备共用的管理口令。本次未重新构建并比较签名证书。

07|打印 BMP 无清理策略,长期运行可能耗尽磁盘(P1)

位置:PrinterService.kt:253。

现象与影响: 每张照片生成带时间戳的 ProcessedPhotos/*_print.bmp。检索主源码未发现针对该目录的清理或容量限制。以 1844×1240 的 24 位像素计算,单张像素数据约 6.54 MiB,1,000 张约 6.4 GiB;这是容量估算,并非设备磁盘实测。临时打印图长期保留也扩大了照片留存范围。

建议: 在确认 SDK 不再读取文件后清理;失败路径也应清理未提交的文件。增加启动时孤儿文件清理、保留时限与容量上限,保护正在使用的任务文件,并在空间不足时明确拒绝新任务。此项依赖第 01 项的可靠任务终态,不能在 addJob() 后立即删除。

验收: 连续打印及异常重启后目录容量维持在设定上限,待打印文件不被误删。

3. 下一轮可靠性与质量优化

08|隐私取片走缩略图,图片处理链路不统一(P2)

位置:PrivacyPrintService.kt:135、PrivacyPrintService.kt:537、BatchQueryResponse.kt:29。

隐私取片建立图片映射时选择 thumbnailLanUrl/thumbnailOssUrl,但响应模型已有原图字段。之后把 Coil Drawable 再复制成 Bitmap,增加峰值内存;该流程也没有复用普通打印流程的下载容器校验。两条流程的输入质量和校验标准不同。

建议打印优先采用原图,缩略图保留给页面预览;原图缺失时按明确的产品策略报错或允许有提示的降级。统一下载、完整性验证、采样和 Bitmap 所有权,避免未经确认就回收图片缓存持有的对象。用相同订单在两个入口打印,比较有效像素、裁切和成片清晰度,并测量峰值内存。

09|50 MiB 下载上限在未知长度响应中生效过晚(P2)

位置:PrintingViewModel.kt:527、PrintImageIntegrityValidator.kt:46。

已实现 Content-Length 检查和解码前大小检查,这是有效改进。但当服务端未提供长度时,body.bytes() 会先完整读取响应,之后才检查 50 MiB 上限,无法阻止读取阶段的内存膨胀。捕获 OutOfMemoryError 不能代替输入限流。

建议按块读取并累计实际字节数,超过上限立即停止;或流式落临时文件后做边界检查与采样解码。协程取消时同时取消底层 Call。验收使用无 Content-Length、超限和中途断流响应,确认超限读取及时停止且不提交打印。

10|错误响应被改写,空数据被强转为 Unit(P2)

位置:ResponseInterceptor.kt:26、ResultHandler.kt:44。

HTTP 成功但业务码不是 100000 时,拦截器将 data 强制替换成 {}。对于列表、字符串等接口类型,这可能在进入业务错误分支前触发反序列化失败,丢失真实错误信息。另一处 response.data ?: Unit as T 则把成功但缺失的数据伪装为任意业务类型,调用方可能发生类型转换异常。

建议保持原始响应结构,在结果层区分业务失败、协议格式错误和传输失败;有返回数据和无返回数据的接口分别建模。补充对象/数组/字符串/null、业务失败和 HTTP 失败的契约测试,确认不会用类型异常覆盖原错误。

11|APK 复用仅判断长度,未知长度时仍直接安装(P2)

位置:VersionUpdateManager.kt:313、VersionUpdateManager.kt:536。

已有文件与服务器长度相同便复用;无法获取有效长度时也复用本地文件。新下载同样主要检查长度,不能识别等长内容损坏或错误版本缓存。

建议版本元数据提供可信摘要,下载到临时路径后校验内容摘要、预期包名、版本及签名,再原子提交。不要把本地计算出的哈希本身当作真实性证明,必须与可信预期值比较。系统安装校验仍然存在;本项不声称任意错误签名 APK 能覆盖安装。验收覆盖等长损坏、未知长度、旧缓存及错误版本。

12|升级迁移可能清除上传任务(P2)

位置:DatabaseModule.kt:20、AppDatabase.kt:10。

app_database 只注册 7 → 8 迁移,并启用了破坏性迁移回退。若设备存在无迁移链覆盖的旧版本数据库,待上传任务会有丢失风险。两个 Room 数据库声明导出 schema,但本次检索构建配置未找到 room.schemaLocation。

建议先列出现网数据库版本,再补齐所有仍支持版本的迁移路径,导出并纳入版本管理的 schema,执行保留任务数据的迁移测试。CloudDatabaseModule 已有 1 → 2 → 3 迁移链,不应将本问题泛化为两个数据库都没有迁移。

13|凭证备份与授权状态缺少显式边界(P2)

位置:MMKVUtils.kt:17、AppStoreDataSource.kt:238、AndroidManifest.xml:60、App.kt:55。

设备密钥和 Token 经默认 MMKV 保存,未传入加密密钥;应用允许备份,两个备份规则文件仍是模板,未显式排除敏感数据。App.onCreate() 无条件告知地图 SDK 已同意,未关联应用内可核查的授权记录。源码存在隐私弹窗类,但未检索到业务调用。

建议定义凭证的存储、迁移和吊销边界,用受设备密钥保护的加密方案保存敏感字段,明确备份/迁移排除规则。地图初始化条件应关联真实授权或有记录的设备部署流程。另需核对 Manifest 中全局明文访问与各项权限的实际用途,再按公网/局域网需求收敛;不要直接阻断确有需要的局域网照片服务。

验收覆盖首次部署、重启、清除授权、备份还原及设备更换,确认凭证不会意外迁移且 SDK 行为与记录一致。此处审查的是技术状态与数据边界,未做法律合规结论。

14|构建入口与质量检查尚未形成稳定门禁(P2)

位置:gradlew、ExampleInstrumentedTest.kt:24。

本次直接运行 ./gradlew 失败,原因是 CRLF shebang:env: sh\r: No such file or directory。当前文件已经有执行权限,不能再沿用旧报告的“无执行权限”结论。为保留用户已有改动,本次通过 Java 直接运行 Gradle Wrapper 完成验证。

建议 Wrapper 固定为 LF,加入换行规范。Lint 的 4 个错误应逐一处理:3 个 Media3 opt-in 错误;另 1 个通知权限错误来自 Glide 的 NotificationTarget 使用分析,需先确认产品是否实际发送通知,再补权限流程或对确实不可达的库路径做有说明的定向处理,避免为消除告警盲目增加权限。

已有 44 个单元测试全部通过,但核心打印终态、回执持久化、WebSocket 停止/重连、迁移链仍缺少对应专项测试。Instrumentation 示例仍断言旧包名 com.zhifly.follow,与当前应用包名不符,应修复或移除失效示例。

验收目标:标准 ./gradlew 命令可运行,Lint 错误清零;增加本报告 P1 场景的故障注入测试和目标机型集成测试。

15|云传输多任务模型与单一取消句柄冲突(P2,启用前处理)

位置:DownloadRepository.kt:57、UploadRepository.kt:57、CloudOssUtils.kt:51。

两个 Repository 以 taskId → Job 管理多个任务,OSS 封装却各只保存一个 currentUploadTask/currentDownloadTask。A、B 同时执行后暂停 A,会取消最后登记的 SDK 任务 B。进度与终态共用 callbackFlow.trySend() 且未检查发送结果,缓冲区被进度占满时还有丢失成功/失败事件的风险;进度回调逐次写库也可能放大压力。

当前可达性限制: 本次检索主源码只找到两个 Repository 的定义,未找到业务引用。应先确认是否是预留/废弃功能,因此不将其列为当前线上必现故障。普通 UploadManager 是另一条传输实现,不能混为同一入口。

若保留此功能,SDK 请求句柄应按任务持有,取消操作绑定明确任务;限制并发并保护任务表;进度可节流或合并,但终态必须可靠送达并入库。验收并行上传/下载时只取消指定任务,模拟缓慢写库仍不丢终态。

16|包体、诊断和职责划分(P3)

方向 当前证据 建议及衡量方式
包体 构建配置:149 关闭 R8/资源压缩,声明多个 ABI;233 行 将 Crashlytics buildtools 作为运行时依赖 先分析依赖树与 APK 构成,确认是否有实际运行时代码依赖;按终端 ABI 交付,逐步开启压缩并验证反射/JNI/厂商 SDK。记录每步产物大小,不沿用旧报告的 144 MB 作为现状
线上诊断 LogUtils.kt:15 将 error/warn 也限制为 Debug 发布版本保留脱敏错误事件、订单/任务关联 ID、重试次数和失败阶段,限制日志容量。验收一笔失败订单能定位到下载、硬件或通知阶段,不记录凭证和完整照片 URL
大类职责 DeviceConfigViewModel 1,574 行、WebSocketService 947 行、VersionUpdateManager 811 行、FaceRecognitionResultViewModel 721 行 按设备配置、连接、支付协调、升级下载/安装划分可测试组件;先修业务边界,再拆文件,避免一次性重构全工程
图片列表刷新 FaceRecognitionResultViewModel.kt:235 每次宽高比更新遍历整表;页面已使用稳定图片 ID 作为 key 先在大量照片场景测滚动帧耗时和重组,再决定批量更新或按项状态。当前没有性能数据,不能断言已有明显卡顿
启动文件 I/O App.kt:66 在启动线程同步清理人脸残留文件 大量残留时可能拖慢启动;测量后迁到 IO,并用初始化屏障或文件代次防止清理新会话文件

4. 已有改进,应保留

这些内容说明旧报告不能原样套用到当前代码:

  • PrinterService 已有互斥锁;PrintImageIntegrityValidator 已有输入容器、尺寸、采样、BMP 结构校验。应在此基础上补真实终态与下载读取上限。
  • 人脸预览解码已有采样、EXIF 处理和最长边 720px 限制,相关实现检查了临时 Bitmap 的身份后再回收。
  • 人脸拍摄有专门的文件管理器,并在流程结束/退出以及下次启动清理残留。
  • 结果网格通过已加载 Drawable 获取比例,避免单独再次下载图片探测尺寸。
  • 人脸购片已有报价代次控制、支付重复处理保护、最近照片请求去重等逻辑,并有专项单元测试。
  • 网络下载客户端已避免通用响应拦截器吞入大文件;普通网络日志仅在 Debug 配置启用。

5. 本次验证记录

项目 本次结果
标准 Wrapper 入口 失败:CRLF shebang;执行权限当前存在
Debug 单元测试 44 项通过,0 失败、0 错误、0 跳过;11 个测试源码文件
Debug Lint 失败:4 errors、329 warnings、8 hints
Instrumentation 测试 源码共 5 个文件、20 个 @Test;本次未运行
物理打印、USB 热插拔、断网重连 本次未做设备实测
Release 构建、签名摘要、APK 大小 本次未重新测量

本次执行命令(先以标准入口尝试,再绕过 CRLF 启动脚本):

./gradlew :app:testDebugUnitTest :app:lintDebug --offline --console=plain
java -classpath gradle/wrapper/gradle-wrapper.jar org.gradle.wrapper.GradleWrapperMain :app:testDebugUnitTest :app:lintDebug --offline --console=plain

第二条命令已完成单元测试,最终因为 Lint 失败返回非零退出码。运行时使用现有离线依赖缓存,部分编译任务为 UP-TO-DATE;这不是全量 clean build。Gradle 缓存写入限制已通过获准的执行权限解决,不属于工程缺陷。

持久化证据:构建与检查日志、单元测试摘要、Lint XML。

本次 4 个 Lint 错误位置:

规则 位置
NotificationPermission AndroidManifest.xml;报告说明来源为 Glide NotificationTarget
UnsafeOptInUsageError AppNavHost.kt:94
UnsafeOptInUsageError PosterScreenWithLiveOrSlideshow.kt:115
UnsafeOptInUsageError PosterScreenWithLiveOrSlideshow.kt:117

6. 推荐实施顺序与验收门槛

  1. 打印闭环: 先明确硬件终态和去重协议,完成 01~04,再实现 07 的安全清理;验收以真实出片、库存和服务端订单一致为准。
  2. 连接与发布控制: 修复 05,处理 06 的凭证及既有安装签名迁移;并行修复 Wrapper 与 4 个 Lint 错误。
  3. 长期运行保障: 完成 08~13,补齐断网、磁盘不足、进程重启和数据库升级的故障场景。
  4. 结构和性能: 先确认 15 的业务去留,再执行 16 的拆分、测量与包体优化。

在开始实现前,需要从设备/服务端取得三类信息:打印 SDK 可用的任务终态接口;消息重复投递及授权补打标识;现网 APK 签名、数据库版本与升级范围。这些信息影响具体实现方案,但不影响上述源码问题的成立。

首轮完成标准应是:打印终态可靠、回执可恢复、重复消息不重复出片、主动停止不重连、文件容量受控、发布凭证管理明确,并将对应回归检查加入日常构建。