iOS 程序员眼中的 Emoji

8,122 阅读16分钟

Emoji 简介

绘文字(日语:絵文字/えもじ emoji)是日本在无线通信中所使用的视觉情感符号,绘指图画,文字指的则是字符,可用来代表多种表情,如笑脸表示笑、蛋糕表示食物等。在中国大陆,emoji通常叫做“小黄脸”,或者直称emoji 在NTTDoCoMo的i-mode系统电话系统中,绘文字的尺寸是12x12 像素,在传送时,一个图形有2个字节。Unicode编码为E63E到E757,而在Shift-JIS编码则是从F89F到F9FC。基本的绘文字共有176个符号,在C-HTML4.0的编程语言中,则另增添了76个情感符号。 最早由栗田穰崇(Shigetaka Kurita)创作,并在日本网络及手机用户中流行。 自苹果公司发布的iOS 5输入法中加入了emoji后,这种表情符号开始席卷全球,目前emoji已被大多数现代计算机系统所兼容的Unicode编码采纳,普遍应用于各种手机短信和社交网络中。

以上引用来自百度百科,提到“一个图形有2个字节,Unicode 编码范围为E63E到E757”。但人的创造性是无穷的,限定的区域无法满足人们表达的欲望。所以 Emoji 并不限定于2个字节,人类针对这个问题制定了越来越多的规则。

但限定的规则总是伴随着两个问题——兼容性以及扩展性,如何过滤掉不支持的 Emoji,如何扩展更多的 Emoji。

核心问题就是 Emoji 编码规则是怎样的

Emoji 编码

MAC 下查看 Unicode 编码 和 UTF-8 编码

按 ctrl + cmd + 空格,展示 Emoji 键盘,点击右上角。

点击左上角设置 - 自定列表。

选中 Unicode 。

现在我们就可以选中 Emoji 查看 Unicode 和 UTF-8 码。

可以看到这个狗东西,Unicode 书写成 U+1F436,UTF-8 占用了四个字节。

如果你点多几个 Emoji 来看,会发现事情并不简单。

中国国旗占了两个 Unicode代码块,UTF-8 占了八个字节。

gay 里 gay 气的 Emoji UTF-8 居然占了...不想数,Unicode 的代码点(后面会提到这概念) 也不止一个。

更有趣的是,晒黑后字节数也不一样。

那 Unicode 和 UTF-8 是什么呢?要了解这个问题,首先要追溯到 ASCII。

ASCII

ASCII ((American Standard Code for Information Interchange): 美国信息交换标准代码)是基于拉丁字母的一套电脑编码系统,主要用于显示现代英语和其他西欧语言。它是最通用的信息交换标准,并等同于国际标准ISO/IEC 646。ASCII第一次以规范标准的类型发表是在1967年,最后一次更新则是在1986年,到目前为止共定义了128个字符。

一个字符的ASCII码占用存储空间为1个字节。所以理论上能表示 2^8 = 256 个字符。

标准ASCII码也叫基础ASCII码,只用到了后7位,即128个字符,剩下最高位(b7)用于校验。

虽然128个足以表示英语中的所有日常字符,但是例如法语注音符号é等就不足以表示,所以一些欧洲国家也用了最高位代表另外的符号。

总的来说,ASCII码 0~127 表示的符号都是一样的,128~255 表示的可能有所差别。比如,130在法语编码中代表了é,在希伯来语编码中却代表了字母Gimel (ג),在俄语编码中又会代表另一个符号。

至于博大精深的汉语,文字就更多了,1个字节不足以表示所有的汉字,所以 GBK 编码等采用了2个字节。

同理,人的创造性是无穷的,Emoji、花漾字等,所以也诞生了许多别的编码方式,所需的字节数会越来越多。

但无论如何,各种编码方式 0~127 代表的字符都建议与标准 ASCII 码中一样,达到兼容的效果。

Unicode

Unicode(统一码、万国码、单一码)是计算机科学领域里的一项业界标准,包括字符集、编码方案等。Unicode 是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。1990年开始研发,1994年正式公布。——百度百科

Unicode码:Unicode码是一种国际标准编码,采用二个字节编码,与ASCII码不兼容。——百度百科

可以看到,Unicode 包括字符集、编码方案等;采用两个字节编码。

Unicode 的一些概念

