第35集:uni-app NFC 功能

核心知识点

1. NFC API

uni-app 提供了完整的 NFC API,用于读写 NFC 标签和模拟 NFC 卡片。主要包括以下几类:

  • NFC 适配器管理uni.getHCEState()uni.startHCE()uni.stopHCE()
  • NFC 标签读写uni.onNDEFDiscovered()uni.readNFCNDEFMessage()uni.writeNFCNDEFMessage()
  • HCE 卡片模拟uni.onHCEMessage()uni.sendHCEMessage()

2. NFC 标签类型

NFC 标签主要有以下几种类型:

  • NDEF 标签:存储 NDEF 格式的数据,是最常见的 NFC 标签类型
  • ISO 14443-4 标签:支持 ISO 14443-4 协议的标签
  • MIFARE 标签:包括 MIFARE Classic、MIFARE Ultralight 等
  • Felica 标签:日本 Sony 公司开发的标签类型

3. NDEF 数据格式

NDEF (NFC Data Exchange Format) 是 NFC 论坛定义的数据交换格式,主要包括以下几种记录类型:

  • URI 记录:存储网址
  • 文本记录:存储文本信息
  • 智能海报记录:存储包含标题、网址等信息的海报
  • 自定义记录:存储自定义格式的数据

4. HCE 卡片模拟

HCE (Host-based Card Emulation) 是一种在移动设备上模拟 NFC 智能卡的技术,主要用于:

  • 移动支付:模拟银行卡、交通卡等
  • 门禁系统:模拟门禁卡
  • 身份认证:模拟身份证、员工卡等

实用案例分析

案例1:实现 NFC 门禁功能

功能需求

实现一个 NFC 门禁应用,包括以下功能:

  1. 读取 NFC 门禁卡信息
  2. 模拟 NFC 门禁卡
  3. 管理门禁卡列表
  4. 记录刷卡历史

代码实现

<template>
  <view class="container">
    <view class="header">
      <text class="title">NFC 门禁系统</text>
    </view>
    
    <view class="tabs">
      <view 
        v-for="(tab, index) in tabs" 
        :key="index"
        class="tab-item"
        :class="{ active: activeTab === index }"
        @click="switchTab(index)"
      >
        <text>{{ tab }}</text>
      </view>
    </view>
    
    <!-- 读取标签 -->
    <view v-if="activeTab === 0" class="tab-content">
      <view class="section">
        <text class="section-title">读取门禁卡</text>
        <view class="read-instruction">
          <text>请将门禁卡靠近手机背面的 NFC 感应区</text>
        </view>
        <button @click="startRead" type="primary">开始读取</button>
        <button @click="stopRead" type="default">停止读取</button>
      </view>
      
      <view v-if="cardInfo" class="card-info">
        <text class="info-title">卡片信息</text>
        <view class="info-item">
          <text class="info-label">卡片类型:</text>
          <text class="info-value">{{ cardInfo.type }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">卡片 ID:</text>
          <text class="info-value">{{ cardInfo.id }}</text>
        </view>
        <view class="info-item">
          <text class="info-label">数据内容:</text>
          <text class="info-value">{{ cardInfo.content }}</text>
        </view>
        <button @click="saveCard" type="primary">保存卡片</button>
      </view>
    </view>
    
    <!-- 模拟卡片 -->
    <view v-if="activeTab === 1" class="tab-content">
      <view class="section">
        <text class="section-title">模拟门禁卡</text>
        <view v-if="!isSimulating" class="card-list">
          <text class="list-title">已保存的卡片</text>
          <view v-for="(card, index) in savedCards" :key="index" class="card-item">
            <view class="card-details">
              <text class="card-name">{{ card.name }}</text>
              <text class="card-id">{{ card.id }}</text>
            </view>
            <button @click="startSimulate(card)" type="primary" size="mini">模拟</button>
          </view>
        </view>
        
        <view v-else class="simulating">
          <text class="simulate-title">正在模拟卡片</text>
          <text class="simulate-card">{{ currentSimulateCard.name }}</text>
          <text class="simulate-instruction">请将手机靠近门禁读卡器</text>
          <button @click="stopSimulate" type="warn">停止模拟</button>
        </view>
      </view>
    </view>
    
    <!-- 刷卡历史 -->
    <view v-if="activeTab === 2" class="tab-content">
      <view class="section">
        <text class="section-title">刷卡历史</text>
        <view class="history-list">
          <view v-for="(record, index) in historyRecords" :key="index" class="history-item">
            <view class="history-details">
              <text class="history-card">{{ record.cardName }}</text>
              <text class="history-time">{{ record.time }}</text>
            </view>
            <text class="history-status" :class="record.status">
              {{ record.status === 'success' ? '成功' : '失败' }}
            </text>
          </view>
          <view v-if="historyRecords.length === 0" class="empty-history">
            <text>暂无刷卡记录</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      activeTab: 0,
      tabs: ['读取标签', '模拟卡片', '刷卡历史'],
      isReading: false,
      cardInfo: null,
      savedCards: [],
      isSimulating: false,
      currentSimulateCard: null,
      historyRecords: []
    };
  },
  onLoad() {
    // 从本地存储加载数据
    this.loadSavedCards();
    this.loadHistoryRecords();
    
    // 监听 NFC 标签发现
    uni.onNDEFDiscovered((res) => {
      this.handleNDEFDiscovered(res);
    });
    
    // 监听 HCE 消息
    uni.onHCEMessage((res) => {
      this.handleHCEMessage(res);
    });
  },
  methods: {
    // 切换标签页
    switchTab(index) {
      this.activeTab = index;
    },
    
    // 开始读取 NFC 标签
    startRead() {
      uni.showToast({ title: '请将卡片靠近手机', icon: 'none' });
      this.isReading = true;
    },
    
    // 停止读取 NFC 标签
    stopRead() {
      this.isReading = false;
      uni.showToast({ title: '已停止读取', icon: 'success' });
    },
    
    // 处理 NDEF 标签发现
    handleNDEFDiscovered(res) {
      if (!this.isReading) return;
      
      console.log('发现 NFC 标签:', res);
      
      // 解析标签数据
      const cardInfo = {
        type: 'NDEF',
        id: this.arrayBufferToHex(res.id),
        content: ''
      };
      
      // 解析 NDEF 消息
      if (res.ndefMessage && res.ndefMessage.length > 0) {
        res.ndefMessage.forEach(record => {
          const payload = this.arrayBufferToString(record.payload);
          cardInfo.content += payload;
        });
      }
      
      this.cardInfo = cardInfo;
      uni.showToast({ title: '读取成功', icon: 'success' });
    },
    
    // 保存卡片信息
    saveCard() {
      if (!this.cardInfo) return;
      
      uni.showModal({
        title: '保存卡片',
        content: '请输入卡片名称',
        editable: true,
        placeholderText: '例如:办公室门禁卡',
        success: (res) => {
          if (res.confirm && res.content) {
            const newCard = {
              id: this.cardInfo.id,
              name: res.content,
              content: this.cardInfo.content,
              type: this.cardInfo.type,
              createTime: new Date().toISOString()
            };
            
            this.savedCards.push(newCard);
            this.saveCardsToStorage();
            uni.showToast({ title: '保存成功', icon: 'success' });
          }
        }
      });
    },
    
    // 开始模拟卡片
    startSimulate(card) {
      uni.getHCEState({
        success: (res) => {
          if (res.errCode === 0) {
            // HCE 可用
            this.currentSimulateCard = card;
            
            // 配置 HCE 服务
            const aidList = [{
              aid: 'F222222222'
            }];
            
            uni.startHCE({
              aidList: aidList,
              success: (res) => {
                console.log('HCE 启动成功:', res);
                this.isSimulating = true;
                uni.showToast({ title: '开始模拟卡片', icon: 'success' });
              },
              fail: (err) => {
                console.error('HCE 启动失败:', err);
                uni.showToast({ title: '模拟失败', icon: 'none' });
              }
            });
          } else {
            uni.showToast({ title: '设备不支持 HCE', icon: 'none' });
          }
        },
        fail: (err) => {
          console.error('获取 HCE 状态失败:', err);
          uni.showToast({ title: 'NFC 不可用', icon: 'none' });
        }
      });
    },
    
    // 停止模拟卡片
    stopSimulate() {
      uni.stopHCE({
        success: (res) => {
          console.log('HCE 停止成功:', res);
          this.isSimulating = false;
          this.currentSimulateCard = null;
          uni.showToast({ title: '已停止模拟', icon: 'success' });
        },
        fail: (err) => {
          console.error('HCE 停止失败:', err);
          uni.showToast({ title: '停止失败', icon: 'none' });
        }
      });
    },
    
    // 处理 HCE 消息
    handleHCEMessage(res) {
      console.log('收到 HCE 消息:', res);
      
      // 模拟门禁卡响应
      if (res.messageType === 'request') {
        // 这里应该根据实际门禁系统的协议返回相应的数据
        // 以下是一个简单的示例
        const response = new ArrayBuffer(4);
        const dataView = new DataView(response);
        dataView.setUint32(0, 0x9000); // 成功响应
        
        uni.sendHCEMessage({
          data: response,
          success: (res) => {
            console.log('发送 HCE 响应成功:', res);
            // 记录刷卡历史
            this.addHistoryRecord(true);
          },
          fail: (err) => {
            console.error('发送 HCE 响应失败:', err);
            // 记录刷卡历史
            this.addHistoryRecord(false);
          }
        });
      }
    },
    
    // 添加刷卡历史记录
    addHistoryRecord(success) {
      if (!this.currentSimulateCard) return;
      
      const record = {
        cardName: this.currentSimulateCard.name,
        time: new Date().toLocaleString(),
        status: success ? 'success' : 'fail'
      };
      
      this.historyRecords.unshift(record);
      // 限制历史记录数量
      if (this.historyRecords.length > 50) {
        this.historyRecords.pop();
      }
      this.saveHistoryToStorage();
    },
    
    // 加载保存的卡片
    loadSavedCards() {
      const savedCards = uni.getStorageSync('nfc_cards');
      if (savedCards) {
        this.savedCards = savedCards;
      }
    },
    
    // 保存卡片到本地存储
    saveCardsToStorage() {
      uni.setStorageSync('nfc_cards', this.savedCards);
    },
    
    // 加载历史记录
    loadHistoryRecords() {
      const historyRecords = uni.getStorageSync('nfc_history');
      if (historyRecords) {
        this.historyRecords = historyRecords;
      }
    },
    
    // 保存历史记录到本地存储
    saveHistoryToStorage() {
      uni.setStorageSync('nfc_history', this.historyRecords);
    },
    
    // ArrayBuffer 转十六进制字符串
    arrayBufferToHex(buffer) {
      const hexArr = Array.prototype.map.call(
        new Uint8Array(buffer),
        function(byte) {
          return ('00' + byte.toString(16)).slice(-2);
        }
      );
      return hexArr.join('');
    },
    
    // ArrayBuffer 转字符串
    arrayBufferToString(buffer) {
      return String.fromCharCode.apply(null, new Uint8Array(buffer));
    }
  }
};
</script>

<style scoped>
.container {
  padding: 20rpx;
}

.header {
  margin-bottom: 30rpx;
}

.title {
  font-size: 36rpx;
  font-weight: bold;
  text-align: center;
  display: block;
}

.tabs {
  display: flex;
  margin-bottom: 30rpx;
  border-bottom: 1rpx solid #eaeaea;
}

.tab-item {
  flex: 1;
  text-align: center;
  padding: 20rpx 0;
  font-size: 28rpx;
  color: #666;
  border-bottom: 3rpx solid transparent;
}

.tab-item.active {
  color: #007aff;
  border-bottom-color: #007aff;
}

.tab-content {
  min-height: 500rpx;
}

.section {
  margin-bottom: 30rpx;
}

.section-title {
  font-size: 30rpx;
  font-weight: bold;
  margin-bottom: 20rpx;
  display: block;
}

.read-instruction {
  padding: 20rpx;
  background-color: #f5f5f5;
  border-radius: 8rpx;
  margin-bottom: 20rpx;
  text-align: center;
}

.card-info {
  margin-top: 30rpx;
  padding: 20rpx;
  background-color: #f5f5f5;
  border-radius: 8rpx;
}

.info-title {
  font-size: 28rpx;
  font-weight: bold;
  margin-bottom: 15rpx;
  display: block;
}

.info-item {
  display: flex;
  margin-bottom: 10rpx;
}

.info-label {
  width: 120rpx;
  font-size: 26rpx;
  color: #666;
}

.info-value {
  flex: 1;
  font-size: 26rpx;
  word-break: break-all;
}

.card-list {
  margin-top: 20rpx;
}

.list-title {
  font-size: 26rpx;
  margin-bottom: 15rpx;
  display: block;
}

.card-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20rpx;
  background-color: #f5f5f5;
  border-radius: 8rpx;
  margin-bottom: 15rpx;
}

.card-details {
  flex: 1;
}

.card-name {
  font-size: 28rpx;
  font-weight: bold;
  margin-bottom: 5rpx;
  display: block;
}

.card-id {
  font-size: 24rpx;
  color: #666;
}

.simulating {
  padding: 30rpx;
  background-color: #f5f5f5;
  border-radius: 8rpx;
  text-align: center;
}

.simulate-title {
  font-size: 30rpx;
  font-weight: bold;
  margin-bottom: 15rpx;
  display: block;
}

.simulate-card {
  font-size: 28rpx;
  color: #007aff;
  margin-bottom: 20rpx;
  display: block;
}

.simulate-instruction {
  font-size: 26rpx;
  color: #666;
  margin-bottom: 30rpx;
}

.history-list {
  margin-top: 20rpx;
}

.history-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20rpx;
  border-bottom: 1rpx solid #eaeaea;
}

.history-details {
  flex: 1;
}

.history-card {
  font-size: 28rpx;
  margin-bottom: 5rpx;
  display: block;
}

.history-time {
  font-size: 24rpx;
  color: #999;
}

.history-status {
  font-size: 26rpx;
  padding: 5rpx 15rpx;
  border-radius: 15rpx;
}

.history-status.success {
  color: #07c160;
  background-color: #e6f7ee;
}

.history-status.fail {
  color: #ff3b30;
  background-color: #ffebee;
}

.empty-history {
  text-align: center;
  padding: 50rpx 0;
  color: #999;
  font-size: 26rpx;
}
</style>

案例2:实现 NFC 标签写入功能

功能需求

实现一个 NFC 标签写入应用,包括以下功能:

  1. 写入文本信息到 NFC 标签
  2. 写入网址到 NFC 标签
  3. 写入自定义数据到 NFC 标签
  4. 读取已写入的标签数据

代码实现

<template>
  <view class="container">
    <view class="header">
      <text class="title">NFC 标签写入工具</text>
    </view>
    
    <view class="tabs">
      <view 
        v-for="(tab, index) in tabs" 
        :key="index"
        class="tab-item"
        :class="{ active: activeTab === index }"
        @click="switchTab(index)"
      >
        <text>{{ tab }}</text>
      </view>
    </view>
    
    <!-- 写入文本 -->
    <view v-if="activeTab === 0" class="tab-content">
      <view class="section">
        <text class="section-title">写入文本</text>
        <textarea 
          v-model="textContent" 
          placeholder="请输入要写入的文本"
          class="textarea"
        ></textarea>
        <button @click="writeText" type="primary">写入标签</button>
      </view>
    </view>
    
    <!-- 写入网址 -->
    <view v-if="activeTab === 1" class="tab-content">
      <view class="section">
        <text class="section-title">写入网址</text>
        <input 
          v-model="urlContent" 
          placeholder="请输入要写入的网址"
          class="input"
        ></input>
        <button @click="writeUrl" type="primary">写入标签</button>
      </view>
    </view>
    
    <!-- 写入自定义数据 -->
    <view v-if="activeTab === 2" class="tab-content">
      <view class="section">
        <text class="section-title">写入自定义数据</text>
        <input 
          v-model="customType" 
          placeholder="数据类型" 
          class="input"
        ></input>
        <textarea 
          v-model="customContent" 
          placeholder="请输入要写入的自定义数据"
          class="textarea"
        ></textarea>
        <button @click="writeCustom" type="primary">写入标签</button>
      </view>
    </view>
    
    <!-- 读取标签 -->
    <view v-if="activeTab === 3" class="tab-content">
      <view class="section">
        <text class="section-title">读取标签</text>
        <view class="read-instruction">
          <text>请将 NFC 标签靠近手机背面的 NFC 感应区</text>
        </view>
        <button @click="startRead" type="primary">开始读取</button>
      </view>
      
      <view v-if="readData" class="read-data">
        <text class="data-title">标签数据</text>
        <view v-for="(record, index) in readData" :key="index" class="data-record">
          <text class="record-type">类型: {{ record.type }}</text>
          <text class="record-content">内容: {{ record.content }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      activeTab: 0,
      tabs: ['写入文本', '写入网址', '写入自定义数据', '读取标签'],
      textContent: '',
      urlContent: '',
      customType: '',
      customContent: '',
      readData: null,
      isReading: false
    };
  },
  onLoad() {
    // 监听 NFC 标签发现
    uni.onNDEFDiscovered((res) => {
      this.handleNDEFDiscovered(res);
    });
  },
  methods: {
    // 切换标签页
    switchTab(index) {
      this.activeTab = index;
    },
    
    // 写入文本到 NFC 标签
    writeText() {
      if (!this.textContent) {
        uni.showToast({ title: '请输入文本内容', icon: 'none' });
        return;
      }
      
      uni.showToast({ title: '请将标签靠近手机', icon: 'none' });
      
      // 构建 NDEF 消息
      const ndefMessage = [{
        tn: 'T', // 文本类型
        payload: this.stringToBuffer(this.textContent)
      }];
      
      // 写入 NDEF 消息
      uni.writeNFCNDEFMessage({
        ndefMessage: ndefMessage,
        success: (res) => {
          console.log('写入成功:', res);
          uni.showToast({ title: '写入成功', icon: 'success' });
        },
        fail: (err) => {
          console.error('写入失败:', err);
          uni.showToast({ title: '写入失败', icon: 'none' });
        }
      });
    },
    
    // 写入网址到 NFC 标签
    writeUrl() {
      if (!this.urlContent) {
        uni.showToast({ title: '请输入网址', icon: 'none' });
        return;
      }
      
      uni.showToast({ title: '请将标签靠近手机', icon: 'none' });
      
      // 构建 NDEF 消息
      const ndefMessage = [{
        tn: 'U', // URI 类型
        payload: this.stringToBuffer(this.urlContent)
      }];
      
      // 写入 NDEF 消息
      uni.writeNFCNDEFMessage({
        ndefMessage: ndefMessage,
        success: (res) => {
          console.log('写入成功:', res);
          uni.showToast({ title: '写入成功', icon: 'success' });
        },
        fail: (err) => {
          console.error('写入失败:', err);
          uni.showToast({ title: '写入失败', icon: 'none' });
        }
      });
    },
    
    // 写入自定义数据到 NFC 标签
    writeCustom() {
      if (!this.customType || !this.customContent) {
        uni.showToast({ title: '请输入类型和内容', icon: 'none' });
        return;
      }
      
      uni.showToast({ title: '请将标签靠近手机', icon: 'none' });
      
      // 构建 NDEF 消息
      const ndefMessage = [{
        tn: this.customType,
        payload: this.stringToBuffer(this.customContent)
      }];
      
      // 写入 NDEF 消息
      uni.writeNFCNDEFMessage({
        ndefMessage: ndefMessage,
        success: (res) => {
          console.log('写入成功:', res);
          uni.showToast({ title: '写入成功', icon: 'success' });
        },
        fail: (err) => {
          console.error('写入失败:', err);
          uni.showToast({ title: '写入失败', icon: 'none' });
        }
      });
    },
    
    // 开始读取 NFC 标签
    startRead() {
      uni.showToast({ title: '请将标签靠近手机', icon: 'none' });
      this.isReading = true;
    },
    
    // 处理 NDEF 标签发现
    handleNDEFDiscovered(res) {
      if (!this.isReading) return;
      
      console.log('发现 NFC 标签:', res);
      
      // 解析 NDEF 消息
      const records = [];
      if (res.ndefMessage && res.ndefMessage.length > 0) {
        res.ndefMessage.forEach(record => {
          const payload = this.bufferToString(record.payload);
          records.push({
            type: record.tn,
            content: payload
          });
        });
      }
      
      this.readData = records;
      this.isReading = false;
      uni.showToast({ title: '读取成功', icon: 'success' });
    },
    
    // 字符串转 ArrayBuffer
    stringToBuffer(str) {
      const encoder = new TextEncoder();
      return encoder.encode(str).buffer;
    },
    
    // ArrayBuffer 转字符串
    bufferToString(buffer) {
      const decoder = new TextDecoder();
      return decoder.decode(new Uint8Array(buffer));
    }
  }
};
</script>

