前言

小程序,作为新一代的应用开发方式,虽然在业务上已经证明了巨大的价值,但是在开发者友好性上却非常的差,例如框架难用,调试困难,跨平台兼容性差。本文便是在尝试提高针对视图的开发者友好性和兼容性,同时进一步提升性能。

为了能够更好的理解本文,需要您最好了解以下内容:

  • 有小程序相关的经验,能够理解基本小程序的架构、基本的框架使用方式、主流的跨端三方框架等

  • 有前端相关的经验,能够理解什么是 DOM 接口,以及如何操作 DOM 接口

本文将不再赘述过多的基础概念内容。

背景

目前本人任职飞书开放平台小程序组,负责小程序相关的运行时的工作内容。由于飞书对小程序有着一些特殊的需求:

  1. 飞书大量官方应用都是通过小程序承载的,这些应用对运行体验和开发效率有着远比外界更高的要求。而且由于是官方应用,且只服务于飞书,所以他们对于小程序的跨端兼容性要求并不高。这也给了我们更大的灵活性去设计更好的方案。

  2. 飞书是一个面向全球 B 端的市场,先不论国内是否每个 B 端企业都能有精力去开发小程序,单论海外市场对小程序的认知就非常浅薄。所以如何尽可能的降低小程序的认知学习门槛,降低接入成本,就显得尤为重要。在国内可以通过类似于 Taro 的框架,但是海外这部分的学习成本依旧很高,所以需要有新的方式去降低这部分的成本。

正是因为如此,我们这边决定不再以兼容社区小程序的方案为首要目标,而是转为提高开发者的开发体验和运行性能为主目标去进行设计和优化。

现有架构的「病区」

小程序在诞生之后,其实做了很多的设定,有的设定是合适的,但更多的设定在逐渐的发展过程中变得不再合适,而后人却依旧沿着这个思路和方向去做。接下来,让我们重新思考一下这些设定:

为什么会诞生 XXML 的视图开发方案?

XXML 在这里是对通过模板开发小程序的一种开发范式(也可以叫开发框架或者 DSL)的统称。因为不同宿主内对应的模板文件的后缀不同,但都有 ML 而因此得名。例如微信是 .wxml ,支付宝是 .axml,而飞书和抖音则为 .ttml

在我的认知中,小程序最开始并不是为资深的前端开发而设计的,相反,他是为了客户端乃至一些没有开发经验的人所设计。

所以借鉴了 Vue 这类非常容易入门的社区框架,再结合上小程序自身的需求所定制了一套 DSL。这套 DSL 针对从来没有写过前端的人来说,确实算是非常友好的,几乎不需要学习就可以上手。

其实让开发者更容易的入门上手算是小程序最开始的目标。例如,一开始是不提供 CLI 工具只提供了可视化的 IDE。如果小程序的受众是不懂开发的人,那其实没问题。

但最终事与愿违,开发小程序最多的人其实就是资深的前端开发,在前端开发的视角来看,这套 DSL 可以用一坨屎来形容。而小程序又必须要要用这套 DSL 去开发,就导致每一个写过小程序的人必须吃一次这个屎。

XXML 会带来什么问题?

虽然以微信为首的厂商做了很多能力去补救,尽可能在保证较低入手门槛的情况下,不断丰富能力,提高对前端开发人员的友好程度。但最开始的设计就不是面向这部分人群,即使后面修补的再多也无济于事了。这其中所带来的的问题随着小程序的发展而愈发严重:

  • 由于这套框架的实现是内置在基础库内,其能力和功能的实现必然有滞后性,同时灵活度差。一旦框架概念落后,开发者将不得不继续咬牙学习落后内容。

  • 虽然最开始借鉴自 JS 和 Web,但最终却无法的很好的利用开源社区的能力,几乎所有都要重造轮子。国内已经发展了这么多年,社区才将将都造完稳定的轮子,如果将这一套推向海外,那成本根本无法估量。

  • 规范缺乏一致性。由于 XXML 本身其实是一个业务开发框架,但又作为唯一开发方式,非常容易变得越来越臃肿和随意,可能一个实习生随便定出的接口都将会影响整个社区,能力的实现也缺乏一致性。例如 PageComponent 的生命周期就非常不一样。

  • 为了解决 XXML 这些问题,降低开发者的开发成本,在社区中诞生了许许多多的三方跨端框架。导致开发小程序的人不仅仅要懂得如何写原生的 XXML,更要懂得如何使用这些跨端框架,学习成本陡增。

  • Taro 这类模拟一套 DOM 接口来嫁接社区框架的做法,最终会让运行架构变成 React => Taro DOM => TTML => DOM 这种有点脱裤子放屁的方式。

