☰
Android NFC读写实战:从工作原理到Tag读写与避坑指南
2026/10/15 16:27:06 网站建设 项目流程

简介:面向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-filter(NDEF_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:name="android.permission.NFC" /> <uses-feature android:name="android.hardware.nfc" android:required="true" />

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对应文本和URI;payload的解析不能简单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. 写卡实战:把数据按协议组织并安全存入Tag

4.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失败后可以再试KeyB,MifareClassic许多卡的读写权限配置不一样:KeyA可读、KeyB可写的情况很常见,只试A就放弃会误伤一批正常卡。blockOffset参数要从0数起,业务数据一般取0或1,2和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相关功能,都会强制把"类型分发框架、工作线程隔离、写入回读校验"三步走完整检查一遍再做业务开发,这套流程比任何单点技巧都值钱。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询