简介面向Android NFC初学者的读写操作学习包完整覆盖近场通信的基础原理、NDEF数据交换格式、NfcAdapter与Tag等核心API以及从读取到写入的典型流程同时介绍读卡器、卡片模拟、点对点三种工作角色帮助理解NFC在移动支付、智能卡模拟等场景下的实际应用。资源以zip压缩包提供共36个文件包含3个Java源文件、12个编译后的class文件、4个XML布局与配置、6张PNG界面素材以及可直接安装运行的APK安装包整体大小仅72KB体量精简。目前已有416人学习下载适合初学者在理解理论后快速上手练习。内置的NFCDemo示例工程集中演示了NFC权限声明、intent过滤器注册、NDEF标签检测回调、读取NdefMessage与构造NdefRecord写入标签等关键步骤并配有对应的UI布局可对照源码梳理完整读写流程还能在此基础上进一步适配MIFARE Classic等不同标签技术作为开发个性化NFC应用的基础模板。1. android NFC读写从小白到能交付的三层地图android NFC读写这门手艺很多开发者第一反应是拿手机碰一下卡片就能自动读出数据真正做项目时却会发现同一张卡在A手机上秒读、在B手机上直接没反应写卡写一半抛I/O异常卡类型判断错了后面所有协议解析全部跑偏。这些坑大多不在业务代码里而在NFC的底层调度和Tag技术选型上。这篇笔记以一线开发视角拆一套完整可落地的NFC读写方案先讲三种工作模式和Android侧框架再给读卡、写卡的可运行代码最后集中写五类高频踩坑。适合刚接手NFC需求、不想从零趟雷的Android开发者看完能直接评估工作量并动手复现。2. 三种工作模式与Android NFC框架先搞懂原理再动手2.1 NFC不是一碰就通三种工作模式怎么选NFC在Android上并不只是碰一下读数据这么简单。它包含三种工作模式读卡器模式Reader/Writer、卡模拟模式Card Emulation和点对点模式P2P。做读写需求时绝大多数场景走的是读卡器模式——手机主动发射RF场读取被动Tag里的数据或者把数据写进Tag。卡模拟模式则是让手机模拟成一张卡被外部读卡器读取常见于门禁、支付场景这部分后面会单独提。三种模式在Android里的实现入口完全不同。读卡器模式走的是NfcAdapter的读卡回调卡模拟模式走的是Host Card Emulation主机卡模拟P2P模式现在已经很少用。开发前先明确产品到底要读别人还是被别人读选错方向后面整个架构都要返工。还有个常见的认知误区以为所有卡片都是NDEF格式。实际上NDEF只是NFC论坛定义的一种数据封装格式很多门禁卡、饭卡是MifareClassic或ISO-DEP协议字段是厂商自定义的原始块数据。同一张卡在不同手机上表现差异大根本原因往往是Android系统对Tag技术的支持策略不同有的机型优先按NDEF解析有的机型直接暴露原始Tag技术业务层得自己对techList做分发。2.2 Android侧NFC的层级与调度机制Android的NFC框架从底层往上可以分成四层NFC控制器硬件、Tag对象系统解析后的抽象、TagTechnology具体协议接口、业务数据NDEF消息或厂商块数据。系统把一次触碰封装成包含Tag的Intent派发到应用应用再根据tag.getTechList()获取这张卡支持哪些技术栈然后选对应的接口去连接。官方框架的调度方式有三种Manifest中的intent-filterNDEF_DISCOVERED / TECH_DISCOVERED / TAG_DISCOVERED、前台Activity的enableForegroundDispatch、以及API 19之后推荐的enableReaderMode。新人最容易踩的就是把intent-filter写在Manifest里结果Activity在后台时能激活、前台时反而无效。我一般直接建议用enableReaderMode原因很实在它专门为读卡器模式设计不受Activity前后台状态干扰回调走的是ReaderCallback接口而不是Broadcast扫描延迟更低还能通过flags精确控制要监听哪种NFC技术。这套机制从Android 4.4开始就是稳定的现在做新项目完全可以直接用它。Tag对象本身是个轻量壳连接和读写操作要通过具体的Technology接口来做。系统在Intent里塞的Tag可以通过Ndef.get(tag)、MifareClassic.get(tag)、NfcA.get(tag)等工厂方法拿到对应技术接口。注意一个Tag可能同时支持多种技术比如MifareClassic卡也经常被系统暴露为NfcA类型业务代码要按优先级判断不能想当然只走一条路。2.3 选型表读卡、写卡、卡模拟分别走哪条路需求场景推荐模式关键技术接口典型卡片/设备注意事项读取NFC标签/海报Reader模式Ndef / NdefFormatableNTAG213/215、FeliCa优先走NDEF解析注意空消息判断门禁/一卡通读写Reader模式MifareClassic / NfcAMifare Classic 1K/4K需要密钥认证块数据按扇区组织身份证/银行卡读取Reader模式IsoDep / NfcAISO/IEC 14443 TypeB涉及PBOC/居住证协议建议用现成SDK手机模拟成门禁卡Card EmulationHostCardEmulation手机作为Tag需要实现APDU响应和读写完全不同点对点传输P2PNdefPushAndroid Beam已被官方边缘化不建议新项目采用这张表可以帮你快速做技术预判如果产品要读的是市场常见的NTAG贴纸把精力放在NDEF解析上如果是老旧的门禁卡核心在MifareClassic扇区读写和密钥管理如果要做手机当门禁卡那就是另一套卡模拟方案别和读写写混在一起。3. 读卡实战把NDEF和MifareClassic数据从Tag里取出来3.1 权限与前台调度让读卡不再随机失效先确认Manifest里的权限只声明NFC权限还不够部分国产ROM还需要用户在系统设置里手动打开NFC开关应用侧只能通过NfcAdapter.isEnabled()做检测并引导。uses-permission android:nameandroid.permission.NFC / uses-feature android:nameandroid.hardware.nfc android:requiredtrue /uses-feature的required属性按需设置如果把NFC当核心功能就设true这样Google Play或应用市场会过滤掉不支持NFC的机型如果NFC只是辅助功能设false让App在所有机型上都能安装。Manifest配置后在Activity里启用读卡器模式Override protected void onResume() { super.onResume(); NfcAdapter adapter NfcAdapter.getDefaultAdapter(this); if (adapter ! null adapter.isEnabled()) { adapter.enableReaderMode(this, nfcReaderCallback, 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, null); } } Override protected void onPause() { super.onPause(); NfcAdapter adapter NfcAdapter.getDefaultAdapter(this); if (adapter ! null) { adapter.disableReaderMode(this); } } private NfcAdapter.ReaderCallback nfcReaderCallback new NfcAdapter.ReaderCallback() { Override public void onTagDiscovered(Tag tag) { // 不直接解析先抛到工作线程处理 handleTagOnBackground(tag); } };这段代码里有个关键参数FLAG_READER_SKIP_NDEF_CHECK。加上它之后系统不会在你读取前自动做NDEF检测而是把原始Tag直接交给回调相当于给业务层留了更多控制权。不过代价是如果卡片本身是NDEF格式你自己要在解析逻辑里补一次Ndef技术判断如果不加这个flag系统会把NDEF分析结果存在Tag对象里但部分机型分析时间会变长、偶发超时。我的经验是一般加SKIP读卡速度更稳定。enableReaderMode必须在onResume里启用、onPause里禁用两处必须对称。很多新手只在onCreate或onNewIntent里调用结果切后台再回来、或者从其他页面返回时回调就失灵了这是典型的前台调度生命周期问题。3.2 读NDEF消息并解析NDEF是最友好的NFC数据格式系统帮忙做了很多脏活。读NDEF代码如下private void handleTagOnBackground(Tag tag) { String[] techList tag.getTechList(); boolean isNdef false; for (String tech : techList) { if (tech.equals(Ndef.class.getName())) { isNdef true; break; } } if (isNdef) { Ndef ndef Ndef.get(tag); if (ndef null) return; try { ndef.connect(); NdefMessage message ndef.getNdefMessage(); if (message null) return; for (NdefRecord record : message.getRecords()) { short tnf record.getTnf(); byte[] payload record.getPayload(); // 根据TNF类型做不同解析 if (tnf NdefRecord.TNF_WELL_KNOWN) { byte[] type record.getType(); if (Arrays.equals(type, NdefRecord.RTD_TEXT)) { String text parseTextPayload(payload); // 回调到主线程更新UI } else if (Arrays.equals(type, NdefRecord.RTD_URI)) { String uri parseUriPayload(payload); // 处理URL } } } } catch (IOException | FormatException e) { e.printStackTrace(); } finally { try { ndef.close(); } catch (IOException ignored) {} } } }代码里两个参数值得说明TNF_WELL_KNOWN是NFC论坛定义的标准类型配合RTD_TEXT、RTD_URI这种Record Type对应文本和URIpayload的解析不能简单new String(payload)Text Record的payload首字节是状态字节包含语言编码长度后面跟着语言码和实际文本直接转字符串会出现乱码或把语言码混进正文。我一般会写一个parseTextPayload把状态字节剥掉private String parseTextPayload(byte[] payload) { if (payload null || payload.length 0) return ; int status payload[0] 0xFF; int languageCodeLength status 0x3F; if (payload.length 1 languageCodeLength) return ; return new String(payload, 1 languageCodeLength, payload.length - 1 - languageCodeLength, StandardCharsets.UTF_8); }这里的status低6位表示语言码长度高位可能带编码标志不要忽略。这是我踩过的坑第一版直接用新String读中文标签全是乱码加了语言码剥离才正常。3.3 读MifareClassic卡绕过NDEF直读块数据MifareClassic卡是门禁场景的主力也是公认的水比较深的地方。它的数据不叫Record而是按扇区Sector和块Block组织。1K容量的卡有16个扇区每个扇区4个块每块16字节。读数据前必须先对所在扇区做密钥认证。private void readMifareClassic(Tag tag) { MifareClassic mfc MifareClassic.get(tag); if (mfc null) return; try { mfc.connect(); int type mfc.getType(); // 1K / 4K / Mini int sectorCount mfc.getSectorCount(); for (int sector 0; sector sectorCount; sector) { // 先认证再读块 boolean auth false; try { auth mfc.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT); } catch (IOException e) { // KEY_DEFAULT失败换KEY_MIFARE_APPLICATION_DIRECTORY } if (!auth) continue; // 没有权限的扇区跳过 int blockCount mfc.getBlockCountInSector(sector); for (int blockOffset 0; blockOffset blockCount; blockOffset) { int blockIndex mfc.sectorToBlock(sector) blockOffset; byte[] data mfc.readBlock(blockIndex); // 解析块数据 if (blockOffset 3) { // 扇区尾部包含密钥A、访问位、密钥B不是普通业务数据 } else { // 业务数据按自己的协议解析 } } } } catch (IOException e) { e.printStackTrace(); } finally { try { mfc.close(); } catch (IOException ignored) {} } }这段逻辑有两个地方特别容易出错。一个是authenticateSectorWithKeyA针对的是扇区不是块传参是扇区索引而readBlock传的是块地址两者不能混用先mfc.sectorToBlock(sector)算出该扇区首块地址再偏移。另一个是每个扇区的第3块偏移3是扇区尾部包含了密钥A、访问控制位和密钥B这块不能当业务数据读更不能去写乱写访问位会把卡直接锁死——这属于越权操作有的卡就是这么变砖的。密钥是另一个重心。MifareClassic的Key A和Key B各6字节出厂默认是0xFF 0xFF 0xFF 0xFF 0xFF 0xFF对应KEY_DEFAULT很多旧门禁卡没改默认密钥所以能直读但真正上线系统里卡通常被初始化过密钥是厂商或集成商写到卡里的读不到就是读不到不要试图暴力穷举那不是应用层该做的事合规上也有问题。4. 写卡实战把数据按协议组织并安全存入Tag4.1 写NDEF的完整流程从NdefRecord到writeNdefMessage写NDEF的核心是构建NdefMessage再把消息通过Ndef接口写入。逻辑上不复杂但准备工作要做好写入前确认Tag是不是Ndef类型、能不能写、容量够不够。private boolean writeNdef(Tag tag, String text) { Ndef ndef Ndef.get(tag); if (ndef null) return false; try { ndef.connect(); if (!ndef.isWritable()) { return false; // 只读Tag写不了 } NdefRecord textRecord createTextRecord(zh, text); NdefMessage message new NdefMessage(textRecord); int maxSize ndef.getMaxSize(); if (message.toByteArray().length maxSize) { return false; // 超容量 } ndef.writeNdefMessage(message); return true; } catch (IOException | FormatException e) { e.printStackTrace(); return false; } finally { try { ndef.close(); } catch (IOException ignored) {} } } private NdefRecord createTextRecord(String languageCode, String text) { byte[] langBytes languageCode.getBytes(StandardCharsets.US_ASCII); byte[] textBytes text.getBytes(StandardCharsets.UTF_8); byte[] payload new byte[1 langBytes.length textBytes.length]; payload[0] (byte) langBytes.length; // 状态字节语言码长度 System.arraycopy(langBytes, 0, payload, 1, langBytes.length); System.arraycopy(textBytes, 0, payload, 1 langBytes.length, textBytes.length); return new NdefRecord(NdefRecord.TNF_WELL_KNOWN, NdefRecord.RTD_TEXT, new byte[0], payload); }createTextRecord里状态字节直接写了langBytes.length实际生产环境最好按NFC论坛规范把状态字节拆成编码位和长度位状态字节高第7位表示UTF-8还是UTF-16低6位表示语言码长度。上面代码只适用于纯UTF-8够用但不够严谨如果产品要兼容多语言参考规范补全状态位。ndef.isWritable()和ndef.getMaxSize()两个前置检查建议都做。经验里最典型的翻车是写入内容明明不长但Tag被之前别的内容占了空间writeNdefMessage一执行就抛IOException或者Tag出厂就被设置为只读连接正常、写不进去。前置检查能避免大部分无效写入。4.2 直接写MifareClassic块权限与页边界MifareClassic写块和读块一样先认证、后操作。写之前要确认块地址属于哪个扇区、以及这块是不是扇区尾部Block 3。数据长度必须是16字节不够就补0多了直接报错。private boolean writeMifareClassic(Tag tag, int sectorIndex, int blockOffset, byte[] data) { MifareClassic mfc MifareClassic.get(tag); if (mfc null) return false; if (blockOffset 3) return false; // 扇区尾部不能写 try { mfc.connect(); boolean auth mfc.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_DEFAULT); if (!auth) { auth mfc.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT); } if (!auth) return false; byte[] blockData new byte[16]; System.arraycopy(data, 0, blockData, 0, Math.min(data.length, 16)); int blockIndex mfc.sectorToBlock(sectorIndex) blockOffset; mfc.writeBlock(blockIndex, blockData); return true; } catch (IOException e) { e.printStackTrace(); return false; } finally { try { mfc.close(); } catch (IOException ignored) {} } }写入时authenticateSectorWithKeyA失败后可以再试KeyBMifareClassic许多卡的读写权限配置不一样KeyA可读、KeyB可写的情况很常见只试A就放弃会误伤一批正常卡。blockOffset参数要从0数起业务数据一般取0或12和3之间要慎重。块2在部分卡上也被用作数据块但块3绝对不碰。4.3 写卡参数边界容量、超时、重试策略参数方面有三个点值得提前关心容量边界MifareClassic 1K总容量1024字节扣掉厂商块、扇区尾部、访问位真正的可用业务区没多大。NDEF写入受getMaxSize()限制这个值不同Tag差异很大不要在代码里硬编码能写多少一律运行时取。连接超时mfc.connect()在部分机型上默认超时较短如果卡片离天线远、或者手机壳带金属读卡器会频繁超时。常见做法是connect失败后延迟200ms再重试一次我在项目里最多重试2次超过就提示用户调整卡的位置。写入容错写NFC不像写文件没有事务回滚。NDEF写一半断电、MifareClassic写了一半就被拿开卡可能处于半写状态读取正常、业务数据不对。我的习惯是业务数据设计时加版本号和校验位写完后立刻回读校验校验不过就提示用户重新写卡而不是直接当成功。5. 避坑五类最常见的NFC读写翻车现场5.1 现象enableReaderMode配上后完全没回调代码写好了、卡放上去了onTagDiscovered就是不触发。这种情况在国产ROM上尤其普遍。原因排查一是系统NFC开关没打开NfcAdapter.isEnabled()返回false二是ROM的后台限制把NFC服务杀了或者Activity在onResume里注册之前就已经被系统回收三是部分机型需要在系统设置里单独授权NFC后台活动。解决onResume里加一层完整检测。if (adapter null) { // 不支持NFC }else if (!adapter.isEnabled()) { // 提示用户打开开关 }然后才是enableReaderMode。同时把回调改为在工作线程里做活避免主线程卡顿导致系统回收注册。这个检测链我每一版代码都会写省了很多线上反馈工单。5.2 现象同一张卡在不同手机上读出不同结果一张卡在A手机上能正常读出NDEF文本在B手机上返回空或者变成了原始Tag技术列表。原因不同厂商对NFC的协议优先级处理不同有的系统优先做NDEF解析有的直接把Tag当作原始技术暴露还有些ROM对NfcA和NfcB的倾向性不一样同一张ISO-DEP卡在一个机型上走IsoDep在另一个机型上走NfcA底层字段解析自然不同。解决业务层不要假设这张卡一定是Ndef用tag.getTechList()做分发。我的做法是写一个统一的解析入口先查有没有Ndef有就走NDEF没有就查MifareClassic有就走扇区读再查IsoDep都没有就返回不支持的卡。这样不同机器之间的差异被收敛到一个分发函数里测试覆盖面至少保证主流机型各来一台。5.3 现象写卡时报Tag was already written或I/O error卡放上去能读、能连接一执行writeNdefMessage就报IOException错误信息各不相同有的直接提示Tag只读。原因卡在出厂时被设置了只读位或者卡上已有NDEF消息且消息较大新消息小但系统仍认为已写满还有可能这张卡是NdefFormatable但没被格式化Ndef.get(tag)返回了对象、操作却走不通。解决代码里加NdefFormatable分支。NdefFormatable是未格式化但可格式化的Tag要先调用format()再write如果NdefFormatable.get(tag)也拿不到基本可以判定这张卡物理只读让用户换卡。这个过程很考验耐心我一般把错误分类打到日志里卡不可写、容量不足、格式化失败分开记录线上定位快很多。5.4 现象MifareClassic卡读出数据全是乱码或字节错位读卡成功、字节也拿到了按本方协议解析先是乱码后来发现字段位置全不对。原因把扇区编译器里的块偏移直接当业务偏移用了。MifareClassic每个扇区有4块块0是厂商块如果扇区0、块3是扇区尾部这两个都不能当普通业务块业务数据通常从扇区0的块1开始排或者从扇区1开始排。把块0或块3塞给解析器数据自然对不上。解决写一个块地址到业务字段的映射表代码里只引用业务块偏移不引用物理块地址。而且读扇区0的块0时拿到的是UID和厂商数据这部分只读拿来当业务数据用一定会出问题。我把这个映射表和扇区布局的注释放在测试用例旁边写单测时按布局逐个验。5.5 现象连续刷了十几张卡后App卡顿甚至ANR单张卡读写正常连续操作多张卡后界面越来越卡最严重的是直接ANR。原因NFC回调在Binder线程上如果在回调里直接做了connect()、readBlock()、UI更新等一系列操作主线程被占满不说Tag连接没有被正确关闭系统NFC服务资源被耗尽。我在一个Demo项目里就吃过这个亏回调里全同步执行十几张卡后NFC服务直接罢工。解决回调里只做两件事——把Tag对象交给单线程线程池然后立刻返回。所有连接、读写、关闭都在工作线程完成finally块里关闭Ndef/MifareClassic连接。后台任务队列保证同一时间只处理一张卡前端给用户一个有意义的进度提示而不是傻等。从那以后我每次接NFC需求都强制先跑一遍连读50张卡不断电的压力测试再谈交付。6. 进阶把NFC读写封装成可复用工具类基础流程跑通后最影响项目进度的是怎么把NFC逻辑和页面解耦。NFC回调天然在Binder线程UI更新要切主线程业务数据要进数据层全串在一个Activity里代码会很快失控。我一般会把读卡、写卡、类型判断抽成一个单例工具对外暴露回调接口内部用单线程线程池串行执行避免并发抢占NFC资源。public class NfcReader { private static final ExecutorService sExecutor Executors.newSingleThreadExecutor(); private static final Handler sMainHandler new Handler(Looper.getMainLooper()); public interface Callback { void onResult(NfcResult result); } public static void readTag(Tag tag, Callback callback) { sExecutor.execute(() - { NfcResult result new NfcResult(); String[] techs tag.getTechList(); result.techList Arrays.asList(techs); // 按优先级解析NDEF / MifareClassic / IsoDep if (contains(techs, Ndef.class)) { result.ndefText readNdefText(tag); } else if (contains(techs, MifareClassic.class)) { result.mifareData readMifareAll(tag); } sMainHandler.post(() - callback.onResult(result)); }); } }封装的核心价值是让业务层只关心NfcResult不用管连接管理、线程切换和异常处理。NfcResult里可以放techList、NDEF文本、Mifare块数据、错误码四个字段UI层拿到后先判错误码再显示。如果项目用的是Kotlin协程可以把Executor换成Dispatchers.IO但串行执行的约束不能去掉两个回调同时读写同一张Tag仍然会冲突。验证环节我习惯用一张新卡做往返测试连续写50次、读50次每次写入后立刻读回逐字节比对写入内容和读回内容是否一致同时每5张卡统计一次平均读写耗时保证性能没有随连接次数增多而劣化。这个测试能同时暴露连接泄漏、写入校验缺失、还有ROM层面NFC服务的不稳定问题。另一个进阶技巧是给卡写业务数据时预留一条后悔药在扇区尾部之外的某块固定存一份当前数据的CRC和块版本号每次写卡前先读版本号、写完后回写新版本号。这样即使某次写了一半被拿开下一轮程序启动时可以通过版本号不一致判断卡处于半写状态主动提示用户重新初始化。这个习惯帮我在项目交付后少收了很多卡坏了的反馈。从那以后我每次做NFC相关功能都会强制把类型分发框架、工作线程隔离、写入回读校验三步走完整检查一遍再做业务开发这套流程比任何单点技巧都值钱。希望帮到你。本文还有配套的精品资源点击获取