小程序的视图开发一定要使用 XXML 么?

一个支持通用应用的宿主,可以提供一套它认为合适的模板 DSL 去生成渲染树,但一定也需要提供一套更加灵活的方式去生成渲染树。例如:

  • 安卓默认使用 XML 作为模板,去渲染内容。但如果不满足使用,同时可以使用命令的操作方式去构建一个渲染树。iOS 也是用类似的逻辑

  • Flutter 定义了一套类似于 React 的 Weight 渲染框架。但依旧暴露了底层的 RenderObject 方便开发者绕过 Weight 直接去构建渲染树。

而小程序,却选择了只提供 XXML,并没有提供其他的方式。如果小程序面向的特定需求场景,这倒也无可厚非,可实际上小程序面向的却是通用需求场景。

于是飞书内部在思考,如果我们一定要再提供一套通用的 DSL,会是什么?React、Vue 这类前端框架么?如果提供了,又会面临和 XXML 类似的局面。

其实在 Web 领域中,早就给出了答案,那么就是 DOM。不管是 React 还是 Vue 还是其他的框架,底层都是基于 DOM 接口所存在的。如果我们能够在小程序提供一套底层的 DOM 接口,那么 XXML 所带来的问题是不是就全都迎刃而解了呢?

为什么之前不提供 DOM 接口?

当我们想明白这个点之后,其实就会更加好奇,为什么这么简单的想法为啥一开始乃至后续的人都没做过呢?

我猜测最开始不这么做的原因有这么几个,但我觉得都不是很成立,或者说在飞书的场景中很难成立(如果大家有不同的想法和意见,随时可以和我沟通交流)。

Q: 无法完整实现,成本高。
双线程无法实现完整的 DOM 接口,且在逻辑层实现一套 DOM 接口的成本过高。

确实,如果想要一比一的复刻 DOM 接口,有些依赖渲染能力的接口确实无法实现。但如果只是实现一套子集,能够满足主流框架的使用,那么成本和难度并不高。比如 Taro 就是这么做的,社区也证明了其可行性。

Q: 首屏性能差。
如果实现 DOM 接口,就必须要等待逻辑层完整渲染并操作完 DOM 接口之后,渲染层才能渲染,那么将失去双线程逻辑层和渲染层并行运行的优势。

我觉得这个是一个屁股决定脑袋的事情。在 90% 的场景中,渲染层就是需要等待逻辑层计算完毕之后才能渲染的。就像是你不能说为了前端页面首屏速度快,要求所有网站都用 SSR 吧。

另外,首屏其实分为 FP 和 TTI 的,和 SSR 场景类似,XXML 可以做到 FP 快,但是 TTI 并不如 DOM 快的。飞书场景更在乎的是 TTI 而非 FP,所以影响不大。

再说,用了 DOM 接口之后自然是有对应的优化策略和方式的。

Q: DOM 指令的通讯压力大。
如果采用 DOM 接口,那么逻辑层往渲染层发送的将是 DOM 指令,如果我要渲染 100 个普通的 view,这部分体积明显比直接发送 data 要大。

没错,某些场景下,DOM 指令的数量确实是会比 data 体积大。

但是在飞书的业务场景中,通常都会有着重度的逻辑以及大量国际化文案,这部分会导致 data 的体积飞涨。而且并不是所有的开发都有合理使用 data 的能力的,有些人就喜欢啥都往 data 里面塞,这更加会加剧其体积的膨胀。

所以对于飞书来说,这个的影响其实并不大。

Q: 觉得 TTML 就够用了

懂的都懂,不多说了

Q: 不希望对外认知上小程序就是前端,所以在一定程度上屏蔽了前端所用的东西。

我觉得这其实是微信小程序的烟雾弹,让大家觉得小程序技术很厉害,它底层是 Native 实现,只是借鉴了前端的一些概念而已。

但实际上缺恰恰相反,从设计开始,就是在 Web 上面打补丁实现的,只是借鉴了一点点 Native 中的概念。

