从Emoji的限制到Unicode编码

某一天,leader找到我说,felix啊,这里有个小需求,给我们的实名认证中的地址加入字数限制,一天时间绰绰有余了吧。我一听,小事啊,赶紧拍拍胸脯告诉leader,一天都不用,以我的效率1个小时就够了。领导很满意的说,小伙子有前途。

那些年的Emoji

打开工程,三下五除二就定位到修改点修改完毕,自测通过,完美。就在我美滋滋的冲上一杯咖啡,幻想年底能不能拿个5星员工,走上人生巅峰的时候,测试妹子找到了我:
『felix你代码有问题,如果最后的字符是emoji表情,会被截断。』
what?作为一名老资历程序员,我自然考虑过不同字符的问题,最典型的就是中英文混排。但在这种情况下,NSString返回的字符长度是相同的,想来是苹果爸爸帮我们处理好了。可这emoji的长度是什么鬼?
我赶紧写了个代码,测试了下emoji的长度。

NSString *a = @"😂";
NSLog(@"%d",a.length);

结果是

2

[图片上传失败...(image-d766cc-1522224040783)]
这下我只好赶紧找找unicode的资料。

从ASCII的荣光到Unicode的崛起

程序员都知道,计算机没法直接处理文本,它只和数字打交道。为了在计算机里用数字表示文本,我们指定了一个从字符到数字的映射。这个映射就叫做编码(encoding)。最有名的一个字符编码是 ASCII。ASCII 码是 7 位的,它将英文字母,数字 0-9 以及一些标点符号和控制字符映射为 0-127 这些整型。但是,由于 8 位的空间对于欧洲的文字来说都不够,更不用说全世界的书写系统了,因此人们开发了更加通用的编码——Unicode。最初,Unicode 编码是被设计为 16 位的,提供了 65,536 个字符的空间。当时人们认为这已经大到足够编码世界上现代文本里所有的文字和字符了。后来,考虑到要编码历史上的文字以及一些很少使用的日本汉字和中国汉字[^2],Unicode 编码扩展到了 21 位(从 U+0000 到 U+10FFFF)。

Unicode编码空间

Unicode 的基本元素 —— 它的 “字符”,虽然这种叫法不是太贴切——被称作编码点(Code Point)。编码点通过数字来区分,通常写成 16 进制的形式再加前缀“U+”,例如 U+0041 表示拉丁字母 “A” 、U+03B8 表示 希腊字母 “θ”。所有编码点组成的集合被称作编码空间(Code Space)。
Unicode 编码空间包含 1,114,112 个编码点。然而,其中只有128,237 个编码点 —— 编码空间的 12% 被赋值,目前。还有很多空间用来增长!Unicode 还保留了另外 137,468 字符 作为 “自用” 空间,这些字符没有标准的含义,可以被个人应用所使用。
为了对编码空间的布局有个了解,把它可视化会比较直观。下面是整个编码空间的布局,一个像素代表一个编码点。使用小方块来表示以保证视觉的一致性;每个小方块是 16×16 = 256 个编码点,每个大方块是一个面有 65536 个 编码点。总共加起来有 17 个面板。


Unicode空间表
  • 白色表示未用空间;
  • 蓝色表示已用空间;
  • 绿色表示自用区域;
  • 小的红色区域是代理区(surrogates,后面会讲)。
    其中第一个面板被称作『基本多语言面板(Basic Multilingual Plane,简称 BMP)』。BMP包含现代文本所需的基本所有字符,包括拉丁文、斯拉夫文、希腊文、汉字(中国),日文、朝鲜文、阿拉伯文、希伯来文、梵文(印度)等等。这个面板就是最初Unicode设计所占用的空间(16位,65536个字符)。后来扩展到现在这个规模,然而,大部分现代字符在BMP的范围内。
    第二个面板则是包括历史上的文字,比如苏美尔楔形文字和埃及象形文字还有今天我们说起的emoji表情。第三个面板包含一大块不常用的和历史上的汉字字符。剩下的是空的,除了 倒数第三个面板中有一小部分被用作格式化字符;倒数两个面板全部保留自用。
    为了和以前的ASCII编码兼容,Unicode的128个字符就是ASCII的拷贝。这样很容易从小编码转向unicode。顺带提一句,由于unicode被设计为以抽象的方式戴表一个字符,而不规定这个字符如何呈现。如此一来,Unicode 对中文、日文和韩文(CJK)里使用的汉字(也就是所谓的统一汉字)都使用完全相同的码点(这一决定颇具争议),尽管在这些书写系统里,每个汉字都发展出了独特的字形变体。

UTF8和UTF16

现在搞懂了Unicode的编码点了,但是在内存或文件中如何用字节表示呢?
当然,最省事的办法就是用32位来存储编码点下标,但是这样的话,每个字符都占四个字节,当你处理大量文本的时候,这样就太浪费内存或带宽了。
在我们讨论解决办法之前,我们先看看一个图:


使用频率

