NSString and Unicode

NSString 是 iOS 开发中最常用的类之一,也是最基本的类之一,包含字符串一些最基本的属性与操作,但是在实际使用过程中还是会遇到各种问题,比如错误截断字符导致的显示异常或者崩溃等等,所以简单的整理下 NSString 在使用过程中编码最容易出现的问题,做个简要分析。

Unicode

Unicode字符集标准为世界上几乎所有的字符定义了一个唯一数字编号,这个数字编号叫做码位(Code Points),Unicode的编码空间从U+0000到U+10FFFF,共有1,112,064个码位可用来映射字符。

Unicode的编码空间可以划分为17个平面(Plane),每个平面包含216(65,536)个码位,17个平面的码位可表示为从U+xx0000到U+xxFFFF,其中xx表示十六进制值从0016到1016,共计17个平面。第一个平面称为基本多语言平面(Basic Multilingual Plane, BMP),或称第零平面(Plane 0),其他平面称为辅助平面(Supplementary Planes)。基本多语言平面内(BMP)从U+D800到U+DFFF之间的码位区块是永久保留,不映射到Unicode字符。

Tips:理解码位、平面、基本多语言平面、辅助平面等相关概念!
更多内容请阅读 Wikipedia-Unicode

UTF-16

UTF-16就利用基本多语言平面内保留下来的0xD800-0xDFFF区段的码位来对辅助平面的字符的码位进行编码。它是根据有16位固定长度的码元(Code Units)定义的,即把Unicode字符集的抽象码位映射为16位长的整数(即码元)的序列,用于数据存储或传递。Unicode字符的码位,需要1个、2个或者多个16位长的码元来表示,因此UTF-16本身是一种长度可变的编码。

基本多文种平面(BMP)几乎囊括了所有常见字符,基本多文种平面(BMP)中的每一个码位都直接与一个16位的码元相映射。其它平面里很少使用的码位都是用两个或者多个16位的码元来编码的,这个组合起来表示一个码位的码元就叫做代理对(Surrogate Pairs)。

Tips:码元、代理对等相关概念!
更多内容请阅读 Wikipedia-UTF-16

NSString

NSString是完全建立在Unicode上的, 一个NSString对象代表的是用UTF-16编码的码元数组,如下表格列举的部分示例字符和对应的Unicode:

字符 Unicode length
U+4e2d 1
C U+0043 1
1 U+0031 1
😂 U+1F602 2
👨‍🎓 U+1F468 U+200D U+1F393 5
👨‍⚕️ U+1F468 U+200D U+2695 U+FE0F 5
👩‍❤️‍💋‍👨 U+1F469 U+200D U+2764 U+FE0F U+200D U+1F48B U+200D U+1F468 11

Emoji versus text presentation

Zero-width joiner

length

NSString相应的length方法返回的也是字符串码元的个数,而不是字符串的可见字符个数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
NSString *originString = @"中C1😂👨‍🎓👨‍⚕️👩‍❤️‍💋‍👨"; //
NSString *charCh = @"中";
NSString *charEn = @"C";
NSString *charNu = @"1";
NSString *emoj0 = @"😂";
NSString *emoj1 = @"👨‍🎓";
NSString *emoj2 = @"👨‍⚕️";
NSString *emoj3 = @"👩‍❤️‍💋‍👨";

NSLog(@"%@: %ld", originString, originString.length);
NSLog(@"%@: %ld", charCh, charCh.length);
NSLog(@"%@: %ld", charEn, charEn.length);
NSLog(@"%@: %ld", charNu, charNu.length);
NSLog(@"%@: %ld", emoj0, emoj0.length);
NSLog(@"%@: %ld", emoj1, emoj1.length);
NSLog(@"%@: %ld", emoj2, emoj2.length);
NSLog(@"%@: %ld", emoj3, emoj3.length);

输出结果,可以看到length取到的实际长度:

1
2
3
4
5
6
7
8
中C1😂👨‍🎓👨‍⚕️👩‍❤️‍💋‍👨: 26
中: 1
C: 1
1: 1
😂: 2
👨‍🎓: 5
👨‍⚕️: 5
👩‍❤️‍💋‍👨: 11

- (NSString *)substringWithRange:(NSRange)range;

通常情况下,在取NSString子字符串的时候,直接使用substringWithRange根据NSRange来截取,这样是错误的:

