探索iOS中Emoji表情的编码与解析

背景

假设以如下规则计算文字长度:

  1. ASCII字符(英文、数字、半角符号)占0.5个长度。
  2. 中文、全角符号、Emoji、特殊符号,占1个长度。

简而言之就是计算用户可以感知的文字实际长度。

原先方法是这样计算的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (NSUInteger)oldUnicodeLengthOfString {
NSUInteger asciiLength = 0;
for (NSUInteger i = 0; i < self.length; i++)
{
// 遍历NSString,取出unichar
unichar uc = [self characterAtIndex:i];
// 判断是否是ascii
asciiLength += isascii(uc) ? 1 : 2;
}
NSUInteger unicodeLength = asciiLength / 2;
if(asciiLength % 2) {
// 向上取整
unicodeLength++;
}
return unicodeLength;
}

其中变量uc的类型为unichar,打开头文件我们可以看到typedef unsigned short unichar;,即为unsigned short类型,长度为16bit,可以表示0-65535,即Unicode中0000-FFFF的字符。

因为大多数文字都在Unicode 0000-FFFF区间内,所以这个方法在大多数时间都是准确的。但是处理超过这个范围的文字时就会出现问题。

NSString 编码

我们举几个常见或不常见的文字,看一下其内存中的数据。其中 UTF-16 这一列为 characterAtIndex: 方法取到的数据。

字符 Unicode UTF-16 [NSString length]
A U+0041 0x0041 1
U+738B 0x738B 1
😄 U+1F604 0xD83D 0xDE04 2
𠁒 U+20052 0xD840 0xDC52 2

在iOS官方文档中,对NSString的描述是这样的:

A string object presents itself as a sequence of UTF–16 code units. You can determine how many UTF-16 code units a string object contains with the length method and can retrieve a specific UTF-16 code unit with the character(at:) method.

简单翻译如下:

NSString对象是由一系列 UTF-16 编码单位组成,通过 length 方法可以得到 UTF-16 编码的单位数量,通过 character(at:) 方法可以遍历每个 UTF-16 编码单位。

那么到底哪些字符会占用多位 UTF-16 字节呢?我们需要探究一下 Unicode 的编码方式。

字符编码与Unicode

很多读者应该对“乱码”记忆犹新。我们在玩比较久远的游戏时遇到的乱码,就是字符编码造成的。例如大陆常用的GB2312与台湾的BIG5就互不兼容,使用简体中文操作系统运行使用BIG5码的游戏程序就会出现乱码的现象。往往需要用“转码器”来进行转换。

Unicode编码旨在解决这个问题,使用一张编码表,来包含所有可能的字符。Unicode表包含1114112个码点,即从0x0000000x10FFFF。整个Unicode表通过最高两位分为16个平面(0x00-0x10),每个平面有65536个码点。其中最常用的字符保存在第一个平面(0x000000-0x00FFFF),这个平面称为BMP(Basic Multilingual Plane),其他平面称为辅助平面(Supplementary Planes)。

Unicode的长度为6位16进制,如果不经过编码就需要24bit来表示。常用字符比如ASCII字符,前16位均为0,这样会造成极大的空间浪费,所以我们需要使用一些方式来编码,高效地表示Unicode。

UTF-8与UTF-16

UTF全称为Unicode Transformation Format,主要有UTF-8、UTF-16和UTF-32。

UTF-8

UTF-8是一种变长编码方式,其编码方式如下:

1st Byte 2nd Byte 3rd Byte 4th Byte 数据位 Unicode表示范围
0xxxxxxx 7 0x00-0x7F (127)
110xxxxx 10xxxxxx (5+6)=11 0x0080-0x07FF (2047)
1110xxxx 10xxxxxx 10xxxxxx (4+6+6)=16 0x0800-0xFFFF (65535)
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (3+6+6+6)=21 0x010000-0x10FFFF (1,114,111)

每个字节的前N位为控制位,其功能表示如下:

  • 0x00-0x7F (0-127):表示单子节字符
  • 0x80-0xBF (128-191):表示多字节字符中的延展字节
  • 0xC2-0xDF (194-223):表示双字节字符的第一个字符
  • 0xE0-0xEF (224-239):表示三字节字符的第一个字符
  • 0xF0-0xFF (240-255):表示四字节字符的第一个字符

UTF-16

UTF-8是不定长的编码,使用1、2、3、4个字节编码,而UTF-16则只使用2或4个字节编码。UTF-16也是Unicode一种具体的编码实现。关于Unicode如何转化为UTf-16编码规则如下:

  • 若Unicode码点在第一平面(BMP)中,则使用2个字节进行编码。
  • 若Unicode码点在其他平面(辅助平面),则使用4个字节进行编码。

表示辅助平面的字符时,UTF-16利用了Unicode BMP中的两个保留区域来进行编码,每个字节的前6位为控制位,后10位为数据位,这两个保留区域分别是:

  1. 代理对高位字(High Surrogates):0xD800-0xDBFF
  2. 代理对低位字(Low Surrogates):0xDC00-0xDFFF
