4、手机端开发(Android):HCE模式实现、Tag读写API调用、Intent过滤配置

好,咱们进入手机端开发环节。说实话,很多做嵌入式出身的朋友一听到「Android开发」就头大,觉得那是纯软件工程师的活。但我个人觉得,搞物联网的,手机端这块你至少得能看懂、能改、能调通。尤其是NFC配网这种场景,手机端是用户直接接触的界面,搞不好体验就全砸了。

这一章我们重点讲三个东西:HCE模式Tag读写APIIntent过滤配置。说白了,就是手机怎么模拟成一张卡、怎么读写实体标签、以及怎么让系统正确地把NFC事件交到你的App手里。

4.1 HCE模式:让手机变成一张「虚拟卡」

HCE,全称Host-based Card Emulation,基于主机的卡模拟。嗯,这个名字有点绕,你只要记住:它让手机能像一张NFC卡片一样被读卡器读取

我在做第一个NFC配网项目时,一开始用的是传统的SE(安全元件)方案,结果发现不同手机兼容性差得要命。后来换成HCE,清爽多了——所有数据都在App进程里处理,不需要依赖硬件安全芯片。

4.1.1 HCE的工作原理

简单说,流程是这样的:

  1. 读卡器(比如你的Wi-Fi模组)发出轮询请求
  2. 手机NFC控制器收到请求,通知系统服务
  3. 系统根据AID(应用ID)匹配到你的App
  4. App的HCE服务被唤醒,处理APDU指令
  5. 返回响应数据给读卡器

整个过程对用户来说是无感的。你只需要把手机往设备上一贴,数据就交换完了。

4.1.2 实现一个HCE服务

在Android里实现HCE,核心是继承HostApduService这个类。来看代码:

public class MyHceService extends HostApduService {

    private static final String TAG = "MyHceService";
    // 我们自定义的AID,用于配网场景
    private static final String SAMPLE_AID = "F222222222";

    @Override
    public byte[] processCommandApdu(byte[] commandApdu, Bundle extras) {
        Log.d(TAG, "收到APDU指令: " + bytesToHex(commandApdu));

        // 解析指令,这里简化处理
        // 实际项目中需要根据ISO 7816-4协议解析
        if (isSelectAidCommand(commandApdu)) {
            // 选择应用指令,返回成功
            return new byte[]{(byte) 0x90, (byte) 0x00};
        }

        // 处理自定义的配网数据请求
        // 比如读取Wi-Fi SSID和密码
        String wifiSsid = "MyHomeWiFi";
        String wifiPwd = "password123";
        String responseData = wifiSsid + "|" + wifiPwd;

        return concatApduResponse(responseData.getBytes());
    }

    @Override
    public void onDeactivated(int reason) {
        Log.d(TAG, "HCE服务被停用,原因: " + reason);
        // 这里可以做一些清理工作
    }

    // 辅助方法:判断是否是SELECT AID指令
    private boolean isSelectAidCommand(byte[] apdu) {
        // 实际判断逻辑略
        return true;
    }

    // 辅助方法:拼接APDU响应(加上状态字)
    private byte[] concatApduResponse(byte[] data) {
        byte[] response = new byte[data.length + 2];
        System.arraycopy(data, 0, response, 0, data.length);
        response[data.length] = (byte) 0x90;  // SW1
        response[data.length + 1] = (byte) 0x00; // SW2
        return response;
    }
}
我的经验:APDU指令解析这块,建议先拿一张现成的Mifare卡片抓包看看。我当时就是先读了几张门禁卡的交互数据,才搞明白SELECT、READ BINARY这些指令的格式。别一上来就啃ISO标准文档,容易劝退。

4.1.3 注册HCE服务

光写代码不够,还得在AndroidManifest.xml里注册服务,并且声明AID过滤规则:

<service
    android:name=".MyHceService"
    android:exported="true"
    android:permission="android.permission.BIND_NFC_SERVICE">
    <intent-filter>
        <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
    </intent-filter>
    <meta-data
        android:name="android.nfc.cardemulation.host_apdu_service"
        android:resource="@xml/apduservice"/>
</service>

然后在res/xml/apduservice.xml里定义AID:

<host-apdu-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/service_description"
    android:requireDeviceUnlock="false">
    <aid-group
        android:description="@string/aid_group_description"
        android:category="other">
        <aid-filter android:name="F222222222"/>
    </aid-group>