所以如果相信了这个烟雾弹,那么才是将自己埋入深坑。

Q: 不希望利用 DOM 接口实现太多灵活的动态更新能力。
例如审核之后通过 API 更改 UI 界面。

这个也是我见过最多的说法了,也是最站不住脚的说法。
XXML 本身就无法避免动态更新,更别说 DOM 了。

Q: DOM 接口就不会有实现上的兼容问题了么?

这是个好问题,虽然 DOM 接口本身是有标准的,但实现上其实各家都会遇见的不一样。

这个自然没有完美的解决方案,但是我觉得以兼容主流框架而不是提供完整兼容的 DOM 接口为目标是更加合理的。不管各家接口实现成什么样子,只要能兼容前端框架就是对的。

至此,其实我们可以看到,DOM 接口本身的引入并不存在什么实质性的阻碍。

而且最重要的事,DOM 本身是作为 XXML 的一种补充开发能力所引入的,每个开发者可以根据自己的架构特点选择 XXML 甚至的 DOM 来作为开发方式。

提供 DOM 接口能带来哪些好处?

最后的最后,我们其实要明白,如果要提供 DOM 接口,将会带来那些好处呢?

  • 能够几乎无缝的兼容市面上主流的前端框架,目前测试下来支持的有 Vue Svelte React18。极大的降低开发者的学习和开发成本。

  • 可以提供极致的性能优化,做到非常细粒度的组件更新。

  • 和前端主流开发方式对齐,不再分割与割裂,提高开发效率和降低接入成本。

  • 针对三方的跨端框架,可以降低其兼容难度和运行时的体积,提高执行效率。

最关键最关键的是,终于可以抛弃掉 XXML 这套恶心的东西了。没有难用的数据绑定,支持传递函数,不需要考虑 data 内数据是否是视图需要的,自定义组件之间的时序能够得到天然的保障,能够支持 CSS-In-JS 等等。

社区中很多优秀的方案都可以拿来使用,总之,就是优雅,优雅,还是优雅。

全新架构

在理解了上文所讲述的内容之后,就该讲解下我们为了实现上面的功能所做的全新架构了:

最简单的理解,就是在新架构中,渲染层和逻辑层之间的通讯不再发送 data 了,而是通过逻辑层实现的一套 DOM 接口,将其转换成 DOM 的操作指令。

如果说渲染的操作是 fn(data) 的过程,那么以前这个过程是在渲染层完成,而新架构下,这个操作将在逻辑层完成。

在新架构中,依旧保留了对原先 XXML 的兼容性,开发者可以通过 renderingModel 控制。控制以 Page 为粒度,保证在开发中可以做到渐进式迁移。另外,DOM 接口与 XXML 只能二选一,两者无法在一个 Page 上同时开启。

我并不认为提供 Page 级别的控制在技术设计上是一个好方案,但这个方案确实是可以最大程度上提供更好的兼容性,方便开发者渐进式的替换。

Document 结构

当开启 DOM 的渲染模型之后,整个小程序的文档模型都将与 Web 有着极其类似的结构:

1
2
3
4
5
6
7
8
9
10
11
<html> <!-- document.documentElement 一般不会用到这个 -->
<head> <!-- document.head -->
<!-- 可以在这里利用 meta 标签配置一些全局内容 -->
</head>
<body> <!-- document.body -->
<page route="xxx"> <!-- Page 实例所对应的渲染节点 -->
<view>Hello World</view>
</page>
<page route="xxx"></page>
</body>
</html>

例如每一个 Page 都对应着 document.body 内的一个 <page/> 元素。页面栈也与 document.body.childNodes 保持一致,页面栈的进出相当于 page 元素的插入和剥离。

当然,这里只是一个类比,实际上会有些许的不同,本文不再展开(其实就是我还没设计好,这是个预期模型)。

小程序内置组件

小程序内置组件在 DOM 接口下名字、属性、使用方式依旧保持不变,并且遵守 Web 的通用规范。提供了对应的 DOM Interface 方便开发者直接操作和使用。例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// tagName 都是和 XXML 内保持一致
const view = document.createElement('view')
const button = document.createElement('button')
const picker = document.createElement('picker')
const pickerView = document.createElement('picker-view')

// 属性名字也与原先的保持一致,只不过当用作属性的时候,需要将其转换成驼峰的格式。
button.openType = 'share'
// or
button.setAttribute('open-type', 'share')

