写在前面
在开发中我们经常会遇到限制字符串长度问题,如果用的是 NSString
很自然使用字符串属性 length
来协助判断,比如
@"我是字符串abc".length < 10
但是如果字符串包含 emoji 表情的时候发现长度会不一样, 比如
var str1InOC: NSString = "";
var str2InOC: NSString = "";
var str3InOC: NSString = "";
var str4InOC: NSString = "";
print(
"str1InOC:", str1InOC.length,
"str2InOC:", str2InOC.length,
"str3InOC:", str3InOC.length,
"str4InOC:", str4InOC.length,
);
/*
输出:
str1InOC: 2 str2InOC: 4 str3InOC: 7 str4InOC: 11
*/
在弄清原因前,需要先来研究一下字符编码问题。
ASCII 字符集
我们知道,计算机内部,所有信息最终都是一个二进制值。每一个二进制位(bit)有0和1两种状态,因此八个二进制位就可以组合出256种状态,这被称为一个字节(byte)。也就是说,一个字节一共可以用来表示256种不同的状态,每一个状态对应一个符号,就是256个符号,从00000000到11111111。
上个世纪60年代,美国制定了一套字符编码,对英语字符与二进制位之间的关系,做了统一规定。这被称为 ASCII 码,一直沿用至今。
ASCII 码一共规定了128个字符的编码,比如空格SPACE是32(二进制00100000),大写的字母A是65(二进制01000001)。这128个符号(包括32个不能打印出来的控制符号),只占用了一个字节的后面7位,最前面的一位统一规定为0。
ASCII 字符集的局限性
英语用128个符号编码就够了,但是用来表示其他语言,128个符号是不够的。比如,在法语中,字母上方有注音符号,它就无法用 ASCII 码表示。于是,一些欧洲国家就决定,利用字节中闲置的最高位编入新的符号。比如,法语中的é的编码为130(二进制10000010)。这样一来,这些欧洲国家使用的编码体系,可以表示最多256个符号。
但是,这里又出现了新的问题。不同的国家有不同的字母,因此,哪怕它们都使用256个符号的编码方式,代表的字母却不一样。比如,130在法语编码中代表了é,在希伯来语编码中却代表了字母Gimel (ג),在俄语编码中又会代表另一个符号。但是不管怎样,所有这些编码方式中,0--127表示的符号是一样的,不一样的只是128--255的这一段。
至于亚洲国家的文字,使用的符号就更多了,汉字就多达10万左右。一个字节只能表示256种符号,肯定是不够的,就必须使用多个字节表达一个符号。比如,简体中文常见的编码方式是 GBK,使用两个字节表示一个汉字,后面会详细讲解。
其他语种也都有自己的字符集,这样一来适配各种语种变得异常繁琐。
Unicode 字符集
Unicode,中文又称万国码、国际码、统一码、单一码,是计算机科学领域的业界标准。它整理、编码了世界上大部分的文字系统,使得电脑可以用更为简单的方式来呈现和处理文字。
Unicode的编码从U+0000到U+10FFFF,共有1,112,064个码位(code point)可用来映射字符。Unicode的编码空间可以划分为17个平面(plane),每个平面包含2^16(65,536)个码位。17个平面的码位可表示为从U+xx0000到U+xxFFFF,其中xx表示十六进制值从0x00到0x10,共计17个平面。第一个平面称为基本多语言平面(Basic Multilingual Plane, BMP),或称第零平面(Plane 0),其他平面称为辅助平面(Supplementary Planes)。
一般Unicode的码位表示成U+XXXXXX 的形式,X 代表一个十六制数字,表示形式的范围在 4-6 位之间,也就是U+0000 ~ U+10FFFF间。当码位值不足 4 位时前面补 0 补足 4 位,超过则按是几位就是几位。
平面 | 始末字符值 | 中文名称 | 英文名称 |
---|---|---|---|
0号平面 | U+0000-U+FFFF | 基本多文种平面 | Basic Multilingual Plane,简称BMP |
1号平面 | U+10000-U+1FFFF | 多文种补充平面 | Supplementary Multilingual Plane,简称SMP |
2号平面 | U+20000-U+2FFFF | 表意文字补充平面 | Supplementary Ideographic Plane,简称SIP |
3号平面 | U+30000-U+3FFFF | 表意文字第三平面 | Tertiary Ideographic Plane,简称TIP |
4号平面 至 13号平面 | U+40000-U+DFFFF | 尚未使用 | |
14号平面 | U+E0000-U+EFFFF | 特别用途补充平面 | Supplementary Special-purpose Plane,简称SSP |
15号平面 | U+F0000-U+FFFFF | 保留作为私人使用区(A区) | Private Use Area-A,简称PUA-A |
16号平面 | U+100000-U+10FFFF | 保留作为私人使用区(B区) | Private Use Area-B,简称PUA-B |
其他更多平面映射详情可以查看Unicode字符平面映射
Unicode 除了使用单个码点表示 Emoji,还允许多个码点组合表示一个 Emoji。其中的一种方式是"零宽度连接符"(ZERO WIDTH JOINER,缩写 ZWJ)U+200D。举例来说,下面是三个 Emoji 的码点。
U+1F468:男人
U+1F469:女人
U+1F467:女孩
上面三个码点使用U+200D连接起来,U+1F468 U+200D U+1F469 U+200D U+1F467,就会显示为一个 Emoji ,表示他们组成的家庭。如果用户的系统不支持这种方法,就还是显示为三个独立的 Emoji
UTF-32、UTF-16、UTF-8 编码
需要注意的是,Unicode 只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。
比如,汉字严的 Unicode 是十六进制数4E25,转换成二进制数足足有15位(100111000100101),也就是说,这个符号的表示至少需要2个字节。表示其他更大的符号,可能需要3个字节或者4个字节,甚至更多。
于是有了Unicode转换格式,缩写为UTF(Unicode Transformation Formats),用来处理编码问题。
UTF-32编码
UTF-32是一种用于编码Unicode的协定,该协定使用32位比特对每个Unicode码位进行编码(但前导比特数必须为零,故仅能表示2^21个Unicode码位)。与其他可变长度的Unicode转换格式(UTF)相比,UTF-32编码长度是固定的,UTF-32中的每个32位值代表一个Unicode码位,并且与该码位的数值完全一致。
优点:可以直接由Unicode码位来索引。在编码序列中查找第N个编码是一个常数时间操作。相比之下,其他可变长度编码需要进行循序访问操作才能在编码序列中找到第N个编码。这使得在计算机程序设计中,编码序列中的字符位置可以用一个整数来表示,整数加一即可得到下一个字符的位置,就和ASCII字符串一样简单。
缺点:每个码位使用四个字节,空间浪费较多。在大多数文本中,非基本多文种平面的字符非常罕见,这使得UTF-32所需空间接近UTF-16的两倍和UTF-8的四倍(具体取决于文本中ASCII字符的比例)。
尽管每一个码位使用固定长度的字节看似方便,但UTF-32并不如其它Unicode编码使用广泛。与UTF-8及UTF-16相比,UTF-32更容易遭到截断。即使使用了"定宽"字体,在大多数情况下用UTF-32计算显示字符串的宽度也并不比其他编码更加容易。主要原因是,存在着一个字符位置会有多于一种可能的码点(结合字符)或一个码点用多于一个字符位置(如CJK表意字符)。结合符号也意味着,文书编辑者不能将一个码位视同一个编辑上的单位。
UTF-16编码
UTF-16使用16位比特对每个Unicode码位进行编码映射,0号平面内的码位可以直接映射;其他平面的字符代理至0号平面的U+D800到U+DFFF(从U+D800到U+DFFF之间的码位区段是永久保留不映射到Unicode字符)。
接下来分析其他平面从U+10000到U+10FFFF的码位如何映射至0号平面的U+D800到U+DFFF。
辅助平面(Supplementary Planes)中的码位,在UTF-16中被编码为一对16比特长的码元(即32位,4字节),称作代理对(Surrogate Pair),具体方法是:
- 码位减去 0x10000,得到的值的范围为20比特长的 0...0xFFFFF。
- 高位的10比特的值(值的范围为 0x000...0x3FF)被加上 0xD800 得到第一个码元或称作高位代理(high surrogate),值的范围是 0xD800...0xDBFF。由于高位代理比低位代理的值要小,所以为了避免混淆使用,Unicode标准现在称高位代理为前导代理(lead surrogates)。
- 低位的10比特的值(值的范围也是 0x000...0x3FF)被加上 0xDC00 得到第二个码元或称作低位代理(low surrogate),现在值的范围是0xDC00...0xDFFF。由于低位代理比高位代理的值要大,所以为了避免混淆使用,Unicode标准现在称低位代理为后尾代理(trail surrogates)。
上述算法可理解为:辅助平面中的码位从U+10000到U+10FFFF,共计FFFFF个,即2^20 = 1,048,576个,需要20位来表示。如果用两个16位长的整数组成的序列来表示,第一个整数(称为前导代理)要容纳上述20位的前10位,第二个整数(称为后尾代理)容纳上述20位的后10位。还要能根据16位整数的值直接判明属于前导整数代理的值的范围(2^10 = 1024),还是后尾整数代理的值的范围(也是2^10 = 1024)。因此,需要在基本多语言平面中保留不对应于Unicode字符的2048个码位,就足以容纳前导代理与后尾代理所需要的编码空间。这对于基本多语言平面总计65536个码位来说,仅占3.125%。
由于前导代理、后尾代理、BMP中的有效字符的码位,三者互不重叠,搜索是简单的:一个字符编码的一部分不可能与另一个字符编码的不同部分相重叠。这意味着UTF-16是自同步(self-synchronizing)的:可以通过仅检查一个码元来判定给定字符的下一个字符的起始码元。UTF-8也有类似优点,但许多早期的编码模式就不是这样,必须从头开始分析文本才能确定不同字符的码元的边界。
编码例子:以U+10437编码()为例:
- 0x10437 减去 0x10000,结果为0x00437,二进制为 0000 0000 0100 0011 0111
- 分割它的上10位值和下10位值(使用二进制):0000 0000 01 和 00 0011 0111
- 添加 0xD800 到上值,以形成高位:0xD800 + 0x0001 = 0xD801
- 添加 0xDC00 到下值,以形成低位:0xDC00 + 0x0037 = 0xDC37
最终:U+10437 通过 UTF-16 大尾端为 D801DC37
相比 UTF-32,UTF-16 节省了一半的空间,但是不兼容 ASCII 码。而已还需要处理小尾端(Little endian) 和 大尾端(Big endian),后面会单独解释。
UTF-8编码
UTF-8(8-bit Unicode Transformation Format)是一种针对Unicode的可变长度字符编码,也是一种前缀码。它可以用一至四个字节对Unicode字符集中的所有有效编码点进行编码,属于Unicode标准的一部分。由于较小值的编码点一般使用频率较高,直接使用Unicode编码效率低下,大量浪费内存空间。UTF-8就是为了解决向后兼容ASCII码而设计,Unicode中前128个字符,使用与ASCII码相同的二进制值的单个字节进行编码,而且字面与ASCII码的字面一一对应,这使得原来处理ASCII字符的软件无须或只须做少部分修改,即可继续使用。因此,它逐渐成为电子邮件、网页及其他存储或发送文字优先采用的编码方式。
UTF-8使用一至六个字节为每个字符编码(尽管如此,2003年11月UTF-8被RFC 3629重新规范,只能使用原来Unicode定义的区域,U+0000到U+10FFFF,也就是说最多四个字节):
- 128个US-ASCII字符只需一个字节编码(Unicode范围由U+0000至U+007F)。
- 带有附加符号的拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文、阿拉伯文、叙利亚文及它拿字母则需要两个字节编码(Unicode范围由U+0080至U+07FF)。
- 其他基本多文种平面(BMP)中的字符(这包含了大部分常用字,如大部分的汉字)使用三个字节编码(Unicode范围由U+0800至U+FFFF)。
- 其他极少使用的Unicode 辅助平面的字符使用四至六字节编码(Unicode范围由U+10000至U+1FFFFF使用四字节,Unicode范围由U+200000至U+3FFFFFF使用五字节,Unicode范围由U+4000000至U+7FFFFFFF使用六字节)。
对上述提及的第四种字符而言,UTF-8使用四至六个字节来编码似乎太耗费资源了。但UTF-8对所有常用的字符都可以用三个字节表示,而且它的另一种选择,UTF-16编码,对前述的第四种字符同样需要四个字节来编码,所以要决定UTF-8或UTF-16哪种编码比较有效率,还要视所使用的字符的分布范围而定。不过,如果使用一些传统的压缩系统,比如DEFLATE,则这些不同编码系统间的的差异就变得微不足道了。
UTF-8编码规则:
- 对于UTF-8编码中的任意字节B,如果B的第一位为0,则B独立的表示一个字符(ASCII码);
- 如果B的第一位为1,第二位为0,则B为一个多字节字符中的一个字节(非ASCII字符);
- 如果B的前两位为1,第三位为0,则B为两个字节表示的字符中的第一个字节;
- 如果B的前三位为1,第四位为0,则B为三个字节表示的字符中的第一个字节;
- 如果B的前四位为1,第五位为0,则B为四个字节表示的字符中的第一个字节;
因此,对UTF-8编码中的任意字节,根据第一位,可判断是否为ASCII字符;根据前二位,可判断该字节是否为一个字符编码的第一个字节;根据前四位(如果前两位均为1),可确定该字节为字符编码的第一个字节,并且可判断对应的字符由几个字节表示;根据前五位(如果前四位为1),可判断编码是否有错误或数据传输过程中是否有错误。
小结:
Unicode符号范围(十六进制) | UTF-8编码方式(二进制) |
---|---|
0000 0000-0000 007F | 0xxxxxxx |
0000 0080-0000 07FF | 110xxxxx 10xxxxxx |
0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
比如:严的 Unicode 是4E25(100111000100101)编码
可以发现4E25处在第三行的范围内(0000 0800 - 0000 FFFF),因此严的 UTF-8 编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx。然后,从严的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0。这样就得到了,严的 UTF-8 编码是11100100 10111000 10100101,转换成十六进制就是E4B8A5。
关于小尾端(Little endian) 和 大尾端(Big endian)
UTF-32和UTF-16在不同的计算机系统会以不同的顺序保存字节。这意味着字符U+4E2D在UTF-16编码方式下可能被保存为4E 2D或者2D 4E,这取决于该系统使用的是大尾端(big-endian)还是小尾端(little-endian)。只要文档没有离开你的计算机,它还是安全的——同一台电脑上的不同程序使用相同的字节顺序(byte order)。但是当我们需要在系统之间传输这个文档的时候,也许在万维网中,我们就需要一种方法来指示当前我们的字节是怎样存储的。不然的话,接收文档的计算机就无法知道这两个字节4E 2D表达的到底是U+4E2D还是U+2D4E。
Unicode 规范定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做"零宽度非换行空格"(zero width no-break space),用FEFF表示。
在UTF-16编码中,如果文本文件的头两个字节是FE FF,就表示该文件采用大尾端;如果头两个字节是FF FE,就表示该文件采用小尾端。
编码 | 小尾端(Little endian) | 大尾端(Big endian) |
---|---|---|
UTF-16 | FFFE | FEFF |
UTF-32 | FFFE0000 | 0000FEFF |
GB2312、Big5、GBK、GB18030 字符集
GB2312字符集
GB2312 最早一版的中文编码,每个字占据两个字节。由于要和ASCII兼容,那这两个字节最高位不可以为0了(否则和ASCII会有冲突)。在GB2312中收录了6763个汉字以及682个特殊符号,已经囊括了生活中最常用的所有汉字。
GB2312中也收录了英文字母和数字等符号(ASCII码中也有这些符号),并且仍然是以俩字节编码,于是GB2312中的英文字母和数字等就成了我们平常所说的全角符号,而ASCII码的符号就叫做半角符号。
Big5字符集
Big5是由台湾财团法人信息产业策进会为五大中文套装软件(并因此得名Big-5)所设计的中文共通内码,在1983年12月完成公告。那个之前还没有繁体字编码,GB2312又不含繁体字,因此才有了Big-5
GBK字符集
GBK 即汉字内码扩展规范,K为汉语拼音 Kuo Zhan(扩展)中“扩”字的声母。英文全称 Chinese Internal Code Specification。
微软利用了GB2312中未使用的编码空间,并且收录了GB13000中的全部字符,从而定制了GBK编码(虽然收录了GB13000的全部字符,但是编码方式并不相同),并且实现于Windows95中文版中。GBK自身并非国家标准,不过1995年由国标局等机构确定为“技术规范指导性文件”。
GBK 共收入 21886 个汉字和图形符号,包括:
- GB2312 中的全部汉字、非汉字符号。
- BIG5 中的全部汉字。
- 与 ISO 10646 相应的国家标准 GB 13000 中的其它 CJK 汉字,以上合计 20902 个汉字。
- 其它汉字、部首、符号,共计 984 个。
简单地说:GBK是从GB2312扩展而来的,支持繁体,并且兼容GB2312。
GB18030字符集
全称:国家标准 GB18030-2005《信息技术中文编码字符集》
GB2312和GBK都是用两个字节来编码的,就算用完所有的位(256*256=65536)也不够为所有的汉字编码。于是就有了目前最新的GB18030,它采用类似UTF-8的编码方式进行编码(每个字符的编码可以是1、2或4个字节),拥有上百万个编码空间,足以支持中日韩三国所有汉字,并且还可以支持国内少数民族的文字。
GB18030 与 GB2312-1980 和 GBK 兼容,共收录汉字70244个。
- 与 UTF-8 相同,采用多字节编码,每个字可以由 1 个、2 个或 4 个字节组成。
- 编码空间庞大,最多可定义 161 万个字符。
- 支持中国国内少数民族的文字,不需要动用造字区。
- 汉字收录范围包含繁体汉字以及日韩汉字。
GB18030 编码是一二四字节变长编码。
- 单字节,其值从 0 到 0x7F,与 ASCII 编码兼容。
- 双字节,第一个字节的值从 0x81 到 0xFE,第二个字节的值从 0x40 到 0xFE(不包括0x7F),与 GBK 标准兼容。
- 四字节,第一个字节的值从 0x81 到 0xFE,第二个字节的值从 0x30 到 0x39,第三个字节从0x81 到 0xFE,第四个字节从 0x30 到 0x39。
iOS 字符长度问题
回到最开始的问题上,NSString 是采用 UTF-16 进行编码的,length 方法的返回值也是字符串包含的码元个数(而不是字符个数),所以这就可以解释为什么emoji表情的长度会有问题。
enumerateSubstringsInRange:options:usingBlock:
方法。这个方法把 Unicode 抽象的地方隐藏了,能让你轻松地循环字符串里的组合字符串、单词、行、句子或段落。你甚至可以加上 NSStringEnumerationLocalized
这个选项,这样可以在确定词语间和句子间的边界时把用户所在的区域考虑进去。要遍历单个字符,把参数指定为 NSStringEnumerationByComposedCharacterSequences
NSString *s = @"123";
NSRange fullRange = NSMakeRange(0, [s length]);
__block NSInteger count = 0;
[s enumerateSubstringsInRange:fullRange
options:NSStringEnumerationByComposedCharacterSequences
usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop)
{
count++;
}];
NSLog(@"%ld", count);
/*
输出:
5
*/
参考资料
- NSString 与 Unicode
- 字符编码笔记:ASCII,Unicode 和 UTF-8
- Unicode
- UTF-32
- UTF-16
- GB2312 HZGB2312 BIG5 GBK GB18030
- Emoji 简介
- 字符集
- Unicode字符平面映射表
- 中日韩汉字编码表
- Emoji编码表