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 dispatching5 秒Input dispatching timed out输入事件(触摸/按键)5 秒没派发出去
Broadcast前台 10s / 后台 60sBroadcast of Intent广播 onReceive 执行超时
Service前台 20s / 后台 200sexecuting serviceService 生命周期函数执行超时

本单是第一种,而且是最特殊的一个亚型:

Input dispatching timed out (Application does not have a focused window)

“没有焦点窗口”——输入事件不是没人处理,而是根本找不到收件窗口。这个亚型的成因几乎总是”外部焦点干扰”,而不是 App 自己卡死(详见第四部分)。

2. 主线程与消息循环(一切卡顿分析的地基)

Android 应用有一个主线程(UI 线程),所有界面操作(画界面、响应点击、刷新列表)都必须在这里执行。主线程内部是一个无限循环,叫 Looper 消息循环:

Looper 循环:
  取一条 Message → 执行 → 取下一条 → 执行 → ...

关键推论:

  1. Message 是串行执行的:一条没跑完,后面的全排队。某条 Message 里写了 3.3 秒的活,这 3.3 秒内界面就是死的——点不动、画不了帧。
  2. Handler.post(runnable) 不是换线程,而是”把任务塞进队列,等当前这条 Message 执行完再执行”。new Handler(Looper.getMainLooper()).post() 只是改变执行时机,不改变执行线程
  3. 界面刷新也是一条 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 在这个回调里做两件事:

  1. 重建装饰数据(背景/边距映射表,本单的 generatePreferenceItemViewData)
  2. 通知 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 行号):

  1. WifiSettingsActivity 创建 → fragment 加载各 controller → PreferenceController.onCreate:401onCreateInternal()refreshUi():411(第 1 次全量刷新)
  2. Activity onStart → PreferenceController.onStart:434refreshUi():440(第 2 次全量刷新) —— 两个列表 controller × 2 = 开页即 ≥4 次重建
  3. controller 的 updateState(preference)(MiCarBaseWifiEntryController.kt:87)走折叠管线:worker 线程 fillWifiEntries() 取数 → mHandler.sendMessage 回主线程 → updateState(preference, list) 刷 UI
  4. 同时 WifiPickerTracker 的 10 个触发点(scan results / state / rssi……)经 MiWifiTrackerUtils.mTrackerListener 扇出 onWifiEntriesChanged → 回到第 3 步,形成波次
  5. 刷 UI:removeAll + addPreference×N → 每个增删回调 adapter onPreferenceHierarchyChange → 遍历+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)
}

两个缺陷让它”弱”:

  1. 窗口期失效:①② 只能取消”还没开始执行”的任务。worker 任务一旦开跑,② 对它就无效——回调再来时旧任务继续跑完并刷一次 UI,新任务又刷一次。波次合并失败。
  2. 共享引用(③ 的 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 次回调,每次回调都:

  1. 同步全表遍历 → 218 × O(N) ≈ O(N²)。更冤的是:WiFi 条目 needAddBackgroundPreference()=false(不是需要背景装饰的类型),遍历结果对 WiFi 页是空 map——纯无效功;
  2. 排队一个 notifyDataSetChanged runnable → 218 个相同的 runnable 挤爆主线程队列,RV 反复全量 layout;
  3. 每条还打一条 MLog I/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 个
② guardTransientEmptycherry-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);

时序安全性(自查三问,均已实证):

  1. map 读端只有 bind 链路(:403 → updateDefaultBackground → onBindViewHolder),bind 只发生在 notify 之后 ✅
  2. 两个构造函数仍直接调 generatePreferenceItemViewData,初始 map 及时建立 ✅
  3. 唯一子类 VoiceAssistHighlightableAdapter 无相关覆写,不受影响 ✅

修复前后主线程时序对比:

修复前:
[当前Message: 同步循环 218 次回调 ×(全表遍历+排队notify+打日志)]
[队列: notify ×218 → 逐个执行 → RV 反复全量 layout]

修复后:
[当前Message: 同步循环 218 次回调 ×(撤旧换新,微秒级)]
[队列: 1 个 runnable: 重建 map → notify → RV 一次 layout]

回调次数不变(框架机制),但每次回调的成本从”全表遍历+排队”降到”一次 removeCallbacks+赋值”,重活全部折叠到循环结束后的一个 runnable 里。

18. 实测对照(台架 ampere,同步骤开 WLAN 页)

指标基线包修复包Δ
列表全量重建(updateState: size= 行数)157-53%
adapter notify(≈notifyDataSetChanged 次数)每次 add 一次(数百)4(每帧 1 次)~-99%
O(N²) map 重建每次 add/remove 同步每帧 1 次~-99%
回调波次(base updateState)3426-24%(波次本身留给 rank3)

19. 后续(rank3 规划,未做)

波次 M’ 本身还靠两件事继续砍:

  1. 快照修复:UI 消息 lambda 捕获 mAllWifiEntries快照拷贝而非活引用,修同数据双重建
  2. 数据版本号短路:updateState() 每次 bump 版本号,worker 跑完发现已有更新版本就丢弃本次 UI 重建——覆盖”SINGLE_ASYNC.remove 对 running task 无效”的窗口;顺带吸收开页双刷(onStart 天然 supersede onCreate)

第四部分 ANR 定性:为什么这是测试手法问题

20. 触发链与”两道闸门”

焦点落到 settings 窗口,需要两道闸门同时且持续打开:

闸门持有者本单状态
A:窗口可聚焦(画完首帧、动画结束、visible)App21: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 的排查顺序

  1. 看 ANR 类型:no focused window 先怀疑外部焦点干扰(测试围栏/系统焦点异常),Waited XXms for MotionEvent 才是 App 卡死
  2. 干扰源:ActivityController reject / 其他 Activity 频繁启动 / 系统焦点异常
  3. 窗口算术:干扰窗口 vs 5s 判定窗,覆盖即定性
  4. 先例:同 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-171onPreferenceHierarchyChange(rank1+去重修复点)
base/settingsBaseUi/.../PreferenceController.java:411/:440onCreate/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.mdevidence-pushback.md(怼测试证据链)
  • 相关:MICOCKPIT-146231(瞬态空列表防护)、MICOCKPIT-144252(notify 去重)、N801PROB-111489(DiffUtil 同项判断)