// 原先组件的使用方式依旧不变,例如 picker-view 下面需要插入 picker-view-column
for (let i = 0; i < 3; i++) {
pickerView.appendChild(document.createElement('picker-view-column'))
}

// 当需要赋值非字符串类型的值的时候,尽量使用 property 的方式
picker.range = [['a', 'b'], ['0', '1', '2']]

// 使用 attribute 可能会得到预期以外的行为,不推荐使用
picker.setAttribute('range', [...])

或许有人会问,为什么使用了 DOM 接口缺不能使用前端的组件呢?

这是因为小程序的组件体系确实是比较特殊的,目前暂时没有很好的办法去兼容。但是在未来,可能会选择开放部分 Web 的组件,例如 div span svg 等。

如何使用

目前该方案还在内部开发阶段,外部没有全量发布。下文中提到的所有接口都属于不稳定接口,在没有正式对外前,只能作为参考。

可能看完上面之后大家依旧会是一头雾水,我这里通过一个简单的 DEMO 来让大家有更加清晰的认识。

  1. 打开飞书开发者工具中的新框架开关
  1. 在要开启 DOM 接口的 Page 所对应的 JSON 中添加 renderingModeldom
1
2
3
{
"renderingModel": "dom"
}
  1. 此时可以在 Page 的 onLoad 生命周期内通过 this.pageElement 获取到 Page 的元素实例了,可以将其理解为前端开发中的 <div id="app"></div> 元素。后续就可以按照前端开发流程去操作这个元素了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Page({
onLoad() {
const root = this.pageElement

// 可以直接控制页面渲染
const view = document.createElement('view') // 这里不能使用 Web 组件,只能使用小程序组件
view.style = 'color: blue;' // style 只能设置不能读取,读取到的值是不准确的
view.className = 'root'
view.appendChild(document.createTextNode('Hello world'))
root.appendChild(view)

// 或者使用主流前端框架进行渲染
React.createRoot(root).render(<App/>) // JSX 小程序不支持,需要进行转换,这里只是举例
// 或
Vue.createApp(App).mount(root)
}
})
  1. 其他的例如样式、API 等与普通小程序无异。样式依旧通过 XXSS 使用,API 则继续可以使用 tt 如果在飞书内的话。

性能对比

性能自然是为什么要引入 DOM 最重要的一个因素了,让我们来看一下性能对比测试

测试方式

仓库:https://github.com/XGHeaven/perf-gadget-dom-vs-framework

使用 Vue3 的框架,Demo 使用 mdn/todo-vue,在此基础上使用了 Taro 将代码分别编译到飞书、微信、支付宝。

  • taro-wx Taro 编译到微信,微信版本为 8.0.35

  • taro-alipay Taro 编译到支付宝,开启 2.0 lib,支付宝版本为 10.3.80

  • taro-tt Taro 编译到抖音(Tiktok),抖音版本为 25.5.0

  • taro-lark Taro 编译到飞书,飞书版本为 6.5.3

  • dom-lark Vue 源码直接编译到飞书新架构 DOM 接口上,飞书版本为 6.5.3

每一个测试项目,有 5 次的预热运行,之后会再运行 10 次,取其平均值。同时每次运行之间都会相隔一段时间,保证系统有时机去做别的事情,从而尽可能避免影响到测试结果。

之所以选择 Taro 而非 XXML 原生写法或者其他跨端框架,其一是因为更加接近于开发者的使用体验,因为不可能有人会选择直接去操作 DOM。其二是因为 Taro 已经是最快的跨端框架了,再比较其他的没有什么太多的意义。

除此以外,其他的内容基本一致:

  • 测试设备为华为 P40 Pro,麒麟 990 处理器,鸿蒙 OS 3.0 系统。

  • Taro 框架统一经过 build:xx 命令后运行,Vue 直接通过 Vite 编译后运行

  • 运行方式都是打开对应平台的开发者工具并上传之后,在后台生成对应的二维码扫码预览,避免被认为是开发状态从而添加对结果有影响的能力。

由于 Taro 框架以及飞书新架构的情况下,组件的 mounted 生命周期只能保证在逻辑层侧完成了渲染,不能保证此时组件更新指令已经发送到渲染层,我们需要寻找一个全新的方式去衡量什么时候组件真正渲染上屏。

