WLAN 列表重建风暴与 no-focused-window ANR 机制详解
以真实案例 N801PROB-137881 为骨架,从 0 基础讲清:ANR 是什么 → 车机 WLAN 页为什么卡 3.3 秒 → 这类 ANR 为什么是测试手法问题 → 怎么修、怎么验。 代码基线:
rls-xcd-u-mp-26-v6.0 @ 523f6e72b(ampere 出货分支);优化 change:371943(v6.0) / 371944(dev)
第一部分 基础知识(小白铺垫)
1. ANR 是什么
ANR(Application Not Responding,应用无响应)是 Android 系统的看门狗:系统给应用的每类任务都设了死线,超时就判定”这个 App 卡死了”,弹 ANR。
| ANR 类型 | 死线 | 典型文案 | 含义 |
|---|---|---|---|
| Input dispatching | 5 秒 | Input dispatching timed out | 输入事件(触摸/按键)5 秒没派发出去 |
| Broadcast | 前台 10s / 后台 60s | Broadcast of Intent | 广播 onReceive 执行超时 |
| Service | 前台 20s / 后台 200s | executing service | Service 生命周期函数执行超时 |
本单是第一种,而且是最特殊的一个亚型:
Input dispatching timed out (Application does not have a focused window)
“没有焦点窗口”——输入事件不是没人处理,而是根本找不到收件窗口。这个亚型的成因几乎总是”外部焦点干扰”,而不是 App 自己卡死(详见第四部分)。
2. 主线程与消息循环(一切卡顿分析的地基)
Android 应用有一个主线程(UI 线程),所有界面操作(画界面、响应点击、刷新列表)都必须在这里执行。主线程内部是一个无限循环,叫 Looper 消息循环:
Looper 循环:
取一条 Message → 执行 → 取下一条 → 执行 → ...
关键推论:
- Message 是串行执行的:一条没跑完,后面的全排队。某条 Message 里写了 3.3 秒的活,这 3.3 秒内界面就是死的——点不动、画不了帧。
Handler.post(runnable)不是换线程,而是”把任务塞进队列,等当前这条 Message 执行完再执行”。new Handler(Looper.getMainLooper()).post()只是改变执行时机,不改变执行线程。- 界面刷新也是一条 Message:
Choreographer#doFrame(编舞者,每 16.6ms 一帧)。如果主线程被占着,doFrame 排不上队 → 掉帧 → 用户看到卡。
flowchart LR subgraph Q["主线程 MessageQueue(串行)"] direction LR M1["Message A<br/>updateState 同步循环<br/>(可能含 3.3s 的活)"] --> M2["Message B<br/>Choreographer#doFrame<br/>(画下一帧)"] --> M3["Message C<br/>点击事件分发"] end L["Looper<br/>无限循环"] -->|取一条,跑完才取下一条| Q M2 -.被 A 堵住排不上队.-> J["掉帧 / 卡顿 / ANR"] style M1 fill:#ffe4e1 style J fill:#ffd700
3. RecyclerView 与 notifyDataSetChanged 的红线
RecyclerView(滚动列表)靠 Adapter 提供数据。数据变了,要调用 notifyDataSetChanged() 通知它重排。
红线:不能在 RecyclerView 正在计算布局(layout)的时候调用 notify。否则会抛:
IllegalStateException: Cannot call this method while RecyclerView is computing a layout or scrolling
工程上的标准解法:把 notify 用 Handler.post 推迟到布局之后——这正是本单 adapter 里 mUIHandler.post(...) 的由来。
4. Preference 框架:设置页为什么”天然爱全量重建”
车机设置页不是手写 View 堆出来的,而是 Preference 框架:每个列表项是一个 Preference 对象,容器是 PreferenceGroup,Adapter(PreferenceGroupAdapter)把容器里的 Preference 逐个摆进 RecyclerView。
关键机制:每次 add/remove 一个 Preference,框架都会同步回调一次 onPreferenceHierarchyChange(层级变化通知)。Adapter 在这个回调里做两件事:
- 重建装饰数据(背景/边距映射表,本单的
generatePreferenceItemViewData) - 通知 RecyclerView 刷新(notifyDataSetChanged)
推论:“removeAll + addPreference×109” 一波操作 = 218 次回调。如果每次回调都干重活,成本就是 218 倍——这就是本单风暴的放大器。
5. 焦点窗口与 InputDispatcher(理解 no-focused-window ANR)
- 焦点窗口(focused window):当前接收键盘/触摸事件的那个窗口。任何时刻全系统最多一个。
- InputDispatcher:系统输入分发器。事件来了,它先问”焦点窗口是谁?“,答不上来就等,最多等 5 秒,等不到就 ANR(no focused window)。
- ActivityController:一个测试装置注入的钩子(正常车辆上没有,本单台架实测
dumpsys activity查无此物)。monkey/自动化框架用它当”围栏”:每个 Activity 启动都要它批准,它返回 false 就 reject——用于把 monkey 关在被测应用里。白名单就是它放行的包名清单。
flowchart TD E["输入事件到达<br/>(触摸/按键)"] --> D{"InputDispatcher:<br/>当前有焦点窗口?"} D -->|有| OK["派发给焦点窗口<br/>正常处理"] D -->|没有| W["等待窗口变可聚焦<br/>Will wait for 5000ms"] W -->|5s 内焦点落定| OK W -->|等满 5000ms 仍无焦点| ANR["💥 ANR:<br/>Input dispatching timed out<br/>(no focused window)"] style ANR fill:#ff6b6b,color:#fff style OK fill:#90ee90
6. monkey 压测与”围栏副作用”
monkey 压测 = 随机事件风暴乱点。测试框架用 ActivityController 围栏把 monkey 限制在被测应用内。但车机上有系统应用会被系统自动拉起(如 AVM 全景影像:随机信号/按键都可能触发它启动):
AVM 启动请求 → 围栏(reject,因为不在白名单)→ 启动方重试 → 再 reject …
每一次启动尝试,AMS 都会先动摇焦点(准备切走),reject 后焦点无处安放——每 reject 一次,焦点就被震飞一次。秒级频率的 reject 风暴 = 焦点长时间悬空。
sequenceDiagram autonumber participant M as monkey(随机事件) participant SYS as 系统服务/发起方 participant AC as ActivityController<br/>(测试围栏) participant AMS as AMS/WMS(焦点状态机) participant ID as InputDispatcher loop 约 1 次/秒 × N(reject 风暴) M->>SYS: 随机按键/信号间接触发 SYS->>AC: startActivity(com.mi.car.avm) AC-->>SYS: reject(不在白名单) Note over AMS: 焦点先被预备切走,启动被否后无处落定 AMS->>ID: setFocusedWindow(null 或被拒 NO_WINDOW) Note over ID: 焦点悬空计时开始累加 end ID->>ID: 焦点缺失满 5000ms → ANR rect rgb(230, 255, 230) Note over M,ID: 修复:AVM 进白名单后,启动放行 → 一次干净的焦点转移,风暴消失 end
第二部分 本单问题:WLAN 页重建风暴(N801PROB-137881)
7. 案件回放
monkey 压测中打开 WLAN 页,5.3 秒后 ANR(no focused window)。现场(loganaly 机器人时间线):
21:06:53.108 WifiSettingsActivity 窗口创建
21:06:53.174 窗口 READY_TO_SHOW 但 animating=true,无法变 visible
21:06:53.195 WifiPickerTracker 开始密集回调 onWifiEntriesChanged
21:06:54.309 WiFi 数据加载完成(109 个网络)
21:06:53~56 主线程持续 removeAll + addPreference×109,约 3.3 秒
21:06:52~58 ActivityController 反复 reject AVM 启动(共 13 次),焦点悬空
21:06:58.465 ANR 触发
两个独立问题叠加:App 侧 3.3s 主线程占用(性能缺陷)+ 测试侧 reject 风暴(ANR 直接成因)。本部分讲前者。
7.5 代码全链路梳理(开 WLAN 页,从点击到渲染)
先建立整体调用链,后面四环逐个对应到代码位置:
flowchart TD subgraph UI["① 页面层"] A["WifiSettingsActivity 启动"] B["MiCarWifiListFragment<br/>(DialogSettingsFragment)"] end subgraph CTRL["② Controller 层(每 controller 双刷 onCreate+onStart)"] C["MiCarAvailableWifiListController<br/>(可用列表)"] D["MiCarSavedWifiListController<br/>(已保存列表)"] end subgraph DATA["③ 数据层"] E["MiWifiTrackerUtils<br/>(InstanceGetter 单例)"] F["WifiPickerTracker<br/>(10 处 updateWifiEntries 触发点)"] end subgraph RENDER["④ 渲染层"] G["PreferenceCategory<br/>removeAll + addPreference×N"] H["HighlightablePreferenceGroupAdapter<br/>onPreferenceHierarchyChange ×218"] I["RecyclerView layout/bind"] end A --> B --> C & D C & D -->|"updateState() 折叠管线<br/>(worker 取数 → 主线程刷 UI)"| G E -->|"onWifiEntriesChanged 回调风暴"| C & D F --> E G --> H --> I style H fill:#ffe4e1 style G fill:#fff3cd
四环放大器总览(数字对应本节 8~12 的章节号):
flowchart LR R1["环 1:扇出<br/>WifiPickerTracker<br/>10 触发点 × 无条件 post<br/>(§8)"] R2["环 2:弱折叠<br/>removeMessages+SINGLE_ASYNC.remove<br/>running 窗口失效 + 共享引用<br/>(§9)"] R3["环 3:全量重建<br/>removeAll + addPreference×N<br/>与内容变没变无关<br/>(§10)"] R4["环 4:adapter 放大<br/>O(N²) 遍历 + notify 洪流<br/>(§11)"] R1 -->|"160ms 内 10+ 波回调"| R2 -->|"波次合并失败"| R3 -->|"每波 218 次回调"| R4 R4 --> OUT["主线程饱和 ≈ M' 波 × 每波 O(N²)"] style OUT fill:#ff6b6b,color:#fff
从点击到第一帧的代码路径(v6.0 行号):
WifiSettingsActivity创建 → fragment 加载各 controller →PreferenceController.onCreate:401→onCreateInternal()→refreshUi():411(第 1 次全量刷新)- Activity onStart →
PreferenceController.onStart:434→refreshUi():440(第 2 次全量刷新) —— 两个列表 controller × 2 = 开页即 ≥4 次重建 - controller 的
updateState(preference)(MiCarBaseWifiEntryController.kt:87)走折叠管线:worker 线程fillWifiEntries()取数 →mHandler.sendMessage回主线程 →updateState(preference, list)刷 UI - 同时
WifiPickerTracker的 10 个触发点(scan results / state / rssi……)经MiWifiTrackerUtils.mTrackerListener扇出onWifiEntriesChanged→ 回到第 3 步,形成波次 - 刷 UI:
removeAll + addPreference×N→ 每个增删回调 adapteronPreferenceHierarchyChange→ 遍历+notify → RV 全量 layout/bind
8. 风暴的第一环:数据源”扇出”
WifiPickerTracker(扫描/状态数据源)有 10 处 updateWifiEntries() 触发点(扫描结果到、WiFi 状态变、信号变化……),每处都无条件往主线程抛回调:
// base/WifiTrackerLib/src/xcddif/.../WifiPickerTracker.java:1383-1384
if (mListener != null) {
mMainHandler.post(mListener::onWifiEntriesChanged);
}开 WLAN 页瞬间,这些触发点密集命中 → 回调风暴。台架实测:开页后 160ms 内 10+ 波 onWifiEntriesChanged。
9. 风暴的第二环:弱折叠管线(波次合并失败)
Controller 收到回调不是直接刷 UI,而是走一条”折叠管线”想合并波次:
// settingsPage/.../wifi/controller/MiCarBaseWifiEntryController.kt:87-111
override fun updateState(preference: PreferenceCategory) {
mHandler.removeMessages(UPDATE_MESSAGE_WHAT) // ① 取消未执行的 UI 消息
if (mUpdateStateRunnable != null) {
ThreadPoolUtils.SINGLE_ASYNC.remove(mUpdateStateRunnable) // ② 取消未执行的 worker 任务
}
mUpdateStateRunnable = Runnable {
synchronized(mAllWifiEntries) {
mAllWifiEntries.clear()
fillWifiEntries(mAllWifiEntries) // worker 线程:取数
}
val msg = Message.obtain(mHandler) {
updateState(preference, mAllWifiEntries) // ③ 回主线程刷 UI
}
mHandler.sendMessage(msg)
}
ThreadPoolUtils.SINGLE_ASYNC.execute(mUpdateStateRunnable)
}两个缺陷让它”弱”:
- 窗口期失效:①② 只能取消”还没开始执行”的任务。worker 任务一旦开跑,② 对它就无效——回调再来时旧任务继续跑完并刷一次 UI,新任务又刷一次。波次合并失败。
- 共享引用(③ 的 lambda 捕获的是
mAllWifiEntries活引用,不是快照):等消息执行时,列表可能已被更新的任务改写过——同一份数据先后全量重建两遍,纯冗余。台架实测:同一个已连接 AP 在 100ms 内被fillWifiEntries重复抓取重建 3 次。
竞态时序(回调 2 来的时候,任务 1 已经在跑了):
sequenceDiagram participant T as WifiPickerTracker participant M as 主线程 Handler participant W as worker(SINGLE_ASYNC) participant UI as 主线程 UI T->>M: 回调 1 onWifiEntriesChanged M->>M: removeMessages(空) + SINGLE_ASYNC.remove(空) M->>W: 任务 1 入队并开跑 Note over W: fillWifiEntries 取数中…<br/>(此时它已不可取消) T->>M: 回调 2 onWifiEntriesChanged(160ms 内又来) M->>M: removeMessages(空) + SINGLE_ASYNC.remove(任务1)<br/>❌ 对 running 任务无效! M->>W: 任务 2 入队(排在任务 1 后) W->>UI: 任务 1 完成 → 发消息刷 UI(第 1 次全量重建) Note over UI: lambda 捕获的是 mAllWifiEntries 活引用<br/>可能已被任务 2 改写 → 同数据建两遍 W->>W: 任务 2 再 fillWifiEntries 一遍 W->>UI: 任务 2 完成 → 再刷 UI(第 2 次全量重建,数据可能相同) rect rgb(230,255,230) Note over M,UI: rank3 修法:快照捕获 + 数据版本号短路<br/>任务 1 的 UI 消息发现版本已旧 → 直接丢弃 end
10. 风暴的第三环:每波都是”全量重建”
列表刷新的实现是推倒重来:
// .../controller/MiCarAvailableWifiListController.kt:39-47
override fun updateState(preference: PreferenceCategory, list: List<WifiEntry>) {
MLog.tag(TAG).d("updateState: size=${list.size}") // ← 本单最重要的计数器,记住它
preference.removeAll() // 全删
list.forEach {
preference.addPreference(createWifiEntryPreference(it, index++)) // 逐个新建
}
}N 个条目一波 = removeAll(N 次删除)+ addPreference×N,与内容变没变无关。
11. 风暴的第四环(放大器):adapter 的 O(N²) 与 notify 洪流
回顾第一部分第 4 节:每个 add/remove 都同步回调 onPreferenceHierarchyChange。出货分支(v6.0)上它长这样:
// base/settingsBaseUi/.../HighlightablePreferenceGroupAdapter.java(修复前)
public void onPreferenceHierarchyChange(Preference preference) {
if (!hasObservers()) return;
generatePreferenceItemViewData(mPreferenceGroup); // 同步全表遍历 O(N)
mUIHandler.post(() -> super.onPreferenceHierarchyChange(preference)); // 排队一次 notify
...
}一波 109 条的重建 = 218 次回调,每次回调都:
- 同步全表遍历 → 218 × O(N) ≈ O(N²)。更冤的是:WiFi 条目
needAddBackgroundPreference()=false(不是需要背景装饰的类型),遍历结果对 WiFi 页是空 map——纯无效功; - 排队一个 notifyDataSetChanged runnable → 218 个相同的 runnable 挤爆主线程队列,RV 反复全量 layout;
- 每条还打一条
MLogI/O。
12. 单条构造成本也没放过
// .../wifi/preference/MiCarWifiEntryPreference.kt:64-66, 185-189
init { onUpdated() } // 构造即执行
...
final override fun onUpdated() {
setSummary(wifiEntry.getSummary(false)) // 多段字符串拼接
}每个 Preference 对象创建时(每波 109 个)就做 summary 字符串拼接,而这活儿完全可以推迟到 bind(只有可见的 ~10 条需要)。
13. 总账:3.3 秒是怎么算出来的
主线程占用 ≈ 波次 M' × 每波成本
每波成本 ≈ N × (单条构造+getSummary) + O(N²) 遍历 + N 次 notify + 全量 relayout
- 波次 M’ 与环境无关(机制决定):开页双刷(onCreate+onStart 各 refreshUi,
PreferenceController.java:411/:440,≥4 次)+ 回调风暴(弱折叠漏过的波)+ 空波真清真建(出货分支无 guard) - 单价随 N 放大:109 个 AP 时每波 ~200-300ms
- 台架实测(1 个 AP):34 次 base updateState + 15 次全量重建 / 46 秒;109 个 AP 时同样的波次 → 3.3s 主线程饱和
第三部分 修复详解(三件套 + rank1)
14. 修复全景
| 修复 | 来源 | 断的是哪一环 |
|---|---|---|
| ① notify 去重 | cherry-pick 65cafc344(dev 原生,MICOCKPIT-144252) | 第 11 环:218 个 notify → 每帧 1 个 |
| ② guardTransientEmpty | cherry-pick 7e1970bee(dev 原生,MICOCKPIT-146231) | 第 9 环:空波不再真清真建 |
| ③ rank1(新写) | change 371943/371944 | 第 11 环:O(N²) 遍历 → 每帧 1 次 |
| ④ DiffUtil 同项判断 | v6.0 已有(c48c56a11,N801PROB-111489) | 第 10 环:渲染端不再全表 replace |
15. ① notify 去重:队列里只留一个
private Runnable mDeferredHierarchyChangeRunnable; // 成员变量持有
if (mDeferredHierarchyChangeRunnable != null) {
mUIHandler.removeCallbacks(mDeferredHierarchyChangeRunnable); // 旧的撤掉
}
mDeferredHierarchyChangeRunnable = () -> super.onPreferenceHierarchyChange(preference);
mUIHandler.post(mDeferredHierarchyChangeRunnable); // 只留新的218 次回调 = 218 次”撤旧换新”,队列里始终最多 1 个 notify。顺带还修了一个真崩溃(多个 notifyDataSetChanged 累积导致 AdapterHelper 的 UpdateOp 对象池双重回收 Already in the pool!)。
为什么安全:218 个 runnable 内容完全相同(都是 notifyDataSetChanged),留哪个都一样;它们本来就要等当前同步循环跑完才执行,折叠时机天然正确。
sequenceDiagram participant CB as onPreferenceHierarchyChange<br/>(主线程,×218) participant Q as 主线程消息队列 participant RV as RecyclerView rect rgb(255,230,230) Note over CB,RV: 去重前:218 个相同 notify 挤爆队列 CB->>Q: post(notify #1) CB->>Q: post(notify #2) CB->>Q: post(notify #3)...(×218) Q->>RV: notifyDataSetChanged ×218<br/>(反复全量 layout) end rect rgb(230,255,230) Note over CB,RV: 去重后:队列里始终最多 1 个 CB->>Q: removeCallbacks + post(留最新) CB->>Q: removeCallbacks + post(留最新)...(×218) Q->>RV: notifyDataSetChanged ×1<br/>(一次全量 layout) end
16. ② guardTransientEmpty:空波延迟 500ms 确认
// MiCarBaseWifiEntryController.kt(cherry-pick 7e1970bee)
protected fun guardTransientEmpty(preference: PreferenceCategory, list: List<WifiEntry>): Boolean {
if (list.isNotEmpty()) { /* 非空波:取消挂起的清空,正常处理 */ return false }
if (mBypassEmptyGuard) { mBypassEmptyGuard = false; return false } // 复核到期仍空:放行真清
if (preference.preferenceCount == 0 || !MiCarWifiUtils.isWifiEnable()) return false // UI 本就空:不用防
mEmptyConfirmRunnable = Runnable { mBypassEmptyGuard = true; refreshUi() }
mHandler.postDelayed(mEmptyConfirmRunnable, 500L) // 延迟 500ms 复核
return true // 本次空波:不清,UI 保持旧数据
}为什么需要它:从详情页返回时 tracker 先 clear 再多波渐进重建,中间波会下发空 list(实测空窗 63~249ms)。没有防护时 UI 对空波也会 removeAll → 列表整块消失再重建(闪屏+白做功)。加了防护:空波先记账 500ms,期间非空波到了就当无事发生;到期还是空(比如真的忘了唯一已保存 AP)才真清——最多多花 500ms,不会错杀。
stateDiagram-v2 [*] --> 正常显示 正常显示 --> 挂起确认: 空波到达<br/>且 UI 非空且 wifi 开<br/>(postDelayed 500ms) 挂起确认 --> 正常显示: 500ms 内非空波到达<br/>取消挂起,照常刷新 挂起确认 --> 放行真清: 500ms 到期仍空<br/>bypass 一次守卫 → refreshUi 放行真清 --> 真空列表: removeAll 真执行<br/>(覆盖忘记唯一已保存 AP 等真实变空) 真空列表 --> 正常显示: 后续非空波重建 note right of 挂起确认 期间 UI 保持旧数据, 不做 removeAll / 不改 isVisible end note
17. ③ rank1:把 O(N²) 遍历也折叠进去重帧
核心洞察:全表遍历的产物(map)只在 notify 之后的 bind 阶段被读(updateDefaultBackground:403 是唯一读者)。那同步抢先算 218 次毫无意义——挪进同一个去重 runnable,先算 map 再发 notify,读到的还是最新值:
mDeferredHierarchyChangeRunnable = () -> {
generatePreferenceItemViewData(mPreferenceGroup); // rank1:每帧最多 1 次
MLog.d("onPreferenceHierarchyChange(preference:" + preference.getTitle() + ")");
super.onPreferenceHierarchyChange(preference);
};
mUIHandler.post(mDeferredHierarchyChangeRunnable);时序安全性(自查三问,均已实证):
- map 读端只有 bind 链路(:403 → updateDefaultBackground → onBindViewHolder),bind 只发生在 notify 之后 ✅
- 两个构造函数仍直接调
generatePreferenceItemViewData,初始 map 及时建立 ✅ - 唯一子类
VoiceAssistHighlightableAdapter无相关覆写,不受影响 ✅
修复前后主线程时序对比:
修复前:
[当前Message: 同步循环 218 次回调 ×(全表遍历+排队notify+打日志)]
[队列: notify ×218 → 逐个执行 → RV 反复全量 layout]
修复后:
[当前Message: 同步循环 218 次回调 ×(撤旧换新,微秒级)]
[队列: 1 个 runnable: 重建 map → notify → RV 一次 layout]
回调次数不变(框架机制),但每次回调的成本从”全表遍历+排队”降到”一次 removeCallbacks+赋值”,重活全部折叠到循环结束后的一个 runnable 里。
18. 实测对照(台架 ampere,同步骤开 WLAN 页)
| 指标 | 基线包 | 修复包 | Δ |
|---|---|---|---|
列表全量重建(updateState: size= 行数) | 15 | 7 | -53% |
| adapter notify(≈notifyDataSetChanged 次数) | 每次 add 一次(数百) | 4(每帧 1 次) | ~-99% |
| O(N²) map 重建 | 每次 add/remove 同步 | 每帧 1 次 | ~-99% |
| 回调波次(base updateState) | 34 | 26 | -24%(波次本身留给 rank3) |
19. 后续(rank3 规划,未做)
波次 M’ 本身还靠两件事继续砍:
- 快照修复:UI 消息 lambda 捕获
mAllWifiEntries的快照拷贝而非活引用,修同数据双重建 - 数据版本号短路:
updateState()每次 bump 版本号,worker 跑完发现已有更新版本就丢弃本次 UI 重建——覆盖”SINGLE_ASYNC.remove 对 running task 无效”的窗口;顺带吸收开页双刷(onStart 天然 supersede onCreate)
第四部分 ANR 定性:为什么这是测试手法问题
20. 触发链与”两道闸门”
焦点落到 settings 窗口,需要两道闸门同时且持续打开:
| 闸门 | 持有者 | 本单状态 |
|---|---|---|
| A:窗口可聚焦(画完首帧、动画结束、visible) | App | 21:06:53.174 起关闭 ~3.3s(animating+主线程占用) |
| B:焦点时序能落定(无其他启动/焦点转移在途) | 系统 | 21:06:52~58 关闭 ~6s(reject×13) |
时刻(s) 52 53 54 55 56 57 58
reject×13 ████████████████████████████████████ 闸门 B 关闭
animating ██████████████████ 闸门 A 关闭
焦点缺失 ≈ 52→58 ≈ 6s > 5s 死线
ANR @58.465
gantt dateFormat ss axisFormat %Ss section 闸门 B(系统侧) reject×13 焦点时序扰动(关闭) :crit, b1, 00, 06 section 闸门 A(App 侧) 窗口创建 @53.1 :milestone, a1, 01, 0 animating+主线程占用 3.3s(关闭) :a2, 01, 04 section 判定 InputDispatcher 5s 死线窗 :active, d1, 01, 05 ANR @58.465 :milestone, crit, d2, 06, 0
焦点悬空窗(~6s)完全覆盖 InputDispatcher 的 5s 判定窗——App 的 3.3s 是加重因素,不是必要条件。
21. 反事实推演与对照实验(125924)
假设 App 零占用(闸门 A 全程打开):闸门 B 仍从 52 关到 58,焦点时序 6 秒落不定 → ANR 照样发生。
这个反事实不用假设——N801PROB-125924 就是现成的对照实验:
- 该单 settings 页面”虽已 onResume 并完成绘制,但 InputDispatcher 设置焦点时被 Reason=NO_WINDOW 忽略”(App 健康)
- 同样的 AVM reject 风暴 → 同样
Will wait for 5000ms→ 同样 ANR - loganaly 定性:“不是主线程卡死,而是 Monkey/ActivityController reject 造成焦点窗口未能及时建立。置信度:高”
- 处置:resolution = Not an issue
反向也成立:只有闸门 A 关闭(纯 App 卡)打不出这种 ANR——那会是 Waited XXms for MotionEvent 型(窗口有焦点但事件没人消费)。“does not have a focused window” 这个 ANR 类型本身就是”外部焦点干扰”的指纹。
22. 为什么”monkey 配 AVM 白名单”能根治
链条每一环的归属:
| 环节 | 归属 |
|---|---|
| ActivityController(围栏钩子) | 测试装置注入(正常车辆没有,台架实测) |
| 白名单(reject 策略) | 测试配置 |
| AVM 反复启动(系统应用被随机事件触发) | 系统正常行为 |
| reject → 焦点扰动 ×13 → 焦空 6s → ANR | 围栏副作用 |
把 com.mi.car.avm 加进白名单:AVM 启动被放行 → 正常全屏启动、一次干净的焦点转移(毫秒级) → 没有 reject、没有重试、没有焦空 → 死线永远不触发。根治的是 AVM 这个触发源;完整建议是把所有可能被系统自动拉起的系统应用都纳入 monkey 白名单,或在否决策略上去抖(同包连续否决降频)。
flowchart LR subgraph BEFORE["❌ 未配白名单(现状)"] direction TB A1["系统触发 AVM 启动"] --> B1{"ActivityController<br/>在白名单?"} B1 -->|不在,reject| C1["启动失败 → 发起方重试"] C1 -.约 1 次/秒,×13.-> A1 B1 --> D1["焦点被预备切走<br/>启动被否 → 悬空"] D1 --> E1["焦点缺失 6s<br/>> 5s 死线 → ANR"] end subgraph AFTER["✅ 配了白名单(根治)"] direction TB A2["系统触发 AVM 启动"] --> B2{"ActivityController<br/>在白名单?"} B2 -->|在,放行| C2["AVM 正常启动<br/>一次干净的焦点转移(毫秒级)"] C2 --> D2["焦点始终健康<br/>死线永不触发"] end style E1 fill:#ff6b6b,color:#fff style D2 fill:#90ee90
23. 甩锅与接活的正确姿势
- ANR 定性 = 测试环境配置问题(monkey 未配 AVM 白名单),参照 125924 同口径;测试方 zhaoruochi 本人在单上已定案:“最终的anr原因是因为monkey时未设置avm白名单”
- App 侧 3.3s = 真实性能缺陷,认账:密集 AP 环境下该缺陷必现,优化 change 已提(371943/371944)
- 锅和活分开,证据和代码说话
第五部分 分析方法论(下次遇到同类问题怎么做)
24. 复现与量化
- 体感复现必须密集 AP 环境(≥50 个可见网络;事发 109 个):单次重建成本 ∝ N(构造)+ O(N²)(遍历),台架 1 个 AP 凑不出秒级耗时
- 台架量化用计数法:
adb logcat | grep "updateState: size="行数 = 全量重建次数 M’(与环境弱相关,修复前后可比);onPreferenceHierarchyChange日志行数 ≈ notify 次数 - 别被 trace 骗:台架 perfetto 里”没有大段耗时”是预期(次数在、单价低);实测本单台架 trace 171 帧仅 2 帧超标,且最重帧(94.9ms)拆开是首帧页面骨架 inflate(
RV CreateView 49.1ms),不是列表重建——最重帧必须先看子树再归因
25. 分支意识(判案铁律)
代码归因必须切出货分支:本单 dev 分支已带两项缓解(guardTransientEmpty、notify 去重),出货分支 v6.0 都没有——按 dev 分析会低估实机严重度。分支判据 = ROM 代号与分支提交 tag 吻合(如 26.08.21.1 ↔ [All-Vehicle-26RC11])+ 提交时间不晚于 ROM 构建日。
26. 甩锅类 ANR 的排查顺序
- 看 ANR 类型:
no focused window先怀疑外部焦点干扰(测试围栏/系统焦点异常),Waited XXms for MotionEvent才是 App 卡死 - 找干扰源:ActivityController reject / 其他 Activity 频繁启动 / 系统焦点异常
- 做窗口算术:干扰窗口 vs 5s 判定窗,覆盖即定性
- 查先例:同 digest/同机制的历史单怎么关的(125924 = Not an issue)
附录
27. 名词速查
| 名词 | 一句话解释 |
|---|---|
| ANR | 系统看门狗:应用任务超死线判无响应 |
| Looper / MessageQueue | 主线程的串行任务队列,一次只干一件事 |
| Handler.post | 把任务排进队列稍后执行(不换线程) |
| Choreographer / doFrame | 编舞者,每 16.6ms 一帧的刷新心跳 |
| notifyDataSetChanged | 通知 RecyclerView”数据全变了,重排”(不能在 layout 中调) |
| Preference / PreferenceGroup | 设置页列表项 / 容器,增删会回调 onPreferenceHierarchyChange |
| 焦点窗口 | 当前接收输入事件的窗口,全系统最多一个 |
| InputDispatcher | 输入分发器,无焦点窗口等 5 秒即 ANR |
| ActivityController | 测试注入的启动围栏钩子(白名单 veto) |
| monkey 白名单 | 围栏放行清单,名单外的启动一律 reject |
| guardTransientEmpty | 瞬态空列表延迟确认防护(500ms) |
| O(N²) 遍历 | 每次增删都全表扫描,N 次增删 = N×N 次扫描 |
28. 关键代码位置(v6.0 @ 523f6e72b)
| 位置 | 作用 |
|---|---|
settingsPage/.../wifi/controller/MiCarBaseWifiEntryController.kt:87-111 | 弱折叠管线(rank3 目标) |
settingsPage/.../wifi/controller/MiCarAvailableWifiListController.kt:39-47 | 全量重建现场 + updateState: size= 计数器 |
base/settingsBaseUi/.../HighlightablePreferenceGroupAdapter.java:148-171 | onPreferenceHierarchyChange(rank1+去重修复点) |
base/settingsBaseUi/.../PreferenceController.java:411/:440 | onCreate/onStart 双刷源头 |
base/WifiTrackerLib/src/xcddif/.../WifiPickerTracker.java:1383-1384 | 回调扇出源头(10 处触发点) |
settingsPage/.../wifi/preference/MiCarWifiEntryPreference.kt:64/:84/:185-189 | 构造期 getSummary、key 迟置 |
29. 参考链接
- JIRA:N801PROB-137881(本单)、N801PROB-125924(同机制先例,Not an issue)
- Gerrit:371943(v6.0 三件套)、371944(dev rank1)
- 分析产物:
jira-data/N801PROB-137881/analysis/ANR_Analysis_Report_20260826.md、evidence-pushback.md(怼测试证据链) - 相关:MICOCKPIT-146231(瞬态空列表防护)、MICOCKPIT-144252(notify 去重)、N801PROB-111489(DiffUtil 同项判断)