|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: "Protobuf 4.x 在 Android 低版本设备的 NullPointerException 分析与修复" |
| 4 | +date: 2026-04-17 18:30:00 +0800 |
| 5 | +categories: android |
| 6 | +tags: [Android, Protobuf, 编译器, Unsafe, R8, D8, AGP, SDK] |
| 7 | +--- |
| 8 | + |
| 9 | +我们维护了一套面向第三方提供的 Android SDK,供外部合作方直接集成使用。 |
| 10 | + |
| 11 | +在一次 SDK 构建链升级中,我们将工程的 Android Gradle Plugin 升级到了 **AGP 7.1.3**,同时协议侧继续使用 **Protobuf 4.x Lite Runtime**。升级后,我们本地基础验证未发现明显异常,但在 SDK 对外提供给第三方接入后,对方反馈:**同一份 SDK 在 Android 10 及以下设备上会稳定崩溃,而在 Android 11 及以上设备上运行正常。** |
| 12 | + |
| 13 | +这类问题最麻烦的地方在于: |
| 14 | + |
| 15 | +- 业务代码没有明显变化 |
| 16 | +- 本地编译完全通过 |
| 17 | +- 新系统表现正常 |
| 18 | +- 只有第三方实际接入、且在低版本系统上才会触发 |
| 19 | + |
| 20 | +最终排查下来,这并不是业务逻辑问题,也不是协议字段本身写错,而是一次典型的 **“Protobuf Lite Runtime + 旧版 Android 构建链 + 低版本 ART”** 组合兼容问题。 |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## 1. 异常现象 |
| 25 | + |
| 26 | +对方反馈的崩溃日志如下: |
| 27 | + |
| 28 | +```java |
| 29 | +java.lang.NullPointerException: Attempt to invoke virtual method 'void com.google.protobuf.ProtobufArrayList.ensureIsMutable()' on a null object reference |
| 30 | + at com.google.protobuf.ProtobufArrayList.add(ProtobufArrayList.java:80) |
| 31 | + at com.google.protobuf.MessageSchema.reflectField(MessageSchema.java:599) |
| 32 | + at com.google.protobuf.MessageSchema.newSchemaForRawMessageInfo(MessageSchema.java:506) |
| 33 | + at com.google.protobuf.MessageSchema.newSchema(MessageSchema.java:225) |
| 34 | + at com.google.protobuf.ManifestSchemaFactory.newSchema(ManifestSchemaFactory.java:53) |
| 35 | + at com.google.protobuf.ManifestSchemaFactory.createSchema(ManifestSchemaFactory.java:48) |
| 36 | + at com.google.protobuf.Protobuf.schemaFor(Protobuf.java:54) |
| 37 | + at com.google.protobuf.Protobuf.schemaFor(Protobuf.java:69) |
| 38 | + at com.google.protobuf.GeneratedMessageLite.makeImmutable(GeneratedMessageLite.java:209) |
| 39 | + at com.google.protobuf.GeneratedMessageLite$Builder.buildPartial(GeneratedMessageLite.java:482) |
| 40 | + at com.google.protobuf.GeneratedMessageLite$Builder.build(GeneratedMessageLite.java:488) |
| 41 | +``` |
| 42 | + |
| 43 | +从业务视角看,触发它的只是一次非常普通的消息构建过程,例如: |
| 44 | + |
| 45 | +```java |
| 46 | +SomeMessage.newBuilder() |
| 47 | + .addItems(...) |
| 48 | + .setSelector(...) |
| 49 | + .build(); |
| 50 | +``` |
| 51 | + |
| 52 | +但真正崩溃发生的位置,并不在业务字段赋值,而是在 `GeneratedMessageLite.makeImmutable()` 内部。也就是说: |
| 53 | + |
| 54 | +> 崩溃出现在 Protobuf Lite Runtime 为消息建立内部 Schema 并冻结 repeated/list 字段结构的阶段。 |
| 55 | +
|
| 56 | +--- |
| 57 | + |
| 58 | +## 2. 为什么编译时没问题,运行时才崩 |
| 59 | + |
| 60 | +这类问题最容易让人误判,因为它满足一个非常典型的特征: |
| 61 | + |
| 62 | +> 源码完全能编过,但运行时在特定系统版本上崩溃。 |
| 63 | +
|
| 64 | +原因在于,编译器和运行时关注的根本不是同一层东西。 |
| 65 | + |
| 66 | +### 2.1 编译期只验证 API 兼容性 |
| 67 | + |
| 68 | +在编译阶段,Java/Kotlin 只检查: |
| 69 | + |
| 70 | +- `newBuilder()` 是否存在 |
| 71 | +- `addXXX()` 是否存在 |
| 72 | +- `setXXX()` 是否存在 |
| 73 | +- `build()` 是否存在 |
| 74 | + |
| 75 | +对于 Protobuf Lite 来说,这些公开 API 在 3.x / 4.x runtime 中大体是兼容的,因此代码可以顺利编译通过。 |
| 76 | + |
| 77 | +### 2.2 运行期才会真正解析消息内部结构 |
| 78 | + |
| 79 | +真正危险的步骤发生在 `build()` 之后: |
| 80 | + |
| 81 | +1. `GeneratedMessageLite$Builder.build()` |
| 82 | +2. `GeneratedMessageLite.makeImmutable()` |
| 83 | +3. `Protobuf.schemaFor(message)` |
| 84 | +4. `ManifestSchemaFactory.createSchema(clazz)` |
| 85 | +5. `MessageSchema.newSchema(...)` |
| 86 | + |
| 87 | +从这里开始,运行时需要根据消息类内部保存的压缩元数据,动态重建字段表、字段类型、字段偏移量、repeated/list 结构等信息。 |
| 88 | + |
| 89 | +也就是说: |
| 90 | + |
| 91 | +- 编译期只验证“方法签名” |
| 92 | +- 运行期才验证“消息内部结构是否能被正确解释” |
| 93 | + |
| 94 | +这也是为什么看起来“什么都没问题”的代码,最终却会在设备上突然炸掉。 |
| 95 | + |
| 96 | +--- |
| 97 | + |
| 98 | +## 3. Protobuf Lite Runtime 的内部机制 |
| 99 | + |
| 100 | +为了理解这个问题,先要知道 Protobuf Lite Runtime 到底在做什么。 |
| 101 | + |
| 102 | +### 3.1 激进的 Schema 编码与元数据字符串 |
| 103 | +为了极致压缩生成的代码量,Protobuf 4.x Lite 放弃了传统的配置描述,将消息的结构信息(Schema)编码进了一个 `String` 常量。 |
| 104 | + |
| 105 | +```java |
| 106 | +return newMessageInfo(DEFAULT_INSTANCE, info, objects); |
| 107 | +``` |
| 108 | + |
| 109 | +这段 `info` 字符串并非普通的文本符号,而是包含了大量物理偏移量和字段类型的二进制位。其中充斥着非标准的 UTF-16 字节流、不可见控制符以及残缺的代理对(Surrogate pairs)。这种设计虽极大地节省了包体积,但也对编译器及 Dex 打包工具的 Unicode 转码兼容性提出了严苛要求。 |
| 110 | + |
| 111 | +这也是 Lite Runtime 性能高、体积小的根本原因之一。 |
| 112 | + |
| 113 | +### 3.2 repeated/list 字段的真实行为取决于运行时 Schema |
| 114 | + |
| 115 | +以 repeated string 字段为例,生成类构造器里通常会有类似代码: |
| 116 | + |
| 117 | +```java |
| 118 | +stockCodes_ = GeneratedMessageLite.emptyProtobufList(); |
| 119 | +``` |
| 120 | + |
| 121 | +这说明生成代码本身并没有漏掉初始化。 |
| 122 | + |
| 123 | +问题在于,后续运行时仍然需要根据内部 Schema 去理解: |
| 124 | + |
| 125 | +- 哪个字段是 repeated |
| 126 | +- 它在对象中的偏移量是多少 |
| 127 | +- 它对应的是哪种 List 实现 |
| 128 | +- 如何在 `makeImmutable()` 过程中冻结这块结构 |
| 129 | + |
| 130 | +一旦这一步元数据解析错误,后续就可能在本应是 `ProtobufList` 的位置读出错误对象甚至 `null`,最终引发 NPE。 |
| 131 | + |
| 132 | +--- |
| 133 | + |
| 134 | +## 4. 深入一点:Unsafe 快路径为什么会把问题放大 |
| 135 | + |
| 136 | +Protobuf 4.x 的 Lite Runtime 为了进一步优化性能,在 Android/JVM 上大量使用 `Unsafe` 快路径。 |
| 137 | + |
| 138 | +典型模式类似这样: |
| 139 | + |
| 140 | +```java |
| 141 | +Object value = UnsafeUtil.getObject(message, fieldOffset); |
| 142 | +``` |
| 143 | + |
| 144 | +也就是说,运行时会先根据元数据算出某个字段在对象内存中的偏移量 `fieldOffset`,然后直接按偏移量读取对象内容,而不是每次都走 `Field.get()` 这样的普通反射。 |
| 145 | + |
| 146 | +这种做法的优点是快,缺点也很明显: |
| 147 | + |
| 148 | +- 对元数据正确性要求极高 |
| 149 | +- 对字段偏移量要求极高 |
| 150 | +- 一旦 dex 产物中的结构信息被错误处理,`Unsafe` 会把问题直接放大成运行时崩溃 |
| 151 | + |
| 152 | +换句话说: |
| 153 | + |
| 154 | +> `Unsafe` 本身不是 bug,但一旦它依赖的元数据出了偏差,问题会比普通反射暴露得更猛烈、更隐蔽。 |
| 155 | +
|
| 156 | +--- |
| 157 | + |
| 158 | +## 5. 关键差异:Android Hidden API 限制与安全降级 |
| 159 | + |
| 160 | +为什么同一份被物理损坏的 Dex 仅在 Android 10 及以下设备崩溃?这主要取决于 Protobuf 内部 `UnsafeUtil` 的初始化逻辑与系统权限的交互。 |
| 161 | + |
| 162 | +* **Android 11 及更高版本(避开缺陷):** 随着 Android 系统对 **Hidden API(私有接口)** 的管控收紧,反射获取 `sun.misc.Unsafe` 的行为会被直接拦截。Protobuf 在捕获到相关异常后,会触发内部的**安全降级(Fallback)机制**,放弃物理寻址路径,转而退回到基于字段名称的标准反射路径 (`Field.get`)。由于标准反射不再依赖损坏字符串中的物理偏移参数,从而规避了崩溃。 |
| 163 | +* **Android 10 及更低版本(触发崩溃):** 系统尚未完全封禁私有接口调用,Protobuf 顺利获取并启用了 `Unsafe` 特权模式。程序随后按照损坏的偏移量图纸进行物理内存读取,最终导致获取到 `null` 对象并抛出 NPE。 |
| 164 | + |
| 165 | +这种基于系统特性的“被动容错”,解释了为何故障仅在低版本设备上稳定复现。 |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +## 6. 根本原因:旧版 AGP / D8 / R8 与 Protobuf 4.x Lite 元数据的兼容问题 |
| 170 | + |
| 171 | +继续往下追,我们最终将问题锁定在构建链上。 |
| 172 | + |
| 173 | +本次 SDK 对外提供时使用的是: |
| 174 | + |
| 175 | +- **AGP 7.1.3** |
| 176 | +- 对应版本的 **D8 / R8** 编译器链路 |
| 177 | +- **Protobuf 4.x Lite Runtime** |
| 178 | + |
| 179 | +而 Protobuf 4.x Lite Runtime 对内部元数据的依赖非常强,尤其是 `newMessageInfo(...)` 中那段压缩编码字符串及其配套对象表。旧版 D8 / R8 在 `.class -> .dex` 转换过程中,对这类结构的兼容性并不充分,最终导致运行时重建出的 Schema 信息出现偏差。 |
| 180 | + |
| 181 | +于是完整故障链就形成了: |
| 182 | + |
| 183 | +1. `pb_gen` 生成类在 Java 层面完全合法,能正常编译 |
| 184 | +2. 旧版 AGP 7.1.3 所带的 D8 / R8 在转 dex 时,对 Protobuf 4.x Lite 的内部元数据处理存在缺陷 |
| 185 | +3. Android 10 运行时走到 Lite Runtime 的 fast path |
| 186 | +4. `MessageSchema` 根据被错误处理的元数据重建字段结构 |
| 187 | +5. repeated/list 字段在 `makeImmutable()` 过程中被解释成错误对象 |
| 188 | +6. 最终在 `ProtobufArrayList.ensureIsMutable()` 处抛出 NPE |
| 189 | + |
| 190 | +这也解释了为什么问题表现为: |
| 191 | + |
| 192 | +- 编译没问题 |
| 193 | +- 高版本系统没问题 |
| 194 | +- 只有旧系统 + 某类 message build 才出问题 |
| 195 | + |
| 196 | +--- |
| 197 | + |
| 198 | +## 7. 关键验证:仅升级 R8 后问题消失 |
| 199 | + |
| 200 | +为了确认问题到底是业务逻辑、协议设计,还是构建链导致,我们做了一个非常关键的对照实验: |
| 201 | + |
| 202 | +- 不改 SDK 的业务代码 |
| 203 | +- 不改消息字段定义 |
| 204 | +- 不改对外调用方式 |
| 205 | +- 不改运行设备 |
| 206 | +- **只在 AGP 7.1.3 工程中覆盖升级 R8** |
| 207 | + |
| 208 | +结果非常明确: |
| 209 | + |
| 210 | +- Android 10 上崩溃消失 |
| 211 | +- Android 11+ 继续正常 |
| 212 | +- 同一条消息构建路径恢复稳定 |
| 213 | + |
| 214 | +这条实验结果非常重要,因为它直接说明: |
| 215 | + |
| 216 | +> 问题不在业务代码本身,而在旧版构建链对 Protobuf 4.x Lite 元数据的处理上。 |
| 217 | +
|
| 218 | +也正因为如此,这个问题在第三方接入阶段才暴露出来,而不是在最初源码开发阶段就被发现。 |
| 219 | + |
| 220 | +--- |
| 221 | + |
| 222 | +## 8. 修复方案:在旧 AGP 工程中局部覆盖升级 R8 |
| 223 | + |
| 224 | +如果项目暂时不能整体升级 AGP,可以采用一个更实际、风险更低的方案: |
| 225 | + |
| 226 | +> 在维持 AGP 7.1.3 不变的前提下,单独覆盖更高版本的 R8。 |
| 227 | +
|
| 228 | +在根目录 `build.gradle` 中加入: |
| 229 | + |
| 230 | +```gradle |
| 231 | +buildscript { |
| 232 | + repositories { |
| 233 | + maven { url 'https://storage.googleapis.com/r8-releases/raw' } |
| 234 | + maven { url 'https://mirrors.huaweicloud.com/repository/maven/' } |
| 235 | + mavenCentral() |
| 236 | + google() |
| 237 | + } |
| 238 | + dependencies { |
| 239 | + classpath "com.android.tools.build:gradle:7.1.3" |
| 240 | +
|
| 241 | + // 覆盖旧版 AGP 内置的 R8 / D8 实现 |
| 242 | + classpath "com.android.tools:r8:3.3.75" |
| 243 | + } |
| 244 | +} |
| 245 | +``` |
| 246 | + |
| 247 | +在我们的验证中,仅做这一项调整后: |
| 248 | + |
| 249 | +- Android 10 上异常消失 |
| 250 | +- 第三方接入恢复正常 |
| 251 | +- 无需立即大规模迁移整个工程的 AGP 版本 |
| 252 | + |
| 253 | +这对于历史工程来说,是一个非常有性价比的修复方案。 |
| 254 | + |
| 255 | +--- |
| 256 | + |
| 257 | +## 9. 最终结论 |
| 258 | + |
| 259 | +这次问题表面上是一个 `NullPointerException`,但本质上并不是业务代码写错,也不是协议字段定义有误,而是一次典型的: |
| 260 | + |
| 261 | +> **Protobuf 4.x Lite Runtime 与旧版 Android 构建链(AGP 7.1.3 + D8/R8)在低版本设备上的兼容问题。** |
| 262 | +
|
| 263 | +更准确地说: |
| 264 | + |
| 265 | +- 代码在编译期是合法的 |
| 266 | +- 生成类本身也不是坏的 |
| 267 | +- 问题发生在旧版构建链将其转成 dex 之后 |
| 268 | +- 只有在 Android 10 等低版本系统运行时,才把这个问题暴露出来 |
| 269 | + |
| 270 | +这个案例也提醒我们: |
| 271 | + |
| 272 | +- **“编译通过”并不意味着“运行时安全”** |
| 273 | +- SDK 对外输出时,构建链本身就是产品质量的一部分 |
| 274 | +- 当异常只在低版本系统或第三方接入环境下出现时,除了排查业务逻辑,更要优先怀疑 **构建链与 runtime 的组合兼容性** |
| 275 | + |
| 276 | +对于短期内无法整体升级 AGP 的项目,**局部覆盖升级 R8** 是一条切实可行、且经过实际验证有效的解决方案。 |
0 commit comments