幸运的是,image 组件有一个特性,当图片被渲染出来并成功加载之后,会触发 load 事件,当此事件触发的时候,可以认为组件已经正确被渲染到屏幕上了。此时,我们只需要在某次更新组件的最后一个组件内,插入一个高宽为 0 的 image 组件作为渲染标记,当事件被触发时,就可以测量出整体的渲染时间。

虽然图片加载会占用一定的时间,但资源本身就是离线保存在代码包内,加载速度很快,且这并不影响最终结果的相对大小。

Taro 默认关闭资源内联功能,避免 Taro 将小资源文件内联成 base64 字符串从而增大通讯成本。

测试内容

这里还需要再补充几点:

  • 不测试应用首屏的性能,由于首屏开始的时间点不同宿主下测量会是不一样的,所以我只在私下里在飞书上通过录屏测试过 Uniapp 对比 DOM,差不多 DOM 可以快 300ms。如果采用 Taro 理论上差距会缩小,但是依旧是快的。

  • 而且首屏性能主要由 JS Parse 和 Execution 的时间组成,这两部分分别由包体积和渲染性能决定,可以分别对比这两项测试从而大体可以得出首屏的性能。

  • 不包含纯静态内容,因为测试使用的是 Taro 框架,其会在逻辑层搞一套虚拟 DOM,会导致最终发送给渲染层的数据量其实基本一致,测试的意义不大。如果有需要,后续可以对比 DOM 和 XXML 的在纯静态内容上的速度差异。

列表组件渲染

渲染 100 个 Todo 项,记录对应的耗时

  • mounted 耗时,指从组件 created 到 mounted 所花费的时间

  • 完整渲染耗时,指从组件 created 到渲染层上屏渲染完毕(图片 onload 触发)所花费的时间

目标 mounted 耗时 完整渲染耗时
taro-wx 141.3 (156%) 1457.4 (443%)
taro-alipay 141.1 (156%) 387.2 (177%)
taro-tt 148.5 (165%) 608.7 (185%)
taro-lark 140.8 (156%) 620.6 (188%)
dom-lark 89.9 (100%) 328.9 (100%)

列表组件内部更新

将 Todo 项内的每一个 checked 进行反转,记录耗时

目标 完整渲染耗时
taro-wx 1374.5 (2220%)
taro-alipay 138.7 (224%)
taro-tt 205.3 (331%)
taro-lark 231.7 (374%)
dom-lark 61.9 (100%)

体积比较

  • 核心逻辑,只包含样式、JS、XXML、SJS 的大小,不包含图片文件,json 等

  • 整包大小,包含图片资源、JS、CSS等整个包的大小

目标 核心逻辑 整包大小
taro-wx 308K (452%) 336K (323%)
taro-alipay 256K (376%) 276K (265%)
taro-tt 248K (364%) 272K (261%)
taro-lark 248K (364%) 272K (261%)
dom-lark 68K (100%) 104K (100%)

测试解读

  • 微信不知为何,性能表现非常差,我也不再倾向于继续和它进行对比,否则数据会非常夸张。

  • 采用 DOM 接口的开发方式,可以简化掉繁重的 Taro runtime 的实现,从而在速度和体积上获取一定的优势。

  • 相比飞书自身,各种情况下速度至少可以提高一倍以上,体积可以降低到原先的 30%。

  • 相比支付宝,体积上依旧保持优势,渲染速度上的优势略低一些,平均不超过 50% 的提升。但这个问题主要是飞书对内置组件(checkboxbutton)实现的性能较差,如果飞书对这部分进行优化后,相信可以获得更好的优势。

新框架会面临那些问题

虽然新架构的 DOM 接口在上面的测试和预期中会带来很多的优势,但这并不意味着一点问题都没有。

  • 新架构虽然保留了对 XXML 的兼容性,但依旧会产生一定的 Break Chagne,具体可以看飞书官方文档

  • 由于双线程的架构,在逻辑层模拟的那一层 DOM 无法实现任何和渲染结果相关的 API,例如 getComutedStyle 。还包括 querySelector 依旧要通过原先的异步方式获取。

  • 由于 DOM 接口某些能力实现难度较高,例如 innerHTML css parser 等,目前与这些相关的能力只有写入没有读取的能力,如果一定要读取,将无法保证一致性。

  • 目前样式还不支持动态插入,后续会支持在 <page> 或者 <head> 内动态插入 <style> 标签来实现。

  • 首屏性能。这个上文也说过,后续会有很多办法去优化和实现的,但优先级不高

