简介:这是一份面向Android开发者的NFC身份证读取项目源码,利用手机NFC模块与二代身份证RFID芯片通信,结合云端解码服务解析姓名、身份证号等个人信息,适合酒店入住、安检、会议签到等身份核验场景。资源包共458个文件,压缩后约15.41MB,涵盖Java主程序、Gradle构建配置、Android界面资源、CMake原生工具链、Tesseract识别字库及网络请求库等,从页面布局到HTTP云解码请求均有完整实现。关键类包括监听NFC事件的NFCReaderActivity、处理数据收发与解析的CloudDecodeService,并附有数据加密、权限校验等安全示例,帮助开发者理清本地读取-云端解码-结果回显的完整链路。目前已有3609人学习,适合具备Android基础并希望掌握NFC、身份证云解码技术的开发者,工程目录清晰,便于二次开发和功能集成。
1. 安卓NFC读身份证:这套源码把“读不出来”变成“秒出卡号”
手持安卓机贴近身份证背面,日志窗口疯狂刷“Tag lost”,NFC天线位置换了好几个角度还是没反应——这种场面在自助终端、酒店入住、访客登记这类项目里太常见了。安卓NFC读身份证这件事,难的不是调API,而是身份证用的ISO 14443 Type B协议在国产手机上兼容性时好时坏,加上身份证数据本身就是加密的,手机端读不到明文,必须配合身份证云解码服务才能拿到姓名和证件号。这套源码包就是干这个的:把射频层读取、APDU指令、云解码对接完整串起来,适合正在做门禁、政务终端、酒店自助机的开发者直接改。
2. 读卡为什么这么难:从射频协议到安卓系统栈的层层限制
2.1 身份证读卡与普通NFC卡的本质差别
身份证走的是ISO 14443 Type B协议,而我们日常接触的门禁卡、公交卡大多是Type A。两者在载波调制方式、防冲突机制上完全不同:Type A用Miller编码,Type B用NRZ编码;Type B的通信速率固定为106 kbps,且卡片上电后必须先回应ATQB(Answer To Request Type B),而不是像Type A那样回应ATQA。手机NFC模块如果只是按Type A的逻辑去轮询,身份证贴上去完全不会有反应。
另一个更关键的限制是:身份证内的数据文件(DG1、DG2等)在出厂时就是用专有密钥加密的,读EF_DG1(包含姓名、性别、出生日期、证件号)之前必须完成BAC(Basic Access Control)或PA(Passive Authentication)鉴权。普通手机端没有放置身份证解密密钥的SE安全模块,所以手机只能读到UID和ATQB返回的协议参数,读不到明文。这就是为什么整套方案必须拆成两段:手机端负责物理层通信,身份证云解码服务端负责用密钥完成鉴权和解密。
Android系统层面对Type B的支持也有历史包袱。NFC相关的类从Android 2.3就有了,但NfcB这个类要等到Android 4.4才稳定,而且很多国产ROM对Type B的轮询做了裁剪——常见做法是系统NFC服务始终在轮询Type A,Type B的tag发现延迟明显。我一般会先跑一遍tag.getTechList(),看看机器到底认不认Type B,不认的话后面全部白搭。
2.2 安卓NFC栈的适配边界与系统版本差异
读身份证的完整链路是这样的:NfcAdapter发现Tag → 系统回调newIntent()→ 获取Tag对象 → 通过NfcB或NfcA连接卡片 → 发送APDU指令 → 解析返回数据 → 组织云解码请求。其中每一步都可能出问题,源码包的设计也是按这个链路来的。
// 判断Tag支持的技术栈,决定走Type A还是Type B Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG); String[] techList = tag.getTechList(); for (String tech : techList) { Log.d("NFCTag", "supported tech: " + tech); } if (Arrays.asList(techList).contains(NfcB.class.getName())) { // 走Type B流程 NfcB nfcB = NfcB.get(tag); } else { // 部分国产机Type B识别延迟,这里需要重试机制 }这段代码在Android 4.4到Android 15上都能跑,但不同版本有细微差别:Android 8.0之后,前台分发系统的enableForegroundDispatch()对Intent过滤要求更严格;Android 12之后,如果targetSdkVersion设为31+,必须声明android.permission.NFC之外的运行时权限检查;Android 14开始,读取NFC Tag不再需要READ_PHONE_STATE权限,但如果你代码里引用了旧版判断逻辑,会在部分机型上直接闪退。
注意参数NfcAdapter.EXTRA_TAG是在onNewIntent()里取的,不是getIntent()。Activity如果是singleTop启动模式,getIntent()拿到的往往是上一次的Intent对象,这是新手最容易犯的错。
2.3 云解码的关键链路:UID读取到明文下发的完整流程
手机端读身份证只能拿到两个东西:UID(卡唯一标识)和ATQB返回的协议信息。云解码的做法是:手机端把UID、卡产生的随机数、以及从卡片读取的部分加密数据块拼装成请求,发送到身份证云解码服务端,服务端用持有的密钥完成BAC鉴权,解密出DG1文件内容,再返回姓名、性别、出生日期、住址、证件号。
源码里对应的是这样一条调用链:
// 读取UID + 发起云解码请求 byte[] uid = tag.getId(); String cardUid = bytesToHex(uid); String deviceNo = getDeviceSerial(); // 设备编号 String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String sign = generateSign(deviceNo, cardUid, timestamp); // 签名 JSONObject requestBody = new JSONObject(); requestBody.put("deviceNo", deviceNo); requestBody.put("cardUid", cardUid); requestBody.put("timestamp", timestamp); requestBody.put("sign", sign); // 发送到云解码服务端,等待返回明文这里有几个参数值得解释:deviceNo是云解码服务分配的设备标识,每个终端一个,服务端靠它做限流和审计;timestamp单位是秒,服务端一般会校验与当前时间的偏差,超过5分钟直接拒绝;sign的算法通常是MD5(deviceNo + cardUid + timestamp + secretKey),注意拼接顺序必须与服务端约定一致,否则永远返回签名错误。
云解码的SDK包一般会封装好HTTP请求和JSON解析,但源码包为了演示完整流程,直接裸写了HttpURLConnection的请求代码,方便开发者看明白底层。实际项目中如果追求稳定,建议换成OkHttp并加上连接池和超时重试。
3. 源码落地:从导入工程到跑通读卡全流程
3.1 源码包结构与工程导入
拿到源码包之后,先看目录再动手。这个包的结构是标准的Android Studio工程格式,分为app模块和nfc_sdk模块,后者是可以直接打进aar的独立库。文件清单如下:
| 路径 | 作用 |
|---|---|
app/src/main/java/.../MainActivity.java | 读卡主界面,处理Intent分发 |
app/src/main/java/.../NfcReaderHelper.java | 核心读卡逻辑,封装Type B通信 |
app/src/main/java/.../CloudDecoder.java | 云解码请求与响应解析 |
app/src/main/AndroidManifest.xml | 权限声明与Activity配置 |
nfc_sdk/src/main/java/.../NfcReaderManager.java | SDK入口类,面向二次开发 |
导入步骤很常规:Android Studio选择File → New → Import Project,选中工程根目录的build.gradle文件,等待Gradle Sync完成。需要注意的是,SDK模块里引用了身份证云解码的jar包,这个jar放在nfc_sdk/libs/目录下,如果你换了自己的云解码服务商,直接替换jar并修改CloudDecoder.java里的常量即可,不需要动NFC通信层的代码。
3.2 权限声明与NFC前台分发配置
NFC功能的核心权限只有一条:android.permission.NFC。但Android 12及以上系统对前台服务有额外要求,如果你要长时间监听NFC事件(比如自助终端常驻),需要声明FOREGROUND_SERVICE权限和对应的前台服务类型。源码包里已经把这套写好了。
<!-- AndroidManifest.xml 关键配置 --> <uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <uses-feature android:name="android.hardware.nfc" android:required="true" /> <activity android:name=".MainActivity" android:launchMode="singleTop" android:exported="true"> <intent-filter> <action android:name="android.nfc.action.TECH_DISCOVERED" /> </intent-filter> <meta-data android:name="android.nfc.action.TECH_DISCOVERED" android:resource="@xml/nfc_tech_filter" /> </activity>launchMode必须设为singleTop,因为NFC Tag发现时系统会通过onNewIntent()把新的Intent传给已有的Activity实例,如果用默认的standard模式,每贴近一次卡片就会新建一个Activity,导致读卡逻辑重复执行。nfc_tech_filter.xml文件声明了允许捕获的技术栈列表,这里是全部Type B相关的技术,包括NfcB、NfcF(部分银行IC卡用)、IsoDep。
3.3 核心读卡代码:连接、收发APDU指令与数据解析
读卡的核心是transceive()方法,它负责向卡片发送APDU指令并接收响应。身份证的APDU指令集遵循ISO 7816-4规范,常用的指令包括:00 A4 04 00选择文件、FF B0 00 04 00 08读取二进制数据。源码包在NfcReaderHelper.java中做了完整封装。
// 读取身份证EF_DG1文件的第4字节起8字节数据 private byte[] readBinary(NfcB nfcB, int offset, int length) throws IOException { byte[] cmd = new byte[7]; cmd[0] = (byte) 0xFF; // CLA:专有指令类别 cmd[1] = (byte) 0xB0; // INS:READ BINARY cmd[2] = (byte) ((offset >> 8) & 0xFF); // P1:偏移量高字节 cmd[3] = (byte) (offset & 0xFF); // P2:偏移量低字节 cmd[4] = (byte) 0x04; // 文件标识符高位 cmd[5] = (byte) 0x00; // 文件标识符低位 cmd[6] = (byte) length; // Le:期望返回的长度 Log.d("APDU", "sent: " + bytesToHex(cmd)); byte[] response = nfcB.transceive(cmd); Log.d("APDU", "recv: " + bytesToHex(response)); // 响应格式:最后两个字节是状态码,9000表示成功 if (response.length < 2) { throw new IOException("empty response"); } int statusCode = ((response[response.length - 2] & 0xFF) << 8) | (response[response.length - 1] & 0xFF); if (statusCode != 0x9000) { throw new IOException("APDU error: 0x" + Integer.toHexString(statusCode)); } // 返回去掉状态码后的有效数据 return Arrays.copyOfRange(response, 0, response.length - 2); }这段代码有两个值得注意的地方。第一是transceive()方法的长度限制:很多国产机型的NFC驱动对单次transceive的数据长度有上限,常见的是239字节或240字节,超过这个长度驱动直接返回IOException,这就是业界常说的“240坑”。解决方案是分块读取,把EF_DG1文件按8字节或16字节的块多次读取,再拼接成完整数据。
第二是响应状态码的判断。ISO 7816-4规定返回0x9000表示操作成功,其他值都表示异常,比如0x6A82表示文件未找到、0x6982表示安全状态不满足。源码包会在状态码非9000时抛出带状态码的异常,方便排查是文件选择问题还是鉴权问题。
3.4 云解码调用与响应解析的封装方式
读出来的EF_DG1原始数据是密文,必须交给云解码服务端处理。CloudDecoder.java里封装了完整的请求逻辑,包括签名生成、POST请求、响应解析。
public DecodeResult decode(String cardUid, byte[] encryptedData) { try { String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String sign = generateSign(cardUid, timestamp); JSONObject json = new JSONObject(); json.put("cardUid", cardUid); json.put("data", Base64.encodeToString(encryptedData, Base64.NO_WRAP)); json.put("timestamp", timestamp); json.put("sign", sign); HttpURLConnection conn = (HttpURLConnection) new URL(BASE_URL).openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); conn.setConnectTimeout(5000); conn.setReadTimeout(10000); conn.setDoOutput(true); conn.getOutputStream().write(json.toString().getBytes("UTF-8")); int code = conn.getResponseCode(); if (code != 200) { throw new IOException("HTTP " + code); } String response = readStream(conn.getInputStream()); JSONObject result = new JSONObject(response); if (result.getInt("code") != 0) { throw new CloudDecodeException(result.getString("message")); } return new DecodeResult( result.getString("name"), result.getString("idNumber"), result.getString("gender"), result.getInt("birthYear") ); } catch (Exception e) { Log.e("CloudDecoder", "decode failed", e); return null; } }这里的generateSign()是签名函数,源码包默认实现是MD5(cardUid + timestamp + secretKey)。云解码服务商不同,签名算法也不同,有的用HMAC-SHA256,有的要求对参数按字典序拼接后再签名。你接入真实服务时,一定要先看服务商的接口文档,把generateSign()改成对应的算法。
另外注意:响应里的idNumber返回的是完整18位身份证号,这个字段属于敏感个人信息。如果应用场景只需要显示后四位(比如访客登记),建议在客户端就做脱敏,不要传到日志系统,也不要存到本地数据库。我一般是在DecodeResult里直接提供一个getMaskedIdNumber()方法,返回前6位加后4位的掩码格式。
4. 避坑指南:五条高频翻车记录与排查清单
4.1 坑位总览:先知道雷区在哪
读卡这件事,看起来代码不多,但实际跑起来问题五花八门。先把高频坑列出来,后面逐条拆解:
| 坑位 | 典型症状 | 关键线索 |
|---|---|---|
| transceive 240字节限制 | 读长文件时IOException | 单次读取长度>239即失败 |
| Tag lost间歇性掉线 | 贴近卡片后2~3秒断开 | 天线线圈位置偏离 |
| 云解码签名失败 | 服务端返回sign error | 参数拼接顺序与约定不符 |
| EF_DG1解析偏移错误 | 姓名/证件号乱码 | TLV解析起点错误 |
| Android 12+系统兼容 | 闪退或权限弹窗异常 | 运行时权限声明缺失 |
4.2 坑1与坑2:transceive 240字节限制与Tag lost
现象:调用transceive()发送超过239字节的命令或期望接收超过240字节的响应时,部分国产手机直接抛出IOException,日志里能看到Tag lost或transceive failed。同样一张卡,在另一台手机上却正常。
原因:这是NFC驱动层的限制,不是Android API的问题。身份证的EF_DG1文件完整数据接近300字节,如果一次性读取,必然超过驱动上限。此外,部分机型的天线线圈较小,身份证这种大尺寸卡贴近后,线圈耦合位置偏移超过2~3毫米就会导致信号衰减,触发Tag lost。
解决:读取时按16字节分块,每次读取后短暂停顿(建议50ms),给射频层留出稳定时间。同时把读取重试次数上调到3次,每次失败后重新连接NfcB。
// 分块读取 + 重试机制的实现片段 private byte[] readFileChunked(NfcB nfcB, int fileSize) throws IOException { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); int offset = 0; int retry = 0; int chunkSize = 16; while (offset < fileSize) { try { byte[] chunk = readBinary(nfcB, offset, chunkSize); buffer.write(chunk); offset += chunkSize; } catch (IOException e) { if (retry >= 3) throw e; retry++; nfcB.close(); nfcB.connect(); // 重新连接后需要重新选择文件 selectFile(nfcB, FILE_ID_DG1); Thread.sleep(50); // 给射频层稳定时间 } } return buffer.toByteArray(); }分块大小chunkSize不是越大越好。实测中16字节比较稳,8字节更保险但速度明显变慢。如果你的机型正好卡在边界,可以先用8字节小步快跑,再逐步加大到16字节找到最优值。
4.3 坑3与坑4:云解码签名失败与EF_DG1解析偏移错误
现象:云解码服务端返回“sign error”或“invalid signature”,排查了好几遍签名算法都一样。另一种情况是服务端返回了明文,但姓名是乱码、证件号缺位。
原因:签名失败大概率是参数拼接顺序问题。比如服务端要求按deviceNo + timestamp + cardUid拼接,而你按cardUid + timestamp + deviceNo拼了,MD5结果自然不同。还有一种容易被忽略的是字符编码:中文参数必须用UTF-8编码后再参与签名计算,如果用平台默认编码(GBK),签名必然对不上。EF_DG1解析偏移错误则是因为TLV(Tag-Length-Value)结构的解析起点写错了。DG1文件的卡数据组(Card Data Group)里,第0个字节是Tag,第1个字节是Length,后面才是数据值,很多代码直接跳过前两个字节去读姓名,结果读出来的是长度值。
解决:签名拼接顺序在接任何一家云解码服务前,先用写死的测试参数跑一遍明文请求,确认服务端能正确认签名。TLV解析用循环遍历,不要用硬编码的偏移量。
// 正确的TLV解析方式:按Tag逐条读取 private void parseDG1(byte[] dg1Data) { int index = 0; while (index < dg1Data.length) { int tag = dg1Data[index] & 0xFF; int length = dg1Data[index + 1] & 0xFF; if (length > 100 || index + 2 + length > dg1Data.length) { // 跳到下一个TLV块,防止越界 index += 2; continue; } byte[] value = Arrays.copyOfRange(dg1Data, index + 2, index + 2 + length); switch (tag) { case 0x61: // 姓名标签,实际是UTF-8字符串 name = new String(value, "UTF-8"); break; case 0x62: // 证件号标签 idNumber = new String(value, "UTF-8"); break; } index += 2 + length; } }注意TLV的Tag可能是多字节的(如0x5F 0x20这种全限定Tag),上面这段代码只处理单字节Tag,遇到多字节Tag场景需要先判断tag & 0x1F == 0x1F再决定是否把下一个字节并入Tag。
4.4 坑5与常见误用边界:Android 12+系统兼容与敏感数据泄漏
现象:targetSdkVersion升级到31或33后,App在部分Android 12+机型上闪退,或者读卡成功后返回主界面时崩溃。还有一个常见误用:把云解码SDK的secretKey硬编码在代码里,反编译之后直接泄露。
原因:Android 12引入了android:exported必须显式声明的要求,targetSdkVersion 31+如果漏掉这个属性,安装时直接报错;Android 13新增了POST_NOTIFICATIONS运行时权限,如果App有前台服务通知,没申请权限会导致服务被杀。
解决:Manifest里给所有带intent-filter的Activity补上android:exported="true";云解码的密钥放到服务端代理转发,或者用加固方案保护,不要迷信源码混淆。
<!-- Android 12+ 兼容性关键配置 --> <activity android:name=".MainActivity" android:launchMode="singleTop" android:exported="true" android:configChanges="orientation|screenSize|keyboardHidden"> </activity>configChanges配置也很重要。NFC读卡过程中如果用户旋转了屏幕,Activity会重建,正在进行的transceive连接直接断开。加上configChanges声明,旋转屏幕时Activity不重建,保留读卡会话。这属于那种“不做不知道,做了才发现救了命”的配置。
5. 进阶技巧:用NDEF把读到的信息变成可交互卡片
读身份证本身是单向过程,但你可以把读取结果反向写回NFC标签或手机内置的NDEF功能区,做一个“身份证信息缓存卡”。这在门禁场景特别有用:访客第一次刷身份证登记后,把脱敏后的信息写入一个空白NFC标签或手机NFC模拟区,之后再次进门不用掏身份证,刷一下标签就能比对身份,减轻云解码服务端的调用压力。
实现方式是在onNewIntent()里同时处理NDEF消息的读写。注意,身份证本身不支持NDEF写入,你需要额外放一张空白NFC标签(Type 2或Type 4均可)。写入时用Ndef.get(tag)拿到连接,然后NdefMessage封装要写的数据:
// 将脱敏后的身份信息写入空白NFC标签 private void writeNdefTag(Tag tag, String maskedId) { Ndef ndef = Ndef.get(tag); if (ndef == null) { return; } try { ndef.connect(); NdefRecord record = NdefRecord.createTextRecord("zh", maskedId); NdefMessage message = new NdefMessage(record); ndef.writeNdefMessage(message); } catch (IOException | FormatException e) { Log.e("NdefWriter", "write failed", e); } finally { try { ndef.close(); } catch (IOException ignored) { } } }createTextRecord的第一个参数是语言代码,zh表示中文内容,Android的NFC系统会按这个字段做文本编码提示。写入前最好先检查ndef.isWritable(),否则会抛FormatException。标签容量也要看好,Type 2标签常见的是180字节,一段姓名加证件号掩码足够用。
在线读卡稳定的前提下,你可以进一步优化云解码的并发参数。CloudDecoder类里的connectTimeout和readTimeout分别设为5000ms和10000ms比较稳妥,云解码服务端的响应时间一般不超过3秒,但网络抖动时10秒也算正常。重试策略我建议最多2次,超过2次直接提示用户稍后再试,避免卡在等待界面。
验证整个方案的最终手段是找一张测试卡(由模拟项目X提供的标准测试卡),按照“UID读取 → EF_DG1分块读取 → 云解码 → 明文返回”的链路走一遍,确认每个环节的日志输出都符合预期。从那以后我每次做NFC读卡需求,都强制走一遍“硬件选型 → 系统版本 → 协议适配 → 云解码 → 错误兜底”这五层检查,翻车的概率至少降了七成。希望帮到你。
本文还有配套的精品资源,点击获取