这是unicode编码面板中的前三个面板的使用频率图(数据来自维基百科和twitter)。频率增长的方向是黑(没出现)、红、黄、白。
可以看到,绝大多数文本分布在BMP内,有些零散的使用来自第二三个面板。第二个面板下高频率使用的字符则是部分emoji表情。
那么,为了解决unicode编码占据的内存问题,unicode就有了几个紧凑的编码 。32 位整数编码被称作 UTF-32(UTF=”Unicode Transformation Format”),但是很少被用来存储。最常见的是,你会看到 Unicode 文本被编码为 UTF-8 或 UTF-16。从上面的热力图可知两个编码涵盖的是最常见的文本,内存能最大程度的利用。这些都是可变长度编码,分别由 8-bit 或 16-bit 为一个单元组成。这些方案中,下标值较小的编码点占用的字节数也少,会节省不少内存。这样做的代价是处理 UTF-8/16 需要以编程的方式来处理,会慢一些。

1. UTF8

在 UTF-8 中,每个编码点依据下标值,被存储为 1 到 4 个字节。
UTF-8 使用二进制前缀系统,在此系统中每个字符的最高位的几个比特表明它是否是单个字节,多字节序列的开始,或中间字节;剩余的比特连接起来表示编码点的下标。下面的表格展示了UTF-8 是如何编码的:

UTF-8 (二进制) 编码点 (二进制) 范围
110xxxxx 10yyyyyy xxxxxyyyyyy U+0080–U+07FF
1110xxxx 10yyyyyy 10zzzzzz xxxxyyyyyyzzzzzz U+0800–U+FFFF
11110xxx 10yyyyyy 10zzzzzz 10wwwwww xxxyyyyyyzzzzzzwwwwww U+10000–U+10FFFF

UTF8有以下几个好处:

  • 对于很常见的西文字符,采用这种编码方式也不会浪费内存。
  • 由于UTF-8 是基于 8 位的码元的,因此它并不需要关心字节顺序。
  • 任何已经是 ASCII 编码的字符串和文件无需转换就可以被 UTF-8 识别。
  • 大量的广泛使用的编程惯例——比如 NULL 结尾,分隔符(n,t,’,’,”)等——在 UTF-8 中也是可用的。
    其中,后面这两点好处是基于UTF8的一个属性,即最开始128 个字符(ASCII字符)被编码为单个字节,所有的非 ASCII 字符被编码为 128-255。

因为这些原因,UTF-8 成为存储和交流 Unicode 文本方面的最佳编码。它也已经是文件格式、网络协议以及 Web API 领域里事实上的标准了。这也是为什么我们在处理字符串时,最常打交道的是NSUTF8StringEncoding

2. UTF16

和 UTF-8 一样,我们可以用二进制前缀的形式表示 UTF-16 的编码规则:

UTF-16 (二进制) 编码点 (二进制) 范围
xxxxxxxxxxxxxxxx xxxxxxxxxxxxxxxx U+0000–U+FFFF
110110xxxxxxxxxx 110111yyyyyyyyyy xxxxxxxxxxyyyyyyyyyy + 0x10000 U+10000–U+10FFFF

正如我们前面所言,最早unicode是设计为16位,包含的范围也就是BMP。但后来为了支持一些更少使用的汉字或其它字符,unicode扩展到21位。为了在UTF16中能访问到后面的码位,这些码位会被编码为一对16bit的码元,称作代理对。具体方法可以参考维基百科。由于BMP剩下可用来做代理的范围仅剩U+D800-U+DFFF中的编码点,1位用于标识高位或低位,剩下的10位成对最多只能支持到220-1 ,所以unicode只能扩展到21位(最大值220 + 216 -1)。
同时由于UTF16被设计为多字节,和所有多字节长度的编码系统一样,它还得解决字节顺序的问题。Unicode 在这个问题上没有说明,虽然它确实鼓励一个惯例,即把 U+FEFF 零宽无间断间隔这个字符放到 UTF-16 文件开头作为字节序标识,来消除字节序问题。

An NSString object encodes a Unicode-compliant text string, represented as a sequence of UTF–16 code units. All lengths, character indexes, and ranges are expressed in terms of UTF–16 code units, with index values starting at 0. The length property of an NSString returns the number of UTF-16 code units in an NSString, and the characterAtIndex: method retrieves a specific UTF-16 code unit. These two "primitive" methods provide basic access to the contents of a string object.
在iOS系统中,NSString是以UTF16编码的,默认是大端字节序,如果要使用其它字节序,则需要使用NSUTF16BigEndianStringEncoding或者NSUTF16LittleEndianStringEncoding。(注:苹果同时还提供了NSUTF32BigEndianStringEncoding和NSUTF32LittleEndianStringEncoding,UTF32是不用关心字节序的,不知道这两个有啥用)

组合字符

看完这些资料,我自信对Unicode的编码方式相当了解了。长度不同的问题是因为编码不同嘛,所以我直接取字符串的32位编码长度,就没问题了。于是代码修改如下:

NSString *a = @"😂";
NSLog(@"%d",[test lengthOfBytesUsingEncoding:NSUTF32StringEncoding]/4);

结果是:

1

