通过 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 数据交换格式 (NDEF),这是一种轻量级二进制消息格式,适用于不同的标签格式。

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

建议的应用场景

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

以下是一些可能使用 Web NFC 的网站示例:

  • 当用户将设备触碰展品附近的 NFC 卡时,博物馆和美术馆可以显示有关展品的更多信息。
  • 库存管理网站可以读取或写入容器上 NFC 标签的数据,以更新有关容器内容的信息。
  • 会议网站可以使用它在活动期间扫描 NFC 徽章,并确保徽章已锁定,以防止进一步更改写入其中的信息。
  • 网站可以使用它来共享设备或服务配置方案所需的初始密钥,还可以在运行模式下部署配置数据。
手机扫描多个 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() 在 Chrome 100 或更高版本中适用于 Android,请通过以下方式检查是否支持此功能:

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

安全与权限

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

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

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

Web NFC 仅适用于顶级框架和安全浏览上下文(仅限 HTTPS)。来源必须先在处理用户手势(例如点击按钮)时请求 "nfc" 权限。如果之前未授予访问权限,NDEFReaderscan()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()AbortController 原语结合使用,然后使用 scan(),可以在向 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.

读取和写入文本记录

文本记录 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] });

读取和写入绝对网址记录

绝对网址记录 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() 访问的载荷。其名称包含签发组织的域名、一个英文冒号和至少一个字符的类型名称,例如 "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] });

浏览器支持

Web NFC 在 Chrome 89 中适用于 Android。

开发者提示

以下是我希望自己在刚开始使用 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 的实现存在 bug?还是实现与规范不同?

请访问 https://new.crbug.com 提交 bug。请务必尽可能详细地说明问题,提供重现 bug 的简单说明,并将组件设置为 Blink>NFC

显示支持

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

发送一条推文给 @ChromiumDev,使用 ##WebNFC 主题标签,告诉我们您在何处以及如何使用它。

实用链接

致谢

非常感谢 Intel 的员工实现 Web NFC。Google Chrome 依赖于一个由提交者组成的社区,他们共同努力推进 Chromium 项目。并非所有 Chromium 提交者都是 Google 员工,这些贡献者值得特别表彰!