字符集、码点

字符集(unicode)是一张码表,它规定了文字与数字的一一对应关系。

在设计字符集时,首先要决定所需字符的数目,并确定所需字符的清单。根据字符的数目,可以设定整数值的上限,这个整数范围称为编码空间(code space)。 在Unicode标准中,编码空间的整数范围是从0到10FFFF(编码空间其中的一个特定整数称为一个码点(code point)),共1,114,112个可用的码点。

然后,为字符清单中的每个字符指定一个整数值,也就是一个码点。这样就得到一个字符集,称作编码字符集(Coded Character Set)。

书写 Unicode 字符的码位时,通常会在前面加一个前缀 U+,而数值部分会用 4 位到 6 位十六进制数值表示。如字符“A”在 Unicode 中的码位为 U+0041。

平面

Unicode 编码空间的范围为0到10FFFF,可以被划分为字符平面(planes of characters),一共有17个平面,每个平面包含2^16,64K个码点。

  • 平面 0 (U+0000 - U+FFFF) 被称为基本多语言平面 Basic Multilingual Plane (BMP),也称为第零平面, 其中包含了那些频繁使用的字符。
  • 平面 1 (U+10000 - U+1FFFF) 被称为增补多语言平 Supplementary Multilingual plane (SMP),也称为第一平面。其中包含了一些不常使用的字母系统,如 Deseret。
  • 平面 2 (U+20000 - U+2FFFF) 被称为增补表意字符平面 Supplementary Ideographic Plane (SIP),也称为第二平面。其中包含的事表意字符(如汉字),这其中的大多数字符是不常使用的。
  • 平面 14 (U+E0000 - U+EFFFF) 被称为增补专用平面 Supplementary Special-purpose Plane(SSP)。
  • 平面 15 和 16 (U+F0000 - U+10FFFF) 是 Private Use planes。加上 U+E000 - U+F8FF 就构成了 Unicode 的 Private Use Area(PUA)。这部分区域是 Unicode 为用户保留的,Unicode 不会给这些码位指定字符,应用可以在这块区域添加自己的字符。
  • 其它的平面都还没被使用。

Unicode 转换格式:UTFs

UTF是“Unicode Transformation Format”的缩写,可以翻译成Unicode字符集转换格式,即怎样将Unicode定义的数字转换成程序数据。

我们应该见过 UTF-8、UTF-16、UTF-32 的编码。它们占用的字节数不是固定的。举个例子。

UTF-8 通常使用一至四个字节为每个字符编码,但最多可用到6个字节。

128 个 ASCII 字符(Unicode 范围由 U+0000 至 U+007F)只需一个字节,带有变音符号的拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文、阿拉伯文、叙利亚文及马尔代夫语(Unicode 范围由 U+0080 至 U+07FF)需要二个字节,其他基本多文种平面(BMP)中的字符(CJK属于此类-Qieqie注)使用三个字节,其他 Unicode 辅助平面的字符使用四字节编码。

UTF-8的编码规则很简单, 只有两条:

  1. 对于单字节的符号, 字节的第一位设为0, 后面7位为这个符号的unicode码. 因此对于 英语字母, UTF-8编码和ASCII码是相同的.

  2. 对于n字节的符号(n>1), 第一个字节的前n位都设为1, 第n+1位设为0, 后面字节的前 两位一律设为10. 剩下的没有提及的二进制位, 全部为这个符号的unicode码.

如下表:

字节数 表达 Unicode 符号范围
1 0xxxxxxx 0000 0000 - 0000 007F
2 110xxxxx 10xxxxxx 0000 0080 - 0000 07FF
3 1110xxxx 10xxxxxx 10xxxxxx 0000 0800 - 0000 FFFF
4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 0001 0000 - 0010 FFFF
5 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 0020 0000 - 03FF FFFF
6 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 0400 0000 - 7FFF FFFF

稍微解释一下。

  • UTF-8 1字节用来表示128个 ASCII 字符,所以 Unicode 符号范围位 0 - 7F,即 0 - 127。其他类比。

  • UTF-8中可以用来表示字符编码的实际位数最多有31位,即6字节中所有x的数目。

Unicode 和 UTF-8 的转换

以"严"举例。unicode 为 4E25(1001110 00100101)。

