通过 Chrome(Android 版)与 NFC 设备互动

现在,您可以对 NFC 标签执行读写操作了。

François Beaufort
François Beaufort

什么是 Web NFC?

NFC 是近距离无线通信的缩写,是一种工作频率为 13.56 MHz 的短距离无线技术,可让设备在距离小于 10 厘米且传输速率高达 424 Kbit/s 的情况下进行通信。

借助 Web NFC,网站可以在 NFC 标签靠近用户设备(通常为 5-10 厘米 [2-4 英寸])时读取和写入 NFC 标签。当前范围仅限于 NFC 数据交换格式 (NDEF),这是一种适用于不同标签格式的轻量级二进制消息格式。

手机为 NFC 标签供电以交换数据
NFC 操作示意图

建议的用例

Web NFC 仅限于 NDEF,因为读取和写入 NDEF 数据的安全属性更易于量化。不支持低级别 I/O 操作(例如 ISO-DEP、NFC-A/B、NFC-F)、点对点通信模式和基于主机的卡模拟 (HCE)。

可能使用 Web NFC 的网站示例包括:

  • 当用户将设备轻触展品附近的 NFC 卡时,博物馆和艺术馆可以显示有关展品的更多信息。
  • 商品目录管理网站可以向容器上的 NFC 标签读取或写入数据,以更新其内容信息。
  • 会议网站可以在活动期间使用此 API 扫描 NFC 胸卡,并确保将其锁定,以防止进一步更改胸卡上写的信息。
  • 网站可以使用它来共享设备或服务预配场景所需的初始 Secret,还可以在运营模式下部署配置数据。
手机扫描多个 NFC 标签
NFC 商品目录管理示意图

当前状态

步骤 状态
1. 创建铺垫消息 完成
2. 创建规范的初始草稿 完成
3. 收集反馈并不断改进设计 完成
4. 来源试用 完成
5. 投放 完成

使用 Web NFC

功能检测

硬件功能检测与您可能习惯的方式不同。如果存在 NDEFReader,则表示浏览器支持 Web NFC,但无法告知您所需的硬件是否存在。特别是,如果缺少硬件,某些调用返回的 promise 将被拒绝。在介绍 NDEFReader 时,我会提供详细信息。

if ('NDEFReader' in window) { /* Scan and write NFC tags */ }

术语

NFC 标签是一种无源 NFC 设备,也就是说,当有源 NFC 设备(例如手机)靠近时,它会通过磁感应供电。NFC 标签有多种形式和款式,例如贴纸、信用卡、手环等。

透明 NFC 标签的照片
透明 NFC 标签

NDEFReader 对象是 Web NFC 中的入口点,用于公开准备在 NDEF 标签靠近时执行的读取和/或写入操作的功能。NDEFReader 中的 NDEF 代表 NFC 数据交换格式,这是一种由 NFC Forum 标准化的轻量级二进制消息格式。

NDEFReader 对象用于对来自 NFC 标签的传入 NDEF 消息执行操作,以及将 NDEF 消息写入范围内的 NFC 标签。

支持 NDEF 的 NFC 标签就像便利贴。任何人都可以读取该文件,除非该文件是只读的,否则任何人都可以向其写入内容。它包含一条封装了一条或多条 NDEF 记录的 NDEF 消息。每条 NDEF 记录都是一个二进制结构,包含数据载荷和关联的类型信息。Web NFC 支持以下 NFC Forum 标准化记录类型:空、文本、网址、智能海报、MIME 类型、绝对网址、外部类型、未知类型和本地类型。

NDEF 消息示意图
NDEF 消息示意图

扫描 NFC 标签

如需扫描 NFC 标签,请先实例化新的 NDEFReader 对象。调用 scan() 会返回一个 Promise。如果之前未授予访问权限,系统可能会提示用户。如果满足以下所有条件,promise 将解析:

  • 它仅在响应用户手势(例如轻触手势或鼠标点击)时调用。
  • 用户已允许网站与 NFC 设备互动。
  • 用户的手机支持 NFC。
  • 用户已在手机上启用 NFC。

解析 promise 后,您可以通过事件监听器订阅 reading 事件,以接收传入的 NDEF 消息。您还应订阅 readingerror 事件,以便在附近有不兼容的 NFC 标签时收到通知。

