谈谈 Emoji 和字符编码

1,720 阅读5分钟

字符编码是计算机原理中一个很重要的一环,然而它里面有很多概念容易混淆,本文就来结合 Emoji 谈谈 Unicode 和它的实现方案们。

当我们谈论 Unicode 时,我们在说什么?

字符编码有很多国际标准,例如 ASCII、ANSI(包括 GBK)、Unicode,这些不同的编码方式说白了就是一个 HashMap(严格来讲应该是一个稀疏数组,因为数字本身不涉及 Hash 和碰撞的问题),K 就是编码,V 就是对应的单个字符。众所周知,ASCII 编码根本不能包含国际上所有的字符,因为它 K 的取值还不过 8 bits。ANSI、ASCII 不在我们今天的讨论范围内。

下面我们说说 Unicode。

可以说,Unicode 几乎可以覆盖国际上所有的文字了,它是一张巨大的表,K 的范围从 0x0 - 0x10FFFF,理论上能容纳 1114112 个字符,已经上百万的数量级了。所以只要一个文本采用 Unicode 编码,那么它的每个字符的编码都应该落在这 0x0 - 0x10FFFF 的区间内了,并且一一对应。

由于 Unicode 太庞大了,因此人们用 Plane(平面)的概念来给这些编码划分区间,这个概念大家可以自行查询,后面我会继续提到它。

所以 UTF-x 都是什么?

我们通常说的 UTF-8、UTF-16 亦或是 UTF-32 是 Unicode 这种编码在计算机中的存储方式。

我们可以看到,Unicode 中最大的取值是 0x10FFFF,占 3 个字节,那么这些字节如何存储就是一个问题了。

UTF-16

通常我们说 Unicode 编码的时候,其实是在说 UTF-16。Windows 内核、Java、Objective-C (Foundation)、JavaScript 中都会将字符的基本单元定为两个字节的数据类型,也就是我们在 C / C++ 中遇到的 wchar_t 类型或 Java 中的 char 类型等等,这些类型占内存两个字节,因为 Unicode 中常用的字符都处于 0x0 - 0xFFFF 的范围之内,因此两个字节几乎可以覆盖大部分的常用字符。

然而我们总需要用到一些不常用的字符,就比如 Emoji。这些字符落在一个名为 Astral 的平面内,总之是超过了 0xFFFF,那么我们如何用两个字节来表示呢?这里肯定不能用两个字节表示了。所以 UTF-16 做了这个的规定:

高位字节为 0b110110 或 0b110111 的字元需要两两配对共同表达一个处于 0x10000 - 0x10FFFF 的字符。

0x10000 - 0x10FFFF 之间有 0xFFFFF 个字符,20 bits。0xD800 和 0xDC00 都有 10 bits 以上的空余空间,所以我们可以将超过 0xFFFF 的编码减掉 0x10000,将结果的 20 bits 中的前 10 bits 与 0xD800 做 OR 运算,将后 10 bits 与 0xDC00 做 OR 运算。这样,解析的时候如果高位字节是 0b110110,那么取其低位 10 bits,存起来,再读取下一个字元,取其低位 10 bits,两个结合起来再加上 0x10000 就能还原出来响应的编码了。

说得比较干涩,举个🌰:

'😂'.charCodeAt(0) // 55357
'😂'.charCodeAt(1) // 56834

55357 二进制 1101 1000 0011 1101,高位有 0b110110,剩下的 10 个低位是 0b0000111101。

56834 二进制 1101 1110 0000 0010,高位有 0b110111,剩下的 10 个低位是 0b1000000010。

两个合在一起再加 0x10000 = 0x1F602,正好是 😂 这个表情的 Unicode 编码。

到这里你应该能理解 UTF-16 了吧。

UTF-8

这里简单提一下,详细的大家可以自己查。

UTF-8 巧妙借助了一个字节内的高位作为标志位,动态调整一个字元的长度。

最高位为 0,这表示这个字元占一个字节,编码为剩余的 7 bits。

最高位为 0b110,代表这个字元占两个字节,接下来的字节高位均为 0b10,要取剩余的 6 bits。

以此类推...

UTF-32

这个比较简单了,就是暴力且奢侈地用 4 个字节来直接存储所有的编码。

其实别看 UTF-32 这么不中用,其实用它来统计文字个数是非常简单的。😂用 UTF-16 表示为两个字节,用 UTF-8 表示是四个字节,因此对于 Foundation 中的 NSString、JavaScript 中的原生 String 等采用 UTF-16 存储文字的容器,😂的 length 就是 2,显然是不正确的,但假如你把它转换成 UTF-32,length 除以 4 就是 1 了。但是编码转换还是有一定开销的,其实结合上面说到的,我们完全可以自己写一个 walker 来精确统计一个固定编码下的字符个数到底是多少个,相信大家不难实现。

事实上,Swift 中的 CharacterView 就隐藏了编码细节,将字符作为基本单元,这样当你调用其 count 属性时,返回的就是我们所看到的字符的个数。

Emoji 好像有点复杂

事实上 Swift 做了更多。

很多 Emoji 其实不是一个 Unicode 字元能表示的,比如 ❤️,这玩意就算转换成了 UTF-32 也占 8 个字节,除以 4 是 2,两个字符??❤️ 其实是由 ❤ 和一个样式控制符组成的,当文字渲染引擎读到这样的字符组合时就会去 Emoji 字体库找 ❤️来渲染,所以对于这类 Emoji 处理起来也要特殊考虑。

还有更变态的是 👩‍❤️‍💋‍👩,这货是 6 个字符组成的,里面用到了零宽字符将几个表情连接起来,最后就能作为一个字符渲染了。


关于字数统计的方法,本文也只是抛砖引玉,就不做完整的实现了。

就这样吧。


^C