1
2
3
4
for (NSInteger index = 0; index < originString.length; index++) {
NSString *tempString = [originString substringWithRange:NSMakeRange(index, 1)];
NSLog(@"tempString:%@", tempString);
}

打印输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
tempString:中
tempString:C
tempString:1
tempString:\ud83d
tempString:\ude02
tempString:\ud83d
tempString:\udc68
tempString:‍
tempString:\ud83c
tempString:\udf93
tempString:\ud83d
tempString:\udc68
tempString:‍
tempString:⚕
tempString:️
tempString:\ud83d
tempString:\udc69
tempString:‍
tempString:❤
tempString:️
tempString:‍
tempString:\ud83d
tempString:\udc8b
tempString:‍
tempString:\ud83d
tempString:\udc68
subString:中C1

可以看到超过1个码元的字符全部被截断了,其实在官方api里面已经明确提示了要使用rangeOfComposedCharacterSequenceAtIndex:方法来处理:

1
2
3
4
5
6
7
8
9
10
11
// NSString.h
// line 117

/* To avoid breaking up character sequences such as Emoji, you can do:
[str substringFromIndex:[str rangeOfComposedCharacterSequenceAtIndex:index].location]
[str substringToIndex:NSMaxRange([str rangeOfComposedCharacterSequenceAtIndex:index])]
[str substringWithRange:[str rangeOfComposedCharacterSequencesForRange:range]
*/
- (NSString *)substringFromIndex:(NSUInteger)from;
- (NSString *)substringToIndex:(NSUInteger)to;
- (NSString *)substringWithRange:(NSRange)range;

注释里提示了为了避免折断像Emoji这样的字符串序列,要使用如下方式来正确的获取字符串子串:

1
2
3
[str substringFromIndex:[str rangeOfComposedCharacterSequenceAtIndex:index].location];
[str substringToIndex:NSMaxRange([str rangeOfComposedCharacterSequenceAtIndex:index])];
[str substringWithRange:[str rangeOfComposedCharacterSequencesForRange:range];

- (NSRange)rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index;

官方推荐用rangeOfComposedCharacterSequenceAtIndex这个方法来获取字符序列,一次遍历的是一个字符子串:

1
2
3
4
5
6
NSRange range;
for (NSInteger index = 0; index < originString.length; index+= range.length) {
range = [originString rangeOfComposedCharacterSequenceAtIndex:index];
NSString *tempString = [originString substringWithRange:range];
NSLog(@"tempString:%@:%ld-%ld", tempString, range.length, tempString.length);
}

输出结果:

1
2
3
4
5
6
7
tempString:中:1-1
tempString:C:1-1
tempString:11-1
tempString:😂:2-2
tempString:👨‍🎓:5-5
tempString:👨‍⚕️:5-5
tempString:👩‍❤️‍💋‍👨:11-11

当然还可以直接使用block形式:

1
2
3
4
5
6
7
8
9

[originString enumerateSubstringsInRange:NSMakeRange(0, originString.length)
options:NSStringEnumerationByComposedCharacterSequences
usingBlock:^(NSString * _Nullable substring,
NSRange substringRange,
NSRange enclosingRange,
BOOL * _Nonnull stop) {
NSLog(@"substring:%@", substring);
}];

- (unichar)characterAtIndex:(NSUInteger)index;

使用characterAtIndex:这个方法来获取特定位置的字符,返回一个16无符号整形,这个方法现在容易给人误会,因为NSString最开始开发的时候,Unicode还是16位编码,现在一个码位实际使用了21位,所有在使用characterAtIndex获取字符的时候,结果可能是错误的;

1
2
3
4
for (NSInteger index = 0; index < originString.length; index++) {
unichar ch = [originString characterAtIndex:index];
NSLog(@"ch:%c", ch);
}

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ch:-
ch:C
ch:1
ch:=
ch:
ch:=
ch:h
ch:
ch:<
ch:\223
ch:=
ch:h
ch:
ch:\225
ch:
ch:=
ch:i
ch:
ch:d
ch:
ch:
ch:=
ch:\213
ch:
ch:=
ch:h

可以发现很多取出来的字符不正确,编码有效位超过16位的字符实际会被截断,所以在使用这个方法的时候要谨慎。

参考

0%