完美!关单让测试小姐姐重测下,我又可以继续喝我的咖啡了。然而,我才喝了一口,测试小姐姐反馈说还是有问题。
[图片上传失败...(image-22aa29-1522224040783)]
无奈,我只能放下我的咖啡,根据测试小姐姐的反馈,重新调试下:

NSString *a = @"🇨🇳";
NSLog(@"%d",[test lengthOfBytesUsingEncoding:NSUTF32StringEncoding]/4);

调试结果:

2

这不科学啊!明明unicode都是32位,咋一个字符还长度为2了呢。

组合字符

继续沉浸在unicode的大部头中,我终于找到了新的信息——组合字符。
Unicode 包含一个系统,可以合并多个编码点,动态组合字符。此系统用各种方式增加灵活性,而不引起编码点的巨大组合膨胀。
例如,在欧洲语言中,组合标记出现在变音符和字母的使用中。 Unicode 支持各种各样的变音符号,包括尖音符号的和重音符号、元音变音符号、变音符号等等。所有这些变音符可以被使用在任何字母表的字母中。事实上,多个变音符号可以被使用在一个字母上。
如果 Unicode 试图为每个字母组合或变音符组合分配一个独立的编码点,事情会变得无法控制。相反,动态组合系统可以让你构造你想要的任何字符,通过以一个基础编码点(字母)开始然后附加额外的编码点,被称作“组合标识”,来指定变音符。当一个文字渲染器看到字符串中有这样的序列时,它会自动堆叠变音符到基础字母的上面或下面来造出一个组合字符。
例如,带重音的字符“Á” 会被表示成由两个编码点组成的字符串:U+0041 “A” 拉丁大写字母 a 加上 U+0301 “◌́”组合尖音符号。这个字符串自动被渲染成单个字符:“Á”。

有时候我们会看到某些人的签名中有很奇怪的字符,其实他们就是利用了组合字符。比如Á́́ 就是多添加了几个尖音符号:U+0041U+0301U+0301U+0301

如今,Unicode 还包含许多 “预设的” 编码点,每个表示一个被使用过的组合,例如 U+00C1 “Á” 带锐音符的拉丁大写字母A 或 U+1EC7 “ệ” 带扬抑符和下点的小写拉丁字母 e。我怀疑这些大多继承自融入 Unicode 的旧编码,来保证兼容性。实际上,对于欧洲语言中的大多数常见的带变音符号的字母都有预设,所以文本中动态组合用的不多。
Unicode 中,预设字符和动态组合系统并存。后果就是有多种方法表示同一个字符串——不同编码点序列产生相同用户可感知的字符。例如,我们之前看到的,表示字符 “Á”,我们可以用一个编码点 U+00C1 ,也可以用两个编码点 U+0041 和U+0301。要解决这个等值字符串的问题,Unicode 定义了几种形式正规化方法。比如NFD和NFC,由于这部分比较复杂就不做赘述。有兴趣的可以参考后面提供的资料。

字位簇

如上所见,Unicode 包含多种情况,用户认为的一个“字符” 事实上底下可能由多个编码点组成。Unicode 使用「字位簇」的概念来表示这种情况。一个由一个或多个编码点组成的字符串构成一个 “用户感知的字符”。
UAX #29 为字位簇定义了精确的规则。它大约是 “一个基本的编码点接着任意数量的组合标记”,但是真实的定义有点复杂;它包含了朝鲜语字母,和 emoji ZWJ 序列。
所以,部分emoji的unicode长度大于1的本质原因是这些emoji是字位簇。具体的emoji列表可以查看这个网址。可以看到苹果爸爸在后面添加的很多emoji长度已经不是单个unicode字符了。毕竟一个emoji表情还要划分人种这种丧心病狂的事情,再多码位也hold不住。

终极解决方案

本来如果只是不同unicode编码的问题,那么统一使用UTF32就可以了。但由于组合字符的存在,一个人类可读的字符串在编码中实际上是一个稀疏阵列。先不说如何处理那么多种语言下的组合字符的问题,单是处理这个阵列找出真正的可读字符串也是需要好些代码。还好,iOS下提供了一个方法enumerateSubstringsInRange:options:usingBlock:可以方便的找出迭代找出字符串中的组合字符。那么判断一个字符串实际长度的做法是:

    NSMutableArray *characters = [NSMutableArray array];
    NSString *test = @"🇹🇯🙍‍♂️👩‍👩‍👦‍👦😆";
    [test enumerateSubstringsInRange:NSMakeRange(0, test.length) options:NSStringEnumerationByComposedCharacterSequences usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) {
        [characters addObject:substring];
    }];
    NSLog(@"%ld",characters.count);

结果正确。这里还需要考虑到,如果是要截取字符串,那么要删除的部分,也应该是完整的组合字符。这里就不写具体的代码,留给大家做作业~~~

结尾

实际上unicode还有很多复杂的内容,还在很多问题都已经由系统帮我们处理好了。但是了解unicode的机制,对于我们解决bug有很大的帮助。

此文是根据以下文章重新整理而成:

推荐阅读更多精彩内容