对我来说,我觉得整体瑕不掩瑜,未来可期。

未来规划与设计

  • 目前新框架能力的开启和 DOM 接口的使用还在内测状态,对外的时间暂时无法确定

  • 未来 DOM 接口还有许多的优化方向,例如如何降低 DOM 指令的数量,降低一些重复字符串的发送等等

  • 由于 DOM 接口在一些非 Web 标准组件中和前端框架有一些冲突,例如 React 无法很好的支持 picker 这类需要针对某些属性设置一个对象的情况。后续会推出一些兼容手段去解决这些问题。

  • 主流的前端组件组(例如 antd/element/ud)都无法很好的运行在这个上面。一方面是因为这些组件库多多少少都依赖了前端的一些接口,而小程序没有;另一方面,组件库使用的也都是 Web 的标签元素,而不是小程序的组件。后续会尝试推出一定的解决方案去处理这个问题,能够让社区做较少的修改就可以兼容。

  • 会考虑以飞书官方的身份推出一套适配于 DOM 接口的主流前端组件库,根据情况会选择是否需要添加 WebComponent 能力的支持。

  • 未来飞书小程序的规划都是尽可能的向前端标准实现,未来可能会逐渐添加例如 fetch navigator 在内的 BOM 接口能力支持。

为什么要写这篇文章

  • 希望能促进小程序的前进。
    小程序业务上的成功并不是其可以恶心开发者的理由,也不应该放弃对其优化的决心。我希望我这一篇有点颠覆传统小程序风格的优化文章,能够探索更多的思路和想法。

  • 寻求合作。
    由于 DOM 接口的特性,需要更多社区的配合才能将这个发挥到极致,例如 Taro/Uniapp 等三方框架,Vant 等小程序原生组件库等等。

  • 沟通交流,拓展思路。
    希望能够得到更多人对这件事情的想法和思路,也希望能够得到更多的输入。

注释和共享

本文主要是以「飞书」小程序为准,兼容「微信」小程序,如果没有了解过「飞书」的同学,可以点击此处去官网了解

什么是 NFC

近距离无线通信(英语:Near-field communication,NFC),又简称近距离通信近场通信,是一套通信协议,让两个电子设备(其中一个通常是移动设备,例如智能手机)在相距几厘米之内进行通信。

近场通信技术由非接触式射频识别(RFID)演变而来,由飞利浦半导体(现恩智浦半导体,缩写 NXP)、诺基亚和索尼共同于2004年研制开发,其基础是RFID及互连技术。近场通信是一种短距高频的无线电技术,在13.56MHz频率运行于20厘米距离内。其传输速度有106 Kbit/秒、212 Kbit/秒或者424 Kbit/秒三种。

NFC 其实在刚诞生的时候我就一直在关注,但是不仅仅应用少,而且搭载的设备也少,甚至小米还出现过前一代搭载 NFC 后一代却不搭载的神奇情况。除此之外,使用起来也是特别复杂,想当初,要用 NFC 去实现刷公交卡,你需要去换一个特殊的 SIM 卡才能够支持(当初不理解,现在想来大概率是因为安全问题)。

在现在,随着安卓厂商的不懈努力,现在不论是应用还是设备的安装率都已经逐渐普及开来。从最初 NFC 也就能在支付宝中扫银行卡快速输入卡号,到现在的公交刷卡、X Pay,甚至传输文件,华为甚至给这个东西换了个名字叫做一碰系统(率感无语)。

除此之外,还能推进智能化的发展。比如以后家庭中加入了一个新设备,那么不再需要繁杂的联网过程,直接扫一下机器身上的 NFC 识别码就可以直接将设备加入到家庭网络中。或者说华为路由器上的一碰连接 Wifi 我觉得就是一个极好的应用。当家里来客人的时候,就不再需要一个人一个人的输入密码了。

NFC 技术一览

运行模式

NFC 现在主要有三种运行模式,分别是卡模拟模式(Card Emulation Mode)、主机模拟模式(Host Emulation Mode)、读卡写卡模式(Reader/Writer Mode)、P2P 模式(P2P Mode)