根据上表, 可以发现4E25处在第三行的 范围内(0000 0800 - 0000 FFFF), 因此"严"的UTF-8编码需要三个字节, 即格式是 "1110xxxx 10xxxxxx 10xxxxxx"。

然后, 从"严"的最后一个二进制位开始, 依次从后向前 填入格式中的x, 多出的位补0. 这样就得到了, "11100100 10111000 10100101", 转换成十六进制就是E4B8A5.


总结一下。

unicode 是一种包含所有字符的编码表格.

UTF8是为传送unicode而想出来的“再编码”方法,将unicode编码之后再在网络传输。

一个unicode码可能转成长度为一个字节(ASCII),或两个(拉丁文等),三个(中文等),四个字节(辅助平面字节)的UTF8码。

如果文本大多数都是 ASCII 中的字符,用 UTF8 编码能节省资源(unicode 2 字节 -> UTF8 ASCII 1字节)。


Unicode 动态组合和预设字符

还记得开头看到有些 Emoji 并不是由一个 Unicode 代码点组成的吗?

“字符”远比代码点复杂,单个字符可能由多个代码点组成。

动态组合

Unicode 包含一个系统,可以合并多个编码点,动态组合字符。此系统用各种方式增加灵活性,而不引起编码点的巨大组合膨胀。

如果 Unicode 尝试为字母和变音符号的每种可能组合分配不同的代码点,那么事情将很快失去控制。相反,动态合成系统可以通过从基字符开始,并附加称为“组合字符”的其他代码点来指定变音符号,最后构造所需的字符。当文本渲染器在字符z串中看到类似这样的序列时,它将自动将变音符号堆叠在基本字母上方或下方,以创建一个组合字符。例如,重音字符“Á”可以表示为两个代码点的字符串: U + 0041“ A” 拉丁大写字母a 加U + 0301“◌” 结合了重音。该字符串会自动呈现为单个字符:“Á”

组合标志系统确实允许任意数量的变音符号被叠加到任何基础字符上。

使用归谬法的 Zalgo 文本,它通过随机叠加任意数量的变音符号在每个字母上,让它溢出行距,产生混乱现象。(如下图)

这其中包含几个概念。

  • 基字符(base character):在书写上,不与前面的字符进行组合的字符,它既不是控制字符也不是格式字符。
  • 组合字符(combining character):在书写上,与前面的基字符进行组合的字符。称组合字符应用于基字符。

尽管组合字符用来与基字符组合显示的,但可能出现两种情况(1)在组合字符前没有基字符;(2)处理过程无法执行组合操作。在这两种情况下,处理过程可能会不进行书写上的合并而显示组合字符。

在编码表中,组合字符的表示使用虚线圆圈描绘。当与前面的基字符组合显示时,基字符要出现在虚线圆圈的位置上。

  • 组合字符序列(combining character sequence):一个字符序列,由一个基字符后跟了一个或多个组合字符组成,或者是一个或多个组合字符的组成的序列。

预设字符

如今,Unicode 还包含许多 “预设的” 编码点,每个表示一个被使用过的组合,例如 U+00C1 “Á” 带锐音符的拉丁大写字母A 或 U+1EC7 “ệ” 带扬抑符和下点的小写拉丁字母 e。实际上,对于欧洲语言中的大多数常见的带变音符号的字母都有预设,所以文本中动态组合用的不多。

猜测,这些预设字符已经被加入到某些版本的 Unicode 字符集中了(但搜不到相关资料支撑这句话)。

动态组合与预设字符等值问题

Unicode 中,预设字符和动态组合系统并存。后果就是有多种方法表示同一个字符串——不同编码点序列产生相同用户可感知的字符。例如,我们之前看到的,表示字符 “Á”,我们可以用一个编码点 U+00C1 ,也可以用两个编码点 U+0041 和U+0301。要解决这个等值字符串的问题,Unicode 定义了几种形式正规化方法。比如NFD和NFC,由于这部分比较复杂(暂时没看懂)就不做赘述。


字位簇

如上所见,Unicode 包含多种情况,用户认为的一个“字符” 事实上底下可能由多个编码点组成。Unicode 使用「字位簇」的概念来表示这种情况。一个由一个或多个编码点组成的字符串构成一个 “用户感知的字符”。