16进制编码范围 UTF-16表示方法(二进制) 10进制码范围 字节数量
U+0000—U+FFFF xxxxxxxx xxxxxxxx yyyyyyyy yyyyyyyy 0-65535 2
U+10000—U+10FFFF 110110yyyyyyyyyy 110111xxxxxxxxxx 65536-1114111 4

UTF-16比起UTF-8,好处在于大部分字符都以固定长度的字节(2字节)存储,但UTF-16却无法兼容于ASCII编码。

Unicode与Emoji表情

Emoji所用的编码可以分为以下五类:

基本平面内的字符

这类字符属于Unicode中基本平面(BMP)中的,所有支持Unicode的设备(系统)均可以显示,只是苹果在显示上替换为Emoji图片。使用UTF16编码后,在NSString中长度为1。

例如:

图片 名称 Unicode
▶️ arrow_forward U+25B6
♊️ gemini U+264A
㊗️ congratulations U+3297

扩展平面内的单字符

这类字符位于Unicode中辅助平面内,使用两个UTF16字符进行编码,在NSString中长度为2。

例如:

图片 名称 Unicode
🌹 rose U+1F339
👨 man U+1F468
😂 joy U+1F602

基本平面内的多字符

这类字符由基本平面内的多个字符组合而成,使用两个UTF16字符进行编码,在NSString中长度为2。

例如:

图片 名称 Unicode
#️⃣ hash U+0023 U+20E3
1️⃣ one U+0031 U+20E3

扩展平面内的多字符

这类字符由扩展平面内的多个字符组合而成,在NSString中长度不确定。

例如:

图片 名称 Unicode UTF16 备注
🇨🇳 cn U+1F1E8 U+1F1F3 0xD83C 0xDDE8 REGIONAL INDICATOR SYMBOL LETTER CREGIONAL INDICATOR SYMBOL LETTER N 组成
👩🏾 U+1F469 U+1F3FD 0xD83D 0xDC69 0xD83C 0xDFFD 由 👩 与 🏾 组成

多个Emoji字符

这类字符是由多个基本的Emoji字符,通过零宽连接符(U+200D)连接而成。其含义可以由组成的字符推测而来。

例如:

图片 Unicode 备注 长度
👩🏽‍🌾 U+1F469 U+1F3FD U+200D U+1F33E 由 👩🏾+连接符+🌾 组成 7
👩‍👩‍👧‍👦 U+1F469 U+200D U+1F469 U+200D U+1F466 U+200D U+1F467 由 👩+连接符+👩+连接符+👦+连接符+👧 组成 11

正确地计算Emoji的实际长度

方案1

逐个遍历字符的unichar,判断是否是0xD800-0xDFFF范围

优点:

速度快,可以判断部分Emoji表情和绝大多数特殊字符。

缺点:

无法正确计算扩展平面内的多字符的长度。

方案2

调用系统API

1
- (void)enumerateSubstringsInRange:(NSRange)range options:(NSStringEnumerationOptions)opts usingBlock:(void (^)(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop))block NS_AVAILABLE(10_6, 4_0);

其中Option使用NSStringEnumerationByComposedCharacterSequences,遍历时会尽可能的合并字符,例如:👩🏽‍🌾是由七个字符组合而成,进行一次遍历。通过计算遍历次数来确定字符串长度。

优点:

计算准确,依赖系统API,在iOS系统添加新的Emoji时也可以正确计算。

缺点:

计算耗时,约为老方法的10倍。

使用方案2实现的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (NSUInteger)newUnicodeLengthOfString {
__block NSUInteger asciiLength = 0;
[self enumerateSubstringsInRange:NSMakeRange(0, self.length)
options:NSStringEnumerationByComposedCharacterSequences
usingBlock:^(NSString * _Nullable substring, NSRange substringRange, NSRange enclosingRange, BOOL * _Nonnull stop) {
if (substringRange.length == 1) {
unichar uc = [substring characterAtIndex:0];
if (isascii(uc)) {
asciiLength += 1;
return;
}
}
asciiLength += 2;
}];
NSUInteger unicodeLength = asciiLength / 2;
if(asciiLength % 2) {
unicodeLength++;
}
return unicodeLength;
}

单元测试

单元测试Case覆盖如下情况:中文、ASCII、混合、存在Emoji、存在特殊字符。

同时进行了性能测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
测试环境:
OS:MacOS 10.12.5
CPU:2.7 GHz Intel Core i5
内存:8GB
测试数据10万条,分别测试计算文字长度与截取文字的耗时
老方法:
计算长度:0.028s
截断:0.045s
新方法:
计算长度:0.259s
截断:0.237s

测试代码:

单元测试结论:

所有case均通过测试

性能测试结论:

在计算长度上,新方法耗时约为老方法的10倍;截断操作耗时约为5倍。存在优化空间。

参考资料