你真的了解Emoji吗?Emoji全貌大揭秘

前言

随着科技发展,智能手机的普及,Emoji已经融入到了我们的生活中,但每天使用Emoji的你真的清楚它是什么,是由什么东西组成的,和普通的字符有什么区别吗?本文就从技术的角度带你揭秘Emoji的全貌。

起因

最近项目里有一个AI聊天机器人,你可以向他提问,他会以流式打印的形式一字一字的将回答呈现给用户。在这过程中我就发现,每当打印到Emoji的时候,总会先出现一个问号形状的乱码,然后才能显示出Emoji,有的Emoji更奇特,以👨‍👩‍👧‍👦为例,在打印过程中会依此显示👨👩👧👦四个Emoji,最后突然啪的一下,合成一整个👨‍👩‍👧‍👦,是不是很神奇?这个现象引起了我的好奇,于是我开始翻阅资料,揭开Emoji的神秘面纱。

Emoji的起源及发展

Emoji来自日语词汇”絵文字”(假名为“えもじ”,读音即 emoji),绘指图画,文字指字符,最早由栗田穰崇(Shigetaka Kurita)创作,设计灵感源于天气预报图标、汉字、漫画和路标等,最初的Emoji有176个,都是12 x 12像素的图片。

1999年,日本通讯运营商DOCOMO公司发布了在当时具有跨时代意义的iMode手机,最早的Emoji便搭载于其中。

Emoji一经诞生,人们便发现这些形象的Emoji实在是太好用了,不仅方便,还能使聊天过程更加有趣,随即便立刻被日本各大科技公司注意到,日本的三大运营商开始把Emoji加入到自己的短信业务中,很快便横扫了全日本,但为了打击竞争对手,各大运营商都使用自己的Emoji标准。这导致了不同运营商的手机无法正常显示对方手机发的Emoji

苹果是Emoji传遍全球的最大功臣。为了把iPhone打入日本市场,苹果决定在iOS 2.2中加入日本消费者的最爱emoji,为了迎合日本市场,他们在3个月的时间推出了400多个表情符号,极大地拓展了Emoji的表情数量,那时的iOS Emoji只在日本地区可用,但“好景不长”,北美的iOS 2.2用户发现了隐藏在系统中的Emoji,之后Emoji很快流行了起来,这种现象得到了其它科技公司的注意。

随着Emoji的流行,2010年,Emoji 首次被纳入Unicode v6.0字符集中。每个字符(表情)都被设定了统一且唯一的二进制码,从而保障了各平台手机都能使用Emoji,截止撰文期间,最新的Unicode v15.1字符集中已有3782个Emoji字符,而更新的Unicode v16版本预计于2024年9月发布release,届时会有更多的Emoji被支持。

Unicode

既然EmojiUnicode所收纳,那我们必先得去了解Unicode

广义上的Unicode是一个标准,定义了Unicode字符集以及一系列的编码规则,是一种收录了世界上所有语言的文字和符号的全球标准。

那么Unicode是怎样收录如此庞大的字符内容呢?很简单,给每个字符指定一个编号就行了,在Unicode中被称为码点CodePoint),它的表现形式为U+后面跟上一个十六进制数,比如U+0041表示大写字母A

世界上有那么多字符,Unicode并不是一次性定义的,而是分区定义,每个区可以存放 65536 (2^16) 个字符,称为一个平面(Plane),目前Unicode从第0平面到第16平面总共有17个平面,其中第0平面被称为基本平面(BMP),它的码点范围从0一直到65535,写成十六进制也就是 U+0000 - U+FFFF ,所有的常见字符都被放在这个平面,这是Unicode最先定义和公布的一个平面,而剩下的平面被称为辅助平面,码点范围从 U+010000 一直到 U+10FFFF

UTF-16

Unicode字符集只规定了每个字符的码点,但这一个个码点应该被计算机传输识别呢?这就涉及到编码的概念了,目前Unicode实际应用使用的编码方式为UCS-2,也就是每个字符占用2个字节,Unicode还有一种4字节的编码方式UCS-4,但这里不做讨论。

使用UCS-2编码方式包含65536个字符空间(2个字节的可用空间即为2^16),对应着表示着Unicode字符集中的基本平面,那剩余的辅助平面又该如何表示呢?UTF-16应运而生。

UTF-16UCS-2的超集,是一种变长编码,它的编码规则很简单:基本平面的字符占用2个字节,辅助平面的字符占用4个字节,也就是说UTF-16的编码长度要么是2个字节(U+0000-U+FFFF),要么是4个字节(U+010000-U+10FFFF)。

