GSY GitHub App 冒烟操作手册:基于 mcp_dart 与 VM Service 的 Flutter 运行时验证实践
【免费下载链接】gsy_github_app_flutterFlutter 超完整的开源项目,功能丰富,适合学习和日常使用。GSYGithubApp 系列的优势:我们目前已经拥有 Flutter、Weex、ReactNative、Kotlin View、Kotlin Jetpack Compose ,Compose MultiPlatform,Harmony ArkUI 七个版本,功能齐全,项目框架内技术涉及面广,完成度高,持续维护,配套文章,适合全面学习,对比参考。项目地址: https://gitcode.com/gh_mirrors/gs/gsy_github_app_flutter
本文是 gsy_github_app_flutter 仓库中tool/ai/smoke/冒烟操作手册的完整讲解,核心回答一个问题:在 Flutter 项目中,如何用可复核、可自动化的方式验证"UI 渲染 / 文案 / 事件行"级别的改动。你会读到这套冒烟体系的全部前置条件、装机命令、通用步骤模板、两种vm_service evaluate操控姿势的边界,以及仓库在 lib/app.dart 中为冒烟专门预留的顶层入口与防御性保底机制,读完即可照抄跑通 PR timeline、首页动态、仓库 Discussions 三个真实场景。
冒烟验证在 GSY 协作流程中的定位
在进入操作细节之前,需要先理解这套手册为什么存在。仓库根目录 AGENTS.md 的"运行时冒烟验证(强制)"章节给出了一条硬性规则:
"能编译过 + 装机不崩"不算测试通过。任何涉及运行时行为(UI 渲染、事件解析、状态流转、网络分支、多语言文案)的改动,在宣告完成前,author 必须在真机或模拟器上跑通对应改动路径,并把真实证据(截图 / 文案 dump / 错误日志)以文件形式产出并写清路径,禁止只凭"app 启动了、日志没红"就报完成。
AGENTS.md 随后把最低证据要求按改动类型分级:
| 改动类型 | 最低证据要求 |
|---|---|
| 纯模型 / 纯工具函数 | flutter analyze+ 单测(若 test 目录已存在);无需截图 |
| UI 渲染 / 文案 / 事件行 | 至少 1 张真机截图 +mcp_dartwidget_inspector get_widget_tree命中目标 widget 或textPreview+get_runtime_errors无异常 |
| 关键路径(登录 / 网络栈 / 根装配 / 状态边界) | 主路径截图 + widget tree 命中 +get_runtime_errors无异常 + 至少 1 个失败/边界分支的证据 |
tool/ai/smoke/README.md正是为满足中间这一档("UI 渲染 / 文案 / 事件行")的最低证据要求而编写:它是一份冒烟操作手册,说明用mcp_dart该走哪条路径、该 grep widget tree 的哪几个命中项、该抓哪几张截图。
工具选型变迁:2026-09-02 全面转向 mcp_dart
手册开篇记录了一次重要的工具决策。历史上tool/ai/smoke/目录堆了一堆.sh/.ps1坐标脚本(基于adb shell input tap/swipe),2026-09-02 作者拍板全部删除,回归mcp_dart,理由有三:
- adb 只是 Android 平台工具,天然把 iOS 排除在外——GSY 用 iOS Simulator 冒烟时它一点忙都帮不上;
- 坐标硬编码脆弱:分辨率一变就全坏;系统条高度、键盘弹起、tab 数量变化都会导致 tap 落错(旋转 override / 状态栏拦截 / 讨论 tab 是否可见 / IDE 缩略图坐标 vs 物理坐标,都反复吃过亏);
mcp_dart是随 Flutter 演进的一等公民:直接连 DTD/VM Service,能拉真实的widget_inspector get_widget_tree(含textPreview文案)、拉get_runtime_errors,跨平台、随版本演进、天然消除坐标依赖。
因此该目录不再放执行脚本,每个冒烟场景改为一份路径描述 md。这条决策与 AGENTS.md 的"工具选型"章节完全一致:mcp_dart是唯一主路径,adb/xcrun simctl只降级为"只截图"的工具,不再承担业务操作职责。
前置条件
开始任何冒烟场景前,必须满足四条前提:
- 设备(iOS Simulator 或 Android 真机 / 模拟器)已启动、
flutter能识别到:- iOS:
xcrun simctl list devices booted - Android:
adb devices
- iOS:
- GSY app 已在设备上运行(debug 首选,release 也可以)。
flutter run的 stdout 里能看到Dart VM Service on ... is available at: <uri>(debug 才有)。- 已登录任意 fixture 账号(推荐
CarSmallGuo,gho_token 只读)。
注意:
mcp_dart依赖 DTD/VM Service URI,这条 URI 只在 debug 构建的flutter runstdout 里出现,所以冒烟自动化路径默认以 debug 构建为前提。
装机命令:为什么禁止 flutter install
手册特别强调一个装机反模式:禁止使用flutter install。该命令内部走adb uninstall <pkg>+adb install,会顺手把/data/data/com.shuyu.gsygithub.gsygithubapp_flutter/下的全部 SharedPreferences 抹掉,TOKEN_KEY一并丢失——设备上等同强制登出,reviewer 无法直接复核 fixture。这条教训在 AGENTS.md 的"禁止行为"章节被固化为打回红线:2026 年装 discussions 冒烟版本时曾因flutter install清空了 CarSmallGuo 的 gho_ token,属于 author 责任事故。
正确的装机姿势:
iOS:
flutter build ios --release # 装机走 Xcode 或 xcrun simctl install <UDID> build/ios/iphonesimulator/Runner.appAndroid:
# 1. 构建 release APK(首选 arm64,跟 CarGuo 主设备一致) flutter build apk --release --target-platform=android-arm64 --no-shrink # 2. 用 adb install -r 覆盖安装,保留 app data adb install -r build/app/outputs/flutter-apk/app-release.apk # ^^ 关键:-r = reinstall,保留 /data/data/<pkg>/如果必须重装(例如包名或签名变了),先手动导出 token:Android 走run-as+cat shared_prefs/FlutterSharedPreferences.xml,iOS 走 Xcode Container 拷贝Library/Preferences/*.plist。装完可以用 GSY 登录页的 "Token 登录" 入口(见 login_page.dart)把 token 粘回来。
场景清单
手册固化了一份场景清单,每个场景一份 md,内含"目标 / fixture / 步骤 / 完成汇报必填 / 反例":
| 场景 md | 覆盖对象 |
|---|---|
| open_pr_timeline.md | PR timeline 事件行 /reviewed body 卡片 |
| open_home_dynamic.md | 首页 Dynamic tab / 事件识别 / 下拉刷新 + 上拉分页 |
| open_repo_discussions_tab.md | 仓库详情 → 讨论 tab / discussion 列表 / 详情页 Markdown |
执行者按步骤走一遍,把证据(widget tree 命中项 + 截图绝对路径 +get_runtime_errors结果)写进 AGENTS.md 完成汇报三段式(看代码 / 看编译 / 看运行)的"看运行"段。三段任一缺失 = 任务未完成。
通用步骤模板:7 步完成一次冒烟
手册给出了一套所有场景通用的步骤模板。这是一个 Flutter 项目:点击 / 触发 / 验证一律走mcp_dart(VM Service 一等公民),不基于adb/ 坐标 / 屏幕像素;adb/xcrun simctl只承担"截图"这一件事。
- 起 app:
flutter run -d <deviceId>,等 stdout 打印 DTD/VM Service URI。 - 连 DTD:
mcp_dartdtd listDtdUris→dtd connect <uri>。 - 基线:
mcp_dartget_runtime_errors(应为No runtime errors found.)。 - 触发路由 / 交互(一等公民 =
mcp_dartvm_serviceevaluate),详见下一节。 - 拉 tree:
mcp_dartwidget_inspector get_widget_tree summaryOnly=true,在返回 JSON 里 grep 该场景 md 指定的textPreview或 widget 类型。 - 截图(仅人眼补充证据,不承担业务验证职责):iOS
xcrun simctl io <UDID> screenshot <path>,Androidadb exec-out screencap -p > <path>。 - 收尾:
mcp_dartget_runtime_errors再拉一次,应仍空。
第 4 步展开:触发路由的三种姿势
GSY 已经在 app.dart 把GlobalKey<NavigatorState> navKey声明为顶层 final 变量,挂在MaterialApp(navigatorKey: navKey)上;同时 app.dart 提供了一批kDebugMode保护的顶层 smoke 入口。这套组合意味着:vm_service evaluate里一行就能跳到任何目标页。
主路径(首选):拉一次Isolate→libraries[]找uri == "package:gsy_github_app_flutter/app.dart"那条拿id作为targetId,然后evaluate一行:
evaluate( targetId: <library id of package:gsy_github_app_flutter/app.dart>, expression: 'gsySmokeGoIssueDetail("CarGuo", "gsy_github_app_flutter", "938")' )关键辨析:<library id>是package:gsy_github_app_flutter/app.dart这个具体 library的id,从Isolate.libraries[]里查,不是Isolate.rootLibrary字段——Dart 中 "root library" 术语专指 isolate 入口 library(本项目是main.dart),两个概念不同。app.dart 的注释对这一点有完整解释,open_pr_timeline.md与open_repo_discussions_tab.md两份场景 md 也反复提醒。
现有顶层入口清单(都在 lib/app.dart 底部,源码可见):
| 入口函数 | 签名 | 跳转目标 |
|---|---|---|
gsySmokeGoIssueDetail | (owner, repo, issueNumber) | issue / PR 详情(GSY 中两者复用同一 detail page) |
gsySmokeGoReposDetail | (owner, repo) | 仓库详情 |
gsySmokeGoDiscussionDetail | (owner, repo, number) | Discussion 详情(GraphQL 通道) |
gsySmokeGoSearch | ({Offset centerPosition = Offset.zero}) | 搜索页(route-topology 后 shellDetail 语义) |
gsySmokeGoPerson | (userName) | 个人页 |
这些函数全部有kDebugMode早退门,release 构建下只debugPrint一条忽略日志并返回,不承担业务逻辑。需要新用例就照现有 pattern 加一个Future<Object?> gsySmokeGoXxx(...)即可,不改其它任何文件。
页面内交互(下拉刷新 / 上拉分页 / tab 切换):这类不是路由,smoke 顶层入口默认不覆盖。仍走"抓对应State的objectId、eval_pullLoadWidgetControl.onRefresh?.call()/_tabController.animateTo(3)"这种姿势。例如 open_home_dynamic.md 里,首页动态的下拉刷新走 gsy_pull_load_widget.dart 的GSYPullLoadWidgetControl,控制器实例实际挂在DynamicBloc上(见 dynamic_bloc.dart),evaluate 直接调用_pullLoadWidgetControl.onRefresh?.call()与onLoadMore?.call()即可触发刷新和分页。如果反复冒烟同一场景,再考虑给 app.dart 加gsySmokeRefreshHome()之类顶层入口,让它内部走 eventBus 广播。
降级 A(旧姿势):如果 debug 构建因某种原因没有相应的gsySmokeGoXxx顶层入口,退回到"抓任意 ElementobjectId+_element!.buildContext+NavigatorUtils.goXxx"的老姿势:
evaluate( targetId: <element_object_id>, expression: ''' (() { final ctx = _element!.buildContext; return NavigatorUtils.goIssueDetail( ctx, "CarGuo", "gsy_github_app_flutter", "938", ); })() ''' )如果 eval 的作用域拿不到NavigatorUtils(未 import),改用evaluateInFrame,并从Isolate.libraries[]里查uri == "package:gsy_github_app_flutter/common/utils/navigator_utils.dart"那条拿libraryId(同样不要用Isolate.rootLibrary)。
降级 B(最后的最后):人肉在 Simulator 上点。必须在完成汇报里说明"这一步为什么无法自动化"。
为什么触发操作走 vm_service eval 而不是 adb shell input tap
手册专门用一节解释这条原则,核心逻辑:
- GSY 是Flutter 项目,widget 是 Dart 世界里的对象。
adb shell input tap X Y只是在系统层伪造触摸事件,命中的是"屏幕像素点",跟 Flutter 的 widget hit test 没有直接映射:分辨率变一变、系统条高度变一变、键盘弹起 / tab 数量变一变,全炸; vm_serviceevaluate直接在 Dart 层执行表达式,等同于让 Dart 自己调用NavigatorUtils.goXxx/TabController.animateTo/ 任意 controller 方法,随 Flutter 版本演进、跨 iOS/Android、随 UI 微调不变,天生就是 Flutter 项目该有的操控姿势;adb/xcrun simctl因此在本仓库里降级为只截图的工具,不再承担业务操作职责。
两种 eval 姿势的适用边界
| 项 | 主路径(顶层gsySmokeGoXxx()) | 降级 A(Element + buildContext) |
|---|---|---|
targetId | package:gsy_github_app_flutter/app.dart的 libraryid | 任意在线Element的objectId |
expression | 一行:gsySmokeGoIssueDetail("...", "...", "938") | 多行:(() { final ctx = _element!.buildContext; return NavigatorUtils.goIssueDetail(ctx, ...); })() |
| 依赖 | debug 构建,app.dart里现有的gsySmokeGoXxx()顶层函数 | 任意已挂载 widget 的 Element 存在 +NavigatorUtils在 eval 作用域可解析 |
| 覆盖场景 | route 类跳转(issue / repo / discussion / person) | 任意 State 内部字段 / controller / 私有方法调用 |
| 推荐度 | 首选(一行、可读、reviewer 直接看得懂) | 只在 debug 构建没有对应gsySmokeGoXxx顶层入口时用 |
| release 副作用 | 无(kDebugMode早退 +debugPrint) | 无(release 版跑 eval 本来就不成立) |
结论:能加顶层入口就加顶层入口。目前 4 个 route 入口够 PR / 仓库 / discussion / 用户页 4 大场景;之后要覆盖新 route 时照现有 pattern 追加一个Future<Object?> gsySmokeGoXxx(...)就行——不改其它任何文件,reviewer 也一眼看得懂"这就是一个 debug-only 顶层函数"。
smokePostFrame:evaluate 时机上的防御性保底
值得深入的是 app.dart 中的smokePostFrame<T>函数——它是所有gsySmokeGoXxx共用的 push 时机保底。其设计基于两条官方语义硬事实:
- 只要当前
schedulerPhase == idle,直接同步Navigator.push完全安全,push 触发的setState会正常scheduleFrame; - 只要当前
schedulerPhase != idle,说明当前一定有一帧在跑,Flutter 保证帧末尾 flush post-frame callbacks,所以在addPostFrameCallback里执行动作一般不会挂死。
同时它做了异常透传处理:用Completer.completeError让异常沿Future冒到 evaluate 侧(VM Service 那头看到ErrorRef而非"正常完成的 Future",避免"evaluate 无异常但页面没跳"的假阳性),并用FlutterError.reportError把异常汇报给全局错误通道,mcp_dart get_runtime_errors能直接捞到。
这个函数的四条分支(navKey null 早退 / idle sync throw / idle async reject / post-frame path)都有单测守约,见 test/app/smoke_post_frame_test.dart:它通过可控 scheduler 的testWidgets覆盖了真机上很难自然触发的 post-frame 分支,这正是 AGENTS.md "稀有分支覆盖率无法靠真机保证时,优先加模型层单测"条款的直接应用。
evidence/ 目录约定:证据如何留档与汇报
- 默认 evidence 落到
tool/ai/smoke/evidence/<yyyymmdd_hhmm>/,已通过根 .gitignore 里的tool/ai/smoke/evidence/忽略,不入 git; - 建议按任务号建子目录(例:
evidence/c1/、evidence/d1_selftest/),在完成汇报里把子目录绝对路径贴出来,reviewer 就能定位到当次证据; - PR / 完成汇报必须内联贴摘要(不能只写"证据在 evidence/xxx/"就交差)。因为 evidence 目录不入 git,reviewer 拉取 PR 时看不到里面的东西,所以汇报正文必须手动摘录足够的关键片段,reviewer 才能不 checkout 就完成 review。摘要至少覆盖:
get_runtime_errors前后对比:改动前基线 + 关键路径跑完之后各拉一次,贴errorsSinceLastRequest.length和相关 error 的renderedErrorText首 3 行(如无新增就写errorsSinceLastRequest=[]);- widget_inspector 命中项:命中的 widget 类型 / 关键
textPreview字符串(例Copilot 提交了评审意见),至少给出行内引号完整包住的一行; - 截图:必须以 PR/issue 附件或 Markdown 内联图片形式贴出来——只写本地绝对路径 reviewer 打不开(evidence 已 gitignore)。绝对路径只作为作者自留档索引;如果用 iOS / Android 平台的分享上传(Slack / 飞书 / 邮件附件)也可以,只要 reviewer 不 checkout 就能拿到图;
- 无法覆盖的分支列表:显式列成 bullet,不要糊成"通过"。
反例:这些做法会被 reviewer 直接打回
手册列出一份明确的禁止清单,与 AGENTS.md 的禁止行为章节互为呼应:
- ❌新增
adb shell input tap/swipe坐标脚本:本次全面清理的历史包袱,reviewer 见到直接打回。Flutter 项目触发操作走vm_service eval,不基于像素点; - ❌把"人肉在 Simulator 上点"当默认路径:默认路径永远是
mcp_dartvm_service eval。只有 eval 走不通 + 降级 A(debug-only 顶层函数)也走不通,才允许降级 B(人肉点),且必须在完成汇报里说明"这一步为什么无法自动化"; - ❌只截图不连 DTD:截图只是"人眼层面补充",不是业务证据;必须配
widget_inspector命中和get_runtime_errors结果; - ❌用
flutter install装机:见"装机命令"章节; - ❌让用户手动操作 UI 代替自己自测:author 必须自己走完路径;
- ❌拿"日志里没 Exception"当行为正确的证据:必须命中
widget_inspector。
本目录不做什么
三个边界约束,保证这套冒烟手册职责单一、不与其它测试手段重叠:
- 本目录 md不做断言(要不要过看的是
widget_inspector命中 + 截图 +get_runtime_errors); - 本目录 md不 mock 数据(要覆盖稀有事件分支请写单测 + JSON fixture,例如 test/model 目录下的模型层单测);
- 本目录 md不依赖
flutter_driver(本仓库未引入相关依赖,因此 AGENTS.md 中flutter_driver_command子工具默认不可用)。
历史勘误:commit 224a0d8 的编造因果挂账
手册末尾挂了一笔重要的历史勘误(errata),用于纠正对vm_service evaluate时序语义的错误认知——这条订正已经同步写入 AGENTS.md 的禁止行为章节("在文档 / commit message / code comment 里编造 VM Service / mcp_dart 时序细节")。
- 编造内容:commit
224a0d8body 的「看代码」段落里写了mcp_dart vm_service evaluate是同步塞进 isolate 当前任意回调栈里跑的,会撞进 build/layout/paint/semantics 遍历,直接Navigator.push就抛 "Build scheduled during frame",并把_smokePostFrame定性为"从根上修 evaluate 时机,不是补丁"; - 实际语义(对照 Dart VM Service Protocol
service.md的evaluate章节与官方文档):evaluateRPC 只承诺在目标 isolate 的事件循环里排队执行表达式,从未承诺"同步塞进任意回调栈中间";Dart isolate 是单线程消息循环,跨进程注入的表达式必须等当前 message 处理完才轮到,不可能同步打断 build/paint 半程。若要真正做到"在栈中间求值",走的是另一个 RPCevaluateInFrame,且需 isolate 处于 paused 状态; - 实际因果(
_smokePostFrame为何保留):Navigator.push触发的setState仍有可能在 evaluate 排到的那一轮 message 结束、下一帧 layout/paint 开始时才被WidgetsBinding._handleBuildScheduled感知,存在边缘触发 "Build scheduled during frame" 的可能,addPostFrameCallback只是防御性保底(最多多等 ~16ms)。语义上是"防边缘炸",不是"必须的根因修复"; - 订正 commit:
ed7077e已把 lib/app.dart 里对应的 doc comment 重写,把「必要修复」降级为「防御性保底 + 官方语义引用」,代码行为无改动。224a0d8的 commit message body 由于 git 历史不可变,保留原文但以本条 errata 显式挂账——凡看git log 224a0d8的人务必对照本节修正对因果的理解。
小结:一份可直接落地的 Flutter 冒烟方法论
从tool/ai/smoke/README.md出发,可以看到 GSY 仓库把"运行时冒烟验证"从一句口号落地成了一整套可执行的工程规范:以mcp_dart(DTD/VM Service)为一等公民,用widget_inspector的真实 widget tree(含textPreview)作为业务证据,用get_runtime_errors作为运行时健康基线,用adb/xcrun simctl只做截图补充,配合 lib/app.dart 中kDebugMode保护的顶层 smoke 入口实现"一次 evaluate、一行跳转"的自动化路径,最后以 evidence 目录 + 完成汇报三段式保证 reviewer 不看设备也能复核。这套方法论不仅适用于本仓库,对任何"必须验证 UI 渲染与文案而不仅仅是编译通过"的 Flutter 项目,都具备直接借鉴价值——关键动作就是三条:装别用flutter install、触发走vm_service eval、证据必须命中widget_inspector。
【免费下载链接】gsy_github_app_flutterFlutter 超完整的开源项目,功能丰富,适合学习和日常使用。GSYGithubApp 系列的优势:我们目前已经拥有 Flutter、Weex、ReactNative、Kotlin View、Kotlin Jetpack Compose ,Compose MultiPlatform,Harmony ArkUI 七个版本,功能齐全,项目框架内技术涉及面广,完成度高,持续维护,配套文章,适合全面学习,对比参考。项目地址: https://gitcode.com/gh_mirrors/gs/gsy_github_app_flutter
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考