UAX #29 为字位丛定义了精确的规则。它大约是 “一个基本的编码点接着任意数量的组合标记”,但是真实的定义有点复杂;它包含了朝鲜语字母,和 emoji ZWJ 序列。

字位簇主要被用在文本编辑:它们对光标和文本选择来说是最明显的单元。使用字位簇,确保在复制和粘贴文本时不会突然丢掉一些符号,同时左右方向键也总是以一个可见字符的距离移动,等等。

另一个用到字位簇的地方是,执行字符串长度限制——比如在数据库域中。其实,底层的限制可能是类似 UTF-8 中的字节长度之类的东西,你不能简单的通过截断字节的方式来限制长度。至少,你得 “舍去” 最近的编码点;但更好的是,舍去最近的字位簇。除此以外,你可以通过舍弃它的一个注音符号破坏一个字符,中断一个 jamo 序列或 ZWJ 序列。

而 Emoji 用到的正是 ZWJ 序列。

Emoji 拼接的实现

现在,我们可以尝试理解 Emoji 拼接的实现。

本质上就是制订了一些编码规则,匹配时按照这个规则进行拼接。

  • 国旗 两个 Unicode 码位组成的 Emoji

可以参考Unicode区域描述符号

规定了某区间字段用来描绘国旗,当文本识别器支持这个匹配规则时,匹配到这区间的码位,自动读取下一个码位,合并起来。

  • 多Unicode使用连接符进行连接。

使用零宽度连接符 ZWJ U+200D连接多个码位。但是实际上是作为一个Emoji显示。

认真看这 Emoji,带着许多 U+200D

最少的为3个Unicode。最长的甚至到7个Unicode。

在不支持的系统,则按照多个Emoji显示。以下是在某软件下 markdown 编辑器和富本文编辑器下同一个 Emoji 的展示效果。

iOS 字符串中的 Emoji

上面从 Unicode 一直介绍到 Emoji 的编码,那 Emoji 在 iOS 日常开发有哪些坑呢?

length 和 range

  • length 的概念

先来一段代码。

    NSString *string = @"👨‍❤️‍💋‍👨";
    NSLog(@"%lu", string.length);

上面输出结果11。

翻看文档,苹果采用了 UTF-16 编码来计算字符串长度。

  • range 的概念

再来一段代码,我们传入第二个位置,期望拿到a。

    NSString *string = @"😀a";
    NSLog(@"%hu", [string characterAtIndex:1]);

结果却拿不到a,a的 index 实际上是2,是按 UTF-16 编码算的第三个字节。

所以就有了 range 的概念,经过当前版本支持的规则,解码后实际展示的区域范围。

主要针对一些特殊字符获取真正的范围,防止你把同一个字符给拆开了。

NSRange是Foundation框架中比较常用的结构体, 它的定义如下:


    typedef struct _NSRange {
        NSUInteger location;// 表示该范围的起始位置
        NSUInteger length;//表示该范围内的长度
    } NSRange;
  • index 和 range 的转换

苹果提供了一些 API 来对他们进行转换。传入一个 index 或者 range,获取完整的 range 范围。

- (NSRange)rangeOfComposedCharacterSequencesForRange:(NSRange)range;
- (NSRange)rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index;
- (NSRange)rangeOfString:

所以处理字符串时,想拿到口头上的第几个字符,应该用 range:

    NSString *string = @"😀a";
    NSRange characterRange = NSMakeRange(2, 1);
    NSLog(@"%@", [string substringWithRange:characterRange]);
  • 拿到展示第x个位置的字符

苹果并没有直接提供这个功能。

但是我们可以定义一个数组把每一个展示的字符存起来。

    NSMutableArray *displayCharArray = [NSMutableArray array];
    NSString *string = @"😀👩🏽👨‍👨‍👧‍👦👩‍👩‍👧‍👦👩‍👩‍👧👩‍👩‍👦‍👦👪👨‍❤️‍💋‍👨👩‍❤️‍👩👨‍❤️‍👨👬";
    [string enumerateSubstringsInRange:NSMakeRange(0, string.length) options:NSStringEnumerationByComposedCharacterSequences usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) {
        [displayCharArray addObject:substring];
        NSLog(@"%@", substring);
    }];
    
    NSLog(@"第 6 个展示字符为%@", displayCharArray[5]);


参考