# flutter_advanced **Repository Path**: jameschenbo/flutter_advanced ## Basic Information - **Project Name**: flutter_advanced - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-08-31 - **Last Updated**: 2026-09-26 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Flutter 进阶:从会写到能交付 > 公众号「搏哥聊技术」同名合集的配套代码仓库。 > 默认你已经会写 Flutter,这里只放**能跑起来、能照着抄**的东西。 ## 合集简介(可直接用于公众号) > 默认你已经会写 Flutter。这里只讲剩下的部分:网络层怎么封装、登录推送支付怎么接、上架备案怎么过审。每篇都能照着抄,每篇结尾都有一份上线前检查清单。 ## 环境要求 | 项 | 版本 | |---|---| | Flutter | **3.47.2**(stable) | | Dart | 3.13.2 | | 开发工具 | 任意,VS Code / Android Studio 都行 | 低于 3.44 的话,部分示例(尤其 `material_ui` 相关)跑不起来,先升版本。 每个目录都是**独立的 Flutter 项目**,互不依赖,进哪个目录就 `flutter pub get` 哪个。 ## 目录索引 **代码目录**(每篇一个,独立可跑): | 目录 | 对应博文 | 内容 | 状态 | |---|---|---|---| | `00A-material-ui-migration/` | 《11 月之后 `material.dart` 要被划红线了》 | 迁移前 / 迁移后对照,可复现那个编译错误 | ✅ | | `01B-ble-plugin-licensing/` | 《flutter_blue_plus 要收费了,我们还有什么选择》 | 依赖许可证核对脚本 | ✅ | | `01-ble-in-action/` | 《接了个硬件 App 的活,我在蓝牙上栽了三周》 | 完整 BLE 扫描连接 Demo + 10 个测试 | ✅ | | `02-thermal-printer-receipt/` | 《80mm 小票机打印中文,我被字库坑了一整晚》 | 中文小票打印服务 + 7 个测试 | ✅ | | `00B-flutter-upgrade-2026/` | 《2026 下半年,Flutter 有四个 deadline 在等你》 | 升级体检脚本(纯 Python,无 Flutter 依赖)+ 22 个测试 | ✅ | | `00C-flutter-web-wasm/` | 《把 Flutter Web 编译成 Wasm 后,我那 30% 的性能提升去哪了》 | 运行时探针 + 无头 Chrome 实测静默降级 + 构建体积对比 | ✅ | | `B1-network-layer/` | 网络层 Dio 封装 | Dio 封装 + 拦截器 + 统一错误 + 重试 + 401 刷新队列(23 测试) | ✅ | | `B2-env-config/` | 多环境配置 | 编译期 dart-define + 运行时 override 切环境(不重编)+ 调试代理解析(27 测试) | ✅ | | `B3-storage-kit/` | 本地存储与序列化 | KvStore 抽象(核心零依赖)+ 类型化存取 + 对象持久化 + 可续跑的版本迁移 + shared_preferences 真实适配器(30 测试) | ✅ | | `B4-state-management/` | 状态管理怎么选 | 用「重建次数」这把尺子实测 setState/Provider/Riverpod/Bloc 差异,6 个测量测试断言化(setState/BlocBuilder=41,select/BlocSelector=2,watch=21) | ✅ | | `B5-routing/` | 路由与导航 | go_router 声明式路由 + 登录守卫(redirect + refreshListenable)+ 深链(/detail/:id 路径参数 + 登录后回跳)+ 自定义过渡,6 路由测试 | ✅ | | `B6-auth/` | 登录与鉴权 | 会话状态机(派生 isLoggedIn)+ token 续期 + 登出清场 + 多端登录踢出(区分被踢/登出)+ 与 B5 守卫咬合,10 测试 | ✅ | | `B7-logging/` | 全局错误与日志 | 三层捕获(FlutterError + platformDispatcher + zone)+ 分级日志 + 环形缓冲 + 可插拔上报,零第三方依赖,5 测试 | ✅ | | `B8-i18n-theme/` | 国际化与主题 | 轻量 i18n(Map 查找,不靠 intl 代码生成)+ 语言/主题共用可订阅状态 + 跟随系统,7 测试 | ✅ | | `B9-push/` | 推送通知封装 | 厂商消息归一化 + 不可变 NotificationPayload + PushRouter 深链跳转 + 本地通知抽象(可测可替换),10 测试 | ✅ | | `B10-release-ci/` | 上架发布与 CI | 上架清单(版本/签名/隐私合规)+ 最小 CI 闸门,串起 B1–B9 共 124 测试全绿 | ✅ | **博文全文**:`articles/`,文件名与代码目录一一对应。 目录名前的编号对应选题库里的编号:`A`= 时效(有 deadline)、`B`= 基建(每个商业 App 都要写)、`C`= 垂直专题。 ## 每个目录的说明 进目录看各自的 `README.md`,里面写了:对应哪篇博文、怎么跑、哪些验过哪些没验。 快速入口: - 想**亲手踩一次迁移的坑** → `00A-material-ui-migration/before/` - 想**查自己项目有没有收费依赖** → `01B-ble-plugin-licensing/check_licenses.py` - 想**照抄一套 BLE 封装** → `01-ble-in-action/lib/ble_manager.dart` - 想**知道今年还得动几次、先动哪个** → `00B-flutter-upgrade-2026/upgrade_check.py` - 想**亲手看 Wasm 被静默降级成单线程** → `00C-flutter-web-wasm/serve.py` + `probe_xvfb.py`(需 `xvfb-run`,无头直跑会因缺 WebGL 回退 JS)抓 `PROBE:` 行 ## 怎么复验(你来验收时按这个跑) 每个目录独立,进哪个验哪个。 ```bash # 00A:迁移前后对照 cd 00A-material-ui-migration/before && flutter pub get && flutter analyze cd ../after && flutter pub get && flutter analyze # 01:BLE 实战(含单元测试) cd ../../01-ble-in-action && flutter pub get flutter analyze # 期望:No issues found! flutter test # 期望:All tests passed(10 个用例) flutter build web # 期望:✓ Built build/web,约 35 秒 # 02:小票打印(含单元测试) cd ../02-thermal-printer-receipt && flutter pub get flutter analyze # 期望:No issues found! flutter test # 期望:All tests passed(7 个通过 + 1 个 skip) flutter build web # 期望:✓ Built build/web,约 54 秒 # 01B:许可证核对脚本(无需 Flutter) cd ../01B-ble-plugin-licensing python3 check_licenses.py ../01-ble-in-action python3 check_licenses.py <你自己的项目路径> # 00B:升级体检脚本(无需 Flutter,只依赖 Python 标准库) cd ../00B-flutter-upgrade-2026 python3 upgrade_check.py ../01-ble-in-action # 新项目:基本全绿 python3 upgrade_check.py fixtures/legacy_app # 老项目:应报 5 项待办 python3 upgrade_check.py <你自己的项目路径> python3 test_upgrade_check.py # 期望:22 个用例全过 # B1:网络层 Dio 封装(纯 Dart 包,无真网,全 Mock 验证) cd ../B1-network-layer flutter pub get flutter analyze # 期望:No issues found flutter test # 期望:All tests passed(23 个用例) # B2:多环境配置(纯 Dart 包,无真网,全逻辑验证) cd ../B2-env-config flutter pub get flutter analyze # 期望:No issues found flutter test # 期望:All tests passed(27 个用例) dart run example/env_config_example.dart # 期望:打印 prod → 切 dev → 只开代理 # B3:本地存储与序列化(核心逻辑全内存验证,适配器用 setMockInitialValues 验证) cd ../B3-storage-kit flutter pub get # 若官方源慢,先换国内源再删 pubspec.lock,见该目录 README flutter analyze # 期望:No issues found flutter test # 期望:All tests passed(30 个用例) dart run example/storage_kit_example.dart # 期望:老数据迁移后名字还在、落盘用新字段名 # B4:状态管理怎么选(重建次数实测台,全 Widget 测试验证) cd ../B4-state-management flutter pub get # 若官方源慢,先换国内源再删 pubspec.lock,见该目录 README flutter analyze # 期望:No issues found flutter test # 期望:All tests passed(6 个测量用例) # B5:路由与导航(go_router,全 Widget 测试验证) cd ../B5-routing flutter pub get # 若官方源慢,先换国内源再删 pubspec.lock,见该目录 README flutter analyze # 期望:No issues found flutter test # 期望:All tests passed(6 个路由用例) # B6:登录与鉴权(纯 Dart 会话逻辑,假时钟+假接口验证) cd ../B6-auth flutter pub get # B6 无第三方依赖,官方源即可 flutter analyze # 期望:No issues found flutter test # 期望:All tests passed(10 个用例) # B7:全局错误捕获与日志(纯 Dart 骨架,不依赖第三方 SDK) cd ../B7-logging flutter pub get # B7 无第三方依赖,官方源即可 flutter analyze # 期望:No issues found flutter test # 期望:All tests passed(5 个用例) # B8:国际化与主题(轻量 i18n + 主题切换,纯 Dart 可测) cd ../B8-i18n-theme flutter pub get # 若官方源慢,先换国内源再删 pubspec.lock,见该目录 README flutter analyze # 期望:No issues found flutter test # 期望:All tests passed(7 个用例) # B9:推送通知封装(归一化 + 深链跳转 + 本地通知抽象,go_router 驱动) cd ../B9-push flutter pub get # 若官方源慢,先换国内源再删 pubspec.lock,见该目录 README flutter analyze # 期望:No issues found flutter test # 期望:All tests passed(10 个用例) # B10:上架发布与 CI(最小 CI 闸门,串起 B1–B9) bash B10-release-ci/ci_check.sh # 期望:9/9 工程 analyze 零告警、test 124/124 全绿 ``` > 02 那条 skip 不是偷懒:`textRaster` 依赖图形后端, > 在无 GPU 的容器里会挂起(实测 6 分半后被强杀)。原因写在测试注释里。 `check_licenses.py` 只依赖 Python 标准库,不用装任何东西。 ## 验证记录 下面这些是我在沙箱里**实际跑出来的**,不是估计值。 | 目录 | 命令 | 结果 | |---|---|---| | `00A/before` | `flutter analyze` | `No issues found!` —— **注意:这正是那篇文章说的坑**,3.47.2 上老 import 零警告 | | `00A/after` | `flutter analyze` | `No issues found!` | | `01` | `flutter analyze` | `No issues found!` | | `01` | `flutter test` | 10/10 通过 | | `01` | `flutter build web --release` | 成功,34.8s;额外输出 `Wasm dry run succeeded` | | `01B` | `check_licenses.py ../01-ble-in-action` | 6 个依赖全部识别,无风险项 | | `01B` | `check_licenses.py`(含 flutter_blue_plus) | 准确报警 `自定义(含商业条款) ⚠` | | `00B` | `test_upgrade_check.py` | **22/22 通过** | | `00B` | `upgrade_check.py fixtures/legacy_app` | 准确报出 5 项待办(2 必须 + 3 建议) | | `00B` | `flutter test .../uiscene_migration_test.dart`(官方套件) | **11/11 通过**,其中 3 条测试名含 `is not exact match` | | `00B` | `diff` 3.44 vs 3.47 的 `uiscene_migration.dart` | **输出为空,两个版本完全一致** | | `00C` | `flutter analyze` | `No issues found!` | | `00C` | `flutter test` | **3/3 通过**(含「首屏渲染出六个探测项」「编译目标在测试环境下应为 JS」) | | `00C` | `flutter build web --release` | ✓ JS 产物 `main.dart.js` 1,913,344 B | | `00C` | `flutter build web --release --wasm` | ✓ WASM 产物 `main.dart.wasm` 1,532,499 B | | `00C` | 两版分别 gzip | Wasm 568,777 B ≈ JS 566,712 B(差 <0.4%) | | `00C` | 抓探针(同份 Wasm,无/有 COOP+COEP) | `coi/sab/mt` 三个值 `false→true` 实测翻转,10 轮可复现 | | `00C` | 回退根因定位 | WasmGC=`true`、WebGL=`-1`;加载器原话"Skwasm requires WebGL support" | | `00C` | `probe_xvfb.py --runs 10` 压测对比 | 多线程吞吐 −18%、光栅化 +35%、掉帧率翻倍(软件渲染下) | | `B1` | `flutter analyze` | `No issues found` | | `B1` | `flutter test` | **23/23 通过** | | `B1` | 业务码非 0 | 抛 `ApiException.business`(code + message 正确) | | `B1` | 网络错误 / 超时 | 分别翻成 `ApiException.network` / `.timeout` | | `B1` | 401 → 刷新 → 重放 | 重放 2 次请求、刷新 1 次、`Authorization` 换成新 token | | `B1` | 刷新返回 null | 清空 token 并抛 `unauthorized` | | `B1` | 刷新后还是 401 | 靠 `_refreshed` 标记止住,2 次请求后不无限循环 | | `B1` | 3 个网络错误 + maxRetries=2 | 重试 2 次后抛最后一次错误(实测踩过 Dio 克隆 `extra` 丢失的坑)| | `B2` | `flutter analyze` | `No issues found` | | `B2` | `flutter test` | **27/27 通过** | | `B2` | 没传任何 define | `baseUrl` 回落到 prod 预设(`https://api.example.com/api`),而非空串 | | `B2` | 不传 `APP_ENV` | 默认 `prod`(安全优先)| | `B2` | `setOverride(forEnv(dev))` | `current` 即切到 dev,不重编 | | `B2` | 局部 override 只开代理 | `baseUrl` 不变、`proxyUrl=PROXY host:port` | | `B2` | 代理开启但没填 host | `proxyUrl=null`(直连,安全默认)| | `B2` | `dart run example` | 打印 `prod → 切 dev → 只开代理` 三步正确 | | `B3` | `flutter analyze` | `No issues found` | | `B3` | `flutter test` | **30/30 通过**(26 核心逻辑 + 4 真实适配器)| | `B3` | 适配器 `SharedPrefsKvStore` | 真实编译并测试(`SharedPreferences.setMockInitialValues` 打桩)| | `B3` | 内存实现 vs 真实适配器 | 同一套迁移逻辑落盘结果**完全一致** | | `B3` | 版本已是最新 | 一步都不跑(`appliedSteps` 为空)| | `B3` | 每跑完一步立刻写版本号 | 第 1/2/3 步执行时读到的版本是 `null/1/2` | | `B3` | 迁移中途崩溃 | 版本号停在最后成功的那步(v1),不退回 0 | | `B3` | 修好后重跑 | 只补跑剩下的(`appliedSteps=[2]`),**不重跑已成功的步骤** | | `B3` | 降级(存储版本 > 目标)| 拒绝执行、数据未被改动、版本号未被改小 | | `B3` | 实战 v1→v2 字段改名 | `userName` → `name`,名字还在,落盘 JSON 只剩 `"name"` | | `B3` | `dart run example` | 老数据 `userName` 读出「老用户」,迁移后落盘统一为 `name` | | `B4` | `flutter analyze` | `No issues found` | | `B4` | `flutter test` | **6/6 通过**(A/E 断言 41、B 断言 21、C/D/F 断言 2,全部 `expect` 断言化)| | `B4` | `setState`/`BlocBuilder` 整块 | 点一次 + 重建 **41** 个 widget(整页重建)| | `B4` | `Provider`+`watch` 整块 | 重建 **21** 个(名字是 `read` 不重建,所以不是 41)| | `B4` | `Provider`+`select` / `Riverpod`+`select` / `Bloc`+`BlocSelector` 精确订阅 | 重建 **2** 个(只动 header + 被改的那一个 qty)| | `B4` | `weight` 维度(给叶子挂真实子 widget 模拟「很重」) | 重建次数不变,但单次 build 成本随 weight 线性上升——「要不要精确订阅」取决于叶子有多重 | | `B4` | Riverpod 缺 `ProviderScope` | 直接抛 `No ProviderScope found`,入口必须包一层 | | `B5` | `flutter analyze` | `No issues found` | | `B5` | `flutter test` | **6/6 通过**(守卫 / 深链参数 / 登录后回跳 / 登出 / 列表点击)| | `B5` | 未登录访问 `/` | 被 `redirect` 打到 `/login` | | `B5` | 未登录深链 `/detail/7` → 登录 | 自动回跳到 `/detail/7`(目标记在 `?from=`)| | `B5` | `go_router` 版本 | 14.2.0,真实安装编译 | | `B6` | `flutter analyze` | `No issues found` | | `B6` | `flutter test` | **10/10 通过**(状态机 / 守卫 / 持久化)| | `B6` | token 过期(提前 30s 判) | `isLoggedIn` 变 false,守卫打去 `/login` | | `B6` | 续期被拒(refresh 失效) | `isKicked`=true,强制回登录页(区分「被踢」与「登出」)| | `B6` | 未登录 persist | 直接清场,不残留脏凭证、不持久化 `kicked` | | `B7` | `flutter analyze` | `No issues found` | | `B7` | `flutter test` | **5/5 通过**(分级过滤 / 环形缓冲 / 上报钩子 / 两层处理器)| | `B7` | `FlutterError` 处理器 | 记一条 error 日志并触发上报 | | `B7` | zone 异步异常处理器 | 记 error 日志(平台/GPU/异步异常兜底)| | `B8` | `flutter analyze` | `No issues found` | | `B8` | `flutter test` | **7/7 通过**(查找 / 回退 / 占位符 / 切语言 / 切主题 / 两类 widget 切换)| | `B8` | 缺 key | 回退默认语言 → 仍缺则回退 key 本身,界面不留白 | | `B8` | Provider `child` 不随 notify 重建 | 验证需 `watch`/`Consumer` 包裹,偏好才生效(已钉进测试)| | `B9` | `flutter analyze` | `No issues found` | | `B9` | `flutter test` | **10/10 通过**(解析 / 跳转决策 / 本地通知抽象 / widget 深链跳转)| | `B9` | 标准 FCM 结构 | title/body/route/data 全部正确解析 | | `B9` | `data` 非字符串值 | `count:5`→`'5'`、`paid:true`→`'true'`(归一化强制 toString)| | `B9` | 深链闸 | `route:'https://evil.com'` → `hasDeepLink=false`,只认 `/` 开头站内路径 | | `B9` | 跳转决策纯函数 | 有深链走深链、无深链回 `/` | | `B9` | widget:点通知 | 首页 → 落 `/detail/123`,订单号来自 payload data | | `B10` | `bash ci_check.sh` | **9/9 工程全绿**(B1–B9 全部 analyze 零告警)| | `B10` | 累计测试 | **124/124 通过**(23+27+30+6+6+10+5+7+10)| ### 00B 抓到的两个反直觉结论 写完这篇时撞到的,两条都跟直觉相反,都写进文章了: > **一、Google Play target API 36 这条 deadline,跟升不升 Flutter 版本没关系。** > > 我拿 git tag 把六个版本的模板逐个翻了一遍:`targetSdkVersion` 从 **3.35.0(2025-08-13)** 起就是 36 了。 > 也就是说只要你的 `build.gradle` 用的是 `flutter.targetSdkVersion`(`flutter create` 的默认写法), > 或者从没手改过,这条你已经过了。真正会被卡住的只有一类:**把版本号写死的人**。 > **二、UIScene 自动迁移是"精确字符串匹配",改一个字就不迁。** > > 官方博客说 "For most apps, the Flutter CLI handles this migration automatically", > 但 flutter_tools 里的判定是 `template.trim() == projectAppDelegate` 的**全等比较**, > 而 `trim()` 只去首尾空白、不动中间内容。所以**多一个空行都不匹配**, > CLI 会完全不动你的文件,只打印一句让你手动改的提示。 > > 验证方式没靠读源码下结论——直接跑了官方自己的测试套件(**11/11 通过**, > 其中 3 条测试名写着 `is not exact match`)。脚本的 `test_多一个空行_就放弃自动迁移` 把这个行为钉死了。 > > 注:早先版本这里写成「12/12」,是把测试输出的 `+11: All tests passed!` 误读成「第 12 个」了—— > `+N` 是「已完成 N 个」的累计数,实际 11 个用例。已按实跑结果更正。 ### 写测试时抓到的一个真 bug 写 `01` 的单测时,实测撞到一个会咬人的坑,已修,代码里留了注释: > **`BleSession.serviceOf()` 一开始只做了 `toLowerCase()` 比较,导致 16 位短 UUID 永远查不到。** > > `universal_ble` 在构造 `BleService`/`BleCharacteristic` 时会把短 UUID 展开成 128 位: > `180f` → `0000180f-0000-1000-8000-00805f9b34fb`。 > 而设备手册上写的恰恰是 `180f`/`2a19` 这种短 UUID。照抄的结果就是返回 `null`, > 现象是**写入不报错但设备没反应**——最难排查的那一类。 > > 修法:比较前统一过一遍 `BleUuidParser.stringOrNull()`(非法 UUID 返回 `null` 而不是抛异常)。 对应测试用例见 `01-ble-in-action/test/ble_manager_test.dart` 里的「16 位短 UUID 也能查到」。 ### 00C 抓到的反直觉结论 > **一、Wasm 没让包变小多少。** > > 同一份代码打 JS 版和 Wasm 版,gzip 后分别是 566,712 B 和 568,777 B——差不到 0.4%。 > raw 字节 Wasm 确实小(1.53M vs 1.91M),但浏览器传的是 gzip,这部分基本打平。 > 想靠 Wasm 减首屏下载体积,基本落空。真收益在运行时多线程。 > **二、"静默降级"是物理可测的,不只是一句警告。** > > 同一份 Wasm 产物,抓 `document.title` 里的探针: > 不加 COOP/COEP → `coi=false, sab=false, mt=false`;加上 → `coi=true, sab=true, mt=true`。 > 只差两个响应头,引擎就从多线程掉回单线程,**全程不报错**。 > 这是这篇文章的核心论点,已实测钉死(10 轮可复现)。 > **三、我差点把"环境不具备"误判成"技术不支持"。** > > 最早用 `chrome --headless=new` 跑,探针恒为 `compiled=false`。我第一反应是"文档说的 > WasmGC 回退",还很合理。查下去才发现:**沙箱 Chrome 的 WasmGC 是支持的** > (`WebAssembly.validate` 那 15 字节 GC 模块返回 `true`),真正卡住的是 **WebGL**—— > `xcb_connect() failed`,无头环境没有 X display,SwANGLE 起不来,skwasm 被拒。 > 给个 `xvfb-run` 之后立刻 `compiled=true`、`mt=true`。 > 教训:先分清"它不支持"和"我的环境不具备",这两个结论天差地别。 > **四、多线程真的打开之后,吞吐反而降了 18%。** > > Xvfb + SwiftShader 跑通后补测了压测数据(各 10 轮中位数): > 吞吐 500.1 → 410.7 fps,光栅化 0.981 → 1.324 ms,掉帧率 1.11% → 2.17%。 > **配齐响应头只是让"快"成为可能,不是快的保证。** 注意这是软件渲染(SwiftShader) > 下的数字,不能推导出"多线程没用"——真实收益得在你的页面、你的机型上量。 ## 关于验证状态 仓库里的代码分两类,我尽量标清楚: **已验证(在 Flutter 3.47.2 沙箱里实跑过):** - `flutter analyze` 全部通过(含 `test/` 目录) - `flutter test` 全部通过 - `flutter build web` 构建成功 - API 签名逐个核对过源码 - 引用的官方文档 / 协议原文都标了出处 **未验证(沙箱条件限制):** - 真机 BLE 行为(沙箱没有蓝牙硬件、没有移动设备) - 各平台实际运行表现 - iOS / Android 原生构建(沙箱没有对应 SDK) - **00B 里的 iOS 结论全部来自读源码 + 跑官方单测**:沙箱没有 macOS/Xcode, 没在 Xcode 27 下见过那个"不迁 UIScene 就启动失败"的实际现象, 也没跑过真实的 CocoaPods → SwiftPM 迁移过程 - **00C 的真实 GPU 硬件加速性能**:沙箱只有 SwiftShader 软件光栅化, 压测里"多线程反而更慢"这个结论**只在软件渲染下成立**,别拿去套真机 **涉及真机的部分,请在你自己设备上验一遍再上生产。** 我不会把没跑过的东西说成跑过。 ## License 代码随意使用,出事不负责。文章版权归「搏哥聊技术」。