Loading... ## Unicode 编码体系 Unicode 是一种字符编码标准,旨在为全球所有书写系统中的字符分配唯一的编码,目前的范围是(U+0000 至 U+10FFFF)。每个字符都被分配了一个位置,这个位置叫做**Code Point(码点)**,通常以 `U+XXXXXX` 的形式表示。例如: * **英文字母 A:**`U+0041` * **中文字符 "你":**`U+4F60` * **Emoji "😊":**`U+1F60A` **你可能注意到,为什么前两个字符使用了四个十六进制数字,而第三个 Emoji 却使用了五个十六进制数字?这是因为 Unicode 引入了 **Plane(平面)** 的概念。 ### 平面是什么 Unicode 1.0 刚刚面世的时候使用的是 16 位编码,因为所需要编码的字符数量很少,最多可以编码 2^16=65536 个字符,可随着所编码的字符数越来越多,为了考虑兼容性问题,不能直接增加编码位的长度,所以引入了平面的概念作为字符集的补充。 Unicode 目前的表示方式是:U+6个十六进制数。其中前两位表示平面,后四位表示码位。 #### 总共有多少个平面 既然两个十六进制数,也就是 1 Byte 来表示平面,那么理论上来说我们最多可以有 2^8=256 个平面。实际上,Unicode 的选择是分成了 17 个平面: | **平面** | **范围** | **中文名** | **英文名** | | ------------------- | ----------------------- | ----------------------------------- | ------------------------------------------------------ | | **0号** | `0000`至 `FFFF` | **基本多文种平面** | **Basic Multilingual Plane,简称BMP** | | **1号** | `10000`至 `1FFFF` | **多文种补充平面** | **Supplementary Multilingual Plane,简称SMP** | | **2号** | `20000`至 `2FFFF` | **表意文字补充平面** | **Supplementary Ideographic Plane,简称SIP** | | **3号** | `30000`至 `3FFFF` | **表意文字第三平面** | **Tertiary Ideographic Plane,简称TIP** | | **4号至13号** | `40000`至 `DFFFF` | **(未启用)** | | | **14号** | `E0000`至 `EFFFF` | **特别用途补充平面** | **Supplementary Special-purpose Plane,简称SSP** | | **15号** | `F0000`至 `FFFFF` | **保留作为私人使用区(A区)** | **Private Use Area-A,简称PUA-A** | | **16号** | `100000`至 `10FFFF` | **保留作为私人使用区(B区)** | **Private Use Area-B,简称PUA-B** | 0 号平面中就是最原始的 Unicode 标准,使用 16 位来表示基本字符,这个平面中包含了最常用的字符,所以被称为 **基本多文种平面**,除 0 号平面之外,其余的平面被称为辅助平面。 > 虽然大部分常用中文都在第〇平面中,但仍有少部分“常用字”处于辅助平面,比如**粤** <div class="tip inlineBlock info simple"> 第 15 和 16 平面叫做 PUA 平面同时 BMP 平面中也存在一段私人使用区,范围是 U+E000 **\~** U+F8FF 共有 6400 个码点,在这些平面中我们可以自定义编码一些字符,比如自定义自己的 Emoji,苹果的 Logo 就位于私人使用区中 </div> #### 为什么是 17 宇宙的终极答案是.... 42? ## UTF 编码 有了字符集之后,我们就需要考虑编码的方式了,毕竟计算机不可能直接存储码点,存储 Unicode 字符的方法叫做 **`UTF(Unicode Transformation Format)`** ,即将码点转为二进制数据的方法。 ### UTF-8 UTF-8 是一种变长编码,字符的长度可以是 **1 到 4 个字节**。 > ASCII 是上个世纪的编码方式,用于编码英文及一些字符。一共规定了 128 位,使用一个字节的后 7 位来表示,第一位统一为 0 * **ASCII** 兼容(1 字节):`A` (U+0041) → `0x41` * **多字节字符(2-4 字节):** * "你" (U+4F60) → `0xE4 0xBD 0xA0`(3 字节) * "😊" (U+1F60A) → `0xF0 0x9F 0x98 0x8A`(4 字节) ### UTF-16 #### 代理对机制 由于 UTF-16 实际上也算是一种可变长的编码,可以使用 16 位(2字节)或者 32 位(4字节) 来表示码点。 | **代理类型** | **Unicode 范围** | **16 进制范围** | **作用** | | ---------------------------------- | ---------------------------------- | ------------------------ | -------------------------------------------------------------- | | **高代理(High Surrogate)** | **U+D800**\~**U+DBFF** | **0xD800\~0xDBFF** | **代理对的第一个 16-bit 码单元,标志着代理对的开始** | | **低代理(Low Surrogate)** | **U+DC00**\~**U+DFFF** | **0xDC00\~0xDFFF** | **代理对的第二个 16-bit 码单元,跟随高代理形成完整字符** | UTF-16 采用 **2 或 4 字节** 进行编码。 * **基本多语言平面(BMP):2 字节** * `A` (U+0041) → `0x0041` * "你" (U+4F60) → `0x4F60` * **超出 BMP(辅助平面,Surrogate Pairs):** * "😊" (U+1F60A) → `0xD83D 0xDE0A`(2 个 16 位单元,合计 4 字节) UTF-16 适用于处理包含大量东亚字符的文本。 ### UTF-32 UTF-32 采用 **固定 4 字节**,直接存储 Unicode 码点。 * `A` (U+0041) → `0x00000041` * "你" (U+4F60) → `0x00004F60` * "😊" (U+1F60A) → *`0x0001F60A` 虽然 UTF-32 访问字符时更简单,但它的存储效率低,因此很少用于文本处理。 ## 字符串处理的陷阱 > 大部分编程语言在内部都使用 UTF-16 作为编码格式 ### 基于码元的实现 <div class="tip inlineBlock warning"> JavaScript, Kotlin, Dart, OC </div> 大部分编程语言中字符串相关的操作都是基于码元的实现,例如 `length`、`substring`、`indexOf` 等等,这样做的后果就是,当所操作的字符串中出现了使用多个 UTF-16 单元组成的字符,比如 Emoji ,就会导致意外的预期值,甚至在对字符串操作时,会发生异常(ZWJ 组合序列) ``` void main() { String text = "😊"; print(text.length); // Dart: 2(UTF-16 编码,存储为两个 16 位单元) } ``` 假如我们对包含 Emoji 的字符串使用 `substring`,但起始或结束索引正好落在 `Surrogate Pair` 之间,就可能截断字符,导致乱码或异常: ``` void main() { String text = "Hello 🧑•🚒!"; print(text.substring(5, 6)); // 可能崩溃或显示乱码 } ``` ### 基于字形集群的实现 ECG(Extended Grapheme Cluster) 扩展字形集群是一种更优雅的字符串处理方式,Swift 就采用了这种设计,在这种设计中,字符串的相关操作都会基于**人类可感知的**字符来操作。 Swift 使用 **`Character` 作为 Unicode 标准化的感知单元**,可以正确处理 Emoji 和组合字符。 ``` let text = "Hello 😊!" print(text.count) // 8(正确) ``` Swift 也保证 `String` 操作不会截断 `Grapheme Cluster`(如 Emoji 组合): ``` let index = text.index(text.startIndex, offsetBy: 7) print(text[..<index]) // "Hello 😊" ``` ## 如何解决 ### Emoji 的复杂性 **Emoji 可以分为这么几类。** | **类型** | **示例** | **码点构成** | | ---------------------- | ---------------------------------- | ------------------------------------------- | | **基础 Emoji** | **😊 U+1F60A** | **单个码点** | | **修饰符序列** | **👍🏻 → 👍 + 🏻** | **基础 Emoji + 肤色修饰符** | | **ZWJ 组合序列** | **👨👩👧👦(🧑🧑🧒🧒)** | **多个 Emoji + U+200D(零宽连接符)** | | **旗帜** | **🇨🇳** | **地区指示符组合(U+1F1E8+U+1F1F3)** | 幸运的是 Unicode 定义了一套规则用于区分不同类型的码点到人类可感知的字符。 ### 编程语言 ### Dart 使用 `Characters` 包,举例:`characters.length` #### Java/Kt 使用 `BreakIterator` ## 结论 1. **`length` 不可靠**:许多语言(Dart、JavaScript、Kotlin)会把 Emoji 视为 2 或 3 个字符,而不是 1。 2. **`substring` 可能截断字符**,导致乱码或异常。 3. **解决方案**: * 遍历 `runes` 或 `codePoints` 以正确处理 Unicode 码点。 * 使用 Swift 这样的语言,其 `String` API 内部保证不会截断 `Grapheme Cluster`。 Emoji 在 Unicode 中的存储方式使得很多编程语言需要特殊处理,否则可能会导致文本显示错误或逻辑异常。理解 Unicode 及其不同的编码方式,有助于开发者在各种语言中正确处理多字符单元的文本。 © 允许规范转载 打赏 赞赏作者 微信 赞