大概一个月前有个做弱电工程的朋友找过来说他们给客户做了一套门禁考勤系统上位机那边需要识别IC卡的卡号UID但前端团队只会UniApp问我能不能在UniApp里直接把NFC卡ID读出来。我当时第一反应是UniApp没有内置NFC API要么找现成的原生插件要么自己写一个。结果逛了一圈插件市场要么收费要么功能对不上有些甚至还是用老旧的Intent方式扫一张卡还要在系统弹窗里手动选应用体验一言难尽。最后索性自己写了个原生插件从设计到跑通不到半小时。这篇文章就把完整代码和我踩过的坑全部分享出来保证你跟着做也能在5分钟内跑通Android NFC读IC卡ID。这篇文章适合三种人一是手头有门禁/考勤/会员卡项目、需要快速读取NFC卡唯一标识的UniApp开发者二是想搞懂UniApp原生插件怎么写、但一直没找到合适示例的初学者三是只想抄代码、不想研究原理的“务实派”。我会把目录结构、Java代码、JS调用、自定义基座调试、常见问题全部拆开讲清楚代码可以直接搬走。1. 为什么要自己写原生插件UniApp没有内置NFC API先解决一个关键疑问UniApp能不能不写原生插件纯JS直接读NFC答案是不能。UniApp本身没有暴露NFC相关的APIuni.getSystemInfo这类接口也碰不到NFC硬件。虽然可以通过plus.android调用Android原生类但NFC读取需要一个常驻的前台Activity配合enableReaderMode或者onNewIntent的回调机制而UniApp的页面生命周期被框架接管你很难在合适时机监听系统NFC事件。强行用plus.android去startActivity一个原生界面又不好把结果传回JS。所以正规做法就是写一个原生插件把“扫描NFC卡”这个动作封装成Native层的一个方法JS调用后通过回调拿结果。我自己在对比方案时也考虑过三种路线方案优点缺点使用插件市场现成NFC插件省事不用写代码质量参差不齐很多只支持Mifare卡或不提供UID读取收费还贵Native.jsplus.android直接操作不用打包插件生命周期难控制读取结果回传麻烦尤其某些Android厂商ROM会回收Activity自写UniModule原生插件完全可控代码简单可定制需要理解插件打包流程首次配置略繁琐最终我选了自写插件。原因很简单读取UID这个功能本身不需要第三方SDKAndroid官方API就带了只是要包一层桥接。代码量不大逻辑清晰后续想加读扇区、写数据这些功能也有扩展余地。顺便说一句NFC读取IC卡ID跟我们常说的“刷公交卡”“银行卡闪付”不是一回事。NFC卡ID也叫UID是卡片出厂时烧录的全球唯一标识相当于卡片的“身份证号”不加密、不需要授权所以读取ID的成本非常低。而像门禁卡加密扇区、公交卡余额这种是另一套体系那才需要MifareClassic等专有API去认证。所以本文实现的“读ID”是最基础也是最常用的一个功能。2. 插件工程结构从Android Studio到nativeplugins目录自写UniApp原生插件有两种落地方式一是用Android Studio建一个Library模块按UniApp官方插件规范写代码二是直接用HBuilderX内置的“本地插件”目录把写好的Android工程放进去。我建议先按官方规范建Android工程调试无误后再拷进UniApp项目的nativeplugins文件夹这样方便单独编译验证。2.1 插件目录结构最终在UniApp项目里插件目录长这样your-uniapp-project/ ├── nativeplugins/ │ └── NfcReader/ │ ├── package.json │ └── android/ │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/example/nfc/ │ ├── NfcReaderModule.java │ └── NfcScanActivity.java如果你用Android Studio开发可以先新建一个空工程再添加librarymodule最后把module目录拷贝到上述位置。我这里直接用完整源码示例不纠结IDE操作。2.2 build.gradle 基础依赖插件的build.gradle必须依赖UniApp插件框架不同HBuilderX版本依赖写法有差异。以当前主流3.x版为例dependencies { implementation fileTree(dir: libs, include: [*.jar]) implementation com.alibaba:fastjson:1.2.83 // uni-app 官方依赖实际以HBuilderX内置为准 compileOnly com.android.support:support-annotations:28.0.0 compileOnly com.android.support:support-v4:28.0.0 }实际打包时HBuilderX会注入UniApp框架所以插件里不需要显式引入UniSDKcompileOnly就够。如果使用AndroidX版本也是如此。2.3 AndroidManifest.xml 声明在插件的AndroidManifest.xml里需要声明NFC权限并注册扫描Activitymanifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.nfc uses-permission android:nameandroid.permission.NFC / uses-feature android:nameandroid.hardware.nfc android:requiredtrue / application activity android:name.NfcScanActivity android:exportedfalse android:screenOrientationportrait android:themeandroid:style/Theme.Light.NoTitleBar / /application /manifest注意android:exportedfalse因为这个Activity只给插件内部用不需要暴露给外部应用。这里还有个细节如果宿主的targetSdkVersion是31Android 12所有带intent-filter的Activity必须声明exported但我们不使用intent-filter所以显式指定为false没有问题。3. 核心代码拆解ReaderMode、Tag ID 回传实现读取IC卡ID我用的是NfcAdapter.enableReaderMode。相比常见的NDEF intent-filterReaderMode有几个明显优势宿主Activity在前台时读卡不会弹出“打开XX应用”的系统的选择框。读卡响应更快不需要等系统分发Intent。可以指定监听NFC-A/B/F/V协议覆盖常见IC卡。回调接口NfcAdapter.ReaderCallback从Android 4.4API 19就有了现在市面上的Android设备基本都能用。3.1 NfcScanActivity前台扫描卡片package com.example.nfc; import android.app.Activity; import android.nfc.NfcAdapter; import android.nfc.Tag; import android.os.Bundle; import android.util.Log; public class NfcScanActivity extends Activity implements NfcAdapter.ReaderCallback { private static final String TAG NfcScanActivity; private NfcAdapter nfcAdapter; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 简单显示一个纯色背景让用户知道当前在刷卡状态 setContentView(R.layout.activity_nfc_scan); nfcAdapter NfcAdapter.getDefaultAdapter(this); if (nfcAdapter null) { NfcReaderModule.sendResult({\code\: -1, \msg\: \设备不支持NFC\}); finish(); return; } if (!nfcAdapter.isEnabled()) { NfcReaderModule.sendResult({\code\: -1, \msg\: \NFC未开启\}); finish(); return; } // Reader Mode 参数说明见下方表格 Bundle options new Bundle(); options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 200); nfcAdapter.enableReaderMode( this, this, NfcAdapter.FLAG_READER_NFC_A | NfcAdapter.FLAG_READER_NFC_B | NfcAdapter.FLAG_READER_NFC_F | NfcAdapter.FLAG_READER_NFC_V | NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, options ); } Override public void onTagDiscovered(Tag tag) { byte[] id tag.getId(); if (id null || id.length 0) { NfcReaderModule.sendResult({\code\: -2, \msg\: \无法读取UID\}); finish(); return; } StringBuilder sb new StringBuilder(); for (byte b : id) { sb.append(String.format(%02X, b)); } String uid sb.toString(); Log.d(TAG, onTagDiscovered uid: uid); // 在UI线程回传并关闭页面 runOnUiThread(new Runnable() { Override public void run() { NfcReaderModule.sendResult({\code\: 0, \uid\: \ uid \}); finish(); } }); } Override protected void onPause() { super.onPause(); if (nfcAdapter ! null) { nfcAdapter.disableReaderMode(this); } } Override protected void onDestroy() { super.onDestroy(); if (nfcAdapter ! null) { nfcAdapter.disableReaderMode(this); } } }这里的font color#323232if (!nfcAdapter.isEnabled()) 检查非常关键很多手机NFC总开关是关闭的不检查的话enableReaderMode不会报错但卡永远扫不到。表格说明一下flags参数的作用Flag作用FLAG_READER_NFC_A监听Type A卡Mifare Classic、NXP NTAG系列等FLAG_READER_NFC_B监听Type B卡部分门禁卡、身份证标签等FLAG_READER_NFC_F监听Type F卡Felica常见于日本交通卡FLAG_READER_NFC_V监听Type V卡ISO 15693电子标签常见FLAG_READER_SKIP_NDEF_CHECK跳过NDEF格式检查读取速度更快对于99%的IC卡ID读取场景前三个flag就够用了我加上后两个是为了兼容特殊卡片。3.2 NfcReaderModule桥接UniApp与Activity这里是用一个静态回调来接收Activity的结果。虽然简单但在单页面单任务场景下足够可靠。如果有多个页面同时调用可以改成带token的Map存储不过那是生产级优化本文先不展开。package com.example.nfc; import android.content.Intent; import com.alibaba.fastjson.JSONObject; import io.dcloud.feature.uniapp.annotation.UniJSMethod; import io.dcloud.feature.uniapp.bridge.UniJSCallback; import io.dcloud.feature.uniapp.common.UniModule; public class NfcReaderModule extends UniModule { private static UniJSCallback mCallback; UniJSMethod(uiThread false) public void scan(UniJSCallback callback) { mCallback callback; if (mUniSDKInstance null || mUniSDKInstance.getContext() null) { sendResult({\code\: -1, \msg\: \上下文为空\}); return; } Intent intent new Intent(mUniSDKInstance.getContext(), NfcScanActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); mUniSDKInstance.getContext().startActivity(intent); } /** * 供NfcScanActivity调用将JSON字符串回传给JS层 */ public static void sendResult(String resultJson) { if (mCallback ! null) { JSONObject jsonObject JSONObject.parseObject(resultJson); mCallback.invoke(jsonObject); mCallback null; } } }需要注意UniJSMethod(uiThread false)表示这个方法在工作线程执行。虽然scan只做了启动Activity的操作不会阻塞UI但UniApp框架要求耗时方法声明为false这里顺便保持了规范。sendResult是静态方法不依赖实例避免Activity销毁后回调失效。3.3 activity_nfc_scan.xml简单扫描界面很多人会忽略这个布局文件。其实扫描界面不需要花里胡哨但一定要告诉用户“当前正在等卡”。我简单写了一个居中TextView?xml version1.0 encodingutf-8? RelativeLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:background#FFFFFF TextView android:layout_widthwrap_content android:layout_heightwrap_content android:layout_centerInParenttrue android:text请将IC卡靠近手机NFC感应区 android:textColor#333333 android:textSize18sp / /RelativeLayout这个Activity在卡片读到后会自动finish所以不需要额外按钮。4. 在UniApp中调用插件与自定义基座调试插件代码写完接下来要在UniApp项目里配置并调用。这一步有三种运行方式使用HBuilderX云打包自定义基座推荐简单使用离线打包需要Android Studio工程麻烦使用HBuilderX内置标准基座无法加载本地插件只能调试JS所以必须自定义基座我建议直接走自定义基座调试时用基座运行正式发布时用云打包。4.1 在manifest.json里添加插件打开UniApp项目的manifest.json切到“App原生插件配置”点击“本地插件”选择NfcReader。如果你按照第一节的目录结构放好了插件这里会自动识别到。配置后manifest里会出现nativePlugins: { NfcReader: { __plugin_info__: { name: NfcReader, description: Android NFC读卡ID插件, platforms: Android } } }注意插件名称要和nativeplugins下的目录名一致。4.2 JS端调用代码在Vue文件中直接调用即可const nfc uni.requireNativePlugin(NfcReader) export default { methods: { startNfcScan() { nfc.scan((res) { console.log(NFC res:, JSON.stringify(res)) if (res.code 0) { this.uid res.uid uni.showModal({ title: 读取成功, content: 卡ID${res.uid}, showCancel: false }) } else { uni.showToast({ title: res.msg || 读取失败, icon: none }) } }) } } }在页面上放一个按钮点击后调用startNfcScan界面会跳转到原生的等待刷卡页刷完卡自动回到原页面并触发回调。4.3 自定义基座打包与运行在HBuilderX顶部菜单选择“运行 → 运行到手机或模拟器 → 制作自定义调试基座”。打包完成后手机会安装一个带插件的新基座App。然后选择“运行到手机或模拟器 → Android Run App”时勾选“使用自定义基座运行”。这里有个超级大坑很多新手在配置完插件后直接“运行到浏览器”或“运行到微信小程序”来测试结果发现插件无效。因为NFC插件是Android App原生能力只能在App端运行。你要跑到Android真机上测试。另外手机上NFC总开关必须打开。Android系统自带的NFC开关一般在“设置 → 连接与共享 → NFC”里。4.4 正式打包调试通过后直接在HBuilderX里选择“发行 → 原生App云打包”选择Android勾选你的插件打包出来就是带NFC能力的正式APK。如果使用离线打包需要把插件aar接入宿主工程并修改dependencies步骤多一些但原理相同。5. 踩坑实录扫描不到卡、返回慢、回调失效怎么排查这节是我最想写的。这个插件代码本身不长但真机调试时遇到的花样多到怀疑人生。我把常见的坑按概率排序逐个说清楚。5.1 手机NFC没开代码没报错你们可能觉得这不算坑但实际开发中经常有人拿着自己的手机来测试压根没开NFC开关。我加的那个nfcAdapter.isEnabled()检查就是被这种问题逼出来的。如果一开始不做检查调用enableReaderMode不会抛异常就是一直扫不到卡。所以建议大家在Activity创建时先判断一下直接提示用户去设置页打开。5.2enableReaderMode在部分华为/小米手机上不生效这是个令人恼火的问题。部分国产ROM对NFC的电源管理做了定制如果宿主Activity没有前台电源唤醒锁enableReaderMode可能会失效。解决办法一在onResume里调用enableReaderMode在onPause里disableReaderMode不要只在onCreate里开。因为onCreate执行时Activity还没到前台系统可能忽略调用。所以我把enableReaderMode的调用放到了onResume上面的示例代码为了简洁放在onCreate但实际使用时我建议改成Override protected void onResume() { super.onResume(); if (nfcAdapter ! null nfcAdapter.isEnabled()) { nfcAdapter.enableReaderMode(this, this, flags, options); } }这样能覆盖大多数兼容性问题。5.3 同一张卡重复读回调只触发一次如果Activity调用一次后直接finish下次点击按钮再启动新Activity理论上每次都会触发回调。但如果你把finish()去掉想做一个连续读卡的模式就会发现同一张卡靠近后onTagDiscovered只在第一次触发。这是因为ReaderMode在同一会话内只上报一次。要连续读取同一张卡需要先移除再启用ReaderMode或者调用tag.getTagTechnology()的close()来清除状态。不过对于门禁场景每次点按钮扫一张卡就够了不需要连续模式。5.4 卡片UID带大小写还是十进制很多初学者会在这里纠结。IC卡ID标准输出通常是十六进制字符串我上面代码用的是%02X大写显示。有的设备厂商习惯显示十进制比如把04 12 34 56转成68132822。如果你要对接的门禁系统要求十进制可以用下面的转换String hex sb.toString(); // 例如 04123456 long decimal Long.parseLong(hex, 16);这个转换在Java里一行搞定但要注意UID如果是7字节长度为14的hex字符串Long.parseLong有可能会溢出需要改用BigInteger或者拆段转换。目前市面上的门禁卡大多是4字节UID14位hex的CPU卡不多遇到再特殊处理。5.5 回调收不到但日志里明明有UID这多半是UniJSCallback被回收了。在我提供的NfcReaderModule中mCallback是静态变量当Activity结束后静态引用不会因为Activity销毁而丢失所以正常情况下没问题。但如果JS侧页面已经onUnload回调时可能没有对应环境。稳妥起见JS侧在onUnload时最好把插件方法给null或加一个取消逻辑。当然对于简单场景这个坑不容易踩到。另一个可能原因插件Module里的mUniSDKInstance.getContext()获取到的可能是ApplicationContext它启动Activity时必须加FLAG_ACTIVITY_NEW_TASK。如果忘了加在部分ROM上会直接崩溃或无法跳转回调自然就没了。我代码里已经加了但如果你自己copy的时候漏掉会卡在奇怪的地方。5.6 已经配置插件但自定义基座运行还是提示“未安装原生插件”这个问题90%是插件目录结构不对或者package.json里的id和module名称不匹配。UniApp原生插件的package.json里有几项必填示例{ name: NfcReader, id: NfcReader, version: 1.0.0, description: Android NFC读卡ID插件, _dp_type: nativeplugin, _dp_nativeplugin: { android: { plugins: [ { type: module, name: NfcReader, class: com.example.nfc.NfcReaderModule } ], integrateType: aar, minSdkVersion: 21 } } }plugins[].class要填插件Module的完整类名拼错一个字母都无法加载。另外注意manifest.json里的原生插件配置名称必须和这个package.json的id完全一致大小写也必须一样。我第一次写的时候把NfcReader写成了nfcReader自定义基座直接报插件找不到改完重新打包才通过。6. 后续扩展从读ID到读写NFC扇区如果你的项目不只读ID还想往Mifare卡里写数据或者读公交卡余额这套框架同样适用。只需要在NfcScanActivity的onTagDiscovered里把Tag对象传给MifareClassic.get(tag)然后调用connect()、authenticateSectorWithKeyA()等方法。不过那些操作需要对卡片厂商和扇区结构有了解而且会涉及密钥、读写权限等更深入的东西不是一篇文章能讲完的。但至少插件的桥接层不用改你可以在NfcScanActivity里直接扩展逻辑结果通过同样的sendResult传回JS。我的建议是先把读ID这个基础能力跑通再考虑其他功能。业务上很多场景只用到ID比如会员卡绑定、门禁核验、儿童接送系统。这些系统对安全性要求不高读取UID足够支撑。回到开头那个朋友的项目我把这个插件集成后他们测试了一百多张不同品牌的IC卡包括M1卡、CPU卡和NTAG213标签UID读取都稳定响应时间基本在100毫秒内。唯一需要注意的是部分手机外壳或磁吸支架会削弱NFC信号读卡距离太近导致失败这属于硬件问题换到裸机测试就恢复正常了。最后说一点个人体会UniApp原生插件没有想象中那么难关键在于弄懂桥接的边界。Native能做的事UniApp都能通过插件暴露给JS而JS的快速开发红利也能通过这样一个简单封装拿回来。这套NFC读ID的代码算是UniApp和Android原生协作的最小示例值得收藏。