const ndef = new NDEFReader();
ndef.scan().then(() => {
  console.log("Scan started successfully.");
  ndef.onreadingerror = () => {
    console.log("Cannot read data from the NFC tag. Try another one?");
  };
  ndef.onreading = event => {
    console.log("NDEF message read.");
  };
}).catch(error => {
  console.log(`Error! Scan failed to start: ${error}.`);
});

当 NFC 标签在附近时,系统会触发 NDEFReadingEvent 事件。它包含以下两个独有属性:

  • serialNumber 表示设备的序列号(例如 00-11-22-33-44-55-66),如果没有,则为空字符串。
  • message 表示存储在 NFC 标签中的 NDEF 消息。

如需读取 NDEF 消息的内容,请循环遍历 message.records,并根据其 recordType 适当处理其 data 成员。data 成员会作为 DataView 公开,因为它允许处理数据采用 UTF-16 编码的情况。

ndef.onreading = event => {
  const message = event.message;
  for (const record of message.records) {
    console.log("Record type:  " + record.recordType);
    console.log("MIME type:    " + record.mediaType);
    console.log("Record id:    " + record.id);
    switch (record.recordType) {
      case "text":
        // TODO: Read text record with record data, lang, and encoding.
        break;
      case "url":
        // TODO: Read URL record with record data.
        break;
      default:
        // TODO: Handle other records with record data.
    }
  }
};

写入 NFC 标签

如需写入 NFC 标签,请先实例化新的 NDEFReader 对象。调用 write() 会返回一个 Promise。如果之前未授予访问权限,系统可能会提示用户。此时,NDEF 消息已“准备就绪”,如果满足以下所有条件,promise 将解析:

  • 它仅在响应用户手势(例如轻触手势或鼠标点击)时调用。
  • 用户已允许网站与 NFC 设备互动。
  • 用户的手机支持 NFC。
  • 用户已在手机上启用 NFC。
  • 用户已点按 NFC 标签,并且 NDEF 消息已成功写入。

如需将文本写入 NFC 标签,请向 write() 方法传递一个字符串。

const ndef = new NDEFReader();
ndef.write(
  "Hello World"
).then(() => {
  console.log("Message written.");
}).catch(error => {
  console.log(`Write failed :-( try again: ${error}.`);
});

如需将网址记录写入 NFC 标签,请将表示 NDEF 消息的字典传递给 write()。在以下示例中,NDEF 消息是一个包含 records 键的字典。其值是记录数组 - 在本例中,网址记录定义为一个对象,其中 recordType 键设置为 "url"data 键设置为网址字符串。

const ndef = new NDEFReader();
ndef.write({
  records: [{ recordType: "url", data: "https://w3c.github.io/web-nfc/" }]
}).then(() => {
  console.log("Message written.");
}).catch(error => {
  console.log(`Write failed :-( try again: ${error}.`);
});

也可以向一个 NFC 标签写入多条记录。

const ndef = new NDEFReader();
ndef.write({ records: [
    { recordType: "url", data: "https://w3c.github.io/web-nfc/" },
    { recordType: "url", data: "https://web.dev/nfc/" }
]}).then(() => {
  console.log("Message written.");
}).catch(error => {
  console.log(`Write failed :-( try again: ${error}.`);
});

如果 NFC 标签包含不应被覆盖的 NDEF 消息,请在传递给 write() 方法的选项中将 overwrite 属性设置为 false。在这种情况下,如果 NFC 标签中已存储 NDEF 消息,则返回的 promise 将被拒绝。

const ndef = new NDEFReader();
ndef.write("Writing data on an empty NFC tag is fun!", { overwrite: false })
.then(() => {
  console.log("Message written.");
}).catch(error => {
  console.log(`Write failed :-( try again: ${error}.`);
});

将 NFC 标签设为只读

为防止恶意用户覆盖 NFC 标签的内容,您可以将 NFC 标签设为永久只读。此操作是单向过程,无法撤消。一旦 NFC 标签变为只读状态,就无法再对其执行写入操作。