那么问题来了,采用UTF-16编码的时候,我们该怎么判断这个字符占用的是2个字节还是4个字节呢?这里有个巧妙的方式,在Unicode的基本平面中,从U+D800U+DFFF是一个空段,即这些码点不对应任何字符,因此,这个空段可以用来映射辅助平面的字符。辅助平面的字符一共有2^20个(一个平面2^16个字符 * 16个平面(2^4)),因此表示这些字符至少需要20个二进制位。UTF-16将这20个二进制位分成两半,前10位映射在U+D800U+DBFFUTF-16的高半区,空间大小2^10),称为高位(H),后10位映射在U+DC00U+DFFFUTF-16的低半区,空间大小2^10),称为低位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。

因此,每当程序遇到2个字节时,便会去判断它的码元是否在U+D800U+DBFF之间,如果在的话则可以假定它是一个4字节的字符,此时接着往后读2个字节,如果这2个字节的的码元在U+DC00U+DFFF之间,将他们组合起来获得到实际字符;而如果不在的话则可以判定为是一个2字节的字符。

我们以Java中获取码点的方法Character.codePointAt为例来解读一下代码中如何获取一个字符的码点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public final
class Character implements java.io.Serializable, Comparable<Character> {

// ...

public static final char MIN_HIGH_SURROGATE = '\uD800';
public static final char MAX_HIGH_SURROGATE = '\uDBFF';
public static final char MIN_LOW_SURROGATE = '\uDC00';
public static final char MAX_LOW_SURROGATE = '\uDFFF';

public static final int MIN_SUPPLEMENTARY_CODE_POINT = 0x010000;

// ...

public static int codePointAt(CharSequence seq, int index) {
char c1 = seq.charAt(index);
// 码元在 U+D800 到 U+DBFF 之间,并且下一个char的index没到结尾
if (isHighSurrogate(c1) && ++index < seq.length()) {
char c2 = seq.charAt(index);
// 下一个char的码元在 U+DC00 到 U+DFFF 之间
if (isLowSurrogate(c2)) {
// 组合起来获得完整字符的码点
return toCodePoint(c1, c2);
}
}
// 字符码点即是单个char的码元
return c1;
}

// 码元是否在 U+D800 到 U+DBFF 之间
public static boolean isHighSurrogate(char ch) {
// Help VM constant-fold; MAX_HIGH_SURROGATE + 1 == MIN_LOW_SURROGATE
return ch >= MIN_HIGH_SURROGATE && ch < (MAX_HIGH_SURROGATE + 1);
}

// 码元是否在 U+DC00 到 U+DFFF 之间
public static boolean isLowSurrogate(char ch) {
return ch >= MIN_LOW_SURROGATE && ch < (MAX_LOW_SURROGATE + 1);
}

/**
* 计算规则:
* 1. 高位上的码元减掉高半区的起始值 0xD800 ,然后左移10位
* 2. 低位上的码元减掉低半区的起始值 0xDC00
* 3. 将 1 和 2 的计算结果以及辅助平面的起始值 0x010000 相加,获取到完整的码点值
*/
public static int toCodePoint(char high, char low) {
// Optimized form of:
// return ((high - MIN_HIGH_SURROGATE) << 10)
// + (low - MIN_LOW_SURROGATE)
// + MIN_SUPPLEMENTARY_CODE_POINT;
return ((high << 10) + low) + (MIN_SUPPLEMENTARY_CODE_POINT
- (MIN_HIGH_SURROGATE << 10)
- MIN_LOW_SURROGATE);
}

// ...

}

Emoji规则

了解了Unicode标准后,我们回过头来思考一下,是不是说EmojiUnicode标准中也仅仅只是被当成普通的字符看待呢?当然并非如此,除了之前说的平面规则等,EmojiUnicode中还有一套自己的规则,这些规则都可以在 Unicode 技术标准 #51 Emoji 中找到,官方的文档乍一看可能比较难理解,接下来就由我来给大家做一个解读。

首先,Emoji在大类上可以分成两种,一种是基本Emoji,一种是多字符组合而成的复合Emoji

基本Emoji

什么是基本Emoji呢?指的是直接在Unicode字符集里定义的一个Emoji字符,大多数基本Emoji字符都被划归到U+1F300-U+1F6FFU+1F900-U+1FAFF这两个区域

