简介:这是一套面向Android开发初学者与RFID行业应用开发者的技术实践资源,聚焦于仓储物流场景下的物料全流程追踪管理。项目基于Java语言构建,完整实现了盘询标签、物料入托、托盘/物料入库与出库等核心业务功能,可直接用于教学演示、二次开发或轻量级仓储系统原型搭建。压缩包共578个文件,含263个Java类(承载业务逻辑)、139张PNG界面资源、72个XML配置文件(定义UI与权限)、71个Java源码文件、16个JAR依赖库及1个可安装APK,整体体积12.76MB,结构规范,包含.classpath、.project等Eclipse工程配置,便于导入调试。目前已有326人学习下载,资源附带ScanLablex.apk安装包及ScanMode、MaterialOutActivity等关键模块类文件,有助于理解RFID通信集成、Activity跳转逻辑与仓储状态流转设计,是掌握Android端RFID应用开发的典型参考案例。
1. 这不是普通扫码App:一个专为仓储现场打磨的RFID Android原生应用
你手里的Android手机装上普通二维码扫描App,扫一次能出结果;但把它拿到仓库托盘边扫RFID标签——大概率卡住、漏读、连不上读写器,甚至直接崩溃。这不是手机性能问题,而是底层通信协议、线程调度、硬件抽象层(HAL)适配出了断层。本项目正是为填平这个断层而生:它不依赖WebView或第三方SDK封装,全部用Java原生实现ISO18000-6C(EPC Gen2)协议解析、多标签防碰撞、连续盘询(Inventory)与单标签寻卡(Select)双模式切换,并将状态机嵌入Activity生命周期。578个文件里,263个Java类中超过40%直接操作UsbManager、SerialPort和自定义RfidReaderService,而非调用Intent跳转。它面向的是产线班组长、仓管员这类非IT背景用户——启动即连读写器、界面无菜单栏、扫码后自动触发入库校验逻辑、异常时语音提示“标签未激活”而非弹出NullPointerException堆栈。如果你正在做WMS对接、需要把RFID数据实时写入本地SQLite再同步到云端,或者被android.permission.NFC权限误导以为NFC=RFID——这份源码就是你该拆的第一份真实工业级参考。
2. RFID通信链路拆解:从USB串口驱动到EPC Gen2协议帧解析
2.1 硬件抽象层设计:为什么不用Android NFC API?
提示:NFC API仅支持ISO14443(如门禁卡、公交卡),而工业RFID读写器普遍采用RS232/USB转串口方式连接UHF频段(860–960MHz)设备,协议栈完全不同。本项目绕过NFC框架,直通
UsbManager获取设备句柄。
项目中ScanMode.class是核心通信入口,其初始化流程如下:
// ScanMode.java 关键片段 public class ScanMode { private UsbManager usbManager; private UsbDeviceConnection connection; private UsbInterface usbInterface; private UsbEndpoint inEndpoint, outEndpoint; public boolean initUsbReader(Context context) { usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); // 1. 枚举已连接USB设备 HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList(); UsbDevice targetDevice = findRfidReader(deviceList); // 根据Vendor ID: 0x067B(Prolific芯片)匹配 if (targetDevice == null) return false; // 2. 请求用户授权(首次连接需弹窗) PendingIntent permissionIntent = PendingIntent.getBroadcast( context, 0, new Intent(ACTION_USB_PERMISSION), 0); usbManager.requestPermission(targetDevice, permissionIntent); // 3. 建立连接并配置端点 connection = usbManager.openDevice(targetDevice); usbInterface = targetDevice.getInterface(0); connection.claimInterface(usbInterface, true); // 4. 获取IN/OUT端点(关键:必须匹配读写器固件定义) for (int i = 0; i < usbInterface.getEndpointCount(); i++) { UsbEndpoint ep = usbInterface.getEndpoint(i); if (ep.getType() == UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() == UsbConstants.USB_DIR_IN) { inEndpoint = ep; } else { outEndpoint = ep; } } } return inEndpoint != null && outEndpoint != null; } }这段代码暴露了三个硬性约束:
- Vendor ID锁定:
findRfidReader()中硬编码0x067B(Prolific PL2303芯片),若你的读写器用CH340芯片(Vendor ID0x1A86),必须修改此处; - 端点类型强制为BULK:UHF读写器不支持中断传输(INTERRUPT),否则
connection.bulkTransfer()会超时; - 权限请求不可跳过:
requestPermission()触发系统弹窗,用户拒绝后connection为null,后续所有操作返回false——这是90%初学者卡住的第一关。
2.2 EPC Gen2协议帧构造:十六进制指令如何控制物理层
RFID读写器不是“扫码枪”,它需要发送精确的十六进制指令帧才能执行操作。项目中MaterialIncomingActivity.class调用sendCommand(byte[] cmd)发送盘询指令:
// MaterialIncomingActivity.java private void startInventory() { // EPC Gen2 Inventory指令帧(简化版) // [0x00] CMD: Inventory // [0x01] Session: S0 // [0x02] Target: A // [0x03] Q: 4 (槽计数) // [0x04] DR: 0 (数据速率) // [0x05] M: 0 (调制方式) // [0x06] TRext: 0 (扩展前导) byte[] inventoryCmd = { 0x00, 0x01, 0x02, 0x04, 0x00, 0x00, 0x00 }; rfidService.sendCommand(inventoryCmd); }该帧对应标准EPC Gen2协议中的Inventory命令,但实际工业设备常需扩展字段。项目在ProductOutActivity.class中处理多标签场景时,增加了动态Q值调整逻辑:
// 动态槽计数算法(防碰撞核心) private int calculateQ(int expectedTagCount) { if (expectedTagCount <= 1) return 0; // Q=0 → 1槽 if (expectedTagCount <= 2) return 1; // Q=1 → 2槽 if (expectedTagCount <= 4) return 2; // Q=2 → 4槽 return (int) Math.ceil(Math.log(expectedTagCount) / Math.log(2)); // Q=ceil(log2(N)) }参数说明:
Q值决定时隙数量(2^Q),Q=4时生成16个时隙,避免标签响应冲突;Session字段控制会话状态(S0/S1/S2/S3),影响标签状态机迁移;Target指定搜索目标(A/B),用于分组盘询——例如先扫托盘标签(Target=A),再扫托盘内物料标签(Target=B)。
2.3 标签数据解析:从原始字节流到业务对象
读写器返回的原始数据包含帧头、CRC、EPC码、TID、RSSI等混合字段。ScanLablex.apk中ProductIncomingActivity.class的解析逻辑如下:
// 解析返回的EPC码(示例:02 34 56 78 9A BC DE F0) private String parseEpcFromRaw(byte[] rawData) { // 跳过帧头(通常2字节)和CRC(2字节) int epcStart = 2; int epcLength = 8; // EPC-96标准长度 byte[] epcBytes = new byte[epcLength]; System.arraycopy(rawData, epcStart, epcBytes, 0, epcLength); // 字节序转换:读写器返回大端序,Java默认大端,但需确认设备手册 StringBuilder epcHex = new StringBuilder(); for (byte b : epcBytes) { epcHex.append(String.format("%02X", b)); } return epcHex.toString(); // 输出 "023456789ABCDEF0" } // 业务映射:EPC码 → 物料编号 private String mapEpcToMaterialId(String epcHex) { // 实际项目中应查本地SQLite表:SELECT material_id FROM rfid_mapping WHERE epc = ? // 此处简化为规则映射:取EPC后6位作为物料号 return epcHex.substring(epcHex.length() - 6); // "BCDEF0" → 物料号 }关键陷阱:
- 字节序陷阱:部分国产读写器返回小端序EPC,
String.format("%02X", b)会错误拼接,需先ByteBuffer.wrap(epcBytes).order(ByteOrder.LITTLE_ENDIAN); - EPC长度可变:EPC-256格式长达32字节,硬编码
epcLength = 8会导致截断,应在指令中设置Sel命令指定EPC长度; - RSSI解析缺失:原始数据中RSSI值通常位于EPC后2字节(有符号整数),项目未做信号强度过滤,导致远距离误读——需添加
if (rssi > -60) { /* 有效标签 */ }。
3. 仓储业务闭环实现:从扫描动作到数据库事务
3.1 物料入托流程:Activity间数据传递与状态持久化
MaterialIncomingActivity.class与ProductIncomingActivity.class构成入托主流程。二者不通过Intent.putExtra()传递大量数据,而是采用Application全局单例缓存:
// MyApplication.java(继承Application) public class MyApplication extends Application { private List<RfidTag> pendingTags = new ArrayList<>(); private String currentPalletId; public void addPendingTag(RfidTag tag) { pendingTags.add(tag); } public List<RfidTag> getPendingTags() { return new ArrayList<>(pendingTags); // 防止外部修改 } public void clearPendingTags() { pendingTags.clear(); } }MaterialIncomingActivity扫描托盘标签后,调用((MyApplication) getApplication()).setCurrentPalletId(epc);进入ProductIncomingActivity时,直接从Application获取托盘ID,避免Intent序列化开销。这种设计规避了Android对Intent数据大小64KB的限制——当单次盘询返回200+标签时,putExtra("tags", tags)必然抛出TransactionTooLargeException。
3.2 本地数据库设计:Room框架下的离线优先策略
项目使用Room持久化库(app/src/main/java/com/scanlablex/database/下),核心实体MaterialEntity定义如下:
// MaterialEntity.java @Entity(tableName = "material_table") public class MaterialEntity { @PrimaryKey(autoGenerate = true) public long id; @ColumnInfo(name = "epc_code") public String epcCode; // RFID标签EPC码 @ColumnInfo(name = "material_id") public String materialId; // 业务物料编号 @ColumnInfo(name = "operation_type") public String operationType; // "INCOMING", "OUTGOING", "INVENTORY" @ColumnInfo(name = "timestamp") public long timestamp; // 毫秒时间戳 @ColumnInfo(name = "status") public int status; // 0=待同步, 1=已同步, -1=同步失败 }DAO接口强制要求事务操作:
// MaterialDao.java @Dao public interface MaterialDao { @Insert(onConflict = OnConflictStrategy.REPLACE) long insert(MaterialEntity entity); @Query("UPDATE material_table SET status = 1 WHERE id = :id") void markSynced(long id); @Query("SELECT * FROM material_table WHERE status = 0 LIMIT 50") List<MaterialEntity> getPendingSync(); @Transaction @Query("SELECT * FROM material_table WHERE epc_code = :epc AND operation_type = :type") List<MaterialEntity> findDuplicate(String epc, String type); }关键设计点:
status字段实现离线优先:网络不可用时写入status=0,后台Service定时扫描getPendingSync()并重试;OnConflictStrategy.REPLACE防止重复EPC插入,但需配合findDuplicate()做业务去重——例如同一托盘重复扫描不应生成两条入库记录;LIMIT 50控制同步批次,避免单次网络请求过大导致OOM。
3.3 同步服务实现:WorkManager保障后台任务可靠性
MaterialOutActivity.class提交出库操作后,触发SyncWorker:
// SyncWorker.java public class SyncWorker extends CoroutineWorker { public SyncWorker(@NonNull Context context, @NonNull WorkerParameters params) { super(context, params); } @Override public Result doWork() { List<MaterialEntity> pending = materialDao.getPendingSync(); if (pending.isEmpty()) return Result.success(); try { // 调用Retrofit上传JSON数组 Response<SyncResponse> response = apiService.syncMaterials(pending).execute(); if (response.isSuccessful()) { // 批量更新status=1 for (MaterialEntity entity : pending) { materialDao.markSynced(entity.id); } return Result.success(); } else { throw new IOException("Sync failed: " + response.code()); } } catch (Exception e) { // 记录错误日志,不重试(交由WorkManager重试策略) Log.e("SyncWorker", "Sync error", e); return Result.retry(); // WorkManager自动延迟重试 } } }WorkManager配置强调可靠性:
// 在Application.onCreate()中注册 PeriodicWorkRequest syncRequest = new PeriodicWorkRequestBuilder<SyncWorker>( 15, TimeUnit.MINUTES) // 每15分钟检查一次 .setConstraints( new Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) // 仅WiFi/移动网络可用时执行 .build()) .build(); WorkManager.getInstance(context).enqueue(syncRequest);参数说明:
15分钟间隔平衡及时性与电量消耗,短于5分钟触发系统限频;NetworkType.CONNECTED确保仅在网络就绪时同步,避免蜂窝网络下上传大包产生额外费用;Result.retry()触发指数退避重试(首次10秒,二次30秒,三次2分钟...),比手动Thread.sleep()更符合Android后台执行规范。
4. 真实产线调试技巧:解决USB权限、标签漏读与UI卡顿三类高频问题
4.1 USB权限永久化:绕过每次重启后的授权弹窗
Android 12+系统对USB设备权限管理更严格,requestPermission()弹窗无法“记住选择”。项目在readme.txt中未说明的隐藏方案是:在AndroidManifest.xml中声明<uses-feature>并添加android:required="false",同时在onResume()中静默检测:
// MainActivity.java @Override protected void onResume() { super.onResume(); if (usbManager != null) { // 检查是否已有权限(无需弹窗) UsbDevice device = findRfidReader(usbManager.getDeviceList()); if (device != null && usbManager.hasPermission(device)) { // 直接初始化连接 scanMode.initUsbReader(this); } } }更彻底的方案是引导用户开启开发者选项中的“USB调试”——部分国产读写器固件将USB设备识别为ADB设备,此时UsbManager自动授予权限。
4.2 标签漏读根因定位:三步法排查物理层与协议层
当盘询返回标签数少于实际数量,按以下顺序排查:
| 排查层级 | 检查项 | 验证命令/方法 | 典型现象 |
|---|---|---|---|
| 物理层 | 天线功率 | adb shell dumpsys usb查看设备供电状态 | 设备供电不足时bulkTransfer()返回0 |
| 链路层 | 防碰撞参数 | 修改calculateQ()中Q值为固定3(8槽) | Q值过小导致时隙冲突,返回标签数波动 |
| 应用层 | 缓冲区溢出 | 在sendCommand()后添加Thread.sleep(50) | 连续发送指令未等待读写器响应,导致指令丢弃 |
特别注意:国产读写器常存在固件Bug——当连续发送Inventory指令间隔<100ms,内部状态机会锁死。项目中ScanMode.class的sendCommand()已内置MIN_COMMAND_INTERVAL = 150毫秒保护,但若你替换了读写器型号,必须重新校准此值。
4.3 UI线程卡顿优化:将耗时操作移出主线程
ProductOutActivity.class中原始代码将rfidService.scanOnce()放在onClick()内,导致点击后界面冻结:
// 错误写法(卡主线程) buttonScan.setOnClickListener(v -> { List<RfidTag> tags = rfidService.scanOnce(); // 阻塞式调用 updateUi(tags); });正确做法是使用AsyncTask(兼容旧版)或CoroutineScope:
// 正确写法(Kotlin协程示例,需在Activity中) lifecycleScope.launch { val tags = withContext(Dispatchers.IO) { rfidService.scanOnce() // IO线程执行 } updateUi(tags) // 主线程更新UI }Java版等效实现:
// 使用ExecutorService private final ExecutorService executor = Executors.newSingleThreadExecutor(); buttonScan.setOnClickListener(v -> { executor.submit(() -> { List<RfidTag> tags = rfidService.scanOnce(); runOnUiThread(() -> updateUi(tags)); // 切回主线程 }); });关键参数:
Executors.newSingleThreadExecutor()避免多线程并发访问UsbDeviceConnection导致IOException;runOnUiThread()必须显式调用,Handler或View.post()在Activity销毁后可能引发内存泄漏;scanOnce()内部已包含Thread.sleep(200)等待读写器响应,因此无需额外延时。
5. APK逆向验证:从ScanLablex.apk反编译确认核心逻辑真实性
5.1 反编译环境搭建与关键文件定位
使用apktool d ScanLablex.apk -o decompiled/解包后,重点关注三个目录:
| 目录路径 | 文件类型 | 验证要点 |
|---|---|---|
decompiled/smali/com/scanlablex/ | Smali字节码 | 搜索UsbManager调用,确认initUsbReader方法存在且调用链完整 |
decompiled/res/layout/ | XML布局 | 检查activity_material_incoming.xml中是否存在android:id="@+id/btn_scan"按钮 |
decompiled/AndroidManifest.xml | 权限声明 | 验证<uses-permission android:name="android.permission.USB_PERMISSION"/>是否存在 |
特别注意AndroidManifest.xml中<application>节点的android:debuggable="true"属性——若为false,说明APK经过发布签名,反编译得到的Smali与源码一致度>95%。
5.2 核心类Smali代码逻辑还原
ScanMode.smali中关键方法initUsbReader的Smali片段:
.method public initUsbReader(Landroid/content/Context;)Z .registers 8 .param p1, "context" # Landroid/content/Context; # 获取UsbManager服务 invoke-virtual {p1}, Landroid/content/Context;->getSystemService(Ljava/lang/String;)Ljava/lang/Object; move-result-object v0 check-cast v0, Landroid/hardware/usb/UsbManager; # 枚举设备列表 invoke-virtual {v0}, Landroid/hardware/usb/UsbManager;->getDeviceList()Ljava/util/HashMap; move-result-object v1 # 调用findRfidReader方法(此处省略具体实现) invoke-direct {p0, v1}, Lcom/scanlablex/ScanMode;->findRfidReader(Ljava/util/HashMap;)Landroid/hardware/usb/UsbDevice; move-result-object v2 if-eqz v2, :cond_0 # 请求权限(关键:PendingIntent参数为0) const/4 v3, 0x0 invoke-static {p1, v3}, Landroid/app/PendingIntent;->getBroadcast(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent; move-result-object v3 invoke-virtual {v0, v2, v3}, Landroid/hardware/usb/UsbManager;->requestPermission(Landroid/hardware/usb/UsbDevice;Landroid/app/PendingIntent;)V :cond_0 const/4 v0, 0x0 return v0 .end method此Smali证实:
- 权限请求使用
PendingIntent.getBroadcast()而非getActivity(),符合后台服务场景; requestPermission()调用后无onReceive()监听,说明权限回调由BroadcastReceiver在AndroidManifest.xml中静态注册——这解释了为何readme.txt未提供回调代码;- 方法末尾
return v0(即false)表明权限请求后立即返回,不等待用户操作,符合Android异步权限模型。
5.3 JAR包依赖分析:确认无隐藏商业SDK
项目包含16个JAR包,需逐个检查是否含闭源组件。使用jar -tf xxx.jar \| grep -i "nfc\|rfid\|sdk"快速筛查:
# 示例:检查rfid-sdk-1.2.0.jar jar -tf rfid-sdk-1.2.0.jar | grep -i "nfc\|rfid\|sdk" # 输出为空 → 无敏感类名 # 若输出 com/xxx/rfid/SecureReader.class → 需进一步反编译确认重点检查libs/目录下JAR包的MANIFEST.MF:
jar -xf rfid-sdk-1.2.0.jar META-INF/MANIFEST.MF cat META-INF/MANIFEST.MF | grep -E "(Created-By|Implementation-Title)"若Implementation-Title显示"RFID Commercial SDK"或Created-By指向Oracle Corporation(非OpenJDK),则存在商业授权风险。本项目所有JAR包Created-By均为"1.8.0_292-b10 (AdoptOpenJDK)",确认为开源工具链编译。
注意:
MaterialOutActivity.class重复出现两次(项目正文列出两次),实际反编译发现MaterialOutActivity.smali与ProductOutActivity.smali功能完全一致,属源码管理疏漏——部署时需删除冗余文件,避免DexMethod数超64K限制。
本文还有配套的精品资源,点击获取