如需将 NFC 标签设为只读,请先实例化新的 NDEFReader 对象。调用 makeReadOnly() 会返回一个 Promise。如果之前未授予访问权限,系统可能会提示用户。如果同时满足以下所有条件,promise 将解析:

  • 它仅在响应用户手势(例如轻触手势或鼠标点击)时调用。
  • 用户已允许网站与 NFC 设备互动。
  • 用户的手机支持 NFC。
  • 用户已在手机上启用 NFC。
  • 用户已触碰 NFC 标签,并且 NFC 标签已成功设为只读。
const ndef = new NDEFReader();
ndef.makeReadOnly()
.then(() => {
  console.log("NFC tag has been made permanently read-only.");
}).catch(error => {
  console.log(`Operation failed: ${error}`);
});

下面介绍了如何在向 NFC 标签写入数据后将其设为永久只读。

const ndef = new NDEFReader();
try {
  await ndef.write("Hello world");
  console.log("Message written.");
  await ndef.makeReadOnly();
  console.log("NFC tag has been made permanently read-only after writing to it.");
} catch (error) {
  console.log(`Operation failed: ${error}`);
}

由于 makeReadOnly() 在 Android 版 Chrome 100 或更高版本中可用,请检查以下设备是否支持此功能:

if ("NDEFReader" in window && "makeReadOnly" in NDEFReader.prototype) {
  // makeReadOnly() is supported.
}

安全与权限

Chrome 团队使用控制对强大 Web 平台功能的访问权限中定义的核心原则(包括用户控制、透明度和人体工学)设计和实现了 Web NFC。

由于 NFC 会扩大恶意网站可能获得的信息范围,因此系统会限制 NFC 的使用,以最大限度地提高用户对 NFC 使用情况的认知度和控制力。

网站上 Web NFC 提示的屏幕截图
Web NFC 用户提示

Web NFC 仅适用于顶级框架和安全浏览环境(仅限 HTTPS)。在处理用户手势(例如按钮点击)时,源必须先请求 "nfc" 权限。如果之前未授予访问权限,则 NDEFReader scan()write()makeReadOnly() 方法会触发用户提示。

  document.querySelector("#scanButton").onclick = async () => {
    const ndef = new NDEFReader();
    // Prompt user to allow website to interact with NFC devices.
    await ndef.scan();
    ndef.onreading = event => {
      // TODO: Handle incoming NDEF messages.
    };
  };

用户发起的权限提示与将设备移至目标 NFC 标签的实际物理移动相结合,反映了其他文件和设备访问权限 API 中所用的选择器模式。

如需执行扫描或写入操作,必须在用户用设备触碰 NFC 标签时显示网页。浏览器使用触感反馈来指示点按。如果显示屏关闭或设备锁定,将禁止访问 NFC 无线装置。对于不可见的网页,系统会暂停接收和推送 NFC 内容,并在网页重新变为可见时恢复。

借助 Page Visibility API,您可以跟踪文档公开范围的变化时间。

document.onvisibilitychange = event => {
  if (document.hidden) {
    // All NFC operations are automatically suspended when document is hidden.
  } else {
    // All NFC operations are resumed, if needed.
  }
};

食谱集

以下是一些入门代码示例。

检查权限

借助 Permissions API,您可以检查是否已授予 "nfc" 权限。此示例展示了如何在不进行用户互动的情况下扫描 NFC 标签(如果之前已授予访问权限),或者在其他情况下显示按钮。请注意,由于在后台使用相同的权限,因此写入 NFC 标签时也适用相同的机制。

const ndef = new NDEFReader();

async function startScanning() {
  await ndef.scan();
  ndef.onreading = event => {
    /* handle NDEF messages */
  };
}

const nfcPermissionStatus = await navigator.permissions.query({ name: "nfc" });
if (nfcPermissionStatus.state === "granted") {
  // NFC access was previously granted, so we can start NFC scanning now.
  startScanning();
} else {
  // Show a "scan" button.
  document.querySelector("#scanButton").style.display = "block";
  document.querySelector("#scanButton").onclick = event => {
    // Prompt user to allow UA to send and receive info when they tap NFC devices.
    startScanning();
  };
}

中止 NFC 操作

使用 AbortController 基元可以轻松取消 NFC 操作。以下示例展示了如何通过 NDEFReader scan()makeReadOnly()write() 方法的选项传递 AbortControllersignal,同时中止这两项 NFC 操作。

const abortController = new AbortController();
abortController.signal.onabort = event => {
  // All NFC operations have been aborted.
};

