Skip to content

Commit 1b1a223

Browse files
committed
feat: add blog post about Android Toolbar state restoration crash
1 parent 44eea80 commit 1b1a223

1 file changed

Lines changed: 207 additions & 0 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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

Comments
 (0)