|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: "一次由重复 Toolbar ID 引发的 Android 状态恢复崩溃" |
| 4 | +date: 2026-04-20 09:00:00 +0800 |
| 5 | +categories: android |
| 6 | +tags: [Android, Toolbar, SavedState, View, DataBinding, include] |
| 7 | +--- |
| 8 | + |
| 9 | +## 背景 |
| 10 | + |
| 11 | +某个 Android 模块在少量场景下出现崩溃,日志核心信息如下: |
| 12 | + |
| 13 | +```java |
| 14 | +java.lang.IllegalArgumentException |
| 15 | + |
| 16 | +Wrong state class, expecting View State but received class androidx.appcompat.widget.Toolbar$SavedState instead. |
| 17 | +This usually happens when two views of different type have the same id in the same hierarchy. |
| 18 | +This view's id is id/toolbar. |
| 19 | +``` |
| 20 | + |
| 21 | +崩溃并不发生在页面首次打开时,而是集中出现在以下时机: |
| 22 | + |
| 23 | +- 页面重建 |
| 24 | +- 配置变更后恢复界面状态 |
| 25 | +- 进程被系统回收后重新进入页面 |
| 26 | + |
| 27 | +这类问题的特点是:普通功能验证往往正常,但一旦触发状态恢复,系统会在 `onRestoreInstanceState` 阶段直接抛异常。 |
| 28 | + |
| 29 | +--- |
| 30 | + |
| 31 | +## 现象 |
| 32 | + |
| 33 | +从异常文本可以直接得到两个关键信息: |
| 34 | + |
| 35 | +1. 系统正在恢复一个 `id=toolbar` 的视图状态。 |
| 36 | +2. 当前接收方期望的是普通 `View` 的状态对象,但实际收到的是 `Toolbar$SavedState`。 |
| 37 | + |
| 38 | +这意味着同一个 `ID` 对应到了两种不同类型的视图。更具体一点说,状态是在一个 `Toolbar` 上保存的,却被恢复到了一个非 `Toolbar` 的视图上。 |
| 39 | + |
| 40 | +这类崩溃常见于布局复用场景,尤其是 `include`、`merge`、Data Binding 和多层容器叠加后的最终视图树。 |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## 根因分析 |
| 45 | + |
| 46 | +问题布局可以抽象成下面这种形式。 |
| 47 | + |
| 48 | +页面布局: |
| 49 | + |
| 50 | +```xml |
| 51 | +<androidx.constraintlayout.widget.ConstraintLayout |
| 52 | + android:layout_width="match_parent" |
| 53 | + android:layout_height="match_parent"> |
| 54 | + |
| 55 | + <include |
| 56 | + android:id="@+id/toolbar" |
| 57 | + layout="@layout/include_toolbar" /> |
| 58 | + |
| 59 | +</androidx.constraintlayout.widget.ConstraintLayout> |
| 60 | +``` |
| 61 | + |
| 62 | +被复用的 toolbar 布局: |
| 63 | + |
| 64 | +```xml |
| 65 | +<layout> |
| 66 | + <androidx.constraintlayout.widget.ConstraintLayout |
| 67 | + android:layout_width="match_parent" |
| 68 | + android:layout_height="wrap_content"> |
| 69 | + |
| 70 | + <androidx.appcompat.widget.Toolbar |
| 71 | + android:id="@+id/toolbar" |
| 72 | + android:layout_width="match_parent" |
| 73 | + android:layout_height="wrap_content" /> |
| 74 | + |
| 75 | + </androidx.constraintlayout.widget.ConstraintLayout> |
| 76 | +</layout> |
| 77 | +``` |
| 78 | + |
| 79 | +这里的问题不在于 `include` 本身,而在于 **外层 include 节点和内层真正的 `Toolbar` 使用了同一个 `ID`**。 |
| 80 | + |
| 81 | +最终运行时,视图树里会同时出现两个概念上都叫 `toolbar` 的节点: |
| 82 | + |
| 83 | +- 一个是 `include` 落地后的外层容器,例如 `ConstraintLayout` |
| 84 | +- 一个是容器内部真正可保存 `Toolbar$SavedState` 的 `Toolbar` |
| 85 | + |
| 86 | +当系统保存状态时,`Toolbar` 会以 `id=toolbar` 存入自己的 `SavedState`。等到恢复状态时,系统按照 `ID` 回填,结果外层容器也占用了同一个 `ID`。这时恢复逻辑命中了错误的目标视图: |
| 87 | + |
| 88 | +- 保存阶段:状态来自 `Toolbar` |
| 89 | +- 恢复阶段:状态被分发给 `ConstraintLayout` |
| 90 | + |
| 91 | +于是系统发现: |
| 92 | + |
| 93 | +- 目标视图只接受普通 `View.BaseSavedState` |
| 94 | +- 实际收到的是 `Toolbar$SavedState` |
| 95 | + |
| 96 | +最终抛出 `IllegalArgumentException`。 |
| 97 | + |
| 98 | +--- |
| 99 | + |
| 100 | +## 为什么不是所有页面都会崩溃 |
| 101 | + |
| 102 | +这个问题虽然是布局层面的,但并不是所有使用了公共 toolbar 的页面都会稳定触发,原因通常有三个: |
| 103 | + |
| 104 | +### 1. 只有触发状态恢复时才会暴露 |
| 105 | + |
| 106 | +如果页面只经历「打开 -> 使用 -> 退出」,可能完全看不到问题。只有系统真正走到视图状态保存与恢复流程时,冲突才会变成异常。 |
| 107 | + |
| 108 | +### 2. 只有重复 ID 对应的视图类型不同才会出错 |
| 109 | + |
| 110 | +如果两个同名节点碰巧都是普通容器,未必会立刻崩。真正危险的是像这次这样,一个是 `Toolbar`,另一个是普通 `ViewGroup`。 |
| 111 | + |
| 112 | +### 3. 复用布局会放大影响范围 |
| 113 | + |
| 114 | +一旦问题存在于公共 toolbar 布局中,所有通过 `include` 复用它、并且外层继续命名为 `toolbar` 的页面,都可能在相同条件下中招。 |
| 115 | + |
| 116 | +--- |
| 117 | + |
| 118 | +## 修复方案 |
| 119 | + |
| 120 | +最小修复方式很简单:**保证外层 include 节点与内层真正的 `Toolbar` 不使用同一个 `ID`。** |
| 121 | + |
| 122 | +例如,将内层 `Toolbar` 的 `ID` 改成 `toolbar_view`: |
| 123 | + |
| 124 | +```xml |
| 125 | +<layout> |
| 126 | + <androidx.constraintlayout.widget.ConstraintLayout |
| 127 | + android:layout_width="match_parent" |
| 128 | + android:layout_height="wrap_content"> |
| 129 | + |
| 130 | + <androidx.appcompat.widget.Toolbar |
| 131 | + android:id="@+id/toolbar_view" |
| 132 | + android:layout_width="match_parent" |
| 133 | + android:layout_height="wrap_content" /> |
| 134 | + |
| 135 | + </androidx.constraintlayout.widget.ConstraintLayout> |
| 136 | +</layout> |
| 137 | +``` |
| 138 | + |
| 139 | +对应代码侧也改为引用新的字段: |
| 140 | + |
| 141 | +```kotlin |
| 142 | +setSupportActionBar(binding.toolbar.toolbarView) |
| 143 | +``` |
| 144 | + |
| 145 | +这样处理有两个好处: |
| 146 | + |
| 147 | +- 页面层不需要重做整体布局结构 |
| 148 | +- 公共布局只改一处,受影响页面统一切换引用即可 |
| 149 | + |
| 150 | +如果页面本身并不需要对 `include` 节点使用 `android:id="@+id/toolbar"`,另一种做法是直接移除外层这个 `ID`。本质都是同一个原则:**一个状态型控件在最终视图树中只能占用一个确定的 ID。** |
| 151 | + |
| 152 | +--- |
| 153 | + |
| 154 | +## 排查这类问题的有效方法 |
| 155 | + |
| 156 | +如果后续再遇到类似的 `Wrong state class` 崩溃,排查顺序可以直接固定为下面几步: |
| 157 | + |
| 158 | +### 1. 先读异常文案 |
| 159 | + |
| 160 | +异常里通常已经给出了最关键的信息: |
| 161 | + |
| 162 | +- 期望的状态类型 |
| 163 | +- 实际收到的状态类型 |
| 164 | +- 对应视图的 `ID` |
| 165 | + |
| 166 | +这一步往往比先看业务代码更快。 |
| 167 | + |
| 168 | +### 2. 全局搜索对应 `ID` |
| 169 | + |
| 170 | +例如直接搜索: |
| 171 | + |
| 172 | +```bash |
| 173 | +rg -n '@\+id/toolbar|@id/toolbar' . |
| 174 | +``` |
| 175 | + |
| 176 | +重点看以下几类位置: |
| 177 | + |
| 178 | +- `include` |
| 179 | +- 公共布局 |
| 180 | +- Data Binding 布局根节点 |
| 181 | +- 自定义 View 容器 |
| 182 | + |
| 183 | +### 3. 看最终层级,而不是只看单个 XML |
| 184 | + |
| 185 | +单独看某一个布局文件,可能只看到一个 `toolbar`。但一旦 `include` 展开、Data Binding 包裹、容器合并后,最终视图树里可能已经出现两个同名节点。 |
| 186 | + |
| 187 | +### 4. 优先修公共布局 |
| 188 | + |
| 189 | +如果重复 `ID` 来自公共组件,优先在公共层修正,再统一替换调用侧引用。这样比逐页修改更稳。 |
| 190 | + |
| 191 | +--- |
| 192 | + |
| 193 | +## 总结 |
| 194 | + |
| 195 | +这次问题可以压缩成一句话: |
| 196 | + |
| 197 | +**状态恢复阶段的崩溃,很多时候不是业务逻辑问题,而是最终视图树中存在重复 `ID`,并且重复节点的视图类型不同。** |
| 198 | + |
| 199 | +对于 `Toolbar`、`RecyclerView`、`FragmentContainerView` 这类自带状态恢复逻辑的控件,这个问题尤其容易放大。 |
| 200 | + |
| 201 | +一条简单但很有效的约束是: |
| 202 | + |
| 203 | +- 公共布局内部的状态型控件,`ID` 要保持唯一 |
| 204 | +- 外层 `include` 如果只是为了拿 binding root,不要继续复用同名 `ID` |
| 205 | +- 只要异常里出现 `Wrong state class`,优先检查最终视图树中的重复 `ID` |
| 206 | + |
| 207 | +这类问题的修复代码通常不多,但前提是先把「状态是谁保存的,恢复时又落到了谁身上」这件事看清楚。 |
0 commit comments