基本Emoji U+1F300-U+1F6FF

基本Emoji U+1F900-U+1FAFF

具体都有哪些基本Emoji字符,我们可以在Unicode的官网文档 emoji-data 中找到

复合Emoji

所谓的复合Emoji(我自己取的名字)指的是由多个字符组成的Emoji,它有着多种构造方式

Unicode 技术标准 #51 Emoji 中的1.4.9小节中,我们可以找到UnicodeEmoji定义的正则表达式,接下来我们就通过对这个正则表达式进行一步步的解析来了解Emoji的组成规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
\p{RI} \p{RI} 
| \p{Emoji}
( \p{EMod}
| \x{FE0F} \x{20E3}?
| [\x{E0020}-\x{E007E}]+ \x{E007F}
)?
(\x{200D}
( \p{RI} \p{RI}
| \p{Emoji}
( \p{EMod}
| \x{FE0F} \x{20E3}?
| [\x{E0020}-\x{E007E}]+ \x{E007F}
)?
)
)*

旗帜

首先我们看第一行的匹配条件\p{RI} \p{RI},这里的\p{RI}全称为Regional Indicator,翻译成中文就是区域指示符,根据这行正则我们可以了解到,两个区域指示符连接便可组成一个Emoji,那么这个区域指示符是什么呢?

通过 维基百科 我们可以得知,区域指示符指的是从U+1F1E6U+1F1FF中的字符,位于Unicode第一辅助平面的带圈字母数字补充区块内。

正如区块描述所说,这些字符看起来就像是一个个英文字母,外面套了个方框,这里是完整的字符表:🇦 🇧 🇨 🇩 🇪 🇫 🇬 🇭 🇮 🇯 🇰 🇱 🇲 🇳 🇴 🇵 🇶 🇷 🇸 🇹 🇺 🇻 🇼 🇽 🇾 🇿,通过两两组合的方式,将它们拼成国家或地区的代号,我们就能得到该国家或地区的旗帜。

以中国🇨🇳举例,中国的代号为CN,那我们就将🇨和🇳两个字符拼接到一起,便能得到中国国旗🇨🇳

肤色修饰符

第二行的\p{Emoji}指的就是我们之前说过的基本Emoji,这个是构成除旗帜外的复合Emoji的基础条件,接着我们看正则的第三到第六行,这里用括号括起了一个条件,括号的尾部跟了一个问号,表示括号中的这个条件最多只可以出现一次(0次或1次),括号内的条件又是由三个子条件组成,用或号分割,满足任意一条条件则视为整个条件成立,我们首先看第一个子条件\p{EMod}

全世界的人们都希望拥有反映更多人类多样性的Emoji,尤其是对于肤色。Unicode v8.0(2015年中)发行了五个为人类表情符号提供一系列肤色的符号修饰符符,具体的修饰符以及效果由下图所示:

肤色修饰符

我们以基本Emoji✋为例,它的码点是U+270B,在他后面加上U+1F3FB,这个Emoji就变成了✋🏻,同样的:

  • ✋ + U+1F3FC = ✋🏼
  • ✋ + U+1F3FD = ✋🏽
  • ✋ + U+1F3FE = ✋🏾
  • ✋ + U+1F3FF = ✋🏿

变体选择符

接着,我们再看第二个子条件x{FE0F} \x{20E3}?,这里的x{FE0F}指的是变体选择符-16Variation Selector-16),那么首先,什么是变体选择符呢?

实际上,支持象形文字的字体最早可以追溯到1993年,我们可以看一下Unicode字符集的装饰符号区U+2700-U+27FF

装饰符号区

那如果Emoji想要在这些象形文字的基础上做扩展,添加颜色,使其更加生动怎么办?没错,此时就需要使用到变体选择符了。

变体选择符(简称VS)是一个基本多文种平面的Unicode区段,包括16个变体选择符。这些选择器用于描述前一个字符的特点字形。目前 Unicode已定义数学符号、绘文字、八思巴字母及中日韩统一表意文字所对应的中日韩兼容表意文字。目前Unicode仅定义 VS1, VS2, VS3, VS15 及 VS16,VS15 和 VS16 分别用于标示某字符应该显示为普通文字或者是Emoji,这些字符被命名为U+FE00(VS1)至U+FE0F(VS16)。选择符仅应用于前一个字符。

以刚才我们在装饰符号区中看到的剪刀符号✂U+2702为例,在它的后面加上VS16 U+FE0F,这个字符就变成了Emoji✂️,是不是很神奇

键帽符

看完了变体选择符后,我们紧接着会疑惑,那这个条件后面的\x{20E3}又是啥呢?它被称为COMBINING ENCLOSING KEYCAP,它对前置的字符有一定的要求,只对数字、星号和井号生效,也就是说仅仅支持*#0123456789这12个字符。

它的规则是,当开头为这12个字符中的一个时,后面加上VS16 U+FE0F变成一个Emoji,然后在加上它U+20E3,这个字符就会变成一个键帽形状的字符:#️⃣ *️⃣ 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣

标签序列

然后是最后一个子条件[\x{E0020}-\x{E007E}]+ \x{E007F},这是一个标签序列,它由一个基础黑旗符号,一系列标签字符以及一个标签终止符组成,首先以基础黑旗符号🏴U+1F3F4开头,然后中间是一系列的U+E0020U+E007E之间的字符,最后以标签终止符U+E007F结尾,这样就组成了一个标签序列Emoji

目前这种Emoji不太常见,仅仅只有英格兰、苏格兰和威尔士的旗帜使用标签序列:

  • 🏴 + U+E0067 + U+E0062 + U+E0065 + U+E006E + U+E0067 + U+E007F = 🏴󠁧󠁢󠁥󠁮󠁧󠁿
  • 🏴 + U+E0067 + U+E0062 + U+E0073 + U+E0063 + U+E0074 + U+E007F = 🏴󠁧󠁢󠁳󠁣󠁴󠁿
  • 🏴 + U+E0067 + U+E0062 + U+E0077 + U+E006C + U+E0073 + U+E007F = 🏴󠁧󠁢󠁷󠁬󠁳󠁿

零宽度连接符

这些子条件看完,我们再回归到正则表达式中来,可以观察到8到14行的条件和1到6行的条件其实是完全一样的,而在第7行出现了一个条件\x{200D}连接了这两个一样的条件,什么意思呢?

没错,结合本文的起因部分,我们很容易的就可以联想到,这个\x{200D}起到的就是连接作用,它被称为零宽度连接符(ZERO-WIDTH JOINER,简称ZWJ),通过上面正则表达式尾部的*号我们可以得知,通过这个连接符,可以连接多个Emoji合成新的Emoji,下面举几个有趣的例子:

  • 👩U+1F469 + U+200D + ✈️U+2708 U+FE0F = 👩‍✈️
  • 👨U+1F468 + U+200D + 💻U+1F4BB = 👨‍💻
  • 🐻U+1F43B + U+200D + ❄️U+2744 U+FE0F = 🐻‍❄️
  • 🏴U+1F3F4 + U+200D + ☠️U+2620 U+FE0F = 🏴‍☠️
  • 🏳️U+1F3F3 U+FE0F + U+200D + 🌈U+1F308 = 🏳️‍🌈

以上是由两个Emoji组成一个新的Emoji的例子,而家庭以及人际关系相关的Emoji通常会由更多Emoji构成,就拿本文开头提到的例子👨‍👩‍👧‍👦,它的构成实际上是这样的:

👨U+1F468 + U+200D + 👩U+1F469 + 👧U+1F467 + 👦U+1F466 = 👨‍👩‍👧‍👦

这也就解释了在AI聊天机器人流式打印文字的时候,为什么会依此显示👨👩👧👦四个Emoji,最后突然合成一整个👨‍👩‍👧‍👦了

Emoji字体

以上便是Emoji构成的所有规则了,但你有没有考虑过,一般字体都是黑白的矢量图形,为什么Emoji会显示成图片呢?

操作系统一般都会内置一种Emoji字体,MacOS/iOS内置的是Apple Color Emoji字体,Windows内置的是Segoe UI Emoji字体,Android内置的是Noto Color Emoji字体。这也是同一个Emoji再不同的设备上长得不一样的原因,除此之外,很多应用也会自带Emoji字体,比如WhatsAppTwitterFacebook

Apple Color Emoji

不同平台下的Emoji样式

回归初心,如何解决流式打印问题

最后,让我们回到本文的出发点,了解了Emoji机制后,我们该如何解决AI聊天机器人的流式打印问题呢?其实很简单,根据字符串向后做一个预测就可以了,啥叫预测?就是往后匹配,看这个字符的结构当前是否符合Emoji规则,以及加上后面的字符后有没有可能组成一个完整的Emoji,这里具体的代码我就不放了,大家自行感悟吧😊

参考文献

工具