客户端开发

ADB 能看到设备,Flutter 为什么找不到?flutter_tools 设备解析修复与多 SDK 排查

记录无线 ADB 设备在 adb devices -l 中可见却被 Flutter 丢弃的原因,从 AOSP 列宽格式定位到提交上游 PR

约 13 分钟读完
目录
  1. 1. 三个独立的证据层
  2. 2. adb devices -l 的列宽填充与 mDNS 冲突边界
  3. 3. 按连接状态贪婪锚定的修复方案(flutter#189973)
  4. 4. 本地构建快照与 FVM 多 SDK 验证细节

日常用真机调试 Flutter 项目时,遇到过一种让人困惑的情况:终端里运行 adb devices -l 明明能看到手机处于正常的 device 状态,但到了 flutter devices 或 Android Studio 的设备下拉框里,无线连接的手机要么直接消失并提示解析失败(Unexpected failure parsing device information from adb output),要么有线设备虽然列了出来,一运行却报 device not found。

顺着这个问题往下查,定位到了 flutter_tools 对 adb devices -l 输出行的正则边界限制,随后向 Flutter 官方仓库提交了修复 PR(flutter/flutter#189973,已合入主干并标记 cp: stable)。这里把设备发现的三个证据层、ADB 序列号列宽边界、上游正则改法以及本地 FVM 多 SDK 对照验证的细节完整记录下来。

1. 三个独立的证据层

遇到“IDE 里选不到设备”时,如果一开始就重启电脑、重插数据线或者反复开关无线调试,很容易在错误的方向上耗费时间。更清晰的排查顺序是按下面三层逐一确认:

  1. ADB 连接层:直接运行 adb devices -l。如果列表为空,或者状态是 offline、unauthorized,说明问题出在数据线、网络、配对授权或 ADB 本身,此时不需要去动 Flutter 配置。
  2. Flutter CLI 解析层:通过项目实际绑定的 Flutter SDK 执行 flutter devices -v。如果 adb devices -l 里设备正常,而 Flutter 提示 Unexpected failure parsing device information from adb output 或直接忽略了这台手机,问题就落在了 flutter_tools(android_device_discovery.dart)对 ADB 文本输出的解析逻辑上。
  3. IDE 与后台 Daemon 层:如果命令行下的 flutter devices -v 能正常识别,唯独 Android Studio 下拉框不一致,就需要把 IDE 当作独立环境看待——重点核对当前项目配置的 Flutter SDK、Dart SDK 以及后台尚未退出的 Flutter Daemon 进程是不是来自同一套 SDK。

特别是本机同时装有全局稳定版 SDK、FVM 缓存的多个版本以及本地源码仓库时,IDE 设置界面里展示的路径未必等同于当前设备列表背后那个 Daemon 实际运行的版本。从项目根目录直接调用软链接下的可执行文件,结果最准确:

& .\.fvm\flutter_sdk\bin\flutter.bat --version
& .\.fvm\flutter_sdk\bin\flutter.bat devices -v

2. adb devices -l 的列宽填充与 mDNS 冲突边界

查看 AOSP 中 ADB 的源码实现(transport.cpp)可以看到,adb devices -l 在长列表模式下使用了 %-22s %s 的格式输出每一行:序列号字段的 22 只是一个最小左对齐宽度,而不是固定列宽。这会导致真实环境里同时出现三种形态各异的输出:

# 1. 无线调试的超长 mDNS 序列号:超过 22 字符最小宽度后,序列号与状态列之间只剩单个普通空格
adb-0123456789abcdef._adb-tls-connect._tcp device product:sample model:Phone device:sample transport_id:1

# 2. 常见的有线 USB 短序列号:不足 22 字符,右侧会被补齐一串空格
USB123456789           device product:sample model:Phone device:sample transport_id:2

# 3. 发生名称冲突的无线 mDNS 序列号:自身内部包含空格(如 " (2)"),且与状态列之间同样只有单个空格
adb-0123456789abcdef (2)._adb-tls-connect._tcp device product:sample model:Phone device:sample transport_id:3

围绕这三种合法输出,如果仅靠空白字符宽度去推断列边界,很容易顾此失彼:

  • 在早先的上游修改 flutter/flutter#189369 中,为了保护第 3 种情况里序列号内部的空格(如 (2))不被误切分,解析正则采用了非贪婪匹配,并要求序列号与状态列之间至少有两个空格或制表符((?:\s{2,}|\t+))。结果一旦无线调试序列号长度达到或超过 22 个字符(状态前只剩单个空格),无论带不带冲突后缀,整行都会匹配失败,触发解析报错并丢弃设备。
  • 最初我也考虑过只给“不含空格的序列号”增加一条单空格回退分支(早期关闭的 #189431),但那样仍然覆盖不到第 3 种组合情况——既带有 (2) 内部空格、总长度又超过 22 字符而只以单空格分隔状态列的无线设备。
  • 反过来,如果在放宽匹配时把第 2 种有线短序列号右侧的填充空格也原样留在了 deviceID 里(变成 "USB123456789 "),设备虽然能出现在列表中,后续拼接 adb -s 执行命令时却会因为尾随空格找不到目标,报 device not found 或显示为 unsupported。

3. 按连接状态贪婪锚定的修复方案(flutter#189973)

要同时兼容上述全部情况,就不能再根据“中间有几个空格”来猜测列边界,而应当改由右侧已知的 ADB 连接状态字段反向确定分界。在 flutter/flutter#189973 中,对 packages/flutter_tools/lib/src/android/android_device_discovery.dart 的核心改动如下:

// 修改前(#189369):依赖两个以上空格或 Tab 分隔,超 22 字符的单空格无线序列号会漏匹配
static final _kDeviceRegex = RegExp(
  r'^(.*?)(?:\s{2,}|\t+)'
  r'(device|offline|unauthorized|no permissions|bootloader|recovery|sideload|rescue|connecting|authorizing|host|unknown)'
  r'(?:\s+(.*)|$)',
);
// ...
final String deviceID = match[1]!;

// 修改后(#189973):显式枚举连接状态确定右边界,左侧贪婪捕获序列号并清理右侧列宽填充
static final _kDeviceRegex = RegExp(
  r'^(.*)\s+'
  r'(device|offline|unauthorized|no permissions|bootloader|recovery|sideload|rescue|connecting|authorizing|host|unknown)'
  r'(?:\s+(.*)|$)',
);
// ...
final String deviceID = match[1]!.trimRight();

改动行数很少,但里面有三个彼此配合的边界细节:

  1. 第一组必须用贪婪捕获 ^(.*) 而非非贪婪 ^(.*?):在允许单空格 \s+ 作为分隔符之后,假设序列号自身恰好包含与连接状态同名的单词(例如冲突后缀 device (2),或极端单测样例 'my device offline device'),非贪婪 ^(.*?) 会在遇到序列号内部的第一个 device 或 offline 时就提前截断;改为贪婪 ^(.*) 后,正则会一直向右匹配到真正的状态列之前,完整保留序列号内部的所有字符与空格。
  2. 完整覆盖 Flutter Tools 识别的 12 种连接状态:状态组显式列出了含空格的 no permissions 以及 host、unknown 等全部受支持状态,避免末尾的 product:... model:... 键值对反过来干扰贪婪回溯。
  3. 提取后仅执行 trimRight():贪婪 ^(.*) 在遇到短 USB 序列号时会把 %-22s 补齐的右侧空格一并收入第 1 组(最后一个分隔空格归属 \s+)。对捕获结果只做 trimRight(),就能去掉列宽填充而不影响序列号内部的合法空格。

在对应的 android_device_discovery_test.dart 中一并补充了单空格超长无线序列号、带 (2) 冲突后缀的 12 种连接状态、序列号内含状态同名单词以及有线短序列号填充等回归用例(共 16 项测试全部通过)。

4. 本地构建快照与 FVM 多 SDK 验证细节

在向 Flutter 提交这类 flutter_tools 补丁并在本机配合真机做 A/B 对照验证时,还有几处工具链细节值得留意:

  1. 删除 bin/cache/flutter_tools.snapshot 触发工具链重建:Flutter 启动脚本主要依据 Git Revision 与缓存标记判断工具链快照是否需要重编。如果在未提交状态下直接修改了本地 SDK 或 FVM 缓存里的 packages/flutter_tools 源码,命令行默认执行的仍是旧的 flutter_tools.snapshot。实机复测前先手动删除该快照文件,再运行 flutter devices -v 观察未修改 upstream/master(丢弃无线设备并报错)与打补丁后(完整识别冲突序列号并跑通 adb -s ... shell getprop)的真实差异。
  2. 单测在 packages/flutter_tools 子目录下运行:运行设备发现单测时,切入 packages/flutter_tools 执行 flutter test test/general.shard/android/android_device_discovery_test.dart 与 flutter analyze,比直接从仓库根目录调用更稳妥。
  3. 手动切换软链接期间避免运行 fvm use:做基准 SDK 与本地补丁 SDK 对照时,若临时手动调整了 .fvm/flutter_sdk 的指向,中途不要再执行 fvm use <version>,否则软链接会被重新覆盖回默认缓存目录;每次切换后也需重启一次 IDE 的 Flutter Daemon。
  4. 留意 .fvmrc 的目录作用域与缓存锁:.fvmrc 只约束其所在的目录树;在多包仓库(monorepo)里,如果某个子应用或根目录缺少对应的 .fvmrc,执行 fvm flutter 可能会静默回退到全局默认版本。另外遇到 fvm flutter 长时间无响应时,可以先排查是否有后台安装任务残留了未清理的 cache.git.lock,不要把它和 SDK 接口不兼容混为一谈。
  5. 临时输出干净的 flutter doctor -v:向上游提交 Issue 或 PR 需要附带 doctor -v 快照时,若全局 PATH 里原本排在前面的是另一套稳定版 SDK,不必改动系统环境变量,只在当前 PowerShell 会话里临时前置目标 SDK 的 bin 目录即可:
$env:PATH = "<flutter-sdk-bin-path>;" + $env:PATH
flutter doctor -v

把连接状态、命令行解析、快照缓存和 SDK 软链接这几件事拆开核对,多数“设备时有时无”的问题都能定位到具体原因。