const ndef = new NDEFReader();
await ndef.scan({ signal: abortController.signal });

await ndef.write("Hello world", { signal: abortController.signal });
await ndef.makeReadOnly({ signal: abortController.signal });

document.querySelector("#abortButton").onclick = event => {
  abortController.abort();
};

写后读

结合使用 write()scan()AbortController 基元,即可在向 NFC 标签写入消息后读取该标签。以下示例展示了如何将文本消息写入 NFC 标签,以及如何读取 NFC 标签中的新消息。它会在 3 秒后停止扫描。

// Waiting for user to tap NFC tag to write to it...
const ndef = new NDEFReader();
await ndef.write("Hello world");
// Success! Message has been written.

// Now scanning for 3 seconds...
const abortController = new AbortController();
await ndef.scan({ signal: abortController.signal });
const message = await new Promise((resolve) => {
  ndef.onreading = (event) => resolve(event.message);
});
// Success! Message has been read.

await new Promise((r) => setTimeout(r, 3000));
abortController.abort();
// Scanning is now stopped.

读取和写入文本记录

您可以使用通过记录 encoding 属性实例化的 TextDecoder 解码文本记录 data。请注意,文本记录的语言可通过其 lang 属性获取。

function readTextRecord(record) {
  console.assert(record.recordType === "text");
  const textDecoder = new TextDecoder(record.encoding);
  console.log(`Text: ${textDecoder.decode(record.data)} (${record.lang})`);
}

要写入简单的文本记录,请将字符串传递给 NDEFReader write() 方法。

const ndef = new NDEFReader();
await ndef.write("Hello World");

文本记录默认采用 UTF-8 编码,并假定当前文档的语言,但您可以使用完整语法指定这两个属性 (encodinglang),以创建自定义 NDEF 记录。

function a2utf16(string) {
  let result = new Uint16Array(string.length);
  for (let i = 0; i < string.length; i++) {
    result[i] = string.codePointAt(i);
  }
  return result;
}

const textRecord = {
  recordType: "text",
  lang: "fr",
  encoding: "utf-16",
  data: a2utf16("Bonjour, François !")
};

const ndef = new NDEFReader();
await ndef.write({ records: [textRecord] });

读取和写入网址记录

使用 TextDecoder 解码记录的 data

function readUrlRecord(record) {
  console.assert(record.recordType === "url");
  const textDecoder = new TextDecoder();
  console.log(`URL: ${textDecoder.decode(record.data)}`);
}

如需写入网址记录,请将 NDEF 消息字典传递给 NDEFReader write() 方法。NDEF 消息中包含的网址记录定义为一个对象,其中 recordType 键设置为 "url"data 键设置为网址字符串。

const urlRecord = {
  recordType: "url",
  data:"https://w3c.github.io/web-nfc/"
};

const ndef = new NDEFReader();
await ndef.write({ records: [urlRecord] });

读取和写入 MIME 类型记录

MIME 类型记录的 mediaType 属性表示 NDEF 记录载荷的 MIME 类型,以便正确解码 data。例如,使用 JSON.parse 解码 JSON 文本,并使用 Image 元素解码图片数据。

function readMimeRecord(record) {
  console.assert(record.recordType === "mime");
  if (record.mediaType === "application/json") {
    const textDecoder = new TextDecoder();
    console.log(`JSON: ${JSON.parse(decoder.decode(record.data))}`);
  }
  else if (record.mediaType.startsWith('image/')) {
    const blob = new Blob([record.data], { type: record.mediaType });
    const img = new Image();
    img.src = URL.createObjectURL(blob);
    document.body.appendChild(img);
  }
  else {
    // TODO: Handle other MIME types.
  }
}

如需写入 MIME 类型记录,请将 NDEF 消息字典传递给 NDEFReader write() 方法。NDEF 消息中包含的 MIME 类型记录定义为一个对象,其中 recordType 键设置为 "mime"mediaType 键设置为内容的实际 MIME 类型,data 键设置为一个对象,该对象可以是 ArrayBuffer 或提供对 ArrayBuffer 的视图(例如 Uint8ArrayDataView)。

