盘点这件事,最终是人在现场用一台手持终端(PDA)一步步走出来的。后台架构做得再漂亮,端上卡顿、断网丢数据、扫了半天不知道盘到哪了,一线就会用脚投票——重新拿回纸质表。
本文聚焦手持端这一个常被低估的环节,按"任务模型 → 离线优先 → 扫描交互 → 端侧比对 → 回传"五个部分展开。
一、端侧不是"简化版网页"
很多团队的第一版盘点 App 是把 Web 页面塞进 WebView,能登录、能查列表、能提交。上线后通常会在三个场景下崩掉:
| 场景 | Web 思维的坑 | 端侧应有的设计 |
|---|---|---|
| 地下室、库房深处没信号 | 页面白屏、提交失败、数据丢失 | 离线优先,无网可完整作业 |
| 连续扫描几百件资产 | 每次扫描等接口返回,卡顿明显 | 端侧即时处理,不等网络 |
| 盘到一半被打断(开会、下班) | 进度丢失,从头再盘 | 断点续盘,进度本地持久化 |
一句话:手持端不是"连着网的瘦客户端",而是"偶尔联网的独立系统"。这个认知决定了后面所有的技术选型。
二、任务模型:下发"任务包"而不是"全量数据"
2.1 任务包里装什么
盘点任务下发到终端时,应打包成一个自包含的任务包:
InventoryTaskPackage
├── taskMeta 任务元信息(任务ID、范围、截止、口径说明)
├── baseline 基线数据(应盘资产清单:资产编号、名称、EPC/条码、期望位置)
├── rules 规则(是否校验位置、是否允许盘盈、差异阈值)
└── orgSnapshot 组织与责任人快照(端侧展示与归属判断用)
关键在于基线数据要在端侧落本地库。这样每一次扫描都能立刻判断"这是应盘的 / 盘盈的 / 已经盘过的",不需要联网查询。
2.2 基线要裁剪,别全量下发
一个常见错误是把资产全表(几十个字段)下发到终端。正确做法是按盘点需要裁剪字段:
| 保留字段 | 用途 |
|---|---|
| asset_id、asset_code | 唯一标识与展示 |
| epc / barcode | 扫码比对 |
| asset_name、model | 现场核对 |
| expect_location | 位置校验 |
| status | 判断是否已报废/封存 |
大字段(图片、长文本备注、附件)一律不下发,只在需要时按需拉取。一个 3 万条资产的盘点任务,裁剪后基线通常在 5~10MB,端侧 SQLite 完全吃得下。
三、离线优先:本地库是唯一真相源
3.1 端侧数据分层
本地库(SQLite / Room)
├── task_baseline 基线(只读,服务端下发)
├── scan_record 扫描流水(每次读到标签的原始记录,含时间戳、RSSI)
├── result_item 盘点结果(按资产聚合后的状态:已盘/盘盈/待盘)
└── upload_queue 上传队列(待回传批次,含失败重试状态)
设计要点:扫描流水与盘点结果分离。流水是原始事实(一次读取一条,不去重),结果是聚合视图(一个资产一条状态)。这样即便聚合逻辑后面要改,历史流水还在,可以重算。
3.2 断点续盘的实现
每次扫描后立即写入本地库并更新内存状态,App 被杀掉、没电关机都不影响。重新打开时按任务 ID 从本地库恢复:
/**
* 盘点会话恢复:从本地库重建内存状态
*
* 模块:手持端盘点引擎
* 节选自 首码信息 移动端盘点模块 InventorySession
*/
public class InventorySession {
private final long taskId;
private final ScanRecordDao recordDao;
private final ResultItemDao resultDao;
/** 内存态:资产编号 -> 结果项,避免每次扫描查库 */
private final Map<String, ResultItem> cache = new ConcurrentHashMap<>();
public InventorySession(long taskId, ScanRecordDao recordDao, ResultItemDao resultDao) {
this.taskId = taskId;
this.recordDao = recordDao;
this.resultDao = resultDao;
}
/** App 冷启动或任务重新打开时调用 */
public void restore() {
cache.clear();
for (ResultItem item : resultDao.findByTask(taskId)) {
cache.put(item.getAssetCode(), item);
}
Log.i("首码信息-盘点", String.format(
"任务[%d] 会话已恢复:已盘 %d 条", taskId, cache.size()));
}
/**
* 处理一次扫描结果。
* 纪律:先写流水,再更新结果,最后刷新界面。顺序不能颠倒——
* 只更新结果不写流水,事后无法追溯"当时到底扫到了什么"。
*/
public ScanFeedback onScan(String epc, String barcode, float rssi) {
// 1. 落原始流水(即便这条标签不在基线里,也要记)
recordDao.insert(ScanRecord.builder()
.taskId(taskId).epc(epc).barcode(barcode)
.rssi(rssi).scanAt(System.currentTimeMillis())
.uploaded(false)
.build());
// 2. 匹配基线资产
AssetRef ref = resolveAsset(epc, barcode);
ResultItem item = cache.get(ref.getAssetCode());
if (item == null) {
item = ResultItem.builder()
.taskId(taskId)
.assetCode(ref.getAssetCode())
.status(ref.isInBaseline() ? ItemStatus.FOUND : ItemStatus.SURPLUS)
.firstScanAt(System.currentTimeMillis())
.scanCount(1)
.build();
cache.put(ref.getAssetCode(), item);
} else {
item.setScanCount(item.getScanCount() + 1);
}
item.setLastScanAt(System.currentTimeMillis());
item.setDirty(true);
resultDao.upsert(item);
// 3. 生成现场反馈
return buildFeedback(item, ref);
}
/** 盘点到一半退出时,把脏数据刷盘 */
public void flush() {
resultDao.upsertAll(new ArrayList<>(cache.values()));
}
}
几个容易被忽略的细节:
- 先写流水再更新结果。只更新结果不落流水,事后无法解释"为什么这台设备判盘盈"——是没扫到,还是扫到了但被去重逻辑吞了。
- 扫描次数要记录。某一台设备被扫了 40 次,往往说明它贴在一个信号特别好的位置(比如正对天线),现场容易反复触发,这个信息对后续分析误读有价值。
- flush 时机:除了每次扫描写库,建议在
onPause、切换任务、电量低于阈值时强制刷盘一次。
四、扫描交互:让一线"不用想就能用"
4.1 反馈要三重:声音、震动、颜色
现场环境嘈杂(机房风机、车间设备),单一提示必然失效。一次扫描的反馈应当同步给出:
| 反馈通道 | 正常(首次扫到应盘资产) | 重复扫描 | 盘盈(不在清单) | 位置不符 |
|---|---|---|---|---|
| 声音 | 短促"滴" | 无或低沉"嗒" | 双声提示 | 长音警告 |
| 震动 | 轻震 | 无 | 双震 | 连续震 |
| 界面 | 绿色,条目置顶 | 灰色,仅更新次数 | 橙色,单独分组 | 红色,展开差异 |
重复扫描不给强反馈是刻意的——否则扫到已盘资产时响个不停,操作员会误以为出错。
4.2 连续扫描与去重窗口
RFID 手持支持"按住扳机连续读",一秒能读几十次。端侧要做去重窗口:同一 EPC 在 N 秒(通常 2~3 秒)内的重复读取只更新计数,不重复刷新界面、不重复触发动画。否则界面会疯狂闪烁,CPU 也会飙升。
4.3 盘盈不要"直接入库"
扫到不在清单里的标签,正确做法是进入待确认列表,而不是自动记为新增资产。原因很实际:
- 可能是邻柜/邻架的资产串读进来了(尤其功率开大时);
- 可能是已报废但未销账的资产还挂着标签;
- 可能是本次盘点范围外的资产(比如兄弟单位寄放的)。
现场让操作员看一眼名称再确认,比回到办公室从几百条盘盈里捞要省事得多。
五、端侧实时比对:把"盘完才知道差多少"变成"盘到哪知道到哪"
传统流程是:全盘完 → 回传 → 后台比对 → 出差异表 → 再去现场复核。一轮下来至少两周。
端侧有基线数据,完全可以实时算:
已盘(FOUND) = 基线里存在且已扫到
待盘(PENDING)= 基线里存在但未扫到 ← 界面实时显示剩余数量
盘盈(SURPLUS)= 不在基线但扫到了
位置异常(MISPLACED)= 扫到了但位置与基线不符
界面上常驻一行概览:"应盘 320 / 已盘 187 / 待盘 133 / 盘盈 4 / 位置异常 2"。操作员随时知道还剩多少,也知道哪些区域还没走到。
最有价值的是"待盘清单的现场定位":临近结束时把待盘资产按期望位置排序展示,操作员直接照着清单去补扫,不用整个区域重新走一遍。这一个功能通常能把收尾时间压缩一半以上。
六、回传:增量、幂等、可断点
6.1 回传的是"流水 + 结果"两份
- 扫描流水:全量回传,作为审计凭证(谁在什么时候扫到了什么)
- 盘点结果:按资产聚合后的最终状态,作为业务数据
流水体积大但价值高——差异复核时能回答"这台设备是全程没扫到,还是扫到过但被判为无效",这在责任认定上是天壤之别。
6.2 幂等与重试
弱网环境下,回传超时但服务端其实已收到的情况很常见。端侧必须支持安全重发:
批次号 batchId = taskId + 终端号 + 序号
服务端按 batchId 幂等:重复批次直接返回上次成功结果,不重复入库
端侧队列状态:PENDING → UPLOADING → SUCCESS / FAILED(保留重试次数)
重试策略建议:指数退避 + 上限 5 次 + 手动重试入口。不要无限自动重试——那会在弱网时把电量和带宽耗光。5 次失败后停在队列里,界面上明确提示"有 3 批数据未上传",让操作员回到有网的地方手动点一次。
6.3 回传完成不等于任务结束
服务端收到后还要做全局比对(多个终端各自回传的结果要合并、跨终端重复盘点要归并)。端侧状态应区分:
LOCAL_DONE(本机完成) → UPLOADED(已上传) → SERVER_MERGED(服务端已合并) → CLOSED(任务关闭)
一线最常问的问题是"我这台机器的数据传上去没有",把这四个状态在界面上显式展示,能省掉大量沟通。
七、性能与体验的几条硬约束
| 问题 | 表现 | 对策 |
|---|---|---|
| 大列表渲染卡顿 | 3000 条资产列表滑动掉帧 | 分页加载 + RecyclerView 复用 + 禁止在 bind 里做 IO |
| 连续扫描耗电发热 | 一小时后烫手、掉电快 | 扫描功率按场景分档;不扫描时释放射频模块 |
| 界面刷新风暴 | 一秒几十次读取导致 UI 重绘卡死 | 去重窗口 + 节流,界面刷新限频(如 200ms 一次) |
| 手套/强光下看不清 | 现场抱怨 | 字号不小于 16sp,关键按钮高度 ≥ 48dp,高对比配色 |
| 误触扳机 | 放口袋里一阵狂扫 | 支持锁定屏幕时自动停止扫描 |
八、上线前检查清单
- [ ] 无网络环境下能否完整走完一次盘点(含扫描、比对、暂存)
- [ ] App 被杀掉后重新打开,进度是否完整恢复
- [ ] 扫描流水与盘点结果是否分离存储
- [ ] 重复扫描是否有去重窗口,界面是否限频刷新
- [ ] 盘盈资产是否进入待确认列表而非自动入库
- [ ] 是否实时展示"应盘/已盘/待盘/盘盈/位置异常"概览
- [ ] 待盘清单是否支持按期望位置排序定位
- [ ] 回传批次是否有幂等键,失败重试是否有上限与手动入口
- [ ] 端侧状态是否区分"本机完成/已上传/服务端已合并/任务关闭"
- [ ] 连续扫描 30 分钟后的发热、耗电、内存表现是否实测过
- [ ] 戴手套、强光环境下的可读性是否现场验证
写在最后
手持端的设计有一个简单的判断标准:让一个第一次上手的人,在没有培训、没有网络、被打断三次的情况下,依然能把盘点做完。
做到这一点,靠的不是多加几个功能,而是把"离线优先、流水留存、进度可恢复、状态可解释"这四件事做扎实。这四件事背后是同一个原则——把不确定性留在端上,把确定性交给系统:现场的情况千变万化,系统能做的是如实记录、准确反馈、随时可恢复,而不是假设一切按理想流程走。