1. 全局替换tool_center/inspiration为discover模块,统一路由路径 2. 调整AppRoutes路由常量,将discover作为主Tab页,inspiration作为子页面 3. 更新页面注册表与路由配置,修正跳转目标 4. 调整启动页可选配置项,修正路由ID对应关系 5. 新增翻译服务、内容发现、热搜相关工具类与数据模型 6. 修复缓存清理后未刷新统计的问题,调整x86_64架构注释 7. 更新AGENTS.md文档约束规则 8. 新增一批调试用截图资源文件
889 lines
30 KiB
Markdown
889 lines
30 KiB
Markdown
# Flutter AI 全流程闭环调试指南:看 → 操作 → 分析 → 修复
|
||
|
||
> 创建时间: 2026-05-28 | 版本: 1.1 | 测试环境: Windows + Android 真机 + Flutter 3.11.5
|
||
> 上次更新: 2026-05-28 | 更新内容: 补充实测结果,修正 API 参数,新增关键发现和注意事项
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
- [0. 前置准备](#0-前置准备)
|
||
- [1. 看 — 观察应用状态](#1-看--观察应用状态)
|
||
- [1.1 获取 Widget 树](#11-获取-widget-树)
|
||
- [1.2 截取屏幕截图](#12-截取屏幕截图)
|
||
- [1.3 检测布局溢出和错误](#13-检测布局溢出和错误)
|
||
- [1.4 获取 Widget 属性详情](#14-获取-widget-属性详情)
|
||
- [2. 操作 — 模拟用户交互](#2-操作--模拟用户交互)
|
||
- [2.1 模拟点击](#21-模拟点击)
|
||
- [2.2 模拟滑动](#22-模拟滑动)
|
||
- [2.3 模拟文字输入](#23-模拟文字输入)
|
||
- [2.4 模拟按键](#24-模拟按键)
|
||
- [2.5 组合操作:导航到指定页面](#25-组合操作导航到指定页面)
|
||
- [3. 分析 — 诊断性能和问题](#3-分析--诊断性能和问题)
|
||
- [3.1 帧率分析(Android 层)](#31-帧率分析android-层)
|
||
- [3.2 内存分析(Android 层)](#32-内存分析android-层)
|
||
- [3.3 Dart 堆内存分析(VM Service)](#33-dart-堆内存分析vm-service)
|
||
- [3.4 ANR 检测](#34-anr-检测)
|
||
- [3.5 Flutter 日志分析](#35-flutter-日志分析)
|
||
- [3.6 GC 分析](#36-gc-分析)
|
||
- [4. 修复 — 修改代码并验证](#4-修复--修改代码并验证)
|
||
- [4.1 Hot Reload 热重载](#41-hot-reload-热重载)
|
||
- [4.2 修复布局溢出](#42-修复布局溢出)
|
||
- [4.3 修复内存泄漏](#43-修复内存泄漏)
|
||
- [4.4 修复 ANR 问题](#44-修复-anr-问题)
|
||
- [4.5 验证修复结果](#45-验证修复结果)
|
||
- [5. 完整闭环示例](#5-完整闭环示例)
|
||
- [6. VM Service API 速查表](#6-vm-service-api-速查表)
|
||
- [7. adb 命令速查表](#7-adb-命令速查表)
|
||
- [8. 故障排除](#8-故障排除)
|
||
|
||
---
|
||
|
||
## 0. 前置准备
|
||
|
||
### 0.1 启动应用
|
||
|
||
```bash
|
||
flutter run -d <device_id>
|
||
```
|
||
|
||
### 0.2 获取 VM Service URI
|
||
|
||
应用启动后,从日志中获取 VM Service 地址:
|
||
|
||
```bash
|
||
adb logcat -d | Select-String "Dart VM service is listening"
|
||
```
|
||
|
||
输出示例:
|
||
```
|
||
I/flutter: The Dart VM service is listening on http://127.0.0.1:43079/quZORSmVYqk=/
|
||
```
|
||
|
||
### 0.3 设置端口转发
|
||
|
||
```bash
|
||
adb forward tcp:<local_port> tcp:<remote_port>
|
||
# 示例:
|
||
adb forward tcp:62562 tcp:43079
|
||
```
|
||
|
||
### 0.4 保存关键变量
|
||
|
||
在后续所有命令中需要用到以下变量:
|
||
|
||
```powershell
|
||
$VM_WS = "ws://127.0.0.1:62562/quZORSmVYqk=/ws" # WebSocket 地址
|
||
$ISOLATE_ID = "isolates/6655121260625127" # Isolate ID
|
||
$PACKAGE = "apps.xy.xianyan" # 包名
|
||
```
|
||
|
||
### 0.5 WebSocket 通信模板
|
||
|
||
所有 VM Service 调用都通过 WebSocket 发送 JSON-RPC 请求:
|
||
|
||
```powershell
|
||
function Call-VMService {
|
||
param([string]$Method, [hashtable]$Params = @{})
|
||
$ws = New-Object System.Net.WebSockets.ClientWebSocket
|
||
$cts = New-Object System.Threading.CancellationTokenSource
|
||
$uri = [System.Uri]::new($VM_WS)
|
||
$ws.ConnectAsync($uri, $cts.Token).Wait()
|
||
$body = @{jsonrpc="2.0"; method=$Method; params=$Params; id=[guid]::NewGuid().ToString()} | ConvertTo-Json -Depth 5
|
||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
|
||
$segment = New-Object System.ArraySegment[byte] -ArgumentList @(,$bytes)
|
||
$ws.SendAsync($segment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $cts.Token).Wait()
|
||
$buffer = New-Object byte[] 262144
|
||
$recvSegment = New-Object System.ArraySegment[byte] -ArgumentList @(,$buffer)
|
||
$result = $ws.ReceiveAsync($recvSegment, $cts.Token).Result
|
||
$response = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $result.Count)
|
||
$ws.Dispose()
|
||
return $response
|
||
}
|
||
```
|
||
|
||
> ✅ **测试结果**: VM Service WebSocket 连接成功,可正常收发 JSON-RPC 消息。
|
||
|
||
---
|
||
|
||
## 1. 看 — 观察应用状态
|
||
|
||
### 1.1 获取 Widget 树
|
||
|
||
**用途**: 查看当前页面的完整 Widget 层级结构,定位 UI 组件。
|
||
|
||
**命令**:
|
||
|
||
```powershell
|
||
Call-VMService -Method "ext.flutter.inspector.getRootWidgetTree" -Params @{
|
||
isolateId = $ISOLATE_ID
|
||
groupName = "debug_group"
|
||
withPreferencedSemantics = $false
|
||
}
|
||
```
|
||
|
||
**返回示例** (实测):
|
||
|
||
```json
|
||
{
|
||
"result": {
|
||
"description": "[root]",
|
||
"type": "_ElementDiagnosticableTreeNode",
|
||
"hasChildren": true,
|
||
"children": [
|
||
{
|
||
"description": "View",
|
||
"children": [
|
||
{
|
||
"description": "RawView",
|
||
"children": [
|
||
{
|
||
"description": "_RawViewInternal",
|
||
"children": [
|
||
{
|
||
"description": "MediaQuery",
|
||
"children": [
|
||
{
|
||
"description": "FocusTraversalGroup",
|
||
"children": [
|
||
{
|
||
"description": "Focus",
|
||
"children": [
|
||
{
|
||
"description": "Navigator",
|
||
"children": [
|
||
{ "description": "你的业务Widget..." }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**关键字段**:
|
||
|
||
| 字段 | 说明 |
|
||
|------|------|
|
||
| `description` | Widget 类型名称 |
|
||
| `hasChildren` | 是否有子 Widget |
|
||
| `valueId` | Widget 引用 ID(用于后续查询属性) |
|
||
| `locationId` | 代码位置 ID |
|
||
| `creationLocation.file` | 创建该 Widget 的源文件路径 |
|
||
| `creationLocation.line` | 创建该 Widget 的行号 |
|
||
|
||
> ✅ **测试结果**: 成功获取 16KB Widget 树数据,包含完整的 Widget 层级。
|
||
|
||
### 1.2 截取屏幕截图
|
||
|
||
#### 方法一:adb screencap(推荐,速度快)
|
||
|
||
```bash
|
||
adb shell screencap -p /sdcard/screenshot.png
|
||
adb pull /sdcard/screenshot.png ./debug_screenshot.png
|
||
```
|
||
|
||
#### 方法二:Android CLI(支持 UI 标注)
|
||
|
||
```bash
|
||
android screen capture -a -o=./debug_annotated.png # 带标注
|
||
android screen capture -o=./debug_screenshot.png # 不带标注
|
||
```
|
||
|
||
**标注模式说明**: `-a` 参数会在截图中为每个可交互 UI 元素绘制编号边框,配合 `android screen resolve` 可解析坐标。
|
||
|
||
> ✅ **测试结果**: adb screencap 约 0.03s 完成,Android CLI 截屏约 1-2s。两者均成功。
|
||
|
||
### 1.3 检测布局溢出和错误
|
||
|
||
**用途**: 实时捕获 Flutter 的布局溢出、RenderFlex overflow 等结构化错误。
|
||
|
||
**步骤一:启用结构化错误监听**
|
||
|
||
```powershell
|
||
Call-VMService -Method "ext.flutter.inspector.structuredErrors" -Params @{
|
||
isolateId = $ISOLATE_ID
|
||
enabled = $true
|
||
}
|
||
```
|
||
|
||
**步骤二:触发错误后查看**
|
||
|
||
错误会通过 VM Service 的事件流推送。也可以直接查看 Flutter 日志:
|
||
|
||
```bash
|
||
adb logcat -s flutter | Select-String "overflow|RenderFlex|Exception|Error"
|
||
```
|
||
|
||
**常见溢出错误类型**:
|
||
|
||
| 错误 | 说明 | 修复方法 |
|
||
|------|------|---------|
|
||
| `RenderFlex overflowed` | Flex 容器溢出 | 添加 `flexible`/`Expanded`,或 `SingleChildScrollView` |
|
||
| `Bottom overflowed by X pixels` | 底部溢出 | 包裹 `SingleChildScrollView` |
|
||
| `A RenderFlex overflowed by X pixels` | 水平/垂直溢出 | 调整约束或使用 `overflow: Clip.hardEdge` |
|
||
| `Incorrect use of ParentDataWidget` | Widget 父子关系错误 | 检查 Widget 嵌套层级 |
|
||
|
||
> ✅ **测试结果**: structuredErrors 已成功启用,返回 `{"enabled":"true"}`。
|
||
|
||
### 1.4 获取 Widget 属性详情
|
||
|
||
**用途**: 查看指定 Widget 的详细属性(大小、约束、颜色等)。
|
||
|
||
> ⚠️ **关键发现**: `getDetailsSubtree` 和 `getProperties` 必须在**同一 WebSocket 连接**内调用,
|
||
> 且必须使用与 `getRootWidgetTree` 相同的 `groupName/objectGroup`。
|
||
> 跨连接调用会返回 `null` 或空数组。
|
||
|
||
**正确用法(同一连接内)**:
|
||
|
||
```powershell
|
||
# 步骤1:建立 WebSocket 连接
|
||
$ws = New-Object System.Net.WebSockets.ClientWebSocket
|
||
$cts = New-Object System.Threading.CancellationTokenSource
|
||
$uri = [System.Uri]::new($VM_WS)
|
||
$ws.ConnectAsync($uri, $cts.Token).Wait()
|
||
|
||
# 步骤2:获取 Widget 树(创建对象组)
|
||
$msg1 = '{"jsonrpc":"2.0","method":"ext.flutter.inspector.getRootWidgetTree","params":{"isolateId":"'+$ISOLATE_ID+'","groupName":"my_group","withPreferencedSemantics":false},"id":"1"}'
|
||
# ... 发送并接收 ...
|
||
|
||
# 步骤3:在同一连接内查询属性(使用相同的 groupName/objectGroup)
|
||
$msg2 = '{"jsonrpc":"2.0","method":"ext.flutter.inspector.getDetailsSubtree","params":{"isolateId":"'+$ISOLATE_ID+'","objectGroup":"my_group","arg0":"inspector-7","subtreeDepth":2},"id":"2"}'
|
||
# ... 发送并接收 ...
|
||
|
||
# 步骤4:释放对象组(在同一连接内)
|
||
$msg3 = '{"jsonrpc":"2.0","method":"ext.flutter.inspector.disposeGroup","params":{"isolateId":"'+$ISOLATE_ID+'","objectGroup":"my_group"},"id":"3"}'
|
||
# ... 发送并接收 ...
|
||
|
||
$ws.Dispose()
|
||
```
|
||
|
||
> ✅ **测试结果**: 同一连接内 `getDetailsSubtree` 成功返回子树详情(含 Focus、Semantics、Actions 等子节点)。
|
||
> 跨连接调用返回 `null`。
|
||
|
||
**参数说明**:
|
||
|
||
| 参数 | 说明 | 注意 |
|
||
|------|------|------|
|
||
| `groupName` | getRootWidgetTree 使用的组名 | 创建对象组时用 |
|
||
| `objectGroup` | 其他 API 使用的组名 | **必须与 groupName 一致** |
|
||
| `arg0` | Widget 的 valueId | 如 `inspector-7` |
|
||
| `subtreeDepth` | 子树深度 | 建议不超过 5,避免数据过大 |
|
||
|
||
---
|
||
|
||
## 2. 操作 — 模拟用户交互
|
||
|
||
### 2.1 模拟点击
|
||
|
||
```bash
|
||
# 基本点击 (x, y 为像素坐标)
|
||
adb shell input tap 720 1600
|
||
|
||
# 点击屏幕中心 (1440x3200 设备)
|
||
adb shell input tap 720 1600
|
||
|
||
# 点击左上角
|
||
adb shell input tap 100 200
|
||
|
||
# 点击右上角
|
||
adb shell input tap 1340 200
|
||
```
|
||
|
||
**坐标系**: 原点在左上角,x 向右增大,y 向下增大。设备分辨率决定最大值。
|
||
|
||
**获取设备分辨率**:
|
||
```bash
|
||
adb shell wm size
|
||
# 输出: Physical size: 1440x3200
|
||
```
|
||
|
||
> ✅ **测试结果**: 点击 (720, 1600) 成功,截屏确认界面有变化。
|
||
|
||
### 2.2 模拟滑动
|
||
|
||
```bash
|
||
# 向上滑动 (从下方滑到上方)
|
||
adb shell input swipe 720 2400 720 800 500
|
||
# x1 y1 x2 y2 持续时间(ms)
|
||
|
||
# 向下滑动
|
||
adb shell input swipe 720 800 720 2400 500
|
||
|
||
# 向左滑动 (翻页)
|
||
adb shell input swipe 1200 1600 200 1600 300
|
||
|
||
# 向右滑动
|
||
adb shell input swipe 200 1600 1200 1600 300
|
||
|
||
# 慢速滑动 (用于查看滚动效果)
|
||
adb shell input swipe 720 2400 720 800 1500
|
||
```
|
||
|
||
**参数说明**:
|
||
|
||
| 参数 | 说明 | 建议值 |
|
||
|------|------|--------|
|
||
| x1, y1 | 起始坐标 | - |
|
||
| x2, y2 | 终止坐标 | - |
|
||
| duration | 持续时间(ms) | 快滑 300,正常 500,慢滑 1500 |
|
||
|
||
> ✅ **测试结果**: 向上滑动 (720,2000→720,800, 500ms) 成功,页面滚动可见。
|
||
|
||
### 2.3 模拟文字输入
|
||
|
||
```bash
|
||
# 输入文字 (需要先点击输入框获取焦点)
|
||
adb shell input tap 720 800 # 点击输入框
|
||
adb shell input text "hello%sworld" # 输入文字 (%s=空格)
|
||
|
||
# 清除输入框内容
|
||
adb shell input keyevent KEYCODE_CLEAR
|
||
|
||
# 使用 adb am broadcast 输入中文 (需要安装 ADBKeyboard)
|
||
adb shell am broadcast -a ADB_INPUT_TEXT --es msg "你好世界"
|
||
```
|
||
|
||
**特殊字符转义**:
|
||
|
||
| 字符 | 转义 | 示例 |
|
||
|------|------|------|
|
||
| 空格 | `%s` | `hello%sworld` |
|
||
| `&` | `\\&` | `test\\&more` |
|
||
| `<` | `\\<` | `a\\<b` |
|
||
| `>` | `\\>` | `a\\>b` |
|
||
|
||
### 2.4 模拟按键
|
||
|
||
```bash
|
||
# 返回键
|
||
adb shell input keyevent KEYCODE_BACK
|
||
|
||
# Home 键
|
||
adb shell input keyevent KEYCODE_HOME
|
||
|
||
# 最近任务键
|
||
adb shell input keyevent KEYCODE_APP_SWITCH
|
||
|
||
# 回车键
|
||
adb shell input keyevent KEYCODE_ENTER
|
||
|
||
# 删除键
|
||
adb shell input keyevent KEYCODE_DEL
|
||
|
||
# 音量增/减
|
||
adb shell input keyevent KEYCODE_VOLUME_UP
|
||
adb shell input keyevent KEYCODE_VOLUME_DOWN
|
||
```
|
||
|
||
### 2.5 组合操作:导航到指定页面
|
||
|
||
```bash
|
||
# 示例:点击底部 Tab 导航到"发现"页
|
||
adb shell input tap 360 3050 # 点击第2个 Tab
|
||
Start-Sleep -Seconds 1
|
||
adb shell screencap -p /sdcard/nav_result.png
|
||
adb pull /sdcard/nav_result.png ./debug_nav.png
|
||
|
||
# 示例:滚动列表并点击某个卡片
|
||
adb shell input swipe 720 2000 720 800 500 # 滚动
|
||
Start-Sleep -Seconds 1
|
||
adb shell input tap 720 1200 # 点击卡片
|
||
Start-Sleep -Seconds 2
|
||
adb shell screencap -p /sdcard/detail.png # 截屏确认
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 分析 — 诊断性能和问题
|
||
|
||
### 3.1 帧率分析(Android 层)
|
||
|
||
```bash
|
||
# 获取帧率数据
|
||
adb shell dumpsys gfxinfo $PACKAGE
|
||
|
||
# 重置帧率计数器 (开始新的测量)
|
||
adb shell dumpsys gfxinfo $PACKAGE reset
|
||
|
||
# 等待 5 秒后获取新数据
|
||
Start-Sleep -Seconds 5
|
||
adb shell dumpsys gfxinfo $PACKAGE | Select-String "Janky|percentile|Missed|Slow|deadline|Total frames"
|
||
```
|
||
|
||
**关键指标** (实测数据):
|
||
|
||
| 指标 | 实测值 | 正常值 | 评价 |
|
||
|------|--------|--------|------|
|
||
| Total frames rendered | 5 | - | 样本少 |
|
||
| **Janky frames** | **40.00%** | <5% | 🔴 严重卡顿 |
|
||
| 50th percentile | **750ms** | <16ms | 🔴 极慢 |
|
||
| Number Missed Vsync | 1 | 0 | 🟡 有丢帧 |
|
||
| Number Slow UI thread | 2 | 0 | 🟡 UI 线程阻塞 |
|
||
| Number Frame deadline missed | 2 | 0 | 🔴 帧超时 |
|
||
|
||
> ⚠️ 注意:Flutter 使用自己的渲染管线,Android gfxinfo 只能捕获 View 层帧数据。
|
||
> Flutter 内部帧率需通过 VM Service 的 `ext.flutter.frames` 获取。
|
||
|
||
### 3.2 内存分析(Android 层)
|
||
|
||
```bash
|
||
adb shell dumpsys meminfo $PACKAGE
|
||
```
|
||
|
||
**关键指标** (实测数据):
|
||
|
||
| 指标 | 实测值 | 正常值 | 评价 |
|
||
|------|--------|--------|------|
|
||
| **Total PSS** | **1,335 MB** | 200-400MB | 🔴 极高 |
|
||
| Native Heap | 101 MB | 30-60MB | 🔴 偏高 |
|
||
| Dalvik Heap | 32 MB | 20-40MB | ✅ 正常 |
|
||
| **EGL mtrack (GPU)** | **130 MB** | 30-80MB | 🔴 GPU 纹理泄漏嫌疑 |
|
||
| **Unknown** | **803 MB** | <50MB | 🔴 图片缓存未释放嫌疑 |
|
||
|
||
### 3.3 Dart 堆内存分析(VM Service)
|
||
|
||
```powershell
|
||
# 获取分配概要 (含 GC)
|
||
Call-VMService -Method "getAllocationProfile" -Params @{
|
||
isolateId = $ISOLATE_ID
|
||
gc = $true
|
||
}
|
||
```
|
||
|
||
**返回关键字段** (实测):
|
||
|
||
```json
|
||
{
|
||
"memoryUsage": {
|
||
"heapUsage": 245066832, // ~234MB Dart 堆使用
|
||
"heapCapacity": 266690560, // ~254MB Dart 堆容量
|
||
"externalUsage": 28192 // ~28KB 外部内存
|
||
}
|
||
}
|
||
```
|
||
|
||
**分析步骤**:
|
||
|
||
1. 记录初始 `heapUsage`
|
||
2. 执行操作(如切换页面、滚动列表)
|
||
3. 再次获取 `heapUsage`
|
||
4. 如果持续增长且不回落 → 内存泄漏
|
||
|
||
### 3.4 ANR 检测
|
||
|
||
```bash
|
||
# 检查 ANR 文件
|
||
adb shell "ls -la /data/anr/" 2>&1 | Select-String "anr_"
|
||
|
||
# 检查最近的 ANR
|
||
adb shell "ls -lt /data/anr/" 2>&1 | Select-Object -First 10
|
||
```
|
||
|
||
**实测发现**: 设备上存在 **6 个正式 ANR + 13 个临时 ANR**,时间集中在 5/27-5/28。
|
||
|
||
**ANR 常见原因**:
|
||
|
||
| 原因 | 检测方法 | 修复方向 |
|
||
|------|---------|---------|
|
||
| 主线程 IO 操作 | 检查同步文件读写 | 改用 `compute()` 或 `Isolate` |
|
||
| 网络请求阻塞 | 检查同步 HTTP 调用 | 使用 async/await |
|
||
| 大量 JSON 解析 | 检查 `jsonDecode` 调用 | 改用 `compute()` 解析 |
|
||
| 数据库操作 | 检查同步 DB 查询 | 使用 Drift 的 isolate 模式 |
|
||
| 插件初始化慢 | 检查 `initState` 中的同步调用 | 延迟初始化 |
|
||
|
||
### 3.5 Flutter 日志分析
|
||
|
||
```bash
|
||
# 实时查看 Flutter 日志
|
||
adb logcat -s flutter
|
||
|
||
# 查看最近 200 条
|
||
adb logcat -d -t 200 -s flutter
|
||
|
||
# 过滤错误
|
||
adb logcat -d -s flutter | Select-String "Exception|Error|SEVERE|overflow"
|
||
|
||
# 过滤网络请求
|
||
adb logcat -d -s flutter | Select-String "http|api|request|response"
|
||
```
|
||
|
||
### 3.6 GC 分析
|
||
|
||
通过 VM Service 的 `getAllocationProfile` 获取 GC 信息:
|
||
|
||
```powershell
|
||
Call-VMService -Method "getVM" -Params @{} | ConvertFrom-Json
|
||
# 查看 isolates → heaps → new/old 的 collections 和 avgCollectionPeriodMillis
|
||
```
|
||
|
||
**实测 GC 数据**:
|
||
|
||
| 堆区 | GC 次数 | 平均 GC 间隔 | GC 耗时 |
|
||
|------|---------|-------------|---------|
|
||
| New (Scavenger) | 1611 次 | 299ms | 3.99s |
|
||
| Old (PageSpace) | 68 次 | 7099ms | 0.43s |
|
||
|
||
> 🟡 New Space GC 间隔 299ms 偏短(正常应 >500ms),说明短命对象创建频繁。
|
||
|
||
---
|
||
|
||
## 4. 修复 — 修改代码并验证
|
||
|
||
### 4.1 Hot Reload 热重载
|
||
|
||
修改代码后,有三种方式触发热重载:
|
||
|
||
**方法一:在 flutter run 终端按 `r`**(推荐,最可靠)
|
||
|
||
flutter run 终端会持续运行,直接向其发送 `r` 即可触发热重载。
|
||
|
||
**方法二:通过 VM Service 触发 `ext.flutter.reassemble`**(推荐,可编程)
|
||
|
||
```powershell
|
||
Call-VMService -Method "ext.flutter.reassemble" -Params @{
|
||
isolateId = $ISOLATE_ID
|
||
}
|
||
```
|
||
|
||
> ✅ **测试结果**: `ext.flutter.reassemble` 成功,返回 `{"type":"_extensionType","method":"ext.flutter.reassemble"}`。
|
||
> 注意:此方法只触发 Widget 重建,**不会重新编译 Dart 代码**。需要先修改文件并保存,
|
||
> 再通过 flutter run 终端按 `r` 完成编译+重载,最后用 reassemble 刷新 Inspector 缓存。
|
||
|
||
**方法三:通过 VM Service 触发 `reloadSources`**(不推荐)
|
||
|
||
```powershell
|
||
Call-VMService -Method "reloadSources" -Params @{
|
||
isolateId = $ISOLATE_ID
|
||
force = $true
|
||
}
|
||
```
|
||
|
||
> ❌ **测试结果**: `reloadSources` 返回 `"Error while starting Kernel isolate task"`。
|
||
> 原因:Kernel isolate 在 `flutter run` 模式下由终端进程管理,VM Service 无法直接调用。
|
||
> 此方法仅在 `flutter attach` 或 Dart VM 直接运行时可用。
|
||
|
||
**最佳实践**:
|
||
|
||
| 场景 | 推荐方法 |
|
||
|------|---------|
|
||
| 修改 Dart 代码后热重载 | flutter run 终端按 `r` |
|
||
| 修改代码后刷新 Inspector | 先按 `r`,再调 `reassemble` |
|
||
| 仅刷新 Widget 树(未改代码) | 调 `reassemble` |
|
||
| 完全重启应用 | flutter run 终端按 `R` |
|
||
|
||
### 4.2 修复布局溢出
|
||
|
||
**典型问题**: `RenderFlex overflowed by X pixels`
|
||
|
||
**诊断流程**:
|
||
1. 获取 Widget 树 → 定位溢出的 Column/Row
|
||
2. 查看 Widget 属性 → 确认约束
|
||
3. 修复代码
|
||
|
||
**修复示例**:
|
||
|
||
```dart
|
||
// ❌ 错误:Column 内容超出屏幕
|
||
Column(
|
||
children: [
|
||
Text('Title'),
|
||
Text('Content'),
|
||
Text('Footer'), // 可能溢出
|
||
],
|
||
)
|
||
|
||
// ✅ 修复方案一:包裹 SingleChildScrollView
|
||
SingleChildScrollView(
|
||
child: Column(
|
||
children: [
|
||
Text('Title'),
|
||
Text('Content'),
|
||
Text('Footer'),
|
||
],
|
||
),
|
||
)
|
||
|
||
// ✅ 修复方案二:使用 Expanded
|
||
Column(
|
||
children: [
|
||
Text('Title'),
|
||
Expanded(child: Text('Content')),
|
||
Text('Footer'),
|
||
],
|
||
)
|
||
```
|
||
|
||
### 4.3 修复内存泄漏
|
||
|
||
**诊断流程**:
|
||
1. `getAllocationProfile` → 记录 heapUsage
|
||
2. 操作应用(切换页面、滚动)
|
||
3. 再次 `getAllocationProfile` → 对比 heapUsage
|
||
4. 如果持续增长 → 定位泄漏类
|
||
|
||
**常见泄漏原因及修复**:
|
||
|
||
| 泄漏原因 | 修复方法 |
|
||
|---------|---------|
|
||
| Stream 未取消订阅 | 在 `dispose()` 中调用 `subscription.cancel()` |
|
||
| Timer 未取消 | 在 `dispose()` 中调用 `timer.cancel()` |
|
||
| Controller 未 dispose | 在 `dispose()` 中调用 `controller.dispose()` |
|
||
| 图片缓存过大 | 使用 `CachedNetworkImage` 并设置缓存上限 |
|
||
| 全局 List 不断追加 | 定期清理或使用 LRU 缓存 |
|
||
|
||
### 4.4 修复 ANR 问题
|
||
|
||
**诊断流程**:
|
||
1. 检查 `/data/anr/` 目录确认 ANR
|
||
2. 查看 Flutter 日志定位阻塞操作
|
||
3. 将同步操作改为异步
|
||
|
||
**修复示例**:
|
||
|
||
```dart
|
||
// ❌ 错误:主线程同步解析大 JSON
|
||
final data = jsonDecode(bigJsonString);
|
||
|
||
// ✅ 修复:使用 compute 在后台线程解析
|
||
final data = await compute(jsonDecode, bigJsonString);
|
||
```
|
||
|
||
### 4.5 验证修复结果
|
||
|
||
**完整验证流程**:
|
||
|
||
```bash
|
||
# 1. 修改代码后 Hot Reload
|
||
# (在 flutter run 终端按 r)
|
||
|
||
# 2. 截屏确认界面正常
|
||
adb shell screencap -p /sdcard/verify.png
|
||
adb pull /sdcard/verify.png ./debug_verify.png
|
||
|
||
# 3. 检查溢出错误
|
||
adb logcat -d -s flutter | Select-String "overflow"
|
||
# 应无输出
|
||
|
||
# 4. 检查帧率
|
||
adb shell dumpsys gfxinfo $PACKAGE reset
|
||
Start-Sleep -Seconds 5
|
||
adb shell dumpsys gfxinfo $PACKAGE | Select-String "Janky|percentile"
|
||
# Janky frames 应 <5%
|
||
|
||
# 5. 检查内存
|
||
adb shell dumpsys meminfo $PACKAGE | Select-String "TOTAL PSS"
|
||
# 应比修复前下降
|
||
|
||
# 6. 检查 ANR
|
||
adb shell "ls -lt /data/anr/" | Select-Object -First 3
|
||
# 不应有新的 ANR 文件
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 完整闭环示例
|
||
|
||
### 示例:发现并修复首页列表卡顿
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 第一步:看 │
|
||
│ │
|
||
│ 1. 获取 Widget 树 │
|
||
│ → ext.flutter.inspector.getRootWidgetTree │
|
||
│ → 发现首页使用 ListView (未使用 builder) │
|
||
│ │
|
||
│ 2. 截屏确认界面 │
|
||
│ → adb screencap → 列表滚动有明显卡顿 │
|
||
│ │
|
||
│ 3. 检测溢出错误 │
|
||
│ → structuredErrors → 发现 2 个 RenderFlex overflow │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ 第二步:操作 │
|
||
│ │
|
||
│ 4. 模拟滚动列表 │
|
||
│ → adb shell input swipe 720 2000 720 800 500 │
|
||
│ → 观察到明显掉帧 │
|
||
│ │
|
||
│ 5. 滚动到底部 │
|
||
│ → adb shell input swipe 720 2000 720 800 500 (多次) │
|
||
│ → 内存从 400MB 涨到 800MB │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ 第三步:分析 │
|
||
│ │
|
||
│ 6. 帧率分析 │
|
||
│ → dumpsys gfxinfo → Janky frames 40% │
|
||
│ │
|
||
│ 7. 内存分析 │
|
||
│ → dumpsys meminfo → Total PSS 824MB │
|
||
│ → getAllocationProfile → heapUsage 234MB 持续增长 │
|
||
│ │
|
||
│ 8. ANR 检测 │
|
||
│ → /data/anr/ → 6 个 ANR 文件 │
|
||
│ │
|
||
│ 9. 定位根因 │
|
||
│ → ListView 未使用 builder,一次性创建所有子项 │
|
||
│ → 图片未使用缓存,每次滚动重新加载 │
|
||
│ → 列表项包含大图,未做缩略图处理 │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ 第四步:修复 │
|
||
│ │
|
||
│ 10. 修改 ListView → ListView.builder │
|
||
│ 11. 添加 CachedNetworkImage │
|
||
│ 12. 修复 RenderFlex overflow → 包裹 Expanded │
|
||
│ 13. Hot Reload (按 r) │
|
||
│ │
|
||
│ 14. 验证: │
|
||
│ → 截屏确认界面正常 ✅ │
|
||
│ → 溢出错误消失 ✅ │
|
||
│ → Janky frames 降至 3% ✅ │
|
||
│ → 内存稳定在 350MB ✅ │
|
||
│ → 无新 ANR ✅ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 6. VM Service API 速查表
|
||
|
||
### Flutter Inspector 扩展
|
||
|
||
| 方法 | 用途 | 参数 | 实测状态 |
|
||
|------|------|------|---------|
|
||
| `ext.flutter.inspector.getRootWidgetTree` | 获取 Widget 树 | `isolateId`, `groupName`, `withPreferencedSemantics` | ✅ 成功 |
|
||
| `ext.flutter.inspector.getRootWidget` | 获取根 Widget | `isolateId`, `groupName` | ⚠️ 需先 disposeGroup |
|
||
| `ext.flutter.inspector.getDetailsSubtree` | 获取子树详情 | `isolateId`, `objectGroup`, `arg0`(valueId), `subtreeDepth` | ✅ 同连接内成功 |
|
||
| `ext.flutter.inspector.getProperties` | 获取 Widget 属性 | `isolateId`, `objectGroup`, `arg0`(valueId) | ⚠️ 同连接内返回空 |
|
||
| `ext.flutter.inspector.structuredErrors` | 启用/禁用结构化错误 | `isolateId`, `enabled` | ✅ 成功 |
|
||
| `ext.flutter.inspector.setPubRootDirectories` | 设置项目根目录 | `isolateId`, `arg0`(路径) | ✅ 成功 |
|
||
| `ext.flutter.inspector.disposeGroup` | 释放对象组 | `isolateId`, `objectGroup` | ⚠️ 同连接内成功 |
|
||
| `ext.flutter.inspector.getSelectedWidget` | 获取选中 Widget | `isolateId`, `objectGroup`, `previousSelectionId` | ✅ 返回 null(未选中) |
|
||
| `ext.flutter.reassemble` | 触发 Widget 重建 | `isolateId` | ✅ 成功 |
|
||
|
||
> ⚠️ **重要**: Inspector API 的对象组(groupName/objectGroup)**必须在同一 WebSocket 连接内使用**。
|
||
> 跨连接调用会返回 null 或报错。
|
||
|
||
### VM 核心 API
|
||
|
||
| 方法 | 用途 | 参数 | 实测状态 |
|
||
|------|------|------|---------|
|
||
| `getVM` | 获取 VM 信息 | 无 | ✅ 成功 |
|
||
| `getIsolate` | 获取 Isolate 详情 | `isolateId` | ✅ 成功 |
|
||
| `getAllocationProfile` | 获取内存分配概要 | `isolateId`, `gc` | ✅ 成功 |
|
||
| `reloadSources` | Hot Reload | `isolateId`, `force` | ❌ Kernel isolate 错误 |
|
||
| `evaluate` | 执行表达式 | `isolateId`, `targetId`, `expression` | ⚠️ 需要正确的 library ID |
|
||
| `evaluateInFrame` | 在栈帧中执行表达式 | `isolateId`, `frameIndex`, `expression` | ⚠️ 需要暂停状态 |
|
||
| `getObject` | 获取对象详情 | `isolateId`, `objectId` | ✅ 成功 |
|
||
| `getStack` | 获取调用栈 | `isolateId` | ✅ 成功 (返回空 frames + messages) |
|
||
| `getScripts` | 获取脚本列表 | `isolateId` | ✅ 成功 |
|
||
|
||
---
|
||
|
||
## 7. adb 命令速查表
|
||
|
||
### 设备交互
|
||
|
||
| 命令 | 用途 |
|
||
|------|------|
|
||
| `adb shell input tap x y` | 点击 |
|
||
| `adb shell input swipe x1 y1 x2 y2 duration` | 滑动 |
|
||
| `adb shell input text "hello"` | 输入文字 |
|
||
| `adb shell input keyevent KEYCODE_BACK` | 按返回键 |
|
||
| `adb shell input keyevent KEYCODE_HOME` | 按 Home 键 |
|
||
| `adb shell input keyevent KEYCODE_ENTER` | 按回车键 |
|
||
|
||
### 截屏和录屏
|
||
|
||
| 命令 | 用途 |
|
||
|------|------|
|
||
| `adb shell screencap -p /sdcard/s.png` | 截屏 |
|
||
| `adb shell screenrecord /sdcard/video.mp4` | 录屏 (最长 180s) |
|
||
|
||
### 性能分析
|
||
|
||
| 命令 | 用途 |
|
||
|------|------|
|
||
| `adb shell dumpsys gfxinfo <pkg>` | 帧率数据 |
|
||
| `adb shell dumpsys gfxinfo <pkg> reset` | 重置帧率计数 |
|
||
| `adb shell dumpsys meminfo <pkg>` | 内存数据 |
|
||
| `adb shell dumpsys cpuinfo` | CPU 使用率 |
|
||
| `adb shell dumpsys battery` | 电池信息 |
|
||
|
||
### 日志和调试
|
||
|
||
| 命令 | 用途 |
|
||
|------|------|
|
||
| `adb logcat -s flutter` | Flutter 日志 |
|
||
| `adb logcat -d -t 200` | 最近 200 条日志 |
|
||
| `adb shell "ls -la /data/anr/"` | ANR 文件列表 |
|
||
| `adb shell dumpsys activity top` | 当前 Activity |
|
||
| `adb shell am start -n <pkg>/<activity>` | 启动 Activity |
|
||
|
||
### 端口转发
|
||
|
||
| 命令 | 用途 |
|
||
|------|------|
|
||
| `adb forward tcp:LOCAL tcp:REMOTE` | 端口转发 |
|
||
| `adb forward --list` | 查看转发列表 |
|
||
| `adb forward --remove tcp:LOCAL` | 移除转发 |
|
||
|
||
---
|
||
|
||
## 8. 故障排除
|
||
|
||
### VM Service 连接失败
|
||
|
||
| 现象 | 原因 | 解决方法 |
|
||
|------|------|---------|
|
||
| 403 Forbidden | 未使用认证 token | URL 中必须包含 auth token(如 `/quZORSmVYqk=/`) |
|
||
| Connection refused | 端口转发未设置 | 执行 `adb forward tcp:LOCAL tcp:REMOTE` |
|
||
| WebSocket 握手失败 | URL 路径错误 | 确保路径以 `/ws` 结尾 |
|
||
| Inspector API 返回 null | 跨连接使用对象组 | 必须在同一 WebSocket 连接内调用 |
|
||
| getSelectedWidget 需要 objectGroup | 参数名错误 | 使用 `objectGroup` 而非 `groupName` |
|
||
|
||
### uiautomator dump 失败
|
||
|
||
| 现象 | 原因 | 解决方法 |
|
||
|------|------|---------|
|
||
| "could not get idle state" | Flutter 持续动画 | 使用 VM Service 的 Inspector API 代替 |
|
||
| Permission denied | 非 root 设备 | 使用 VM Service 方案 |
|
||
|
||
### Hot Reload 失败
|
||
|
||
| 现象 | 原因 | 解决方法 |
|
||
|------|------|---------|
|
||
| "Reload not supported" | 修改了非 Dart 文件 | 需要完全重启 (`R`) |
|
||
| "Reload rejected" | 修改了全局状态 | 需要完全重启 (`R`) |
|
||
| "Error while starting Kernel isolate task" | flutter run 模式下 VM Service 无法调用 reloadSources | 使用 flutter run 终端按 `r` |
|
||
| 连接断开 | 设备断开 | 重新 `flutter run` |
|
||
|
||
### 内存数据异常
|
||
|
||
| 现象 | 原因 | 解决方法 |
|
||
|------|------|---------|
|
||
| Unknown 内存过高 | 图片缓存未释放 | 检查 `CachedNetworkImage` 配置 |
|
||
| EGL mtrack 过高 | GPU 纹理泄漏 | 检查图片加载和释放逻辑 |
|
||
| Native Heap 偏高 | 原生插件泄漏 | 逐个禁用插件排查 |
|
||
| Total PSS 超过 1GB | 综合内存问题 | 优先修复 Unknown 和图片缓存 |
|
||
|
||
### Inspector 对象组问题
|
||
|
||
| 现象 | 原因 | 解决方法 |
|
||
|------|------|---------|
|
||
| getDetailsSubtree 返回 null | 跨连接使用对象组 | 在同一 WebSocket 连接内调用 |
|
||
| getProperties 返回空数组 | valueId 已过期 | 重新获取 Widget 树 |
|
||
| disposeGroup 报错 | 跨连接释放 | 在同一连接内释放 |
|
||
| groupName vs objectGroup | 参数名不一致 | getRootWidgetTree 用 `groupName`,其他 API 用 `objectGroup`,值必须一致 |
|