卡模拟模式

  • NFC手机可以模拟成为一张非接触卡,通过 POS 机(非接触读卡器)的 RF 来供电,即使 NFC 手机没电也可以工作。

  • 现在很常见的比如 Apple Pay,BYD NFC 钥匙,都是能够实现在断电情况下的刷卡

主机模拟模式

  • 该模式与卡模拟模式很类似,只不过卡模拟无需供电或者说无需 App 的参与就可以完成,但是主机模拟模式是不行的,他是通过将所有的消息转发给应用,由应用去决定该模拟什么内容,也就说该返回什么内容

  • 现阶段很多支付钱包,比如云闪付、京东闪付等等都是通过该模式实现的。

读卡写卡模式

  • NFC手机可以通过触碰NFC标签(Tag),从中读取非接触标签中的内容,采集数据并发送到对应的应用进行处理。

  • 最常见的应用其实就是华为的一碰系列,除此之外,支付宝支持直接读取信用卡、储蓄卡的卡号。

P2P 模式

  • 两个NFC设备可以近距离内互相连接,直接传递数据,实现点对点数据传输。

  • 例如协助快速建立蓝牙连接、交换手机名片和数据通信等。

  • 最常见的是手机互传、Android Beam。

协议标准类型

因为 NFC 的发展过程的原因,曾经出现过多个协议,甚至每家公司都有不同的协议内容。但现在主要是有一下几个协议标准:

ISO / IEC

主要定义了一下几个协议:

  • ISO/IEC 18092 / ECMA-340— (NFCIP-1)

    Near Field Communication Interface and Protocol-1

  • ISO/IEC 21481 / ECMA-352— (NFCIP-2)

    Near Field Communication Interface and Protocol-2

除此以外,还有一个协议标准很常用,是 ISO-14443 协议,其实这个协议是 RFID 的协议,和上面的唯一区别就是上面的多了一些其他模式的标准,比如点对点模式。

ISO-14443 协议有两个子类,分别是 Type-A 和 Type-B,这两个在 Android 也被称为 NFC-A 和 NFC-B。

但不幸的是,这些协议也不能免费看,要花钱的

NFC Forum

NFC Forum 是一个在 2004 年创建的非盈利行业协会,其成员来自NFC生态系统的各个部分。另外我主要关注了下国内公司,比较知名的有小米、中国移动通讯。

但是你想要从该组织获取任何关于 NFC 相关的技术标准,首先你的公司要成为该组织的成员才行,因为字节根本不在该组织,所以没法从这里获得一手的信息。

不过办法也是有的,该协会的创办者 NXP 公司网站上是有相关的数据资源,后文的参考此资源。

其他

不用管.jpg

沟通协议

以 NFC-A 为例

整个 NFC 卡片其实内部就是一个有限状态机,根据当前不同的状态需要不同的操作。这个图看起来很复杂,其实主要额外包含了两个操作:

  • 密码校验

    • 这个是说 NFC 卡是经过加密的,只有在密码校验通过之后,才能够进行相关的操作。

    • 有一点特殊的是,NFC 的密码长度其实是固定的,即 32 位,4 个字节。

  • 防冲突

    • 之所有有这个设计,是因为在使用过程中,可能会出现同时扫描到多个 NFC 设备的情况,此时就需要通过 READY1/READY2 两个状态来选择正确的 NFC 设备进行操作。

    • 每个卡片都有一个唯一 UID,长度为 7 字节,而每次操作只能选择 4 个字节,所以不得不拆分成两个状态两步去操作。

当没有上面两个操作的时候,可以简单的执行 IDLE -> ACTIVE -> HALT 的状态流程,也就是说连接、操作(也就是读写)、关闭。

存储设计

NFC 在存储上设计了页的概念,一个页表示 4 个字节,以页为最小单位进行操作。所以 NFC 卡片的存储容量其实都是 4 的整数倍。

这里以 NTAG213 180 字节的存储结构为例

这里只需要关注两点:

  • 用户数据存储的空间是从第四页开始

  • 用户可存储空间其实只有 144 字节

只需要记住这两点,在开发 NFC 需求的时候,不要去修改非用户空间的数据,不要存储过长的内容。

设备准备

在有了上面的基础之后,别急你还是不能开始开发 NFC,因为你还缺少至关重要的一个东西,设备