const encoder = new TextEncoder();
const data = {
  firstname: "François",
  lastname: "Beaufort"
};
const jsonRecord = {
  recordType: "mime",
  mediaType: "application/json",
  data: encoder.encode(JSON.stringify(data))
};

const imageRecord = {
  recordType: "mime",
  mediaType: "image/png",
  data: await (await fetch("icon1.png")).arrayBuffer()
};

const ndef = new NDEFReader();
await ndef.write({ records: [jsonRecord, imageRecord] });

读取和写入绝对网址记录

绝对网址记录 data 可以使用简单的 TextDecoder 进行解码。

function readAbsoluteUrlRecord(record) {
  console.assert(record.recordType === "absolute-url");
  const textDecoder = new TextDecoder();
  console.log(`Absolute URL: ${textDecoder.decode(record.data)}`);
}

如需写入绝对网址记录,请将 NDEF 消息字典传递给 NDEFReader write() 方法。NDEF 消息中包含的绝对网址记录定义为一个对象,其中 recordType 键设置为 "absolute-url"data 键设置为网址字符串。

const absoluteUrlRecord = {
  recordType: "absolute-url",
  data:"https://w3c.github.io/web-nfc/"
};

const ndef = new NDEFReader();
await ndef.write({ records: [absoluteUrlRecord] });

读取和写入智能海报记录

智能海报记录(用于杂志广告、宣传单、广告牌等)将某些 Web 内容描述为包含 NDEF 消息作为载荷的 NDEF 记录。调用 record.toRecords()data 转换为智能海报记录中包含的记录列表。它应包含一个网址记录、一个标题文本记录、一个图片 MIME 类型记录,以及一些自定义本地类型记录(例如 ":t"":act"":s"),分别用于表示智能海报记录的类型、操作和大小。

本地类型记录仅在所包含 NDEF 记录的本地上下文中是唯一的。当类型的含义在包含记录的本地上下文之外不重要且存储用量是硬性约束条件时,请使用它们。在 Web NFC 中,本地类型记录名称始终以 : 开头(例如 ":t"":s"":act")。这是为了区分文本记录与本地类型文本记录,例如。

function readSmartPosterRecord(smartPosterRecord) {
  console.assert(record.recordType === "smart-poster");
  let action, text, url;

  for (const record of smartPosterRecord.toRecords()) {
    if (record.recordType == "text") {
      const decoder = new TextDecoder(record.encoding);
      text = decoder.decode(record.data);
    } else if (record.recordType == "url") {
      const decoder = new TextDecoder();
      url = decoder.decode(record.data);
    } else if (record.recordType == ":act") {
      action = record.data.getUint8(0);
    } else {
      // TODO: Handle other type of records such as `:t`, `:s`.
    }
  }

  switch (action) {
    case 0:
      // Do the action
      break;
    case 1:
      // Save for later
      break;
    case 2:
      // Open for editing
      break;
  }
}

如需写入智能海报记录,请将 NDEF 消息传递给 NDEFReader write() 方法。NDEF 消息中包含的智能海报记录定义为一个对象,其 recordType 键设置为 "smart-poster",而 data 键设置为一个对象,该对象表示(再次)智能海报记录中包含的 NDEF 消息。

const encoder = new TextEncoder();
const smartPosterRecord = {
  recordType: "smart-poster",
  data: {
    records: [
      {
        recordType: "url", // URL record for smart poster content
        data: "https://my.org/content/19911"
      },
      {
        recordType: "text", // title record for smart poster content
        data: "Funny dance"
      },
      {
        recordType: ":t", // type record, a local type to smart poster
        data: encoder.encode("image/gif") // MIME type of smart poster content
      },
      {
        recordType: ":s", // size record, a local type to smart poster
        data: new Uint32Array([4096]) // byte size of smart poster content
      },
      {
        recordType: ":act", // action record, a local type to smart poster
        // do the action, in this case open in the browser
        data: new Uint8Array([0])
      },
      {
        recordType: "mime", // icon record, a MIME type record
        mediaType: "image/png",
        data: await (await fetch("icon1.png")).arrayBuffer()
      },
      {
        recordType: "mime", // another icon record
        mediaType: "image/jpg",
        data: await (await fetch("icon2.jpg")).arrayBuffer()
      }
    ]
  }
};

const ndef = new NDEFReader();
await ndef.write({ records: [smartPosterRecord] });

