diff --git a/POC/Flutter-OH-POC.md b/POC/Flutter-OH-POC.md index 119c8b1..023d890 100644 --- a/POC/Flutter-OH-POC.md +++ b/POC/Flutter-OH-POC.md @@ -11,7 +11,7 @@ | 版本 | 日期 | 状态 | 说明 | | --- | --- | --- | | 0.1 | 2026-09-02 | POC 方案已冻结 | 定义范围、验收矩阵、证据要求及决策规则,实施后持续回写实际结果 | -| 0.2 | 2026-09-02 | 部分通过 | 记录 Flutter_OH 3.41.10-ohos-1.0.0 / Dart 3.11.5 的依赖解析和 `main_dev.dart` debug HAP 构建通过;确定首期 ArkTS 采用宿主 `entry` MethodChannel 路径 | +| 0.2 | 2026-09-02 | 部分通过 | 记录 Flutter_OH 3.41.10-ohos-1.0.0 / Dart 3.11.5 的依赖解析和默认 `main.dart` debug HAP 构建通过;确定首期 ArkTS 采用宿主 `entry` MethodChannel 路径 | ## 0. 结论摘要(实施后填写) @@ -31,7 +31,7 @@ | --- | --- | | 推荐级别 | `部分通过;尚不足以给出 Conditional Go` | | 使用的 Flutter_OH 版本 | Flutter `3.41.10-ohos-1.0.0`;Dart `3.11.5` | -| App 基线 | `harmony_3.41.9` 分支;本轮使用 `lib/main_dev.dart` | +| App 基线 | `harmony_3.41.9` 分支;使用默认 `lib/main.dart`(未指定 flavor 时加载 dev 配置) | | 模拟器基线 | 待记录 DevEco Studio、SDK、设备镜像/API、系统 Build | | 已证明的能力 | OHOS 工程存在;依赖解析完成;debug HAP 可由 Flutter_OH + hvigor 构建输出 | | 未证明的能力 | 模拟器运行/热重载、Dart/ArkTS 桥接、真机相机与实体扫码、相册/文件、权限、定位、推送、性能、签名发布、真实 F6 页面 | @@ -44,7 +44,7 @@ 在 App 仓库根目录执行 `. .\enable-ohos.ps1`,再进入 `app` 目录执行: ```powershell -flutter build hap --debug -t lib\main_dev.dart +flutter build hap --debug ``` 依赖解析及 hvigor 构建成功,输出 `app/ohos/entry/build/default/outputs/default/entry-default-unsigned.hap`。该产物是**未签名的 debug HAP**,不等同于已安装、已启动或可发布的完整验证。构建期间仍有 `targetSdkVersion` 未显式配置的 hvigor warning,需在后续 OHOS 工程配置中处理。 @@ -227,7 +227,7 @@ flutter devices ### 4.5 使用既有 OHOS 工程首次运行 -`app/ohos` 已在本轮生成并完成 HAP 构建,**不得重复执行** `flutter create --platforms ohos .`,也不创建新的 Dart `main` 文件。现有环境入口使用 `lib/main_dev.dart`、`lib/main_uat.dart` 和 `lib/main_prod.dart`。 +`app/ohos` 已在本轮生成并完成 HAP 构建,**不得重复执行** `flutter create --platforms ohos .`。项目使用唯一 Dart 入口 `lib/main.dart`;未指定 flavor 的 OHOS 默认加载 dev 配置。 只有在一个新的 `harmony` 工作树中确认 `app/ohos` 缺失、并在运行前后人工审查 Git diff 时,才允许在 `app` 目录执行一次 `flutter create --platforms ohos .`。当前工作树的后续运行与构建从仓库根目录执行: @@ -236,7 +236,7 @@ flutter devices cd app flutter pub get flutter devices -flutter run --debug -d -t lib\main_dev.dart +flutter run --debug -d ``` 上游 README 公开了 `flutter create --platforms ohos `、`flutter devices`、`flutter run --debug -d ` 和 `flutter build hap --release` 作为 OHOS 工程的创建、设备发现、调试运行和正式构建入口。本 POC 仅在 OHOS 平台目录缺失时使用一次 `--platforms ohos` 生成;任何生成操作前后都要看 Git diff,防止工具意外改写 Android/iOS 配置。[上游 Flutter_OH README](https://gitee.com/openharmony-sig/flutter_flutter/blob/master/README.md?force_mobile=true) @@ -250,7 +250,7 @@ flutter run --debug -d -t lib\main_dev.dart 首次可运行后执行一次构建验证(若只需复现本轮已通过的编译证据,也可直接执行本命令): ```powershell -flutter build hap --debug -t lib\main_dev.dart +flutter build hap --debug git status --short git diff --stat ``` diff --git a/flutter-app/04-routing.md b/flutter-app/04-routing.md index 39ec4fa..bb45f78 100644 --- a/flutter-app/04-routing.md +++ b/flutter-app/04-routing.md @@ -37,7 +37,7 @@ final goRouterProvider = Provider((ref) { final router = GoRouter( navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它 initialLocation: '/home', - observers: [NavigationObserver(ref.read(crashReporterProvider))], // 崩溃前的页面路径,见 13 + observers: const [], // 路由观测由 app 层按需注入 redirect: (context, state) { // read 不是 watch:这里只要当前值,订阅由下面的 listen 负责 final auth = ref.read(authStateProvider); diff --git a/flutter-app/07-native-integration.md b/flutter-app/07-native-integration.md index 4653928..7d497d5 100644 --- a/flutter-app/07-native-integration.md +++ b/flutter-app/07-native-integration.md @@ -291,7 +291,7 @@ app/ohos/entry/src/main/ets/ **延后抽取独立 OHOS plugin。** 只有出现第二个 OHOS App 的复用需求、扫描实现和契约已稳定、Flutter_OH plugin 注册/模板已成熟并经验证,或 SDK 化收益明确超过维护成本时,才评审将 ArkTS 从 `app/ohos/entry` 抽到 `packages/native_scan/ohos`。在此之前,不添加 `ohos:` plugin 声明或额外 `ohos/` 目录。 -**SDK 收敛条件。** 当前 Android/iOS 使用 Flutter 3.44.9,OHOS 使用 Flutter_OH 3.41.10-ohos-1.0.0;Flutter_OH 上游文档计划在 **2026 年 9 月**提供 Flutter 3.44.x / Dart 3.12.x 对齐版本。实际 Tag/Commit 发布并验证可用后,可评审统一基线。升级前必须以该候选 SDK 完成依赖解析、Pigeon 与 Riverpod 生成物核验、`flutter build hap --debug -t lib\main_dev.dart` 和真机原生能力回归;不能仅因计划日期或版本号相同就替换工具链。 +**SDK 收敛条件。** 当前 Android/iOS 使用 Flutter 3.44.9,OHOS 使用 Flutter_OH 3.41.10-ohos-1.0.0;Flutter_OH 上游文档计划在 **2026 年 9 月**提供 Flutter 3.44.x / Dart 3.12.x 对齐版本。实际 Tag/Commit 发布并验证可用后,可评审统一基线。升级前必须以该候选 SDK 完成依赖解析、Pigeon 与 Riverpod 生成物核验、`flutter build hap --debug` 和真机原生能力回归;不能仅因计划日期或版本号相同就替换工具链。 ## 使用规则 diff --git a/flutter-app/08-build-flavors.md b/flutter-app/08-build-flavors.md index 46a812c..514f109 100644 --- a/flutter-app/08-build-flavors.md +++ b/flutter-app/08-build-flavors.md @@ -16,11 +16,11 @@ App 侧维护 **3 个 flavor:`dev` / `uat` / `prod`**,环境划分与现有 ## 使用规则 -- 每个 flavor 对应一个独立的 Dart 入口文件(`main_dev.dart`/`main_uat.dart`/`main_prod.dart`),三者都只是设置好环境标识后调用同一个共享的 `bootstrap()` 启动函数,不允许在入口文件里写业务逻辑分支。 -- 环境相关的可变配置(API base URL、是否开启日志等,见 [05-networking.md](./05-networking.md) 的 `appEnvProvider`)通过 `--dart-define-from-file=env/{flavor}.json` 注入,不写死在代码里、也不用 `if (flavor == 'dev')` 这种运行时字符串判断来分支配置。 +- 全项目只保留 `lib/main.dart` 一个 Dart 入口。Flutter 的 `appFlavor` 自动对应 `--flavor`,启动代码据此加载打包的 `env/{flavor}.json`;未传 flavor 时默认 dev,DevEco 不需要额外入口配置。 +- 环境配置只包含可公开随包发布的 API 地址、日志开关与 H5 host;不在 Dart 代码中写死,也不要求开发者为入口文件额外传 `-t` 或 `--dart-define-from-file`。 - `env/*.json` 只包含非敏感配置(API 地址等);密钥类配置(如第三方 SDK App Key)通过 CI 变量在构建时注入,不提交进仓库。 - Android 侧用 Gradle `productFlavors` 区分 `applicationIdSuffix`/图标/`versionNameSuffix`;iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier`/图标;两端 flavor 名称必须完全一致(`dev`/`uat`/`prod`),不允许两端用不同命名。 -- CI 流水线阶段固定为:`melos run analyze` → `melos run test` → 按 flavor `flutter build apk/ipa --flavor {flavor} --dart-define-from-file=env/{flavor}.json` → 上传对应分发渠道。`prod` flavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。 +- CI 流水线阶段固定为:`melos run analyze` → `melos run test` → 按 flavor `flutter build apk/ipa --flavor {flavor}` → 上传对应分发渠道。`prod` flavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。 ## 用 `applicationIdSuffix` 而不是覆盖 `applicationId` @@ -40,8 +40,7 @@ productFlavors { ```bash fvm flutter build appbundle \ - --flavor prod --target lib/main_prod.dart \ - --dart-define-from-file=env/prod.json \ + --flavor prod \ --split-debug-info=build/symbols/$CI_COMMIT_TAG ``` @@ -103,7 +102,7 @@ after_script: **当前阶段的两条纪律**,它们是"手工出包"唯一的真实风险来源: -- **构建命令必须来自仓库里的脚本**,flavor、`--dart-define-from-file`、`--split-debug-info` 路径都在脚本里写死。手敲命令漏一个参数,出来的包看起来正常,实际连的是错的环境或者没有归档符号表。 +- **构建命令必须来自仓库里的脚本**,flavor 与 `--split-debug-info` 路径都在脚本里写死。手敲命令漏掉 flavor,可能构建出错误的原生包变体。 - **符号表和 dSYM 要从 Mac 上带回来归档**(见上文 Release 构建)。这是手工出包最容易漏的一步——Linux 上有 CI artifact 自动兜着,Mac 上没有。 证书和描述文件(Provisioning Profile)无论哪个阶段都用 [fastlane match](https://docs.fastlane.tools/actions/match/) 管理,存在一个私有 git 仓库里,**不靠人肉在钥匙串之间导来导去**。这一条在只有一台 Mac 的情况下更重要:机器坏了、人换了,签名能力不能跟着丢。 @@ -115,8 +114,7 @@ build_ios_uat: stage: build tags: [macos] # 只有 mac runner 有这个 tag script: - - fvm flutter build ipa --flavor uat --target lib/main_uat.dart \ - --dart-define-from-file=env/uat.json --export-options-plist=ios/ExportOptions-uat.plist + - fvm flutter build ipa --flavor uat --export-options-plist=ios/ExportOptions-uat.plist ``` ## 内测分发:托管平台 + 后台发布管理 @@ -162,7 +160,6 @@ Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有 ## 参考链接 - [Flutter 官方 Flavors 文档](https://docs.flutter.dev/deployment/flavors) -- [--dart-define-from-file 官方说明](https://docs.flutter.dev/deployment/flavors#configuration-approaches) - [Flutter: 混淆 Dart 代码](https://docs.flutter.dev/deployment/obfuscate) - [Android: 从命令行构建并签名](https://developer.android.com/build/building-cmdline) - [fastlane match(证书管理)](https://docs.fastlane.tools/actions/match/) @@ -182,7 +179,7 @@ Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有 1. **Android `productFlavors`**:在 `android/app/build.gradle` 里声明多套 `applicationId`/`versionNameSuffix`/资源目录,编译时用 `--flavor` 选择其中一套。 2. **iOS Scheme + xcconfig**:iOS 没有 Gradle 那样的单文件配置,而是通过 Xcode 里多个 Build Configuration(对应不同的 `.xcconfig` 文件设置 Bundle ID 等)+ 多个 Scheme 组合实现同样的效果,`flutter build ipa --flavor xxx` 背后就是选中同名 Scheme。 3. **Dart 入口文件(`main_xxx.dart`)**:flavor 决定的是"编译出什么样的原生外壳",Dart 代码本身默认只有一个 `main.dart` 入口——项目约定用多个入口文件对应各 flavor,让每个 flavor 能设置不同的启动参数(比如传给 `bootstrap()` 一个环境枚举)。 -4. **`--dart-define-from-file`**:flavor 解决的是原生层面的差异(图标、包名),但 API 地址这类 Dart 侧读取的配置,用编译期注入的 JSON 文件解决,避免打包进一个写死 `http://dev-api...` 的字符串常量。 +4. **内置环境配置**:Dart 固定从 `main.dart` 启动,Flutter 将 `--flavor` 暴露为 `appFlavor`;启动代码按其加载随包发布的 JSON 配置。未提供 flavor 时使用 dev,适配 DevEco 的默认构建。 ### 使用示例 @@ -240,8 +237,11 @@ Future bootstrap(AppEnv env) async { ``` ```dart -// lib/main_dev.dart -void main() => bootstrap(AppEnv.fromDartDefine(name: 'dev')); +// lib/main.dart +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await bootstrap(await loadBundledAppEnv(flavor: appFlavor)); +} ``` 构建命令: @@ -249,8 +249,6 @@ void main() => bootstrap(AppEnv.fromDartDefine(name: 'dev')); ```bash fvm flutter build apk \ --flavor dev \ - --target lib/main_dev.dart \ - --dart-define-from-file=env/dev.json ``` GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上): @@ -268,7 +266,7 @@ build_android_dev: script: - melos run analyze - melos run test - - flutter build apk --flavor dev --target lib/main_dev.dart --dart-define-from-file=env/dev.json + - flutter build apk --flavor dev artifacts: paths: [build/app/outputs/flutter-apk/app-dev-release.apk] rules: @@ -278,8 +276,7 @@ build_android_prod: <<: *flutter_base stage: build script: - - flutter build appbundle --flavor prod --target lib/main_prod.dart - --dart-define-from-file=env/prod.json + - flutter build appbundle --flavor prod --build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID --split-debug-info=build/symbols/$CI_COMMIT_TAG artifacts: @@ -294,8 +291,7 @@ build_ios_prod: stage: build tags: [macos] # 必须是 mac runner,Linux runner 跑不了,见上文「iOS 构建:远程 Mac」 script: - - fvm flutter build ipa --flavor prod --target lib/main_prod.dart - --dart-define-from-file=env/prod.json + - fvm flutter build ipa --flavor prod --build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID --split-debug-info=build/symbols/$CI_COMMIT_TAG rules: diff --git a/flutter-app/11-store-context-and-session.md b/flutter-app/11-store-context-and-session.md index 4d9e0b3..5ed55f1 100644 --- a/flutter-app/11-store-context-and-session.md +++ b/flutter-app/11-store-context-and-session.md @@ -145,8 +145,7 @@ Future switchStore(int targetStoreId) async { // ── 7. 记住这次选择,供下次冷启动预选 ──────────────── await ref.read(prefsProvider).setInt('last_store_id', newStore.storeId); - // ── 8. 同步观测上下文(见 13)──────────────────────── - ref.read(crashReporterProvider).setTag('storeId', '${newStore.storeId}'); + // ── 8. 同步埋点上下文 ────────────────────────────────── ref.read(analyticsProvider).registerSuperProperties({'storeId': newStore.storeId}); // 切店事件本身由后端从 /stores/{id}/switch 的接口日志出,客户端不重复上报,见 13 } @@ -158,7 +157,7 @@ Future switchStore(int targetStoreId) async { | 3 在 5 之前 | H5 页面里可能有在途请求。先 `about:blank` 停掉,再换上下文,否则旧门店的 H5 请求会带着新门店的票据回来 | | 4 在 5 之前 | 缓存表带 `storeId`(见 06),但**清理和新上下文之间不能有窗口期**:如果先落新上下文,provider 立刻失效并重新请求,可能在清理完成前就把新数据写进去,然后被 `clearBusinessCache()` 一起删掉 | | 6 在 5 之后 | 清栈时目标页面(首页)要用新上下文渲染 | -| 8 在 5 之后 | 观测上下文要和业务上下文保持一致;漏了这一步的表现是**切店后的崩溃和埋点还挂在旧门店名下**,按门店维度分析时数据是错的,而且错得很隐蔽 | +| 8 在 5 之后 | 埋点上下文要和业务上下文保持一致;漏了这一步会让切店后的埋点还挂在旧门店名下,按门店维度分析时数据会悄悄出错 | **关于步骤 0(未完成写操作)**:切店时用户可能正在提交订单或上传图片。默认策略是**阻止切换并提示**,而不是静默取消——取消一个已经发出去的下单请求,客户端不知道服务端到底成没成。`pendingWriteProvider` 由发起写操作的 feature 自己注册/注销。 @@ -190,7 +189,6 @@ Future logout({LogoutReason reason = LogoutReason.userInitiated}) async { ref.read(analyticsProvider) ..track(AnalyticsEvent.logout, {'reason': reason.name}) // 报完再 reset,顺序不能反 ..reset(); - ref.read(crashReporterProvider).setUser(''); // 6. 兜底:清掉所有 provider 缓存 ref.invalidate(...); // 或在 ProviderScope 层重建,见下文 diff --git a/flutter-app/13-observability-analytics.md b/flutter-app/13-observability-analytics.md index 053c8d8..098906a 100644 --- a/flutter-app/13-observability-analytics.md +++ b/flutter-app/13-observability-analytics.md @@ -1,464 +1,22 @@ -# 13. 可观测性与埋点 - -## 为什么单独一篇 - -PRD REQ-NFR-015(崩溃与错误上报)和第 6.8 节(埋点)有明确要求——主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、埋点事件——但 01-12 里完全没有落点。同时,**App 侧的可观测性是排查线上问题唯一的手段**——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。 - -这一篇定三件事:**崩溃上报**、**日志规范**、**埋点规范**。 +# 13. 日志与埋点 ## 决策 -| 项 | 决策 | -|---|---| -| 崩溃上报 | **腾讯 Bugly**(原生崩溃 / ANR / 启动崩溃),通过自建的 `native_crash` Pigeon 包接入 | -| 错误明细与看板 | **神策自定义事件 `app_error` + 在神策上二次开发错误看板**,不引入独立崩溃平台 | -| release 混淆 | **不开 `--obfuscate`**,只 `--split-debug-info` 归档符号表,见下文「必须关掉混淆」 | -| 本地日志 | **[logger](https://pub.dev/packages/logger) `^2.7.0`**,封装在 `core_logging` 的 `AppLogger` 后面 | -| 客户端埋点 | **[神策 `sensors_analytics_flutter_plugin`](https://pub.dev/packages/sensors_analytics_flutter_plugin) `^4.2.3`**(官方 verified publisher `sensorsdata.cn`;团队过往项目用过) | -| 业务埋点 | **以后端为主**,客户端只补后端看不到的那部分 | -| 链路关联 | 客户端生成 `X-Trace-Id`(见 05),写入本地日志并作为崩溃/错误上报的自定义属性 | +首期不接入任何崩溃上报 SDK,也不向第三方传输异常、用户或门店上下文。业务代码通过 +`core_logging` 输出经过脱敏的本地日志;行为分析继续通过 `core_analytics` 的抽象接口接入。 -## 一、崩溃上报:Bugly + 神策 +## 日志规则 -### 分工 +- `AppLogger` 是唯一日志入口;请求日志和业务日志都必须经 `scrubber.dart` 脱敏。 +- dev 输出 debug 级别日志,uat 输出 info 及以上,prod 不输出控制台日志。 +- 日志不持久化、不上传;不保留无消费者的崩溃黑匣子缓存。 +- Provider 失败和未知路由仅记录脱敏日志。业务拒绝、登录失效和主动取消属于预期流程, + 不作为错误日志噪音处理。 -崩溃上报要收两类东西,它们的技术性质完全不同: +## 埋点 -- **原生崩溃**(SIGSEGV、ANR、iOS crash、启动期崩溃)—— 交给 **Bugly**。团队现成在用,国内可达性没有任何问题,原生侧自动符号化是它的强项。 -- **Dart 异常**(`setState` 期间抛错、null check、JSON 解析失败、未 catch 的 `Future`)—— 我们**自己捕获**,作为结构化的自定义事件 `app_error` 上报到**神策**,在神策上二次开发出错误看板(按 `errorType` / `route` / `storeId` / `appVersion` 分组看趋势和影响设备数)。 -- 同一条 Dart 异常**再通过 Bugly 的自定义异常接口报一份**,这样 Bugly 后台的崩溃率是全量口径,不会因为"Dart 异常不在里面"而虚低。两边口径见下文「两边报同一件事,怎么不打架」。 +`Analytics` 仍由 app 层注入。会话建立时设置用户与角色维度,切店时更新门店维度,登出时 +先记录登出事件再 reset,避免共用门店设备上的用户上下文串联。 -不引入独立崩溃平台(Sentry 等),省掉一次采购、一套部署运维和一轮跨境数据合规评估。**代价在下一节,必须一起接受。** - -### 必须关掉混淆 - -一个独立崩溃平台在这件事上唯一真正的优势,是能自动上传 Dart 符号表并在后台还原混淆堆栈。**Bugly 和神策都做不到** —— Bugly 只能把 Dart 异常当成一段字符串收下,神策更是只当事件属性存着。 - -如果继续按原计划用 `--obfuscate` 打 release 包,结果是**线上占比最大的那一半崩溃,堆栈是一串读不出来的 `_x12`**,每次排查都要人工把堆栈拷出来跑一遍 `flutter symbolize`。所以这里必须做一个连带决策: - -| release 构建参数 | 堆栈可读性 | 结论 | -|---|---|---| -| `--obfuscate --split-debug-info` | 类名方法名全部混淆,必须 `flutter symbolize` 才能看 | ❌ 放弃(原计划) | -| **只 `--split-debug-info`** | **类名、方法名直接可读**(`OrderRepository.submit`),行号被剥离到符号表,需要精确行号时再 `symbolize` | ✅ **采用** | -| 两个都不加 | 完全可读(带 `file:line`),但产物变大、Dart 符号全部留在包里 | ❌ 不采用 | - -**这是本次选型真正付出的东西**:上报回来的堆栈能直接定位到出错的类和方法,够用;代价是 Dart 层的符号不再混淆,逆向门槛降了一档。这一条要和安全侧确认(见待确认项)——安全侧若坚持要混淆,就必须接受"每条 Dart 崩溃都要人工 symbolize"这个排查成本,并把 [08-build-flavors.md](./08-build-flavors.md) 的构建参数改回去。 - -**符号表照旧归档**(08 要求 ≥1 年)。关掉混淆后它不再是日常排查的必需品,但仍是拿精确行号的唯一手段,且丢了不可逆。 - -### 存档:为什么不另起一个崩溃平台 - -| | Bugly + 神策(采用) | 独立崩溃平台(如 Sentry) | -|---|---|---| -| **Dart 堆栈还原** | 做不到,靠"不混淆"绕开 | 做得到(插件自动上传符号表) | -| Flutter 官方 SDK | Bugly **没有**,pub.dev 上只有 unverified 社区插件,要自己写 `native_crash` | 有,`pubspec.yaml` 加两行 | -| 原生崩溃 | Bugly 强项,自动符号化 | 支持 | -| 账号 / 采购 | **Bugly、神策都是现成的** | 要新开,自建还要内网资源和运维承接方 | -| 数据出境 | 无(都在境内) | SaaS 形态属数据出境,要走合规评估 | -| 门店网络可达性 | 无问题 | SaaS 形态丢报率无法预估 | -| 接入成本 | 高:`native_crash` + 三个 Dart 捕获入口 + 神策看板都要自己做 | 低 | - -**决定性的是「现成」那一行。** 崩溃平台这类基础设施,"已经在用、有人维护、合规口径已经过"的价值,高于"SDK 接入省两天"。混淆那一条是可以用构建参数换掉的,采购周期和数据出境评估换不掉。 - - -### `native_crash`:Bugly 得自己包 - -Bugly 只有 Android / iOS 原生 SDK。pub.dev 上那两个社区插件(`flutter_bugly`、`bugly_pro_flutter`)都是 unverified,**不采用**——崩溃上报是出了问题最不该再出问题的一环,不押在一个可能停更的包上。 - -所以 `native_*` 里**要加一个 `native_crash` 包**(包清单见 [01-project-structure.md](./01-project-structure.md),Pigeon 约定见 [07-native-integration.md](./07-native-integration.md)),几十行 Kotlin/Swift: - -```dart -// packages/native_crash/pigeons/crash_api.dart -@HostApi() -abstract class NativeCrashApi { - void setUserId(String userId); - void putUserData(String key, String value); // storeId / roleCode / flavor 等维度 - void postException(String type, String message, String stackTrace, Map extra); - void log(int level, String tag, String message); // 进 Bugly 的崩溃附加日志 -} -``` - -**`native_crash` 和其它 `native_*` 包有一条本质区别:它的初始化不在 Dart 侧。** Bugly SDK 必须在原生的 `Application.onCreate()` / `AppDelegate` 里尽早初始化,**启动期原生崩溃发生在 Flutter engine 起来之前**,等 Dart 调过来就已经漏了。因此: - -- SDK 初始化写在原生侧,`appId` / `appKey` 按 flavor 从原生资源里取(Android 走 `src/{flavor}/`,iOS 走 xcconfig,见 [08-build-flavors.md](./08-build-flavors.md)),**不经过 `--dart-define`**——Dart 侧拿到的时候太晚了。 -- Pigeon 接口里因此**没有 `init()`**,只有初始化之后才用得上的那几个方法。 -- 但**「用户同意隐私政策前不初始化」这条硬要求依然成立**:原生侧读一个本地标记位,没同意就不初始化 SDK。这个标记位由 Dart 侧在同意后写入(见「不采集什么」)。 - -### Dart 异常的三个捕获入口 - -没有 SDK 帮忙接管,三个入口要自己写全,**漏掉哪个就是那一类异常静默丢失**。完整的 `main()` 写法见 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 第五节,这里只强调三者缺一不可: - -| 入口 | 覆盖 | -|---|---| -| `FlutterError.onError` | widget 构建 / 布局 / 绘制期的异常 | -| `PlatformDispatcher.instance.onError` | 框架之外、engine 层冒上来的异步异常 | -| `runZonedGuarded` 的 onError | zone 内未捕获的异步异常(`Future` 里 throw 且没人 catch) | - -**这里和"用现成 SDK"是一个反转的风险**:接三方 SDK 时最常见的错误是"SDK 已经接管了还手写一遍,同一个异常报两次";自己接反过来变成**"以为有人管,结果三个入口都没写"**,表现是后台一片安静、看起来 App 特别稳定。这比重复上报危险得多,见下文「验证接入真的成功了」。 - -### `ErrorReporter`:业务代码只见这一个接口 - -```dart -// packages/core_logging/lib/src/error_reporter.dart -abstract interface class ErrorReporter { - void setUser(String userId); - void setTag(String key, String value); - void leaveBreadcrumb(String message); - void recordFlutterError(FlutterErrorDetails details); - void recordError(Object error, StackTrace? stack, {bool fatal = false, Map context = const {}}); -} -``` - -`feature_*` 不直接 import `native_crash`,也不直接 import 神策。这一层的价值有三个:测试里能 mock 掉、`feature_*` 少一条对三方 SDK 的直接依赖(见 [01-project-structure.md](./01-project-structure.md) 的依赖规则),以及**"一条异常同时进 Bugly 和神策"这个双写逻辑只写在一个地方**。 - -默认实现做的事: - -```dart -void recordError(Object error, StackTrace? stack, {bool fatal = false, Map context = const {}}) { - final type = error.runtimeType.toString(); - final scrubbed = scrub(stack.toString()); // 见「脱敏」 - final extra = {...context, ...currentTags}; // storeId / roleCode / route / traceId / flavor - - // ① Bugly:进崩溃率口径,堆栈按 SDK 的长度上限截断 - _native.postException(type, scrub(error.toString()), truncate(scrubbed), extra); - - // ② 神策:进错误看板,属性可查询、可分组 - _analytics.track('app_error', { - 'errorType': type, - 'errorMessage': scrub(error.toString()), - 'stackTrace': truncate(scrubbed), - 'fatal': fatal, - ...extra, - }); -} -``` - -**双写只在这个方法里发生,业务侧永远只调一次 `recordError`。** - -### 两边报同一件事,怎么不打架 - -同一条 Dart 异常在 Bugly 和神策各有一份,两边数字对不上是必然的(采样、投递时机、去重规则都不同)。所以先把口径定死,**不要指望两个数字相等**: - -| | Bugly | 神策 `app_error` | -|---|---|---| -| 看什么 | **崩溃率、稳定性趋势、版本对比** | **错误明细、按门店/角色/路由下钻、和业务事件关联** | -| 权威口径 | ✅ 对外汇报稳定性用这个 | 参考 | -| 强项 | 原生崩溃自动符号化、设备/系统分布 | 自定义属性可查询,能和 `api_failed`、`h5_failed` 放在同一套分析里 | - -**"这个版本稳不稳"看 Bugly,"这个错误是怎么来的"看神策。** 任何一份周报里不要把两个数字并排放,会引出解释不清的问题。 - -### 神策上的错误看板要自己搭 - -神策不是崩溃平台,`app_error` 对它来说就是一个普通事件。所以下面这些是**必须自己做的二次开发**,排期里要留出来: - -- **事件属性先在神策后台建好**(`errorType`、`errorMessage`、`stackTrace`、`fatal`、`route`、`traceId`)。属性没预先定义,上报上去会被丢弃或者变成不可分组的字符串。 -- **`stackTrace` 必须截断**。神策的字符串属性有长度上限,超长属性会被截断甚至导致整条事件入库失败。约定**只取前若干帧**(建议 20 帧)而不是按字符数硬切——按字符切会把最关键的顶层帧留下、底层调用链切没,反而是切错了方向。具体上限值以所用神策版本的文档为准(见待确认项)。 -- **看板**:错误数趋势、`errorType` TOP N、影响设备数、按 `appVersion` 对比、按 `storeId` 分布。 -- **告警**:新版本发布后错误数突增、某个 `errorType` 首次出现。神策的预警功能够用,不需要另做。 - -**`stackTrace` 不做去重聚合是这套方案最大的短板。** 崩溃平台会自动把同一个错误的不同实例归并成一个 issue,神策没有这个能力——只能靠 `errorType` + 堆栈首帧拼一个 `errorGroup` 属性自己分组。**这个 `errorGroup` 要在客户端算好再上报**,放到神策里用公式算不出来。 - -### 用户与门店上下文 - -会话状态变化时同步(见 [11-store-context-and-session.md](./11-store-context-and-session.md)): - -```dart -ErrorReporter.instance - ..setUser(session.user.userId) // 只传 ID,不传手机号/姓名 - ..setTag('storeId', '${session.store.storeId}') - ..setTag('roleCode', session.user.roleCode) - ..setTag('flavor', env.flavorName); -``` - -内部同时落到两侧:`setUser` 走 Bugly 的 `setUserId` 和神策的 `login`,`setTag` 走 Bugly 的 `putUserData` 和神策的超级属性。**这也是「一次调用、两处生效」只写在 `ErrorReporter` 里的原因**——散到调用点去写,迟早有一侧漏掉。 - -**`storeId` 一定要带。** 它能直接回答"这个崩溃是不是只发生在某几家门店"——门店设备型号和网络环境高度集中,很多崩溃是设备相关的,没有这个维度只能盲猜。 - -`traceId` 在网络相关的错误上报时作为自定义属性带上,这样一条崩溃能直接关联到后端 ELK 里的那次请求(见 [backend/08-observability.md](../backend/08-observability.md))。 - -### 崩溃前的页面路径 - -崩溃报告里最有用的上下文之一是"崩之前用户在哪几个页面"。go_router 的 `observers` 挂一个 `NavigationObserver`(见 [04-routing.md](./04-routing.md)),把最近的路由变化写进环形缓冲,随崩溃一起上报: - -```dart -class NavigationObserver extends NavigatorObserver { - NavigationObserver(this._reporter); - final ErrorReporter _reporter; - - @override - void didPush(Route route, Route? previous) => - _reporter.leaveBreadcrumb('nav: ${route.settings.name}'); -} -``` - -**这里没有 SDK 自带的面包屑可用**,环形缓冲是我们自己的:Bugly 侧靠 `log()` 写进崩溃附加日志,神策侧作为 `app_error` 的 `breadcrumbs` 属性带上(同样要截断)。当前路由名另外单独作为 `route` 属性上报——它是错误看板里最常用的分组维度,埋在一段拼接文本里就没法分组了。 - -注意**记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据(见脱敏一节)。同理再补三处业务关键节点:H5 启动/失败、门店切换、扫码。这三条链路最长、最容易出问题。 - -### 上报什么、不上报什么 - -- **上报**:未捕获的 Dart 异常、原生崩溃、ANR、Riverpod provider 抛出的异常(通过 `ErrorObserver`,即使 UI 已经优雅处理了——"用户看到了漂亮的错误页"和"不需要知道有多少人看到"是两回事,见 12)。 -- **不上报**:`RequestCancelledException`(用户正常退出页面)、`UnauthorizedException`(正常的登出流程)。这两类是业务流程的一部分,上报只会把真正的崩溃淹掉。 - -### 验证接入真的成功了 - -崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 App 很稳定。自己接三个捕获入口之后,这个风险比用现成 SDK 时更高,所以验证是硬要求: - -- `core_logging` 暴露一个 `throwTestException()`,**只在 dev flavor 下可调**,每次发版前在 dev 上验证一遍 Android 和 iOS。 -- **三个入口要分别触发、分别验证**:build 期抛错(`FlutterError.onError`)、`Future` 里抛错不 catch(`runZonedGuarded`)、原生侧主动 crash(Bugly)。只测一个入口是通不过的——漏写的那个恰恰是没被测到的那个。 -- **每次验证都要确认两侧都收到了**:Bugly 后台有这条崩溃、神策里有对应的 `app_error` 事件。只对一侧就等于双写逻辑没验证。 -- **验证堆栈是不是可读的**——这一步比"能收到"更容易漏。打一个 release 包(按上文的 `--split-debug-info` 且**不带** `--obfuscate`)触发异常,确认上报回来的是 `OrderRepository.submit` 而不是 `_x12`。看到 `_x12` 就说明构建参数被改回去了。 -- uat 环境跑一个迭代后,对一下 Bugly 的错误数和神策里 `app_error` 的数量级,差得离谱说明有一侧漏了。 - -## 二、日志规范 - -### `AppLogger` - -```dart -// packages/core_logging/lib/src/app_logger.dart -abstract interface class AppLogger { - void d(String message, {Map? data}); - void i(String message, {Map? data}); - void w(String message, {Object? error, StackTrace? stackTrace}); - void e(String message, {Object? error, StackTrace? stackTrace}); -} -``` - -各包**不直接用 `logger` 包,也不用 `print`/`debugPrint`**,统一注入 `AppLogger`。理由:将来换日志实现只改一处;同时 `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。 - -### 级别与环境 - -| 环境 | 级别 | 输出 | -|---|---|---| -| dev | `debug` | 控制台,带颜色和调用栈 | -| uat | `info` | 控制台 + 内存环形缓冲(最近 500 条) | -| prod | `warning` | **不输出到控制台**,只进内存环形缓冲 + 随崩溃上报 | - -**prod 不打控制台日志**:Android 上 `logcat` 是全局可读的,任何装了 adb 或第三方日志 App 的人都能看到。门店设备上这不是理论风险。 - -**内存环形缓冲**的作用是:崩溃时把最近 N 条日志一起传上去,相当于一个"黑匣子"。不落磁盘,App 退出即消失,避免日志文件成为新的泄漏面。实现上由 `ErrorReporter.recordError` 在上报时取出,Bugly 侧走 `log()` 写进崩溃附加日志、神策侧作为事件属性带上;两边都有长度上限,所以实际带的是**最近 30 条**,不是全部 500 条。 - -### 脱敏(PRD REQ-NFR-019 日志脱敏) - -```dart -// packages/core_logging/lib/src/scrubber.dart -const _sensitiveKeys = { - 'token', 'accessToken', 'refreshToken', 'ticket', 'password', - 'code', // 短信验证码 - 'phone', 'mobile', 'idCard', 'bankCard', -}; - -String maskPhone(String v) => v.length >= 11 ? '${v.substring(0, 3)}****${v.substring(7)}' : '***'; -``` - -规则: - -- **请求/响应体不整体打日志**。只打 method、path、状态码、耗时、`code`、`traceId`。真要看 body 只在 dev 下开,且过一遍脱敏器。 -- **`Authorization` 头永远不打**,一个字符都不打——打前 8 位也不行,那既足够辅助暴力破解,也足够在日志里认出是谁的 token。 -- **H5 URL 打日志前必须去掉 query**:URL 里带着 `ticket`,整条打出去等于打 token。 -- 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志、异常 message 和堆栈都会随崩溃一起传上去。 - -**脱敏必须做在 `ErrorReporter` 的出口,不是各调用点。** 自己接上报有一个隐蔽的好处:没有任何 SDK 会背着我们自动记录 HTTP 面包屑,我们在 05/10 里保证的"URL 不落日志"不会被某个默认行为绕过去。但反过来,**凡是要送出去的东西都得自己过一遍脱敏器**,没有 SDK 的钩子兜底: - -```dart -// packages/core_logging/lib/src/scrubber.dart -/// 异常 message 与堆栈里同样可能出现 URL、手机号、车牌 -String scrub(String raw) => raw - .replaceAllMapped(_urlPattern, (m) => Uri.parse(m[0]!).replace(query: '').toString()) - .replaceAllMapped(_phonePattern, (m) => maskPhone(m[0]!)); -``` - -最容易漏的是**异常 message 本身**:`DioException` 的 `toString()` 里带着完整 URL(含 `ticket`),原样上报等于把 token 发出去。`recordError` 里对 `error.toString()` 和 `stack.toString()` 都要跑一遍 `scrub`,见上文的实现。 - -### 不采集什么 - -出于合规(个人信息保护法「最小必要」原则)和 PRD REQ-NFR-019: - -| 项 | 结论 | -|---|---| -| 崩溃截图 / 页面录制 / 控件树 | **不采集**。收银、经营分析页面上有金额和客户信息 | -| 精确位置 | 不采集。App 没有需要精确位置的功能 | -| IMEI / IDFA / MAC / AndroidID | **不采集**(见 05,`X-Device-Id` 用的是匿名安装 UUID)。**神策原生 SDK 默认会采集设备标识来生成 `distinct_id`,必须在初始化时逐项关掉**;**Bugly 默认也会采集设备信息,同样要逐项过一遍并按隐私政策裁剪** | -| 通讯录、短信 | 不申请权限 | -| 用户输入的原文 | 不打日志(包括搜索关键词里可能出现的车牌、手机号) | - -这份清单要和 App 的隐私政策(PRD REQ-LGN-005,由 App Backend 下发)**逐条对齐**——隐私政策里没写的,代码里就不能采。**第三方 SDK 的默认采集行为是最容易在合规审查时出问题的地方**:神策和 Bugly 的隐私说明都要单独过一遍,并且要在**用户同意隐私政策之前不初始化**,否则「同意前不采集」这条硬要求就破了。 - -**Bugly 这一条要特别注意**:它在原生侧初始化(见上文 `native_crash`),"延迟到同意之后"不是 Dart 里加个 if 就行的——原生侧要读一个本地标记位来决定初不初始化,标记位由 Dart 在用户同意后写入并持久化。**这个标记位一旦漏写,表现是"合规过不了"而不是"崩溃收不到",测不出来。** - -## 三、埋点:以后端为主 - -**大部分业务埋点由后端从自己的请求日志和审计日志里出,客户端不重复做一遍。** - -理由很直接:任何一个业务动作(登录、切店、下单、入库、打开 H5)都会打到 App Backend 的接口上,后端已经有 `traceId`、用户上下文、门店上下文和 `@Audited` 审计通道(见 [backend/08-observability.md](../backend/08-observability.md))。客户端再报一遍,得到的是同一件事的两份数据——而且客户端那份还更不可靠(可能丢、可能延迟、可能被篡改)。 - -**这两份数据最好落到同一个地方。** 神策有服务端 SDK / 数据导入接口,后端把业务事件写进同一个神策项目的话,运营就能做「扫码失败的门店,后续下单转化率是不是更低」这种跨端漏斗;分成两套系统也能跑,但每次跨端分析都要人工对数。**这一条要和后端确认**,见待确认项。 - -### Metabase 不是神策的替代品 - -后端侧提到过 Metabase。**它和神策不冲突,也不是二选一**——两者根本不在一层: - -| | 神策 | Metabase | -|---|---|---| -| 客户端采集 SDK | **有** | **没有**,它不采集任何数据 | -| 数据来源 | 自己的 SDK / 服务端导入 | 接已有的数据库、数仓 | -| 定位 | 采集 + 管道 + 分析平台 | BI / 看板层 | - -Metabase 官网自己把 Mixpanel、PostHog、Amplitude 列为**上游集成**——由那些工具负责采集,Metabase 在导出的数据上出图。这就说明了它的位置。 - -所以合理的分工是:**神策收客户端事件;后端的业务埋点本来就在自己库里,Metabase 接上去出报表。** 后端如果已经在用 Metabase,那是个好消息而不是冲突信号——它意味着上面「两份数据落到一个地方」这条有了第二种解法:不把业务数据推进神策,而是反过来把神策的客户端事件导出到同一个库,用 Metabase 统一出图,还能直接和订单、门店主数据 join。哪一种更合适取决于后端的数仓现状,一并列进待确认项。 - -### 分工 - -| 埋点事件 | 谁来出 | 说明 | -|---|---|---| -| 登录成功/失败 | **后端** | 登录本身就是接口调用 | -| 首页曝光 | **后端** | 首页聚合接口的调用即曝光 | -| 门店切换 | **后端** | 切换接口 | -| 采购下单 | **后端** | | -| 入库成功 | **后端** | | -| 待办点击 | **后端** | 点击后会请求详情接口 | -| **扫码成功/失败** | **客户端** | 扫码是 App 原生实现(见 07),**不产生任何请求**,后端完全看不到 | -| **H5 关闭 / 异常** | **客户端** | 「打开」有 `/h5/launch` 请求后端能看到;**关闭、白屏、超时、加载失败后端看不到** | -| 客服点击 | **客户端** | 拨号、企微二维码是纯客户端行为 | - -**客户端埋点的判据只有一条:这件事会不会产生一次后端请求?不会,才由客户端上报。** - -### 客户端事件表 - -按上面的判据筛下来,客户端只需要这几个: - -| 事件 | 触发 | 关键参数 | -|---|---|---| -| `scan_succeeded` / `scan_failed` | 扫码结果 | `mode`(barcode/vin/plate)、`durationMs`、`failReason` | -| `h5_closed` | H5 页关闭 | `target`、`stayDurationMs` | -| `h5_failed` | 白屏 / 超时 / 加载失败 | `target`、`errorCode`、`elapsedMs`、`traceId` | -| `h5_first_paint` | H5 首屏完成 | `target`、`ticketMs`(换票耗时)、`loadMs`(页面加载耗时) | -| `support_clicked` | 客服入口点击 | `channel`(hotline/dealer/o2o) | -| `api_failed` | 请求失败 | `path`、`code`、`httpStatus`、`traceId` | -| `app_cold_start` | 冷启动完成 | `durationMs` | -| `logout` | 登出 | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出没有对应的接口调用**,见 [11](./11-store-context-and-session.md) | -| `session_restore_failed` | 冷启动恢复会话失败 | 失败阶段(读 storage / me / stores)。卡在读 secure storage 时不产生任何网络请求 | - -两条说明: - -- **`h5_first_paint` 必须把耗时拆成 `ticketMs` 和 `loadMs` 两段**。合成一个数字的话,慢了不知道该找 App Backend / F6 / 还是网络——这是这个 App 里最长的一条跨系统链路,也是最容易互相甩锅的地方。 -- **`api_failed` 客户端也要报**,虽然后端也能看到失败。因为**后端看不到"请求根本没发出去"和"响应没收到"**:超时、连接失败、DNS 失败、运营商劫持,这些在后端日志里要么完全没有记录,要么表现为一次正常的成功响应。门店网络不稳时这类失败占大头。 - -### 实现约定:神策 SDK,外面包一层 - -客户端埋点走**神策 `sensors_analytics_flutter_plugin`**:官方 verified publisher `sensorsdata.cn`,`4.2.3` 一个多月前发布,是当前维护中的官方插件——这在 pub.dev 上的国内三方 SDK 里不多见。**团队过往项目用过,事件模型和数据接入的坑踩过一遍**,这是选它最实在的理由。 - -**注意本项目里神策承担了两件事**:客户端行为埋点(本节),以及上文的错误明细与看板(`app_error` 事件)。后者是二次开发出来的,不是神策的现成能力——**排期要按"两块工作"算**。 - -#### 神策不是"开箱即用",这些活一样要干 - -先把预期摆正,否则排期一定会低估。**接了神策之后,下面这些工作量和自建一套上报是完全一样的**: - -- **事件方案设计**——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 SDK 无关。 -- **`core_analytics` 的接口封装**(见下)。 -- **接入点的编排**——超级属性什么时候注册、切店后重注册、登录/登出的 ID 关联。神策给了 API,但在哪调是我们的事(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。 -- **私有化部署的运维**(如果走私有化)。 - -**神策真正替我们省掉的只有一件具体的事:客户端的可靠投递。** 原生 SDK 自带本地缓存、批量上报、弱网重传、后台 flush 和进程被杀后的补发。门店网络不稳,这个模块不能省,自己写的话是**容易写得看起来对、实际在丢数据**的那一类——丢了还不会有人发现。这一条就是选现成 SDK 的全部收益,其余都要照做。 - -#### 依赖与封装 - -```yaml -dependencies: - sensors_analytics_flutter_plugin: ^4.2.3 -``` - -业务代码仍然只见 `core_analytics` 的接口,不直接 import 神策: - -```dart -// packages/core_analytics/lib/src/analytics.dart -abstract interface class Analytics { - void track(String event, [Map params = const {}]); - void registerSuperProperties(Map props); // 公共属性,注册一次全局附加 - void identify(String userId); // 登录成功后调 - void reset(); // 登出时调 -} -``` - -理由和 `ErrorReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方 SDK 的直接依赖。**另外它也是神策不可用时的缓冲**——接口先定、事件方案先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件(代价就是上面那条可靠投递要自己补,而且错误看板要跟着换地方)。 - -接入约定: - -- **公共属性用「超级属性」注册一次,不在每个调用点手写**:`storeId`、`roleCode`、`flavor`、`appVersion`、`buildNumber`。`storeId` 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。**门店切换后必须重新注册**(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。 -- **登录/登出走 `login()` / `logout()`**:登录成功后用后端的 `userId` 关联匿名 ID,登出时断开,否则同一台设备上换人登录的数据会串到一起(门店设备是共用的,这个场景一定会发生)。 -- **埋点失败绝不能影响业务**:`track()` 内部 try-catch 兜住,任何异常只记日志不外抛。 -- **dev/uat 与 prod 必须分开**——独立项目,或至少用不同的数据接收地址。共用一个项目的话,测试数据会直接污染运营报表,且事后无法剔除。 - -#### 全埋点(AutoTrack):只开启动/退出,其余关掉 - -神策的全埋点支持 `APP_START` / `APP_END` / `APP_CLICK` / `APP_VIEW_SCREEN` 四类。我们的结论: - -| 类型 | 结论 | -|---|---| -| `APP_START` / `APP_END` | **开**。启动次数、使用时长是零成本拿到的基础指标 | -| `APP_CLICK` | **关**。Flutter 的控件树没有原生 `id`/`resource-name`,采上来的元素标识基本不可读,是纯噪音 | -| `APP_VIEW_SCREEN` | **关**。改用我们自己的 `NavigationObserver` 上报路由名——既更准,也**避免把 `/webview?target=X&ticket=...` 整条 URL 采上去**(见脱敏一节) | - -“少采一点”在这里不是保守,是因为**采上来读不懂的数据比没有更糟**:它会让报表看起来有数据,实际没法用。 - -#### H5 内部的埋点不归我们 - -F6 的 H5 页面是外部系统,页面内部的行为埋点由 F6 自己负责。**客户端只报容器级事件**(打开/关闭/失败/首屏耗时),不往 WebView 里注入神策的 JS SDK——注进去就等于我们要为别人页面里的数据质量负责,而且 JSBridge 的能力清单([10-webview-h5.md](./10-webview-h5.md) 的 13 项)里也没有埋点这一项。 - -### 命名约定 - -`snake_case`,`对象_动作` 或 `对象_动作_结果`。结果类用过去式(`succeeded`/`failed`),动作类用现在式(`clicked`)。 - -事件名和参数名一旦上线**不再改**——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(`AnalyticsEvent.scanSucceeded`),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。 - -## 四、性能指标 - -| 指标 | 怎么测 | 目标 | -|---|---|---| -| 冷启动到首帧 | `WidgetsBinding.instance.addTimingsCallback` | < 2s | -| 冷启动到首页可用 | `main()` → 首页数据渲染完成 | < 3s | -| H5 打开耗时 | `h5_first_paint` 的 `ticketMs + loadMs` | < 3s(PRD REQ-NFR-005 要求定义 H5 白屏超时阈值) | -| 接口耗时 | 后端侧统计即可,客户端不重复报 | P95 < 1s | -| 帧率 | 先不做自动采集,用 DevTools 人工测关键页面 | — | - -**接口耗时不由客户端报**:后端有完整的请求日志和 Micrometer 指标(见 backend/08)。客户端唯一能补充的是"客户端观测到的耗时 - 服务端处理耗时 = 网络耗时",这个差值有价值但不是首版必须,先不做。 - -## 五、和后端审计日志的分工 - -`backend/08-observability.md` 已经有 `@Audited` 审计日志通道。**审计以服务端为准,客户端不做审计**——客户端日志可被篡改,不能作为审计依据。 - -| | 客户端埋点 | 服务端日志/审计 | -|---|---|---| -| 目的 | 补齐后端看不到的行为 | 业务分析、合规追溯 | -| 可信度 | 参考 | 权威 | -| 覆盖 | 纯客户端行为、请求失败 | 所有到达服务端的操作 | - -## 待确认项 - -- **release 到底混不混淆**——本篇最需要拍板的一条。本文的决策是**不混淆**(只 `--split-debug-info`),换来 Dart 堆栈直接可读;要和安全侧确认能否接受 Dart 符号暴露。**若安全侧坚持混淆,就要接受每条 Dart 崩溃人工 `flutter symbolize` 的排查成本**,[08-build-flavors.md](./08-build-flavors.md) 的构建参数需一并改回。 -- **Bugly 的 appId / appKey 与项目划分**:dev/uat/prod 是三个 Bugly 产品还是一个产品靠版本号区分(建议 prod 单独一个,避免测试崩溃污染线上崩溃率)。 -- **神策的数据接收地址与项目划分**:私有化部署还是神策云,dev/uat/prod 怎么分。地址走 `--dart-define-from-file`,代码里不写死。 -- **神策字符串属性的实际长度上限**(决定 `stackTrace` 截多少帧),以所用神策版本的官方文档为准,接入时实测确认。 -- **`errorGroup` 的分组算法**:`errorType` + 堆栈首帧够不够用,还是要按包名过滤掉框架帧再取第一条业务帧。这一条直接决定错误看板可不可用,建议接入后拿真实数据调一轮。 -- 神策原生 SDK 与 Bugly SDK 的默认设备信息采集项(`distinct_id` 生成方式、是否取 AndroidID/IDFA),需逐项关闭并与隐私政策对齐(法务侧)。 -- 两个 SDK 都要在**用户同意隐私政策之后**才初始化。Bugly 在原生侧初始化,需要一个由 Dart 写入、原生读取的持久化标记位,具体时机要和 `feature_auth` 的协议弹窗流程对齐。 -- **客户端事件和后端业务数据怎么汇到一起**:是后端用神策服务端 SDK 写进同一个神策项目,还是把神策的客户端事件导出到后端数仓、统一用 Metabase 出图。取决于后端数仓现状和 Metabase 的实际使用情况,要和后端一起定。 -- **后端的业务埋点写不写进同一个神策项目**(用神策服务端 SDK / 数据导入),还是留在自己的 ELK 里出报表。影响的是能不能做跨端漏斗分析。 -- 后端从请求日志出业务埋点的具体口径(哪个接口对应哪个事件),需要和后端一起把埋点事件逐条落到接口上。 -- 性能指标目标值需在真机(门店常用的中低端 Android)实测后校准,上表是初始预期值。 - -## 参考链接 - -- [腾讯 Bugly](https://bugly.qq.com/)(崩溃上报,原生侧) -- [Bugly Android SDK 接入文档](https://bugly.qq.com/docs/user-guide/instruction-manual-android/) -- [Bugly iOS SDK 接入文档](https://bugly.qq.com/docs/user-guide/instruction-manual-ios/) -- [sensors_analytics_flutter_plugin | Dart package](https://pub.dev/packages/sensors_analytics_flutter_plugin) -- [神策:Flutter 插件集成文档](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_plugin/v0300) -- [神策:Flutter 全埋点](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_auto_track/v0205) -- [Metabase](https://www.metabase.com/)(BI 层,非采集方案;官网把 Mixpanel/PostHog/Amplitude 列为上游采集集成) -- [Flutter: 混淆与 `flutter symbolize`](https://docs.flutter.dev/deployment/obfuscate) -- [Flutter: 错误处理(`FlutterError.onError` / `PlatformDispatcher.onError`)](https://docs.flutter.dev/testing/errors) -- [logger | Dart package](https://pub.dev/packages/logger) -- [backend/08-observability.md:traceId 与审计日志](../backend/08-observability.md) -- [PRD 第 6.8 节 埋点 / 第 8.3 节 可用性与容错](../prd/Continental-Retail-APP-PRD.md) +如未来需要崩溃上报,须单独完成合规、数据字段、脱敏、符号表保存和 SDK 选型评审;不得 +直接恢复本项目已删除的第三方 SDK 配置。