前言
随着科技发展,智能手机的普及,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
既然Emoji 被Unicode 所收纳,那我们必先得去了解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-16 是UCS-2 的超集,是一种变长编码,它的编码规则很简单:基本平面的字符占用2个字节,辅助平面的字符占用4个字节,也就是说UTF-16 的编码长度要么是2个字节(U+0000 -U+FFFF ),要么是4个字节(U+010000 -U+10FFFF )。
那么问题来了,采用UTF-16 编码的时候,我们该怎么判断这个字符占用的是2个字节还是4个字节呢?这里有个巧妙的方式,在Unicode 的基本平面中,从U+D800 到U+DFFF 是一个空段,即这些码点不对应任何字符,因此,这个空段可以用来映射辅助平面的字符。辅助平面的字符一共有2^20 个(一个平面2^16 个字符 * 16个平面(2^4 )),因此表示这些字符至少需要20个二进制位。UTF-16 将这20个二进制位分成两半,前10位映射在U+D800 到U+DBFF (UTF-16 的高半区,空间大小2^10 ),称为高位(H),后10位映射在U+DC00 到U+DFFF (UTF-16 的低半区,空间大小2^10 ),称为低位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。
因此,每当程序遇到2个字节时,便会去判断它的码元是否在U+D800 到U+DBFF 之间,如果在的话则可以假定它是一个4字节的字符,此时接着往后读2个字节,如果这2个字节的的码元在U+DC00 到U+DFFF 之间,将他们组合起来获得到实际字符;而如果不在的话则可以判定为是一个2字节的字符。
我们以Java 中获取码点的方法Character.codePointAt 为例来解读一下代码中如何获取一个字符的码点:
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 标准后,我们回过头来思考一下,是不是说Emoji 在Unicode 标准中也仅仅只是被当成普通的字符看待呢?当然并非如此,除了之前说的平面规则等,Emoji 在Unicode 中还有一套自己的规则,这些规则都可以在 Unicode 技术标准 #51 Emoji 中找到,官方的文档乍一看可能比较难理解,接下来就由我来给大家做一个解读。
首先,Emoji 在大类上可以分成两种,一种是基本Emoji ,一种是多字符组合而成的复合Emoji
基本Emoji
什么是基本Emoji 呢?指的是直接在Unicode 字符集里定义的一个Emoji 字符,大多数基本Emoji 字符都被划归到U+1F300 -U+1F6FF 和U+1F900 -U+1FAFF 这两个区域


具体都有哪些基本Emoji 字符,我们可以在Unicode 的官网文档 emoji-data 中找到
复合Emoji
所谓的复合Emoji (我自己取的名字)指的是由多个字符组成的Emoji ,它有着多种构造方式
在 Unicode 技术标准 #51 Emoji 中的1.4.9 小节中,我们可以找到Unicode 对Emoji 定义的正则表达式,接下来我们就通过对这个正则表达式进行一步步的解析来了解Emoji 的组成规则
\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+1F1E6 到U+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} 指的是变体选择符-16 (Variation 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+E0020 到U+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 字体,比如WhatsApp 、Twitter 和Facebook


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