遗憾的是,不是所有的设备都有 NFC 硬件的,也不是说有了 NFC 硬件就能用的

  • 苹果设备只有升级到 iOS 13 以上才能具有开发 NFC 读卡器的能力,不能写入,除此之外,几乎没有其他的 NFC 能力可以使用。机圈也会叫做阉割版 NFC。(暂时没有能力去调研 NFC 的能力一定是需要硬件支持还是说只是软件限制)

  • 安卓设备理论上可以使用几乎所有的 NFC 能力,机圈内叫全功能 NFC,包括读卡、写卡、卡模拟、P2P 等模式。但是不同的手机有着不同的操作系统的限制,所以要选择一个合适的操作系统(原生安卓、类原生安卓是最推荐的)。

所以,请准备好一台安卓手机!

NFC 卡片

在有了设备之后,还要选择正确的 NFC 卡片,因为不是任意一个 NFC 卡片都是可以用的,比如工牌、银行卡等等。这是因为 NFC 卡片是带有加密的,在操作之前必须要通过验证才能操作,所以建议去淘宝买一些可读可写无加密的 NFC 贴纸用于测试。

不过你在淘宝上买的可能是写着 NTAG213 的型号,其实这个是 NXP 出的一款设备,但是支持兼容 ISO NFC-A 协议以及 NFC Forum Type 2 协议,所以大家可以放心使用。

当然了,你也可能看到 NTAG215/NTAG216,这两个都没有任何区别,只是存储空间不同而已。

小程序 API

此时在有了上述的基础知识后,别急,还要了解下小程序的 API 才能更好的开发。

就目前来说,所有与 NFC 相关的操作都被封装到了 NfcAdaptar 类中,通过 tt.getNFCAdaptar() 获取 nfcAfaptar 对象。

具体的 API 参数细节请参考「飞书开放平台

NFC 整体流程

  • 注册 NFC 发现事件回调 nfcAdaptar.onDiscovered

  • 开启 NFC 扫描 nfcAdaptar.startDiscovered

  • NFC 卡片贴近设备

  • 触发回调,通过回调可以获得 NFC 支持的协议

    • 回调参数内的 techs 字段可以用于判断当前卡片支持的协议
  • 根据协议去读写 NFC 卡片内容 nfcAdaptar.getNfcA()

  • 关闭 NFC 扫描,关闭事件监听 nfcAdaptar.offDiscovered / nfcAdaptar.stopDiscovered()

可以发现这个流程非常容易理解,也非常容易操作。那么接下来我们看下重头戏

读写 NFC 卡片

这里以 NFC-A 协议为主

通过 nfcAdaptar.getNfcA() 获取操作 NFC-A 卡片的操作类实例 nfca,流程如下

  • 连接卡片 nfca.connect()

  • 读写卡片 nfca.transceive()

  • 读写完成之后关闭连接 nfca.close()

关键点来了,NFC 卡片的读写不和其他的 IO 设备类似,有专门的 read 和 write 函数。对 NFC 来说,通过给 NFC 卡片发送不同的指令来做到完成不用的操作。

这些指令都在 NTAG213 文档中有写,这里简单列一下常用的数据

命令 代码 功能 参数
Read 0x30 一次读取四个页的数据 <Addr:1B>
Write 0xA2 一次写入一个页的数据 <Addr:1B> <Data:4B>
Fast Read 0x3A 一次读取多个页的数据 <StartAddr:1B> <StopAddr:1B>

比如我要读取第四页的数据,可以写如下代码

1
2
3
4
5
6
7
nfca.transceive({
data: new Uint8Array([0x30, 0x04]).buffer, // 必须要传入 ArrayBuffer
success: (res) => {
// res.data 是 ArrayBuffer,转成数组方便查看
console.log(Array.from(new Uint8Array(res.data))
}
})

根据协议,其实还要传 CRC:2B,也就是校验位,不过这个操作已经由 Android 去做掉了,所以就不需要传了,也不需要去了解校验算法

结语

至此,NFC 开发算是入门了,不过这里要注意不同的 NFC 卡片不同的协议会有不同的读写方式,这里要根据你们各自具体的卡片来看。而且有的还有密码保护,还需要额外再走校验的逻辑。

Refs

注释和共享

  • 第 1 页 共 1 页

XGHeaven

一个弱弱的码农


杭州电子科技大学学生一枚


Weifang Shandong, China