</host-apdu-service>
注意:AID的长度必须是偶数位,且不能以FF开头(那是RFU保留区域)。我曾经因为AID写成了"F22222222"(少了一位),调试了一整个下午才发现问题。嗯,这种低级错误,犯过一次就记住了。

4.2 Tag读写API:和实体NFC标签打交道

HCE是让手机模拟成卡,但很多时候我们需要手机去读实体标签——比如把配网信息预先写入一个NFC贴纸,用户一碰就完成配置。这时候就要用到Tag读写API了。

4.2.1 发现Tag并获取句柄

当手机靠近NFC标签时,系统会通过Intent把Tag对象传给你。获取方式很简单:

// 在Activity或Fragment中
Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
if (tag != null) {
    // 获取标签ID
    byte[] tagId = tag.getId();
    Log.d(TAG, "标签ID: " + bytesToHex(tagId));

    // 获取支持的技术列表
    String[] techList = tag.getTechList();
    for (String tech : techList) {
        Log.d(TAG, "支持技术: " + tech);
    }
}

4.2.2 读写NDEF数据

对于配网场景,最常用的是NDEF格式的数据。因为Android原生支持NDEF解析,而且大多数NFC标签出厂就支持这个格式。

// 读取NDEF消息
private String readNdefMessage(Tag tag) {
    Ndef ndef = Ndef.get(tag);
    if (ndef == null) {
        return null;
    }

    try {
        ndef.connect();
        NdefMessage message = ndef.getNdefMessage();
        if (message != null) {
            NdefRecord[] records = message.getRecords();
            for (NdefRecord record : records) {
                // 这里假设记录是文本格式
                if (record.getTnf() == NdefRecord.TNF_WELL_KNOWN
                        && Arrays.equals(record.getType(), NdefRecord.RTD_TEXT)) {
                    return new String(record.getPayload(), "UTF-8");
                }
            }
        }
    } catch (IOException | FormatException e) {
        e.printStackTrace();
    } finally {
        try {
            ndef.close();
        } catch (IOException e) {
            // 忽略
        }
    }
    return null;
}
避坑指南:我曾经在读取某些国产NFC标签时,发现ndef.connect()总是超时。后来排查发现,这些标签的ANTICIPATION时间设置得太短,手机还没来得及建立连接就断开了。解决方案是在connect之前加一个短暂延时,或者使用NdefFormatable接口先格式化标签。

4.2.3 写入配网信息到标签

写入操作稍微复杂一点,因为需要先检查标签是否可写、是否已格式化:

private boolean writeNdefMessage(Tag tag, String data) {
    Ndef ndef = Ndef.get(tag);
    if (ndef != null) {
        // 标签已格式化为NDEF
        try {
            ndef.connect();
            if (!ndef.isWritable()) {
                Log.e(TAG, "标签不可写");
                return false;
            }
            // 检查剩余空间
            int size = data.getBytes().length + 10; // 加上NDEF记录开销
            if (ndef.getMaxSize() < size) {
                Log.e(TAG, "标签空间不足");
                return false;
            }

            NdefRecord record = NdefRecord.createTextRecord("en", data);
            NdefMessage message = new NdefMessage(new NdefRecord[]{record});
            ndef.writeNdefMessage(message);
            return true;

        } catch (IOException | FormatException e) {
            e.printStackTrace();
            return false;
        } finally {
            try {
                ndef.close();
            } catch (IOException e) {
                // 忽略
            }
        }
    } else {
        // 标签未格式化,尝试格式化并写入
        NdefFormatable formatable = NdefFormatable.get(tag);
        if (formatable != null) {
            try {
                formatable.connect();
                NdefRecord record = NdefRecord.createTextRecord("en", data);
                NdefMessage message = new NdefMessage(new NdefRecord[]{record});
                formatable.format(message);
                return true;
            } catch (IOException | FormatException e) {
                e.printStackTrace();
                return false;
            } finally {
                try {
                    formatable.close();
                } catch (IOException e) {
                    // 忽略
                }
            }
        }
    }
    return false;
}

4.3 Intent过滤配置:让系统把NFC事件交给你

最后一个关键点:怎么让Android系统知道,当NFC标签被检测到时,应该启动你的App?

答案是Intent过滤。说白了,就是在AndroidManifest.xml里告诉系统:嘿,这种类型的NFC标签归我管。

4.3.1 三种调度模式

Android NFC的Intent调度有三种模式,我整理了一个表格方便对比:

模式 优先级 适用场景 配置方式
前台调度 最高 App在前台时优先处理 代码中设置enableForegroundDispatch
Intent过滤 中等 特定类型的标签 Manifest中声明
默认调度 最低 系统默认处理 无需配置

我个人习惯是:前台调度 + Intent过滤 组合使用。App在前台时用前台调度保证响应速度,App在后台时靠Intent过滤唤醒。

4.3.2 配置Intent过滤

AndroidManifest.xml的Activity声明里加上:

<activity android:name=".NfcScanActivity"
    android:launchMode="singleTop">
    <intent-filter>
        <action android:name="android.nfc.action.NDEF_DISCOVERED"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <data android:scheme="vnd.android.nfc"
            android:host="ext"
            android:pathPrefix="/android.com:p2p"/>
    </intent-filter>

    <intent-filter>
        <action android:name="android.nfc.action.TECH_DISCOVERED"/>
        <category android:name="android.intent.category.DEFAULT"/>
    </intent-filter>

    <meta-data android:name="android.nfc.action.TECH_DISCOVERED"
        android:resource="@xml/nfc_tech_filter"/>
</activity>

然后在res/xml/nfc_tech_filter.xml里指定要过滤的技术类型:

<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
    <tech-list>
        <tech>android.nfc.tech.Ndef</tech>
        <tech>android.nfc.tech.NdefFormatable</tech>
        <tech>android.nfc.tech.MifareClassic</tech>
        <tech>android.nfc.tech.MifareUltralight</tech>
    </tech-list>
</resources>
小技巧:如果你只想处理特定厂商的标签,可以在tech-list里只保留对应的技术类型。比如只处理NXP的Mifare系列,就只写MifareClassic和MifareUltralight。这样可以避免你的App被其他类型的NFC标签误唤醒。

4.3.3 前台调度实现

前台调度需要在Activity的onResumeonPause中设置:

public class NfcScanActivity extends AppCompatActivity {

    private NfcAdapter nfcAdapter;
    private PendingIntent pendingIntent;
    private IntentFilter[] intentFilters;
    private String[][] techLists;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_nfc_scan);

        nfcAdapter = NfcAdapter.getDefaultAdapter(this);
        if (nfcAdapter == null) {
            // 设备不支持NFC
            Toast.makeText(this, "设备不支持NFC", Toast.LENGTH_LONG).show();
            finish();
            return;
        }

        // 创建前台调度的Intent
        pendingIntent = PendingIntent.getActivity(
            this, 0,
            new Intent(this, getClass()).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
            PendingIntent.FLAG_IMMUTABLE
        );

        // 设置Intent过滤器
        IntentFilter ndefFilter = new IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED);
        try {
            ndefFilter.addDataType("*/*");
        } catch (IntentFilter.MalformedMimeTypeException e) {
            e.printStackTrace();
        }
        intentFilters = new IntentFilter[]{ndefFilter};

        // 设置技术列表
        techLists = new String[][]{
            new String[]{Ndef.class.getName()},
            new String[]{NdefFormatable.class.getName()}
        };
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (nfcAdapter != null) {
            nfcAdapter.enableForegroundDispatch(
                this, pendingIntent, intentFilters, techLists
            );
        }
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (nfcAdapter != null) {
            nfcAdapter.disableForegroundDispatch(this);
        }
    }

    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        // 处理NFC事件
        if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(intent.getAction())
                || NfcAdapter.ACTION_TECH_DISCOVERED.equals(intent.getAction())) {
            Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
            if (tag != null) {
                // 在这里处理标签数据
                handleTag(tag);
            }
        }
    }

    private void handleTag(Tag tag) {
        // 具体的标签处理逻辑
        String data = readNdefMessage(tag);
        if (data != null) {
            // 解析配网信息并执行连接
            Log.d(TAG, "读取到配网数据: " + data);
        }
    }
}
重要提醒:前台调度一定要在onPause中禁用,否则你的App退出后,其他App将无法接收NFC事件。我见过有开发者忘记调用disableForegroundDispatch,结果用户每次碰标签都只能打开他的App,被用户骂惨了。

好了,手机端开发的三个核心点就讲到这里。HCE模式让手机能「装成」一张卡,Tag读写API让手机能读写实体标签,Intent过滤配置则确保系统能正确地把NFC事件路由到你的App。这三块搞定了,手机端的NFC配网基础就算打牢了。

下一章我们会讲设备端怎么配合手机完成整个配网流程,包括Wi-Fi模组的NFC驱动适配和配网协议设计。到时候见。