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

现在可以读取和写入 NFC 标签。

François Beaufort
François Beaufort

什么是网络 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)。

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

  • 当用户将设备靠近展览附近的 NFC 卡时,博物馆和美术馆可显示有关显示屏的更多信息。
  • 产品目录管理网站可以通过读取或写入某个容器上的 NFC 标签来更新有关其内容的信息。
  • 会议网站可以使用它在活动期间扫描 NFC 徽章,并确保它们处于锁定状态,以防止上面写入的信息进一步更改。
  • 网站可以使用它来共享设备或服务预配场景所需的初始密钥,以及在操作模式下部署配置数据。
手机扫描多个 NFC 标签
图示:NFC 广告资源管理

当前状态

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

使用网络 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 记录都是一个二进制结构,包含数据载荷和关联的类型信息。网络 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。在这种情况下,如果 NDEF 消息已存储在 NFC 标签中,返回的 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 NFC。

由于 NFC 扩展了恶意网站可能获取的信息域,因此限制 NFC 的可用性,以便最大限度地了解和控制 NFC 的使用。

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

网络 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 标签中的新消息。三秒后停止扫描。

// 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.

读取和写入文本记录

文本记录 data 可通过使用记录 encoding 属性实例化的 TextDecoder 进行解码。请注意,文本记录的语言可通过其 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 编码,并采用当前文档的语言,但您可以使用用于创建自定义 NDEF 记录的完整语法来指定这两个属性(encodinglang)。

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] });

读取和写入绝对网址记录

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

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] });

读取和写入智能海报记录

智能海报记录(用于杂志广告、传单、广告牌等)会将某些网页内容描述为包含 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() 访问的载荷。其名称包含签发组织的域名、一个冒号,以及长度至少为一个字符的类型名称,例如 "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] });

浏览器支持

Chrome 89 中的 Android 设备支持网络 NFC。

开发提示

在开始使用网络 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 卡演示

反馈

网络 NFC 社区小组和 Chrome 团队非常期待听到您对网络 NFC 的看法和体验。

向我们介绍 API 设计

是否存在 API 无法正常运行的问题?或者,您是否需要缺少一些方法或属性来实现您的想法?

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

报告实施方面的问题

您是否发现了 Chrome 实现方面的错误?或者实现方式是否不同于规范?

https://new.crbug.com 上提交 bug。请务必提供尽可能多的详细信息,提供重现 bug 的简单说明,并将组件设置为 Blink>NFCGlitch 非常适合用于快速轻松地分享重现的视频。

表达支持

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

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

实用链接

致谢

非常感谢 Intel 员工实现 Web NFC。Google Chrome 有赖于提交者社区的紧密协作,以便推进 Chromium 项目。并非每位 Chromium 提交者都是 Google 员工,这些贡献者值得特别表彰!