某一天伏穆,leader找到我說(shuō)宇植,felix啊,這里有個(gè)小需求,給我們的實(shí)名認(rèn)證中的地址加入字?jǐn)?shù)限制埃儿,一天時(shí)間綽綽有余了吧。我一聽(tīng)册踩,小事啊工闺,趕緊拍拍胸脯告訴leader,一天都不用温峭,以我的效率1個(gè)小時(shí)就夠了猛铅。領(lǐng)導(dǎo)很滿意的說(shuō),小伙子有前途凤藏。
那些年的Emoji
打開(kāi)工程奸忽,三下五除二就定位到修改點(diǎn)修改完畢,自測(cè)通過(guò)揖庄,完美栗菜。就在我美滋滋的沖上一杯咖啡,幻想年底能不能拿個(gè)5星員工蹄梢,走上人生巔峰的時(shí)候疙筹,測(cè)試妹子找到了我:
『felix你代碼有問(wèn)題,如果最后的字符是emoji表情禁炒,會(huì)被截?cái)喽亍!?br>
what齐苛?作為一名老資歷程序員翘盖,我自然考慮過(guò)不同字符的問(wèn)題,最典型的就是中英文混排凹蜂。但在這種情況下馍驯,NSString
返回的字符長(zhǎng)度是相同的阁危,想來(lái)是蘋(píng)果爸爸幫我們處理好了√保可這emoji的長(zhǎng)度是什么鬼狂打?
我趕緊寫(xiě)了個(gè)代碼,測(cè)試了下emoji的長(zhǎng)度混弥。
NSString *a = @"??";
NSLog(@"%d",a.length);
結(jié)果是
2
這下我只好趕緊找找unicode的資料趴乡。
從ASCII的榮光到Unicode的崛起
程序員都知道,計(jì)算機(jī)沒(méi)法直接處理文本蝗拿,它只和數(shù)字打交道晾捏。為了在計(jì)算機(jī)里用數(shù)字表示文本,我們指定了一個(gè)從字符到數(shù)字的映射哀托。這個(gè)映射就叫做編碼(encoding)惦辛。最有名的一個(gè)字符編碼是 ASCII。ASCII 碼是 7 位的仓手,它將英文字母胖齐,數(shù)字 0-9 以及一些標(biāo)點(diǎn)符號(hào)和控制字符映射為 0-127 這些整型。但是嗽冒,由于 8 位的空間對(duì)于歐洲的文字來(lái)說(shuō)都不夠呀伙,更不用說(shuō)全世界的書(shū)寫(xiě)系統(tǒng)了,因此人們開(kāi)發(fā)了更加通用的編碼——Unicode添坊。最初剿另,Unicode 編碼是被設(shè)計(jì)為 16 位的,提供了 65,536 個(gè)字符的空間帅腌。當(dāng)時(shí)人們認(rèn)為這已經(jīng)大到足夠編碼世界上現(xiàn)代文本里所有的文字和字符了驰弄。后來(lái),考慮到要編碼歷史上的文字以及一些很少使用的日本漢字和中國(guó)漢字[^2]速客,Unicode 編碼擴(kuò)展到了 21 位(從 U+0000 到 U+10FFFF)。
Unicode編碼空間
Unicode 的基本元素 —— 它的 “字符”五鲫,雖然這種叫法不是太貼切——被稱作編碼點(diǎn)(Code Point)溺职。編碼點(diǎn)通過(guò)數(shù)字來(lái)區(qū)分,通常寫(xiě)成 16 進(jìn)制的形式再加前綴“U+”位喂,例如 U+0041 表示拉丁字母 “A” 浪耘、U+03B8 表示 希臘字母 “θ”。所有編碼點(diǎn)組成的集合被稱作編碼空間(Code Space)塑崖。
Unicode 編碼空間包含 1,114,112 個(gè)編碼點(diǎn)七冲。然而,其中只有128,237 個(gè)編碼點(diǎn) —— 編碼空間的 12% 被賦值规婆,目前澜躺。還有很多空間用來(lái)增長(zhǎng)蝉稳!Unicode 還保留了另外 137,468 字符 作為 “自用” 空間,這些字符沒(méi)有標(biāo)準(zhǔn)的含義掘鄙,可以被個(gè)人應(yīng)用所使用耘戚。
為了對(duì)編碼空間的布局有個(gè)了解,把它可視化會(huì)比較直觀操漠。下面是整個(gè)編碼空間的布局收津,一個(gè)像素代表一個(gè)編碼點(diǎn)。使用小方塊來(lái)表示以保證視覺(jué)的一致性浊伙;每個(gè)小方塊是 16×16 = 256 個(gè)編碼點(diǎn)撞秋,每個(gè)大方塊是一個(gè)面有 65536 個(gè) 編碼點(diǎn)∠桑總共加起來(lái)有 17 個(gè)面板吻贿。
- 白色表示未用空間;
- 藍(lán)色表示已用空間拗慨;
- 綠色表示自用區(qū)域廓八;
- 小的紅色區(qū)域是代理區(qū)(surrogates,后面會(huì)講)赵抢。
其中第一個(gè)面板被稱作『基本多語(yǔ)言面板(Basic Multilingual Plane剧蹂,簡(jiǎn)稱 BMP)』。BMP包含現(xiàn)代文本所需的基本所有字符烦却,包括拉丁文宠叼、斯拉夫文、希臘文其爵、漢字(中國(guó))冒冬,日文、朝鮮文摩渺、阿拉伯文简烤、希伯來(lái)文、梵文(印度)等等摇幻。這個(gè)面板就是最初Unicode設(shè)計(jì)所占用的空間(16位横侦,65536個(gè)字符)。后來(lái)擴(kuò)展到現(xiàn)在這個(gè)規(guī)模绰姻,然而枉侧,大部分現(xiàn)代字符在BMP的范圍內(nèi)。
第二個(gè)面板則是包括歷史上的文字狂芋,比如蘇美爾楔形文字和埃及象形文字還有今天我們說(shuō)起的emoji表情榨馁。第三個(gè)面板包含一大塊不常用的和歷史上的漢字字符。剩下的是空的帜矾,除了 倒數(shù)第三個(gè)面板中有一小部分被用作格式化字符翼虫;倒數(shù)兩個(gè)面板全部保留自用屑柔。
為了和以前的ASCII編碼兼容,Unicode的128個(gè)字符就是ASCII的拷貝蛙讥。這樣很容易從小編碼轉(zhuǎn)向unicode锯蛀。順帶提一句,由于unicode被設(shè)計(jì)為以抽象的方式戴表一個(gè)字符次慢,而不規(guī)定這個(gè)字符如何呈現(xiàn)旁涤。如此一來(lái),Unicode 對(duì)中文迫像、日文和韓文(CJK)里使用的漢字(也就是所謂的統(tǒng)一漢字)都使用完全相同的碼點(diǎn)(這一決定頗具爭(zhēng)議)劈愚,盡管在這些書(shū)寫(xiě)系統(tǒng)里,每個(gè)漢字都發(fā)展出了獨(dú)特的字形變體闻妓。
UTF8和UTF16
現(xiàn)在搞懂了Unicode的編碼點(diǎn)了菌羽,但是在內(nèi)存或文件中如何用字節(jié)表示呢?
當(dāng)然由缆,最省事的辦法就是用32位來(lái)存儲(chǔ)編碼點(diǎn)下標(biāo)注祖,但是這樣的話,每個(gè)字符都占四個(gè)字節(jié)均唉,當(dāng)你處理大量文本的時(shí)候是晨,這樣就太浪費(fèi)內(nèi)存或帶寬了。
在我們討論解決辦法之前舔箭,我們先看看一個(gè)圖:
這是unicode編碼面板中的前三個(gè)面板的使用頻率圖(數(shù)據(jù)來(lái)自維基百科和twitter)罩缴。頻率增長(zhǎng)的方向是黑(沒(méi)出現(xiàn))、紅层扶、黃箫章、白。
可以看到镜会,絕大多數(shù)文本分布在BMP內(nèi)檬寂,有些零散的使用來(lái)自第二三個(gè)面板。第二個(gè)面板下高頻率使用的字符則是部分emoji表情戳表。
那么焰薄,為了解決unicode編碼占據(jù)的內(nèi)存問(wèn)題,unicode就有了幾個(gè)緊湊的編碼 扒袖。32 位整數(shù)編碼被稱作 UTF-32(UTF=”Unicode Transformation Format”),但是很少被用來(lái)存儲(chǔ)亩码。最常見(jiàn)的是季率,你會(huì)看到 Unicode 文本被編碼為 UTF-8 或 UTF-16。從上面的熱力圖可知兩個(gè)編碼涵蓋的是最常見(jiàn)的文本描沟,內(nèi)存能最大程度的利用飒泻。這些都是可變長(zhǎng)度編碼鞭光,分別由 8-bit 或 16-bit 為一個(gè)單元組成。這些方案中泞遗,下標(biāo)值較小的編碼點(diǎn)占用的字節(jié)數(shù)也少惰许,會(huì)節(jié)省不少內(nèi)存。這樣做的代價(jià)是處理 UTF-8/16 需要以編程的方式來(lái)處理史辙,會(huì)慢一些汹买。
1. UTF8
在 UTF-8 中,每個(gè)編碼點(diǎn)依據(jù)下標(biāo)值聊倔,被存儲(chǔ)為 1 到 4 個(gè)字節(jié)晦毙。
UTF-8 使用二進(jìn)制前綴系統(tǒng),在此系統(tǒng)中每個(gè)字符的最高位的幾個(gè)比特表明它是否是單個(gè)字節(jié)耙蔑,多字節(jié)序列的開(kāi)始见妒,或中間字節(jié);剩余的比特連接起來(lái)表示編碼點(diǎn)的下標(biāo)甸陌。下面的表格展示了UTF-8 是如何編碼的:
UTF-8 (二進(jìn)制) | 編碼點(diǎn) (二進(jìn)制) | 范圍 |
---|---|---|
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有以下幾個(gè)好處:
- 對(duì)于很常見(jiàn)的西文字符须揣,采用這種編碼方式也不會(huì)浪費(fèi)內(nèi)存。
- 由于UTF-8 是基于 8 位的碼元的钱豁,因此它并不需要關(guān)心字節(jié)順序耻卡。
- 任何已經(jīng)是 ASCII 編碼的字符串和文件無(wú)需轉(zhuǎn)換就可以被 UTF-8 識(shí)別。
- 大量的廣泛使用的編程慣例——比如 NULL 結(jié)尾寥院,分隔符(n,t,’,’,”)等——在 UTF-8 中也是可用的劲赠。
其中,后面這兩點(diǎn)好處是基于UTF8的一個(gè)屬性秸谢,即最開(kāi)始128 個(gè)字符(ASCII字符)被編碼為單個(gè)字節(jié)凛澎,所有的非 ASCII 字符被編碼為 128-255。
因?yàn)檫@些原因估蹄,UTF-8 成為存儲(chǔ)和交流 Unicode 文本方面的最佳編碼塑煎。它也已經(jīng)是文件格式、網(wǎng)絡(luò)協(xié)議以及 Web API 領(lǐng)域里事實(shí)上的標(biāo)準(zhǔn)了臭蚁。這也是為什么我們?cè)谔幚碜址畷r(shí)最铁,最常打交道的是NSUTF8StringEncoding
。
2. UTF16
和 UTF-8 一樣垮兑,我們可以用二進(jìn)制前綴的形式表示 UTF-16 的編碼規(guī)則:
UTF-16 (二進(jìn)制) | 編碼點(diǎn) (二進(jìn)制) | 范圍 |
---|---|---|
xxxxxxxxxxxxxxxx | xxxxxxxxxxxxxxxx | U+0000–U+FFFF |
110110xxxxxxxxxx 110111yyyyyyyyyy | xxxxxxxxxxyyyyyyyyyy + 0x10000 | U+10000–U+10FFFF |
正如我們前面所言冷尉,最早unicode是設(shè)計(jì)為16位,包含的范圍也就是BMP系枪。但后來(lái)為了支持一些更少使用的漢字或其它字符雀哨,unicode擴(kuò)展到21位。為了在UTF16中能訪問(wèn)到后面的碼位,這些碼位會(huì)被編碼為一對(duì)16bit的碼元雾棺,稱作代理對(duì)膊夹。具體方法可以參考維基百科。由于BMP剩下可用來(lái)做代理的范圍僅剩U+D800-U+DFFF中的編碼點(diǎn)捌浩,1位用于標(biāo)識(shí)高位或低位放刨,剩下的10位成對(duì)最多只能支持到220-1 ,所以u(píng)nicode只能擴(kuò)展到21位(最大值220 + 216 -1)尸饺。
同時(shí)由于UTF16被設(shè)計(jì)為多字節(jié)进统,和所有多字節(jié)長(zhǎng)度的編碼系統(tǒng)一樣,它還得解決字節(jié)順序的問(wèn)題侵佃。Unicode 在這個(gè)問(wèn)題上沒(méi)有說(shuō)明麻昼,雖然它確實(shí)鼓勵(lì)一個(gè)慣例,即把 U+FEFF 零寬無(wú)間斷間隔這個(gè)字符放到 UTF-16 文件開(kāi)頭作為字節(jié)序標(biāo)識(shí)馋辈,來(lái)消除字節(jié)序問(wèn)題抚芦。
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系統(tǒng)中,NSString是以UTF16編碼的迈螟,默認(rèn)是大端字節(jié)序叉抡,如果要使用其它字節(jié)序,則需要使用NSUTF16BigEndianStringEncoding或者NSUTF16LittleEndianStringEncoding答毫。(注:蘋(píng)果同時(shí)還提供了NSUTF32BigEndianStringEncoding和NSUTF32LittleEndianStringEncoding)
組合字符
看完這些資料褥民,我自信對(duì)Unicode的編碼方式相當(dāng)了解了。長(zhǎng)度不同的問(wèn)題是因?yàn)榫幋a不同嘛洗搂,所以我直接取字符串的32位編碼長(zhǎng)度消返,就沒(méi)問(wèn)題了。于是代碼修改如下:
NSString *a = @"??";
NSLog(@"%d",[test lengthOfBytesUsingEncoding:NSUTF32StringEncoding]/4);
結(jié)果是:
1
完美耘拇!關(guān)單讓測(cè)試小姐姐重測(cè)下撵颊,我又可以繼續(xù)喝我的咖啡了。然而惫叛,我才喝了一口倡勇,測(cè)試小姐姐反饋說(shuō)還是有問(wèn)題。
無(wú)奈嘉涌,我只能放下我的咖啡妻熊,根據(jù)測(cè)試小姐姐的反饋,重新調(diào)試下:
NSString *a = @"????";
NSLog(@"%d",[test lengthOfBytesUsingEncoding:NSUTF32StringEncoding]/4);
調(diào)試結(jié)果:
2
這不科學(xué)奥刈睢扔役!明明unicode都是32位,咋一個(gè)字符還長(zhǎng)度為2了呢警医。
組合字符
繼續(xù)沉浸在unicode的大部頭中厅目,我終于找到了新的信息——組合字符。
Unicode 包含一個(gè)系統(tǒng),可以合并多個(gè)編碼點(diǎn)损敷,動(dòng)態(tài)組合字符。此系統(tǒng)用各種方式增加靈活性深啤,而不引起編碼點(diǎn)的巨大組合膨脹拗馒。
例如,在歐洲語(yǔ)言中溯街,組合標(biāo)記出現(xiàn)在變音符和字母的使用中诱桂。 Unicode 支持各種各樣的變音符號(hào),包括尖音符號(hào)的和重音符號(hào)呈昔、元音變音符號(hào)挥等、變音符號(hào)等等。所有這些變音符可以被使用在任何字母表的字母中堤尾。事實(shí)上肝劲,多個(gè)變音符號(hào)可以被使用在一個(gè)字母上。
如果 Unicode 試圖為每個(gè)字母組合或變音符組合分配一個(gè)獨(dú)立的編碼點(diǎn)郭宝,事情會(huì)變得無(wú)法控制辞槐。相反,動(dòng)態(tài)組合系統(tǒng)可以讓你構(gòu)造你想要的任何字符粘室,通過(guò)以一個(gè)基礎(chǔ)編碼點(diǎn)(字母)開(kāi)始然后附加額外的編碼點(diǎn)榄檬,被稱作“組合標(biāo)識(shí)”,來(lái)指定變音符衔统。當(dāng)一個(gè)文字渲染器看到字符串中有這樣的序列時(shí)鹿榜,它會(huì)自動(dòng)堆疊變音符到基礎(chǔ)字母的上面或下面來(lái)造出一個(gè)組合字符。
例如锦爵,帶重音的字符“á” 會(huì)被表示成由兩個(gè)編碼點(diǎn)組成的字符串:U+0041 “A” 拉丁大寫(xiě)字母 a 加上 U+0301 “??”組合尖音符號(hào)舱殿。這個(gè)字符串自動(dòng)被渲染成單個(gè)字符:“á”。
有時(shí)候我們會(huì)看到某些人的簽名中有很奇怪的字符棉浸,其實(shí)他們就是利用了組合字符怀薛。比如á?? 就是多添加了幾個(gè)尖音符號(hào):U+0041U+0301U+0301U+0301
如今,Unicode 還包含許多 “預(yù)設(shè)的” 編碼點(diǎn)迷郑,每個(gè)表示一個(gè)被使用過(guò)的組合枝恋,例如 U+00C1 “á” 帶銳音符的拉丁大寫(xiě)字母A 或 U+1EC7 “?” 帶揚(yáng)抑符和下點(diǎn)的小寫(xiě)拉丁字母 e。我懷疑這些大多繼承自融入 Unicode 的舊編碼嗡害,來(lái)保證兼容性焚碌。實(shí)際上,對(duì)于歐洲語(yǔ)言中的大多數(shù)常見(jiàn)的帶變音符號(hào)的字母都有預(yù)設(shè)霸妹,所以文本中動(dòng)態(tài)組合用的不多十电。
Unicode 中,預(yù)設(shè)字符和動(dòng)態(tài)組合系統(tǒng)并存。后果就是有多種方法表示同一個(gè)字符串——不同編碼點(diǎn)序列產(chǎn)生相同用戶可感知的字符鹃骂。例如台盯,我們之前看到的,表示字符 “á”畏线,我們可以用一個(gè)編碼點(diǎn) U+00C1 静盅,也可以用兩個(gè)編碼點(diǎn) U+0041 和U+0301。要解決這個(gè)等值字符串的問(wèn)題寝殴,Unicode 定義了幾種形式正規(guī)化方法蒿叠。比如NFD和NFC,由于這部分比較復(fù)雜就不做贅述蚣常。有興趣的可以參考后面提供的資料市咽。
字位簇
如上所見(jiàn),Unicode 包含多種情況抵蚊,用戶認(rèn)為的一個(gè)“字符” 事實(shí)上底下可能由多個(gè)編碼點(diǎn)組成施绎。Unicode 使用「字位簇」的概念來(lái)表示這種情況。一個(gè)由一個(gè)或多個(gè)編碼點(diǎn)組成的字符串構(gòu)成一個(gè) “用戶感知的字符”泌射。
UAX #29 為字位簇定義了精確的規(guī)則粘姜。它大約是 “一個(gè)基本的編碼點(diǎn)接著任意數(shù)量的組合標(biāo)記”,但是真實(shí)的定義有點(diǎn)復(fù)雜熔酷;它包含了朝鮮語(yǔ)字母孤紧,和 emoji ZWJ 序列。
所以拒秘,部分emoji的unicode長(zhǎng)度大于1的本質(zhì)原因是這些emoji是字位簇号显。具體的emoji列表可以查看這個(gè)網(wǎng)址√删疲可以看到蘋(píng)果爸爸在后面添加的很多emoji長(zhǎng)度已經(jīng)不是單個(gè)unicode字符了押蚤。畢竟一個(gè)emoji表情還要?jiǎng)澐秩朔N這種喪心病狂的事情,再多碼位也hold不住羹应。
終極解決方案
本來(lái)如果只是不同unicode編碼的問(wèn)題揽碘,那么統(tǒng)一使用UTF32就可以了。但由于組合字符的存在园匹,一個(gè)人類(lèi)可讀的字符串在編碼中實(shí)際上是一個(gè)稀疏陣列雳刺。先不說(shuō)如何處理那么多種語(yǔ)言下的組合字符的問(wèn)題,單是處理這個(gè)陣列找出真正的可讀字符串也是需要好些代碼裸违。還好掖桦,iOS下提供了一個(gè)方法enumerateSubstringsInRange:options:usingBlock:
可以方便的找出迭代找出字符串中的組合字符。那么判斷一個(gè)字符串實(shí)際長(zhǎng)度的做法是:
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);
結(jié)果正確供汛。這里還需要考慮到枪汪,如果是要截取字符串涌穆,那么要?jiǎng)h除的部分,也應(yīng)該是完整的組合字符雀久。這里就不寫(xiě)具體的代碼宿稀,留給大家做作業(yè)~~~
結(jié)尾
實(shí)際上unicode還有很多復(fù)雜的內(nèi)容,還在很多問(wèn)題都已經(jīng)由系統(tǒng)幫我們處理好了岸啡。但是了解unicode的機(jī)制原叮,對(duì)于我們解決bug有很大的幫助。
此文是根據(jù)以下文章重新整理而成: