ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从AOSP源码看ICU:Android国际化底座原理与定制实战

从AOSP源码看ICU:Android国际化底座原理与定制实战 如果你翻过 Android 系统源码大概率会盯上external/icu4c这个目录。名字看起来平平无奇但它的存在直接决定了手机上所有 App 的日期、数字、货币、排序和文本断词行为。这个组件就是 ICU全称 International Components for Unicode是 Android 国际化能力的底座。这篇文章我想从 AOSP 源码的角度把 ICU 在系统里的位置、它和上层 API 之间的关系以及系统定制时需要动手改一条规则并编进 ROM 的完整流程整体串一遍。适合正在啃系统源码的同学也适合 App 层开发做到一定阶段、想搞清楚setLocale之后到底发生了什么的人。1. 先搞懂 ICU 在 Android 里的位置1.1 从一次格式化翻车说起先讲一个我早年间遇到的线上问题。当时业务方反馈某些海外用户的 App 里金额显示多了一个空格比如1 234,56 €而不是1,234.56 €。第一反应是服务端字符串拼接出了问题查了半天最后发现锅在设备区域设置上——法区用户默认的NumberFormat就会把数字分组符号和货币符号的位置整成这个样子。而真正干活的不是 JDK 原生实现是系统里预编译好的 ICU 规则和数据。在 Android 上日期格式化、数字格式化、排序、断词、音译、正则表达式、文本双向显示这些能力背后基本都是 ICU 在支撑。Java 层的java.text.*和android.icu.*最终会通过 JNI 调到底层的libicuuc和libicui18n两个库再读取一份编译好的二进制数据文件。所以想研究 Android 的国际化不能只看上层 API得从源码和数据两层入手。数据决定了输出长什么样代码决定数据怎么被读取和使用。1.2 ICU 与 CLDR数据才是灵魂ICU 本身分两部分代码和规则数据。规则数据来自 CLDRCommon Locale Data Repository这是 Unicode 联盟维护的一套跨平台区域数据仓库涵盖了全球几百个语言地区的日期格式、数字格式、货币符号、排序规则、时区名称、日历算法等信息。Android 源码里的区域数据不是凭空造的而是从 CLDR 上游数据转换而来。数据链路大致是这样的CLDR 上游数据 (XML/JSON) → 进入 external/icu4c/data 目录 → 通过 icu4c 工具链 (genrb / icupkg) 编译为 .dat 二进制文件 → 由构建系统打包进 system.img 的 /usr/icu/ 目录 → 运行时由 native 层 ICU 库加载解析开发者定位一个区域问题第一直觉往往是去翻Locale和SimpleDateFormat的源码但最后都会绕到 ICU 的数据文件里。改一行数据、调一个格式模式效果有时候比改 Java 代码更直接。当然前提是你得知道具体改哪份数据以及如何重新编译打包。1.3 为什么 Android 坚持用 ICU而不是 JDK 自带那套有人会问Java 标准库不是已经提供了java.text和java.util的区域化能力吗里面的DateFormat、NumberFormat、Collator看着也都挺齐全为什么还要单独引入 ICU/JDK 的归属原因主要有三点。第一是历史包袱太深。Android 刚起步时Java 类库对 Unicode 和复杂文字的支持还非常寒酸尤其是对 emoji 断词、印度语系回退组合、阿拉伯语双向文本这些场景JDK 原生实现根本扛不住。ICU 在这方面是行业标准Chromium、WebKit、Flutter 都在用Android 直接选它是性价比最高的方案。第二是跨平台一致性。一个 App 可能在 Android、iOS、Web 三端跑你当然希望同一份区域数据在三个平台格式化出来的日期、数字、排序结果完全一致。如果 Android 自己造轮子或者直接用 JDK 那套资源很可能出现“同一个Locale不同平台输出不同”的尴尬。ICU 数据源统一对齐成本低很多。第三是性能和体积可控。ICU 的数据是高度压缩的二进制格式只加载必要区域还能做裁剪定制。这对于手机存储这种资源敏感型的场景非常重要。而且 C 层直接用Java 层通过 JNI 调用不产生额外的字符串格式转换开销。2. 源码里 ICU 的家底模块、目录和调用链2.1 external/icu4c原生层的重头戏在external/icu4c目录下主要能看到这几个子模块external/icu4c/ ├── common/ # ICU 公共部分Unicode 字符串、错误码、资源加载器 ├── i18n/ # ICU 国际化部分日期、数字、排序、断词、音译等 ├── stubdata/ # 一份最小化的 ICU 数据用于构建工具链和单元测试 ├── tools/ # genrb、icupkg、uconv、icuinfo 等工具 ├── data/ # 区域数据源和构建脚本 └── Android.bp # Android 构建配置common和i18n编译出来分别是libicuuc.so和libicui18n.so它们的作用范围不一样。libicuuc偏底层提供UnicodeString、UErrorCode、资源包管理相当于 ICU 的“地基”libicui18n偏业务提供DateFormat、NumberFormat、Collator、BreakIterator这些相对高层的 API依赖前者。做系统定制时data目录是最有意思的地方。AOSP 为了控制镜像体积会额外做数据裁剪只保留系统支持的 locale 和时区数据其余的会被过滤掉。这一点在后续“系统定制”章节会专门提。你在 Android 的settings里能看到多少种语言其实和这里保留了多少个 locale 数据包是强相关的。2.2 从 native 到 Java 的桥接Java 层能直接用到 ICU依赖的是一个 JNI 桥接层。在源码里搜NativeICU能找到libcore/ojluni/src/main/native/NativeICU.cpp。这个文件干的事很纯粹把 C 层的ucol_open、udat_open、unum_open一串函数注册成 Java native 方法供libcore.icu下的 Java 类调用。调用链大致长这样Java 代码: java.text.SimpleDateFormat → libcore.icu.ICU → native ICU4JNI (java.lang.ICU4JNI) → NativeICU.cpp → libicui18n.so / libicuuc.so → 读取 icudt*.dat 数据文件另外frameworks/base/core/java/android/icu/下还有一份独立的 ICU4J 封装。它把 native ICU 的能力包装成了类似标准 ICU4J 的 API比如android.icu.text.DateFormat、android.icu.util.Calendar、android.icu.util.ULocale。为什么要搞两份 Java API这是兼容性考虑。java.text.*是 Android 框架对外承诺的 SDK API不能随便改实现细节而android.icu.*是后来引入的增强 API可以暴露更多 ICU 特性。两者底层数据一致只是封装层级不同。2.3 系统里谁在消费 ICUICU 不只是给应用层开发用系统内部到处都是它。Zygote进程预热的时候会预加载一部分 ICU 数据降低后续 App 启动时的加载延迟。ActivityManagerService处理onConfigurationChanged的时候会携带地区信息变化底层资源管理重算字符串、布局方向等这部分也要靠 ICU 判断地区差异。SettingsProvider存储用户选择的语言列表系统启动时读取并配置到全局LocaleList之后所有进程的默认Locale都会变化。WebView和Browser做页面文本断词、表单校验时也会用到 ICU 的BreakIterator和字符转换能力。可以这么理解ICU 就像 Android 国际化的“市政供水管网”App 自来水龙头一拧就有水但水从哪来、水质如何、能不能按时供应都是这套管网决定的。3. 实战改一条区域规则编译进系统并验证这部分是很多人看源码时最想动手又最不敢动手的环节。我挑一个实际需求来演示修改zh_CN的数字分组规则把默认的三位一分组改成两位一分组用来模拟一些金融类定制 ROM 的特殊展示需求。说明一下最终产品里直接改 global 规则的风险比较大容易影响所有使用该 locale 的用户更稳妥的做法是新增一个自定义 region 或使用私有的 locale 扩展。但为了讲清楚数据从源码到运行的完整路径这里直接用zh_CN做演示。3.1 环境准备与源码定位在开始之前你本机得有一套能编译通过的 AOSP 源码树。版本不限但建议至少选 Android 12 以上因为 ICU 的数据组织方式和构建脚本在历史版本里差别很大越新的版本越接近当前主流方案。准备内容包括Ubuntu 20.04 或更新版本硬盘剩余空间 300GB 以上。OpenJDK 对应版本编译指令不同版本要求不同以 build 文档为准。源码编译环境初始化source build/envsetup.sh lunch target。然后定位源码目录cd external/icu4c ls -la data如果一切正常你能看到data下面是若干子目录和.gnu文件。Android 的 ICU 数据会从 ICU 上游仓库导入数据源文件再通过 ICU 自带的工具链生成最终.dat。3.2 第二步修改区域数据源ICU 的区域数据源实际上是一系列按 locale 拆分的文件常见的是root.txt、en.txt、zh.txt这样的文本格式里面是 ICU 资源语法。数字格式的数据通常这样描述zh{ NumberElements{ decimal{ . } group{ , } patterns{ decimalFormat{ #,##0.### } percentFormat{ #,##0% } currencyFormat{ ¤#,##0.00 } } } }我想把分组从三位改成两位需要同时改两组内容minimumGroupingDigits这个值影响某些语言在特定位数下是否显示分隔符改成 2 表示两位起就显示。decimalFormat的模式把#,##0改成#,##0本质上解决不了两位分组因为 ICU 的 pattern 里,就是“分组符”并不直接控制组大小。真正控制字节数的是数据里的groupingSize属性或者你干脆在 pattern 里写死#,##0的变体。这里最实用的做法是直接修改相应的数据条目。为简化演示我们直接修改NumberElements的groupingSize相关字段把三位改两位。需要注意现有 ICU 数据源未必每行都带这个字段如果没带就补上如果带了直接把值改成 2。3.3 第三步数据编译与产物生成ICU 工具链会用一个命令把文本格式的数据源编译成二进制资源包cd external/icu4c # 进入构建环境 source build/envsetup.sh lunch your-target # 命令行直接跑 m 目标产物 m -j$(nproc) icu4c如果你只想单独重新生成数据不改 native 代码可以尝试这样操作cd external/icu4c/data ./runConfigureICU # 生成 Makefile make但实际在 AOSP 环境里更推荐直接用m走完整构建流程因为 AOSP 的构建规则会处理依赖关系避免你手动生成的文件和最终系统镜像里的数据不一致。编译成功后产物会出现在out/target/product/device/system/usr/icu/目录里通常有一个icudt*.dat文件名字带机型架构和 ICU 版本号。这就是运行时 ICU 加载的主数据文件。3.4 第四步刷机验证与回归测试验证思路分两步。第一步验证 native 层数据是否生效。可以写一段极简 C 程序链接libicui18n和libicuuc调用unum_open去格式化一个数字然后通过adb push到设备上执行。不过这样比较麻烦要处理交叉编译和动态链接路径不推荐新手做。第二步验证 Java 层是否生效。写一个最小 APK使用系统 SDK API 直接调用NumberFormatimport android.icu.text.NumberFormat; import java.util.Locale; NumberFormat nf NumberFormat.getNumberInstance(Locale.CHINA); String result nf.format(1234567L); Log.d(ICUTest, result);刷入新系统镜像后运行这个 App预期输出是12,34,567如果还是1,234,567说明数据没有加载或改错了位置。最后的自查项还包括检查Settings里语言切换是否正常切换到其他 locale 时数字格式是否回落到原始状态。检查货币、百分比格式化是否受此次修改影响避免只改了数字主格式却把货币也带跑偏。跑一轮 CTS 中 locale 相关的用例防止全局数据变化导致系统兼容性测试崩掉。4. 这些年我在 ICU 上踩过的坑4.1 区域识别与语言标签不匹配最常见的线上问题就是Locale字符串和系统支持的 region 不匹配。举个例子你调用Locale.forLanguageTag(zh-Hans-CN)和new Locale(zh, CN)在多数情况下表现一致但在某些系统版本上前者走的是 BCP47 标签解析路径后者走的是旧式语言-国家路径两者在 ICU 数据索引时的 fallback 链可能不同导致个别字段取到默认值。建议应用层代码统一使用一个Locale构造方式不要混合使用。系统源码层排查时可以用ULocale.addLikelySubtags检查数据是否匹配到目标条目比如zh-Hans-CN会自动补全脚本标签但zh-CN不一定。4.2 日期、时间与十二小时制陷阱日期格式化是最容易出神坑的地方。在英文地区HH表示 24 小时制hh表示 12 小时制但在阿拉伯语、希伯来语等地区即使你写了HH系统也可能根据地区的默认时间周期自动切换成 12 小时制展示。这不是 ICU 数据错误而是 CLDR 特意定义的“区域偏好”。更头疼的是DateFormat.getTimeInstance(DateFormat.SHORT)在部分地区输出带秒在不带秒的版本上又可能因为am/pm标记被截断直接导致 UI 文本过长。做系统定制时如果发现某些系统时间控件显示异常优先检查res里是否有对应的字符串资源而不是怀疑 ICU 数据只有当你确认DateFormat的样式真的是从 ICU 数据读出来的才需要进入数据定制环节。4.3 排序、断词与 Unicode 版本差异中文排序通常按拼音但 ICU 默认可能走的是“笔画”或“拼音变体”的老逻辑。Collator并非所有 locale 都预设了合适的排序规则zh_Hans_CN在新版本里的默认排序可能是基于 pinyin 的但如果地区被识别成zh_HK排序结果会混入繁体字的笔画逻辑。这类问题往往要进入 ICU 的collation数据里找答案。断词更麻烦BreakIterator.getLineInstance(locale)在处理连续 emoji 和多语言混排时经常不如预期。新版 ICU 引入了 Extended Pictographic 和 GCB 规则但依然无法覆盖所有自定义 emoji 序列。像一些社交类 App 在自定义表情包后出现长按划词异常、复制多选错位多半就是断词规则没跟上数据版本。4.4 系统定制时容易忽略的裁剪问题Android 为了保证包体积ICU 数据会被裁剪。AOSP 构建系统里有一个标记叫PRODUCT_MINIMIZE_ICU_DATA开启后系统只会保留核心语言和地区其他区域的日期、数字、时区数据会被移除。此时你调Locale.getAvailableLocales()返回的列表会大幅缩短。很多厂商定制 ROM 时会新增一个语言到Settings的语言列表里但忘了检查 ICCICU 数据裁剪白名单结果用户选完语言后系统界面大量字段 fallback 回英文甚至显示成root格式。正确的做法是同时更新以下几个方面external/icu4c的数据裁剪白名单。frameworks/base/packages/SettingsLib里的 locale 白名单。frameworks/base/core/res里的locale_config.xml。如果有输入法需求还要确保Ime对应的语言包存在。只改一个地方永远不够。4.5 时区数据的隐藏版本问题时区数据由external/icu/tzdata提供而不是直接塞在 ICU 数据里。ICU 数据和时区数据库的版本可能不同步。某个地区改了夏令时规则ICU 的数据更新了但 tzdata 没跟上日期类 API 输出的绝对时间戳正确但本地时间显示会差一个小时。排查方法不难用TimeZone.getDefault()对比系统时区文件和 ICU 数据里的首条规则即可。这类问题在定制度高的 ROM 上特别常见因为厂商可能只更新了部分时区文件。一点个人体会我在实际源码阅读中踩过最大的坑就是太把 ICU 当“黑盒”数据去用上来就改.dat或者 XML 数据结果编译出来的镜像体积暴涨运行时资源冲突频发。后来才明白阅读 ICU 源码的最好姿势是先从external/icu4c/README和Android.bp入手理解哪些数据会被编译进去、哪些会被裁剪再动手改数据。建议你在真机刷机之前先在模拟器上跑通整条链路至少能省下一半的排错时间。还有一个非常实用的小技巧源码树的out/host/linux-x86/bin/下往往有编好的icuinfo或uconv工具你可以直接在电脑上执行用它们来快速验证某条 ICU 数据在当前版本下的输出结果比反复刷机高效得多。
返回列表