<style scoped>
.container {
  padding: 20rpx;
}

.header {
  margin-bottom: 30rpx;
}

.title {
  font-size: 36rpx;
  font-weight: bold;
  text-align: center;
  display: block;
}

.tabs {
  display: flex;
  flex-wrap: wrap;
  margin-bottom: 30rpx;
  border-bottom: 1rpx solid #eaeaea;
}

.tab-item {
  width: 25%;
  text-align: center;
  padding: 20rpx 0;
  font-size: 24rpx;
  color: #666;
  border-bottom: 3rpx solid transparent;
}

.tab-item.active {
  color: #007aff;
  border-bottom-color: #007aff;
}

.tab-content {
  min-height: 500rpx;
}

.section {
  margin-bottom: 30rpx;
}

.section-title {
  font-size: 30rpx;
  font-weight: bold;
  margin-bottom: 20rpx;
  display: block;
}

.input {
  width: 100%;
  padding: 20rpx;
  border: 1rpx solid #eaeaea;
  border-radius: 8rpx;
  font-size: 28rpx;
  margin-bottom: 20rpx;
}

.textarea {
  width: 100%;
  padding: 20rpx;
  border: 1rpx solid #eaeaea;
  border-radius: 8rpx;
  font-size: 28rpx;
  min-height: 200rpx;
  margin-bottom: 20rpx;
}

.read-instruction {
  padding: 20rpx;
  background-color: #f5f5f5;
  border-radius: 8rpx;
  margin-bottom: 20rpx;
  text-align: center;
}

.read-data {
  margin-top: 30rpx;
  padding: 20rpx;
  background-color: #f5f5f5;
  border-radius: 8rpx;
}

.data-title {
  font-size: 28rpx;
  font-weight: bold;
  margin-bottom: 15rpx;
  display: block;
}

.data-record {
  margin-bottom: 15rpx;
  padding: 15rpx;
  background-color: #fff;
  border-radius: 8rpx;
}

.record-type {
  font-size: 26rpx;
  margin-bottom: 5rpx;
  display: block;
}

.record-content {
  font-size: 26rpx;
  color: #666;
  word-break: break-all;
}
</style>

学习目标

通过本集的学习,你应该能够:

  1. 掌握 uni-app NFC API 的使用方法
  2. 理解 NFC 标签的基本类型和数据格式
  3. 实现 NFC 标签的读写操作
  4. 实现基于 HCE 的卡片模拟功能
  5. 开发基于 NFC 功能的实际应用

小结

本集详细介绍了 uni-app 中的 NFC 功能,包括 NFC API、标签读写、卡片模拟等核心知识点,并通过两个实际案例展示了如何实现 NFC 门禁功能和标签写入功能。

NFC 功能在移动应用开发中有着广泛的应用场景,如门禁系统、移动支付、身份认证等。掌握这些技能可以帮助你开发出更加便捷和安全的应用。在实际开发中,你还需要注意设备兼容性、NFC 权限、数据安全等问题,以确保应用的可靠性和用户体验。

通过本集的学习,你已经具备了在 uni-app 中使用 NFC 功能的基本能力,可以开始开发需要 NFC 功能的应用了。

« 上一篇 uni-app 蓝牙功能 下一篇 » uni-app 传感器使用