读取和写入外部类型记录

如需创建应用定义的记录,请使用外部类型记录。这些事件可能包含一个 NDEF 消息作为载荷,可通过 toRecords() 访问。其名称包含签发组织的域名、英文冒号和长度至少为 1 个字符的类型名称,例如 "example.com:foo"

function readExternalTypeRecord(externalTypeRecord) {
  for (const record of externalTypeRecord.toRecords()) {
    if (record.recordType == "text") {
      const decoder = new TextDecoder(record.encoding);
      console.log(`Text: ${textDecoder.decode(record.data)} (${record.lang})`);
    } else if (record.recordType == "url") {
      const decoder = new TextDecoder();
      console.log(`URL: ${decoder.decode(record.data)}`);
    } else {
      // TODO: Handle other type of records.
    }
  }
}

如需写入外部类型记录,请将 NDEF 消息字典传递给 NDEFReader write() 方法。NDEF 消息中包含的外部类型记录定义为一个对象,其中 recordType 键设置为外部类型的名称,data 键设置为表示外部类型记录中包含的 NDEF 消息的对象。请注意,data 键也可以是 ArrayBuffer,或者提供对 ArrayBuffer 的视图(例如 Uint8ArrayDataView)。

const externalTypeRecord = {
  recordType: "example.game:a",
  data: {
    records: [
      {
        recordType: "url",
        data: "https://example.game/42"
      },
      {
        recordType: "text",
        data: "Game context given here"
      },
      {
        recordType: "mime",
        mediaType: "image/png",
        data: await (await fetch("image.png")).arrayBuffer()
      }
    ]
  }
};

const ndef = new NDEFReader();
ndef.write({ records: [externalTypeRecord] });

读取和写入空记录

空记录没有载荷。

如需写入空记录,请将 NDEF 消息字典传递给 NDEFReader write() 方法。NDEF 消息中包含的空记录被定义为 recordType 键设置为 "empty" 的对象。

const emptyRecord = {
  recordType: "empty"
};

const ndef = new NDEFReader();
await ndef.write({ records: [emptyRecord] });

浏览器支持

Android 设备(Chrome 89)支持 Web NFC。

开发者提示

下面列出了一些我开始使用 Web NFC 时就应该知道的事项:

  • 在 Web NFC 运行之前,Android 会在操作系统级别处理 NFC 标签。
  • 您可以在 material.io 上找到 NFC 图标。
  • 使用 NDEF 记录 id 可在需要时轻松识别记录。
  • 支持 NDEF 且未格式的 NFC 标签包含一个空类型的记录。
  • 编写 android 应用记录非常简单,如下所示。
const encoder = new TextEncoder();
const aarRecord = {
  recordType: "android.com:pkg",
  data: encoder.encode("com.example.myapp")
};

const ndef = new NDEFReader();
await ndef.write({ records: [aarRecord] });

演示

试用官方示例,并查看一些很酷的 Web NFC 演示:

2019 年 Chrome 开发者峰会上的 Web NFC 卡片演示

反馈

Web NFC 社区群组和 Chrome 团队非常期待听取您对 Web NFC 的看法和使用体验。

向我们介绍 API 设计

API 是否存在无法按预期运行的地方?或者,您是否缺少实现想法所需的方法或属性?

Web NFC GitHub 代码库中提交规范问题,或在现有问题中添加您的想法。

报告实现方面的问题

您在 Chrome 的实现过程中是否发现了错误?还是实现与规范不同?

https://new.crbug.com 上提交 bug。请务必提供尽可能多的详情,提供有关如何重现 bug 的简单说明,并将 Components 设置为 Blink>NFC故障非常适合分享快速简便的重现步骤。

表达支持

您打算使用 Web NFC 吗?您的公开支持有助于 Chrome 团队确定功能的优先级,并向其他浏览器供应商表明支持这些功能的重要性。

使用 # 标签 #WebNFC@ChromiumDev 发送一条推文,告诉我们您使用它的位置和方式。

实用链接

致谢

非常感谢 Intel 团队实现了 Web NFC。Google Chrome 依赖于一个由提交者组成的社区,他们通力合作推动 Chromium 项目向前发展。并非所有 Chromium 提交者都是 Google 员工,这些贡献者值得特别表彰!