Harmony 3.41.9 #1
+1
-7
@@ -96,8 +96,6 @@ coverage-gate:
|
|||||||
- >
|
- >
|
||||||
flutter build apk
|
flutter build apk
|
||||||
--flavor $FLAVOR
|
--flavor $FLAVOR
|
||||||
-t lib/main_$FLAVOR.dart
|
|
||||||
--dart-define-from-file=env/$FLAVOR.json
|
|
||||||
$EXTRA_ARGS
|
$EXTRA_ARGS
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
@@ -122,12 +120,8 @@ android-prod:
|
|||||||
<<: *android-build
|
<<: *android-build
|
||||||
variables:
|
variables:
|
||||||
FLAVOR: prod
|
FLAVOR: prod
|
||||||
# release 包必须混淆 + 拆符号表,否则 Sentry 上的堆栈是一堆十六进制地址。
|
# release 包必须混淆 + 拆符号表,符号表保留供后续排障使用。
|
||||||
# --split-debug-info 的产物要留着,sentry_dart_plugin 靠它还原堆栈。
|
|
||||||
EXTRA_ARGS: --release --obfuscate --split-debug-info=build/symbols
|
EXTRA_ARGS: --release --obfuscate --split-debug-info=build/symbols
|
||||||
after_script:
|
|
||||||
# SENTRY_AUTH_TOKEN 只从 CI 变量读,仓库里任何文件都不许出现它。
|
|
||||||
- cd app && dart run sentry_dart_plugin
|
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- app/build/app/outputs/flutter-apk/*.apk
|
- app/build/app/outputs/flutter-apk/*.apk
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# conti-retail-app
|
# conti-retail-app
|
||||||
|
|
||||||
大陆马门店 App。Flutter + Melos 单仓多包。
|
Conti-Retail-App。Flutter + Melos 单仓多包。
|
||||||
|
|
||||||
架构约束全部来自相邻仓库 `conti-docs/` 的 01–14 号文档。**本仓库对这些文档的
|
架构约束全部来自相邻仓库 `conti-docs/` 的 01–14 号文档。**本仓库对这些文档的
|
||||||
偏离,逐条记在 [SCAFFOLD-NOTES.md](SCAFFOLD-NOTES.md)**——那份文件是回写文档的
|
偏离,逐条记在 [SCAFFOLD-NOTES.md](SCAFFOLD-NOTES.md)**——那份文件是回写文档的
|
||||||
@@ -25,16 +25,12 @@ melos run gen
|
|||||||
|
|
||||||
# 3. 跑起来
|
# 3. 跑起来
|
||||||
cd app
|
cd app
|
||||||
flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
flutter run --flavor dev
|
||||||
```
|
```
|
||||||
|
|
||||||
`--flavor` / `-t` / `--dart-define-from-file` **三个参数缺一不可**:
|
项目只有一个 Dart 入口 `lib/main.dart`。`--flavor dev|uat|prod` 仍用于选择
|
||||||
|
Android/iOS 的原生包变体,同时 Flutter 会通过 `appFlavor` 选择对应的内置环境
|
||||||
- 少了 `--dart-define-from-file`,`AppEnv.fromDartDefine` 会在启动瞬间抛错。
|
配置。未传 flavor 时默认使用 dev,因此 DevEco 的普通 HAP 构建无需额外入口配置。
|
||||||
这是故意的——带着空 baseUrl 跑起来,问题会在第一个请求 404 时才暴露。
|
|
||||||
- 少了 `-t`,Flutter 会去找 `lib/main.dart`,本仓库没有这个文件。
|
|
||||||
|
|
||||||
VS Code / Android Studio 用户建议把三条 flavor 配进 `launch.json`。
|
|
||||||
|
|
||||||
## 环境与工具链版本
|
## 环境与工具链版本
|
||||||
|
|
||||||
@@ -93,6 +89,18 @@ melos run format # dart format --set-exit-if-changed
|
|||||||
melos run test # flutter test --coverage
|
melos run test # flutter test --coverage
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### OHOS(`harmony` 分支)构建
|
||||||
|
|
||||||
|
`enable-ohos.ps1` 只为当前 PowerShell 会话注入 Flutter_OH、ohpm、hvigor 与 DevEco 环境变量,不修改用户或系统 PATH。先在仓库根目录执行,再进入 `app` 使用已有的开发环境入口构建:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
. .\enable-ohos.ps1
|
||||||
|
cd app
|
||||||
|
flutter build hap --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物为未签名 debug HAP:`app/ohos/entry/build/default/outputs/default/entry-default-unsigned.hap`。Hvigor 默认使用现有的 `lib/main.dart`,并加载 dev 环境配置。HAP 编译通过不代表 ArkTS 原生桥接、扫码或真机安装已经完成,边界见 [docs/07-native-integration.md](docs/07-native-integration.md)。
|
||||||
|
|
||||||
`analyze` 带 `--fatal-infos`。不加等于没加 lint——绝大多数 riverpod_lint 规则
|
`analyze` 带 `--fatal-infos`。不加等于没加 lint——绝大多数 riverpod_lint 规则
|
||||||
报的是 info 级。
|
报的是 info 级。
|
||||||
|
|
||||||
@@ -106,7 +114,7 @@ melos run test # flutter test --coverage
|
|||||||
app/ 壳工程。唯一知道所有包的地方:环境注入、启动编排、路由聚合
|
app/ 壳工程。唯一知道所有包的地方:环境注入、启动编排、路由聚合
|
||||||
packages/
|
packages/
|
||||||
core_foundation/ AppEnv / AppException 体系 / ApiCode ← 叶子包,谁都能依赖
|
core_foundation/ AppEnv / AppException 体系 / ApiCode ← 叶子包,谁都能依赖
|
||||||
core_logging/ AppLogger / CrashReporter / 脱敏
|
core_logging/ AppLogger / 脱敏
|
||||||
core_analytics/ Analytics 接口 + 事件常量表
|
core_analytics/ Analytics 接口 + 事件常量表
|
||||||
core_storage/ Prefs(KV)。Drift 暂缓,见 SCAFFOLD-NOTES
|
core_storage/ Prefs(KV)。Drift 暂缓,见 SCAFFOLD-NOTES
|
||||||
core_auth/ AppSession 四态 / TokenStorage / SessionNotifier
|
core_auth/ AppSession 四态 / TokenStorage / SessionNotifier
|
||||||
@@ -167,5 +175,5 @@ flutter analyze → info: depend_on_referenced_packages
|
|||||||
模板。现在调 `startScan` 会抛 `MissingPluginException`,属预期。
|
模板。现在调 `startScan` 会抛 `MissingPluginException`,属预期。
|
||||||
- **神策 SDK 未采购**:`analyticsProvider` 是 `NoopAnalytics`。接入时注意必须在
|
- **神策 SDK 未采购**:`analyticsProvider` 是 `NoopAnalytics`。接入时注意必须在
|
||||||
用户同意隐私政策之后再初始化。
|
用户同意隐私政策之后再初始化。
|
||||||
- **env/*.json 里全是占位域名**,`SENTRY_DSN` 全空,待运维确认。
|
- **env/*.json 里全是占位域名**,待运维确认。
|
||||||
- **CI 镜像名未定**,`.gitlab-ci.yml` 里标了 TODO(ops)。
|
- **CI 镜像名未定**,`.gitlab-ci.yml` 里标了 TODO(ops)。
|
||||||
|
|||||||
+7
-4
@@ -4,8 +4,8 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "6b182d2c7585eba26d4edce0f97630effd256c33"
|
revision: "eb712e92d4c63e8051990ceea01fdcf88a7afd5d"
|
||||||
channel: "stable"
|
channel: "[user-branch]"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
|
|
||||||
@@ -13,14 +13,17 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
|
create_revision: eb712e92d4c63e8051990ceea01fdcf88a7afd5d
|
||||||
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
|
base_revision: eb712e92d4c63e8051990ceea01fdcf88a7afd5d
|
||||||
- platform: android
|
- platform: android
|
||||||
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
|
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
|
||||||
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
|
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
|
||||||
- platform: ios
|
- platform: ios
|
||||||
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
|
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
|
||||||
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
|
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
|
||||||
|
- platform: ohos
|
||||||
|
create_revision: eb712e92d4c63e8051990ceea01fdcf88a7afd5d
|
||||||
|
base_revision: eb712e92d4c63e8051990ceea01fdcf88a7afd5d
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ android {
|
|||||||
// 1. 用 applicationIdSuffix 而不是覆盖 applicationId——后者一改,Firebase /
|
// 1. 用 applicationIdSuffix 而不是覆盖 applicationId——后者一改,Firebase /
|
||||||
// 推送 / 应用市场的包名对应关系全部要重配一遍;
|
// 推送 / 应用市场的包名对应关系全部要重配一遍;
|
||||||
// 2. app_name 走 resValue,让三个环境在桌面上一眼能分清。装了三个图标都叫
|
// 2. app_name 走 resValue,让三个环境在桌面上一眼能分清。装了三个图标都叫
|
||||||
// "大陆马门店"的时候,测试提的 bug 会有一半定位不到环境;
|
// "Conti-Retail-App" 的时候,测试提的 bug 会有一半定位不到环境;
|
||||||
// 3. prod 没有后缀,就是正式包名。
|
// 3. prod 没有后缀,就是正式包名。
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
flavorDimensions += "env"
|
flavorDimensions += "env"
|
||||||
@@ -44,16 +44,16 @@ android {
|
|||||||
create("dev") {
|
create("dev") {
|
||||||
dimension = "env"
|
dimension = "env"
|
||||||
applicationIdSuffix = ".dev"
|
applicationIdSuffix = ".dev"
|
||||||
resValue("string", "app_name", "马店(开发)")
|
resValue("string", "app_name", "Conti-Retail-App (Dev)")
|
||||||
}
|
}
|
||||||
create("uat") {
|
create("uat") {
|
||||||
dimension = "env"
|
dimension = "env"
|
||||||
applicationIdSuffix = ".uat"
|
applicationIdSuffix = ".uat"
|
||||||
resValue("string", "app_name", "马店(测试)")
|
resValue("string", "app_name", "Conti-Retail-App (UAT)")
|
||||||
}
|
}
|
||||||
create("prod") {
|
create("prod") {
|
||||||
dimension = "env"
|
dimension = "env"
|
||||||
resValue("string", "app_name", "大陆马门店")
|
resValue("string", "app_name", "Conti-Retail-App")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_comment": "dev 环境。构建时必须带 --dart-define-from-file=env/dev.json,否则 AppEnv.fromDartDefine 会直接抛错。所有地址都是占位符,TODO(ops) 待运维确认真实域名。",
|
"_comment": "dev 环境。未指定 flavor 的启动(包括 DevEco 默认 HAP 构建)使用此配置。所有地址都是占位符,TODO(ops) 待运维确认真实域名。",
|
||||||
"API_BASE_URL": "https://api-dev.example.com",
|
"API_BASE_URL": "https://api-dev.example.com",
|
||||||
"ENABLE_LOG": true,
|
"ENABLE_LOG": true,
|
||||||
"SENTRY_DSN": "",
|
|
||||||
"H5_ALLOWED_HOSTS": "h5-dev.example.com"
|
"H5_ALLOWED_HOSTS": "h5-dev.example.com"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_comment": "prod 环境。ENABLE_LOG 必须为 false(13:生产不写控制台、不落日志文件)。TODO(ops) 待运维确认真实域名与 Sentry DSN。",
|
"_comment": "prod 环境。ENABLE_LOG 必须为 false,生产不写控制台日志。TODO(ops) 待运维确认真实域名。",
|
||||||
"API_BASE_URL": "https://api.example.com",
|
"API_BASE_URL": "https://api.example.com",
|
||||||
"ENABLE_LOG": false,
|
"ENABLE_LOG": false,
|
||||||
"SENTRY_DSN": "",
|
|
||||||
"H5_ALLOWED_HOSTS": "h5.example.com"
|
"H5_ALLOWED_HOSTS": "h5.example.com"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-1
@@ -2,6 +2,5 @@
|
|||||||
"_comment": "uat 环境。TODO(ops) 待运维确认真实域名与 Sentry DSN。",
|
"_comment": "uat 环境。TODO(ops) 待运维确认真实域名与 Sentry DSN。",
|
||||||
"API_BASE_URL": "https://api-uat.example.com",
|
"API_BASE_URL": "https://api-uat.example.com",
|
||||||
"ENABLE_LOG": true,
|
"ENABLE_LOG": true,
|
||||||
"SENTRY_DSN": "",
|
|
||||||
"H5_ALLOWED_HOSTS": "h5-uat.example.com"
|
"H5_ALLOWED_HOSTS": "h5-uat.example.com"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//
|
//
|
||||||
// 跑法:
|
// 跑法:
|
||||||
// flutter test integration_test/app_test.dart \
|
// flutter test integration_test/app_test.dart \
|
||||||
// --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
// --flavor dev
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import 'package:app/bootstrap.dart';
|
import 'package:app/bootstrap.dart';
|
||||||
@@ -20,7 +20,16 @@ void main() {
|
|||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
testWidgets('冷启动后停在登录页', (WidgetTester tester) async {
|
testWidgets('冷启动后停在登录页', (WidgetTester tester) async {
|
||||||
await bootstrap(AppEnv.fromDartDefine(flavor: 'dev'));
|
await bootstrap(
|
||||||
|
AppEnv.fromConfig(
|
||||||
|
flavor: 'dev',
|
||||||
|
values: <String, Object?>{
|
||||||
|
'API_BASE_URL': 'https://api-dev.example.com',
|
||||||
|
'ENABLE_LOG': true,
|
||||||
|
'H5_ALLOWED_HOSTS': 'h5-dev.example.com',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// 没有 token → SessionUnauthenticated → redirect 到 /login(04)。
|
// 没有 token → SessionUnauthenticated → redirect 到 /login(04)。
|
||||||
|
|||||||
+3
-4
@@ -62,7 +62,7 @@ Xcode 自己维护的二进制风格文本,手写会在下一次 Xcode 打开
|
|||||||
6. 验证:
|
6. 验证:
|
||||||
```bash
|
```bash
|
||||||
cd app
|
cd app
|
||||||
flutter build ios --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
flutter build ios --flavor dev
|
||||||
```
|
```
|
||||||
三个 flavor 各跑一次,确认桌面上能同时装下三个图标、名字不同。
|
三个 flavor 各跑一次,确认桌面上能同时装下三个图标、名字不同。
|
||||||
|
|
||||||
@@ -71,6 +71,5 @@ Xcode 自己维护的二进制风格文本,手写会在下一次 Xcode 打开
|
|||||||
- **CocoaPods 和 flavor 无关**:`Podfile` 不需要改,pod 是按 target 装的,
|
- **CocoaPods 和 flavor 无关**:`Podfile` 不需要改,pod 是按 target 装的,
|
||||||
不是按 configuration。但新增 configuration 后要跑一次 `pod install`,否则
|
不是按 configuration。但新增 configuration 后要跑一次 `pod install`,否则
|
||||||
会报 `Unable to find a configuration named 'Debug-dev'`。
|
会报 `Unable to find a configuration named 'Debug-dev'`。
|
||||||
- **`--flavor` 和 `-t` 必须同时给**。只给 `--flavor dev` 会用默认的
|
- 项目固定使用 `lib/main.dart`。`--flavor` 除了选择 Scheme,还会让启动代码
|
||||||
`lib/main.dart`——这个文件在本仓库里**不存在**(入口是 `main_dev.dart`),
|
选择同名的内置环境配置;不要再额外传 `-t` 或 `--dart-define-from-file`。
|
||||||
报错信息是找不到文件,和 flavor 看不出关系。
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。
|
// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。
|
||||||
|
|
||||||
BUNDLE_ID_SUFFIX=.dev
|
BUNDLE_ID_SUFFIX=.dev
|
||||||
APP_DISPLAY_NAME=马店(开发)
|
APP_DISPLAY_NAME=Conti-Retail-App (Dev)
|
||||||
|
|
||||||
// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。
|
// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。
|
||||||
PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX)
|
PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。
|
// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。
|
||||||
|
|
||||||
BUNDLE_ID_SUFFIX=
|
BUNDLE_ID_SUFFIX=
|
||||||
APP_DISPLAY_NAME=大陆马门店
|
APP_DISPLAY_NAME=Conti-Retail-App
|
||||||
|
|
||||||
// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。
|
// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。
|
||||||
PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX)
|
PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。
|
// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。
|
||||||
|
|
||||||
BUNDLE_ID_SUFFIX=.uat
|
BUNDLE_ID_SUFFIX=.uat
|
||||||
APP_DISPLAY_NAME=马店(测试)
|
APP_DISPLAY_NAME=Conti-Retail-App (UAT)
|
||||||
|
|
||||||
// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。
|
// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。
|
||||||
PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX)
|
PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX)
|
||||||
|
|||||||
+7
-49
@@ -1,6 +1,6 @@
|
|||||||
/// 三个入口共用的启动编排。
|
/// 唯一入口共用的启动编排。
|
||||||
///
|
///
|
||||||
/// 来源:08(多环境)、13(Sentry / 日志)、12(全局错误)、03(Riverpod 装配)。
|
/// 来源:08(多环境)、12(全局错误)、03(Riverpod 装配)。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:app/src/app_widget.dart';
|
import 'package:app/src/app_widget.dart';
|
||||||
@@ -20,12 +20,11 @@ import 'package:feature_auth/feature_auth.dart';
|
|||||||
import 'package:feature_home/feature_home.dart';
|
import 'package:feature_home/feature_home.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
|
||||||
|
|
||||||
/// TODO(app): 换成 package_info_plus 读真实版本,现在写死会在灰度期骗人。
|
/// TODO(app): 换成 package_info_plus 读真实版本,现在写死会在灰度期骗人。
|
||||||
const String _appVersion = '1.0.0+1';
|
const String _appVersion = '1.0.0+1';
|
||||||
|
|
||||||
/// 全部三个 main_*.dart 都只调这一个函数。
|
/// 唯一 `main.dart` 调用的启动函数。
|
||||||
///
|
///
|
||||||
/// ---------------------------------------------------------------------------
|
/// ---------------------------------------------------------------------------
|
||||||
/// 这个函数是**整个仓库唯一一处知道所有包的地方**。各个 core_* 只声明自己需要
|
/// 这个函数是**整个仓库唯一一处知道所有包的地方**。各个 core_* 只声明自己需要
|
||||||
@@ -40,14 +39,7 @@ Future<void> bootstrap(AppEnv env) async {
|
|||||||
// 装一次全局单例,供拿不到 Ref 的 TokenRefresher 用;重复调用会抛错。
|
// 装一次全局单例,供拿不到 Ref 的 TokenRefresher 用;重复调用会抛错。
|
||||||
AppEnv.install(env);
|
AppEnv.install(env);
|
||||||
|
|
||||||
final LogBuffer buffer = LogBuffer();
|
final AppLogger logger = LoggerAppLogger(env: env);
|
||||||
final AppLogger logger = LoggerAppLogger(env: env, buffer: buffer);
|
|
||||||
|
|
||||||
// dsn 为空(dev 默认)时连 SDK 都不初始化:开发期的噪音不该混进线上数据。
|
|
||||||
final bool sentryEnabled = env.sentryDsn.isNotEmpty;
|
|
||||||
final CrashReporter reporter = sentryEnabled
|
|
||||||
? const SentryCrashReporter()
|
|
||||||
: const NoopCrashReporter();
|
|
||||||
|
|
||||||
final Prefs prefs = Prefs();
|
final Prefs prefs = Prefs();
|
||||||
final ClientInfo clientInfo = ClientInfo(
|
final ClientInfo clientInfo = ClientInfo(
|
||||||
@@ -62,13 +54,11 @@ Future<void> bootstrap(AppEnv env) async {
|
|||||||
|
|
||||||
Widget buildApp() {
|
Widget buildApp() {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
observers: <ProviderObserver>[ErrorObserver(logger: logger, reporter: reporter)],
|
observers: <ProviderObserver>[ErrorObserver(logger: logger)],
|
||||||
overrides: [
|
overrides: [
|
||||||
// --- 基础设施:值已经在上面造好了,直接注入 -----------------------
|
// --- 基础设施:值已经在上面造好了,直接注入 -----------------------
|
||||||
appEnvProvider.overrideWithValue(env), // core_foundation
|
appEnvProvider.overrideWithValue(env), // core_foundation
|
||||||
appLoggerProvider.overrideWithValue(logger), // core_logging
|
appLoggerProvider.overrideWithValue(logger), // core_logging
|
||||||
logBufferProvider.overrideWithValue(buffer), // core_logging(和 beforeSend 同一实例)
|
|
||||||
crashReporterProvider.overrideWithValue(reporter), // core_logging
|
|
||||||
prefsProvider.overrideWithValue(prefs), // core_storage
|
prefsProvider.overrideWithValue(prefs), // core_storage
|
||||||
clientInfoProvider.overrideWithValue(clientInfo), // core_network 端口
|
clientInfoProvider.overrideWithValue(clientInfo), // core_network 端口
|
||||||
// --- 端口:接口在 core_*,实现在能依赖 core_network 的这一层 --------
|
// --- 端口:接口在 core_*,实现在能依赖 core_network 的这一层 --------
|
||||||
@@ -87,10 +77,7 @@ Future<void> bootstrap(AppEnv env) async {
|
|||||||
),
|
),
|
||||||
sessionObserversProvider.overrideWith(
|
sessionObserversProvider.overrideWith(
|
||||||
(Ref ref) => <SessionObserver>[
|
(Ref ref) => <SessionObserver>[
|
||||||
AppSessionObserver(
|
AppSessionObserver(analytics: ref.watch(analyticsProvider)),
|
||||||
analytics: ref.watch(analyticsProvider),
|
|
||||||
reporter: ref.watch(crashReporterProvider),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -98,16 +85,8 @@ Future<void> bootstrap(AppEnv env) async {
|
|||||||
appRoutesProvider.overrideWith(
|
appRoutesProvider.overrideWith(
|
||||||
(Ref ref) => <RouteBase>[...buildAuthRoutes(), ...buildHomeRoutes()],
|
(Ref ref) => <RouteBase>[...buildAuthRoutes(), ...buildHomeRoutes()],
|
||||||
),
|
),
|
||||||
navigatorObserversProvider.overrideWith(
|
|
||||||
(Ref ref) => <NavigatorObserver>[
|
|
||||||
CrashBreadcrumbObserver(ref.watch(crashReporterProvider)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
routeReporterProvider.overrideWith(
|
routeReporterProvider.overrideWith(
|
||||||
(Ref ref) => AppRouteReporter(
|
(Ref ref) => AppRouteReporter(logger: ref.watch(appLoggerProvider)),
|
||||||
logger: ref.watch(appLoggerProvider),
|
|
||||||
reporter: ref.watch(crashReporterProvider),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- 日志出口:core_network 只知道"往这里写字符串" -------------------
|
// --- 日志出口:core_network 只知道"往这里写字符串" -------------------
|
||||||
@@ -123,26 +102,5 @@ Future<void> bootstrap(AppEnv env) async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sentryEnabled) {
|
|
||||||
runApp(buildApp());
|
runApp(buildApp());
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await SentryFlutter.init(
|
|
||||||
(SentryFlutterOptions options) {
|
|
||||||
options.dsn = env.sentryDsn;
|
|
||||||
options.environment = env.flavor.name;
|
|
||||||
options.release = 'conti-retail-app@$_appVersion';
|
|
||||||
options.tracesSampleRate = env.isProd ? 0.1 : 1.0;
|
|
||||||
// 合规红线:不自动带用户 IP / 请求头 / cookie。
|
|
||||||
options.sendDefaultPii = false;
|
|
||||||
options.beforeBreadcrumb = scrubBreadcrumb;
|
|
||||||
options.beforeSend = buildScrubEvent(buffer);
|
|
||||||
options.debug = false;
|
|
||||||
},
|
|
||||||
// 用 appRunner 而不是自己写 FlutterError.onError:SentryFlutter 已经在
|
|
||||||
// 里面装好了 Flutter / PlatformDispatcher / Zone 三层钩子,再手写一遍
|
|
||||||
// 会**每个异常上报两次**(13)。
|
|
||||||
appRunner: () => runApp(buildApp()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ abstract class AppLocalizations {
|
|||||||
/// App 名称。目前只有这一条——其余文案等 16-i18n.md 定了方案再统一迁入。
|
/// App 名称。目前只有这一条——其余文案等 16-i18n.md 定了方案再统一迁入。
|
||||||
///
|
///
|
||||||
/// In zh, this message translates to:
|
/// In zh, this message translates to:
|
||||||
/// **'大陆马门店'**
|
/// **'Conti-Retail-App'**
|
||||||
String get appTitle;
|
String get appTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
AppLocalizationsZh([String locale = 'zh']) : super(locale);
|
AppLocalizationsZh([String locale = 'zh']) : super(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appTitle => '大陆马门店';
|
String get appTitle => 'Conti-Retail-App';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "zh",
|
"@@locale": "zh",
|
||||||
"appTitle": "大陆马门店",
|
"appTitle": "Conti-Retail-App",
|
||||||
"@appTitle": {
|
"@appTitle": {
|
||||||
"description": "App 名称。目前只有这一条——其余文案等 16-i18n.md 定了方案再统一迁入。"
|
"description": "App 名称。目前只有这一条——其余文案等 16-i18n.md 定了方案再统一迁入。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/// 应用唯一 Dart 入口。
|
||||||
|
///
|
||||||
|
/// Android、iOS 与 Web 用 `--flavor dev|uat|prod` 选择环境;未提供 flavor
|
||||||
|
/// 的平台(例如 DevEco 的默认 HAP 构建)统一使用 dev。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:app/bootstrap.dart';
|
||||||
|
import 'package:app/src/bundled_app_env.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart' show appFlavor;
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await bootstrap(await loadBundledAppEnv(flavor: appFlavor));
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/// dev 环境入口。
|
|
||||||
///
|
|
||||||
/// 跑法(08):
|
|
||||||
/// ```
|
|
||||||
/// flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
|
||||||
/// ```
|
|
||||||
/// flavor 名写死在这里而不是从 dart-define 读——"用 dev 的入口配了别的环境的
|
|
||||||
/// json"这种事故必须在代码里看得见。
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:app/bootstrap.dart';
|
|
||||||
import 'package:core_foundation/core_foundation.dart';
|
|
||||||
|
|
||||||
Future<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'dev'));
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/// prod 环境入口。
|
|
||||||
///
|
|
||||||
/// 跑法(08):
|
|
||||||
/// ```
|
|
||||||
/// flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
|
||||||
/// ```
|
|
||||||
/// flavor 名写死在这里而不是从 dart-define 读——"用 prod 的入口配了别的环境的
|
|
||||||
/// json"这种事故必须在代码里看得见。
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:app/bootstrap.dart';
|
|
||||||
import 'package:core_foundation/core_foundation.dart';
|
|
||||||
|
|
||||||
Future<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'prod'));
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/// uat 环境入口。
|
|
||||||
///
|
|
||||||
/// 跑法(08):
|
|
||||||
/// ```
|
|
||||||
/// flutter run --flavor uat -t lib/main_uat.dart --dart-define-from-file=env/uat.json
|
|
||||||
/// ```
|
|
||||||
/// flavor 名写死在这里而不是从 dart-define 读——"用 uat 的入口配了别的环境的
|
|
||||||
/// json"这种事故必须在代码里看得见。
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:app/bootstrap.dart';
|
|
||||||
import 'package:core_foundation/core_foundation.dart';
|
|
||||||
|
|
||||||
Future<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'uat'));
|
|
||||||
@@ -39,7 +39,7 @@ class ContiApp extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: '大陆马门店',
|
title: 'Conti-Retail-App',
|
||||||
theme: AppTheme.light,
|
theme: AppTheme.light,
|
||||||
darkTheme: AppTheme.dark,
|
darkTheme: AppTheme.dark,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// 随 App 打包的环境配置加载器。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:core_foundation/core_foundation.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show FlutterError;
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// 根据 Flutter 的 flavor 加载对应环境配置;未指定时默认开发环境。
|
||||||
|
Future<AppEnv> loadBundledAppEnv({String? flavor}) async {
|
||||||
|
final String selectedFlavor = flavor ?? AppFlavor.dev.name;
|
||||||
|
final String assetPath = 'env/$selectedFlavor.json';
|
||||||
|
|
||||||
|
try {
|
||||||
|
final String jsonObj = await rootBundle.loadString(assetPath);
|
||||||
|
final decoded = jsonDecode(jsonObj);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw const FormatException('根节点必须是 JSON 对象');
|
||||||
|
}
|
||||||
|
return AppEnv.fromConfig(flavor: selectedFlavor, values: decoded.cast<String, Object?>());
|
||||||
|
} on StateError {
|
||||||
|
rethrow;
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
throw StateError('环境配置 $assetPath 格式错误:${error.message}');
|
||||||
|
} on FlutterError catch (error) {
|
||||||
|
throw StateError('无法加载环境配置 $assetPath:${error.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,15 +15,34 @@ import 'package:core_storage/core_storage.dart';
|
|||||||
/// 这里的语义是"这次安装":卸载重装换一个新 ID 是**预期行为**,不需要跨安装
|
/// 这里的语义是"这次安装":卸载重装换一个新 ID 是**预期行为**,不需要跨安装
|
||||||
/// 追踪——它的用途只有一个,把同一台设备的日志串起来排查问题。
|
/// 追踪——它的用途只有一个,把同一台设备的日志串起来排查问题。
|
||||||
/// ---------------------------------------------------------------------------
|
/// ---------------------------------------------------------------------------
|
||||||
Future<String> loadOrCreateDeviceId(Prefs prefs) async {
|
Future<String> loadOrCreateDeviceId(
|
||||||
|
Prefs prefs, {
|
||||||
|
Duration timeout = const Duration(seconds: 2),
|
||||||
|
}) async {
|
||||||
const String key = 'device_id';
|
const String key = 'device_id';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await _loadOrCreatePersistedDeviceId(prefs, key).timeout(timeout);
|
||||||
|
} on Object {
|
||||||
|
// 本地存储只增强日志关联,不能因未注册的平台插件阻断 runApp()。
|
||||||
|
// 当前值仅在本次进程有效;下次启动仍会尝试读写持久化 ID。
|
||||||
|
return _newDeviceId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _loadOrCreatePersistedDeviceId(Prefs prefs, String key) async {
|
||||||
final String? existing = await prefs.getString(key);
|
final String? existing = await prefs.getString(key);
|
||||||
if (existing != null && existing.isNotEmpty) {
|
if (existing != null && existing.isNotEmpty) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
final Random random = Random.secure();
|
|
||||||
final List<int> bytes = List<int>.generate(16, (int _) => random.nextInt(256));
|
final String created = _newDeviceId();
|
||||||
final String created = base64Url.encode(bytes).replaceAll('=', '');
|
|
||||||
await prefs.setString(key, created);
|
await prefs.setString(key, created);
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _newDeviceId() {
|
||||||
|
final Random random = Random.secure();
|
||||||
|
final List<int> bytes = List<int>.generate(16, (int _) => random.nextInt(256));
|
||||||
|
return base64Url.encode(bytes).replaceAll('=', '');
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
///
|
///
|
||||||
/// ---------------------------------------------------------------------------
|
/// ---------------------------------------------------------------------------
|
||||||
/// 它是**兜底**,不是主路径:UI 该显示的错误由 `AsyncValueView` 负责,这里只
|
/// 它是**兜底**,不是主路径:UI 该显示的错误由 `AsyncValueView` 负责,这里只
|
||||||
/// 负责"这个异常有没有人处理过"之外的另一件事——落日志和上报。
|
/// 负责"这个异常有没有人处理过"之外的另一件事——落日志。
|
||||||
///
|
///
|
||||||
/// 三类要**主动排除**,否则线上告警会被噪音淹没:
|
/// 三类要**主动排除**,否则线上告警会被噪音淹没:
|
||||||
/// - [BusinessException]:后端明确告诉我们"这个操作不允许",是预期内的流程
|
/// - [BusinessException]:后端明确告诉我们"这个操作不允许",是预期内的流程
|
||||||
@@ -20,14 +20,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
/// ---------------------------------------------------------------------------
|
/// ---------------------------------------------------------------------------
|
||||||
final class ErrorObserver extends ProviderObserver {
|
final class ErrorObserver extends ProviderObserver {
|
||||||
/// 构造。
|
/// 构造。
|
||||||
ErrorObserver({required this.logger, required this.reporter});
|
ErrorObserver({required this.logger});
|
||||||
|
|
||||||
/// 日志出口。
|
/// 日志出口。
|
||||||
final AppLogger logger;
|
final AppLogger logger;
|
||||||
|
|
||||||
/// 崩溃上报出口。
|
|
||||||
final CrashReporter reporter;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void providerDidFail(ProviderObserverContext context, Object error, StackTrace stackTrace) {
|
void providerDidFail(ProviderObserverContext context, Object error, StackTrace stackTrace) {
|
||||||
final String name = context.provider.name ?? context.provider.runtimeType.toString();
|
final String name = context.provider.name ?? context.provider.runtimeType.toString();
|
||||||
@@ -42,6 +39,5 @@ final class ErrorObserver extends ProviderObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.e('provider 失败 $name', error: error, stackTrace: stackTrace);
|
logger.e('provider 失败 $name', error: error, stackTrace: stackTrace);
|
||||||
reporter.report(error, stackTrace, extra: <String, String>{'provider': name});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/// 会话事件的旁路接线:埋点身份、崩溃上报的用户上下文、路由错误上报。
|
/// 会话事件的旁路接线:埋点身份与路由错误日志。
|
||||||
///
|
///
|
||||||
/// 这些都是 core_auth 声明的端口(`session_ports.dart` / `core_router/ports.dart`)
|
/// 这些都是 core_auth 声明的端口(`session_ports.dart` / `core_router/ports.dart`)
|
||||||
/// 的实现——core_auth 不能依赖 core_analytics / core_logging,所以实现落在这里。
|
/// 的实现——core_auth 不能依赖 core_analytics / core_logging,所以实现落在这里。
|
||||||
@@ -9,26 +9,20 @@ import 'package:core_auth/core_auth.dart';
|
|||||||
import 'package:core_logging/core_logging.dart';
|
import 'package:core_logging/core_logging.dart';
|
||||||
import 'package:core_router/core_router.dart';
|
import 'package:core_router/core_router.dart';
|
||||||
|
|
||||||
/// 把会话变化广播给埋点和崩溃上报。
|
/// 把会话变化广播给埋点。
|
||||||
class AppSessionObserver implements SessionObserver {
|
class AppSessionObserver implements SessionObserver {
|
||||||
/// 构造。
|
/// 构造。
|
||||||
const AppSessionObserver({required this.analytics, required this.reporter});
|
const AppSessionObserver({required this.analytics});
|
||||||
|
|
||||||
/// 埋点。
|
/// 埋点。
|
||||||
final Analytics analytics;
|
final Analytics analytics;
|
||||||
|
|
||||||
/// 崩溃上报。
|
|
||||||
final CrashReporter reporter;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onUserIdentified(UserContext user) {
|
void onUserIdentified(UserContext user) {
|
||||||
analytics.identify(user.userId);
|
analytics.identify(user.userId);
|
||||||
analytics.registerSuperProperties(<String, Object?>{
|
analytics.registerSuperProperties(<String, Object?>{
|
||||||
AnalyticsSuperProperty.roleCode: user.roleCode,
|
AnalyticsSuperProperty.roleCode: user.roleCode,
|
||||||
});
|
});
|
||||||
// 只传 userId,不传手机号——Sentry 侧 sendDefaultPii = false 的前提就是
|
|
||||||
// 我们自己也不往里塞 PII。
|
|
||||||
reporter.setUser(user.userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -37,7 +31,6 @@ class AppSessionObserver implements SessionObserver {
|
|||||||
analytics.registerSuperProperties(<String, Object?>{
|
analytics.registerSuperProperties(<String, Object?>{
|
||||||
AnalyticsSuperProperty.storeId: store.storeId,
|
AnalyticsSuperProperty.storeId: store.storeId,
|
||||||
});
|
});
|
||||||
reporter.setTag('storeId', '${store.storeId}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -46,7 +39,6 @@ class AppSessionObserver implements SessionObserver {
|
|||||||
analytics.track(AnalyticsEvent.logout, <String, Object?>{AnalyticsParam.reason: reason.name});
|
analytics.track(AnalyticsEvent.logout, <String, Object?>{AnalyticsParam.reason: reason.name});
|
||||||
// 门店设备是共用的:不 reset,下一个人的数据会串到上一个人身上。
|
// 门店设备是共用的:不 reset,下一个人的数据会串到上一个人身上。
|
||||||
analytics.reset();
|
analytics.reset();
|
||||||
reporter.clearUser();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -57,26 +49,18 @@ class AppSessionObserver implements SessionObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 路由未命中时上报。
|
/// 路由未命中时记录日志。
|
||||||
class AppRouteReporter implements RouteReporter {
|
class AppRouteReporter implements RouteReporter {
|
||||||
/// 构造。
|
/// 构造。
|
||||||
const AppRouteReporter({required this.logger, required this.reporter});
|
const AppRouteReporter({required this.logger});
|
||||||
|
|
||||||
/// 日志。
|
/// 日志。
|
||||||
final AppLogger logger;
|
final AppLogger logger;
|
||||||
|
|
||||||
/// 崩溃上报。
|
|
||||||
final CrashReporter reporter;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onRouteNotFound(String location) {
|
void onRouteNotFound(String location) {
|
||||||
// 记路径不记 query——H5 相关路径的 query 里带票据(13 §脱敏)。
|
// 记路径不记 query——H5 相关路径的 query 里带票据(13 §脱敏)。
|
||||||
final String path = Uri.tryParse(location)?.path ?? location;
|
final String path = Uri.tryParse(location)?.path ?? location;
|
||||||
logger.w('路由未命中: $path');
|
logger.w('路由未命中: $path');
|
||||||
reporter.report(
|
|
||||||
StateError('route not found'),
|
|
||||||
StackTrace.current,
|
|
||||||
extra: <String, String>{AnalyticsParam.path: path},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/node_modules
|
||||||
|
/oh_modules
|
||||||
|
/local.properties
|
||||||
|
/.idea
|
||||||
|
**/build
|
||||||
|
/.hvigor
|
||||||
|
.cxx
|
||||||
|
/.clangd
|
||||||
|
/.clang-format
|
||||||
|
/.clang-tidy
|
||||||
|
**/.test
|
||||||
|
**/BuildProfile.ets
|
||||||
|
**/oh-package-lock.json5
|
||||||
|
/package.json
|
||||||
|
/package-lock.json
|
||||||
|
|
||||||
|
**/src/main/resources/rawfile/flutter_assets/
|
||||||
|
**/libs/**/libapp.so
|
||||||
|
**/libs/**/libflutter.so
|
||||||
|
**/libs/**/libvmservice_snapshot.so
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"bundleName": "com.conti.app",
|
||||||
|
"vendor": "example",
|
||||||
|
"versionCode": 1000000,
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"icon": "$media:app_icon",
|
||||||
|
"label": "$string:app_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "app_name",
|
||||||
|
"value": "app"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"signingConfigs": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"type": "HarmonyOS",
|
||||||
|
"material": {
|
||||||
|
"certpath": "C:\\Users\\A200477427\\.ohos\\config\\default_ohos_yW8CJeJiNlUT6oywq41Uc115VHPdXxvAbtRnmkeaxSQ=.cer",
|
||||||
|
"keyAlias": "debugKey",
|
||||||
|
"keyPassword": "0000001B3CF04C0E3E6DBB38D72D64B7C8B65A62F75FFA5603AC50AB5C3364CB0AA1E2A480FC6D25DF6CFF",
|
||||||
|
"profile": "C:\\Users\\A200477427\\.ohos\\config\\default_ohos_yW8CJeJiNlUT6oywq41Uc115VHPdXxvAbtRnmkeaxSQ=.p7b",
|
||||||
|
"signAlg": "SHA256withECDSA",
|
||||||
|
"storeFile": "C:\\Users\\A200477427\\.ohos\\config\\default_ohos_yW8CJeJiNlUT6oywq41Uc115VHPdXxvAbtRnmkeaxSQ=.p12",
|
||||||
|
"storePassword": "0000001BE9AAD820D3FF8C4809435B90558226EFFB2EF6F5A57909A601E5B27596F032906E5088119F3BC0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"signingConfig": "default",
|
||||||
|
"compatibleSdkVersion": "5.1.0(18)",
|
||||||
|
"runtimeOS": "HarmonyOS",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buildModeSet": [
|
||||||
|
{
|
||||||
|
"name": "debug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"name": "entry",
|
||||||
|
"srcPath": "./entry",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"applyToProducts": [
|
||||||
|
"default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/node_modules
|
||||||
|
/oh_modules
|
||||||
|
/.preview
|
||||||
|
/build
|
||||||
|
/.cxx
|
||||||
|
/.test
|
||||||
|
GeneratedPluginRegistrant.ets
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
{
|
||||||
|
"apiType": 'stageMode',
|
||||||
|
"buildOption": {
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"runtimeOS": "HarmonyOS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ohosTest",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
// Script for compiling build behavior. It is built in the build plug-in and cannot be modified currently.
|
||||||
|
import { hapTasks } from '@ohos/hvigor-ohos-plugin';
|
||||||
|
export default {
|
||||||
|
system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
|
||||||
|
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
{
|
||||||
|
"name": "entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Please describe the basic information.",
|
||||||
|
"main": "",
|
||||||
|
"author": "",
|
||||||
|
"license": "",
|
||||||
|
"dependencies": {},
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
|
||||||
|
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
|
||||||
|
|
||||||
|
export default class EntryAbility extends FlutterAbility {
|
||||||
|
configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
GeneratedPluginRegistrant.registerWith(flutterEngine)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
import common from '@ohos.app.ability.common';
|
||||||
|
import { FlutterPage } from '@ohos/flutter_ohos'
|
||||||
|
|
||||||
|
let storage = LocalStorage.getShared()
|
||||||
|
const EVENT_BACK_PRESS = 'EVENT_BACK_PRESS'
|
||||||
|
|
||||||
|
@Entry(storage)
|
||||||
|
@Component
|
||||||
|
struct Index {
|
||||||
|
private context = getContext(this) as common.UIAbilityContext
|
||||||
|
@LocalStorageLink('viewId') viewId: string = "";
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
FlutterPage({ viewId: this.viewId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBackPress(): boolean {
|
||||||
|
this.context.eventHub.emit(EVENT_BACK_PRESS)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
{
|
||||||
|
"module": {
|
||||||
|
"name": "entry",
|
||||||
|
"type": "entry",
|
||||||
|
"description": "$string:module_desc",
|
||||||
|
"mainElement": "EntryAbility",
|
||||||
|
"deviceTypes": [
|
||||||
|
"phone"
|
||||||
|
],
|
||||||
|
"deliveryWithInstall": true,
|
||||||
|
"installationFree": false,
|
||||||
|
"pages": "$profile:main_pages",
|
||||||
|
"abilities": [
|
||||||
|
{
|
||||||
|
"name": "EntryAbility",
|
||||||
|
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||||
|
"description": "$string:EntryAbility_desc",
|
||||||
|
"icon": "$media:icon",
|
||||||
|
"label": "$string:EntryAbility_label",
|
||||||
|
"startWindowIcon": "$media:icon",
|
||||||
|
"startWindowBackground": "$color:start_window_background",
|
||||||
|
"exported": true,
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"entities": [
|
||||||
|
"entity.system.home"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
"action.system.home"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestPermissions": [
|
||||||
|
{"name" : "ohos.permission.INTERNET"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"color": [
|
||||||
|
{
|
||||||
|
"name": "start_window_background",
|
||||||
|
"value": "#FFFFFF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "module_desc",
|
||||||
|
"value": "module description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_desc",
|
||||||
|
"value": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_label",
|
||||||
|
"value": "app"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"src": [
|
||||||
|
"pages/Index"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "module_desc",
|
||||||
|
"value": "module description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_desc",
|
||||||
|
"value": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_label",
|
||||||
|
"value": "app"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "enable_impeller",
|
||||||
|
"value": "true"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "module_desc",
|
||||||
|
"value": "模块描述"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_desc",
|
||||||
|
"value": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_label",
|
||||||
|
"value": "app"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import hilog from '@ohos.hilog';
|
||||||
|
import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'
|
||||||
|
|
||||||
|
export default function abilityTest() {
|
||||||
|
describe('ActsAbilityTest', function () {
|
||||||
|
// Defines a test suite. Two parameters are supported: test suite name and test suite function.
|
||||||
|
beforeAll(function () {
|
||||||
|
// Presets an action, which is performed only once before all test cases of the test suite start.
|
||||||
|
// This API supports only one parameter: preset action function.
|
||||||
|
})
|
||||||
|
beforeEach(function () {
|
||||||
|
// Presets an action, which is performed before each unit test case starts.
|
||||||
|
// The number of execution times is the same as the number of test cases defined by **it**.
|
||||||
|
// This API supports only one parameter: preset action function.
|
||||||
|
})
|
||||||
|
afterEach(function () {
|
||||||
|
// Presets a clear action, which is performed after each unit test case ends.
|
||||||
|
// The number of execution times is the same as the number of test cases defined by **it**.
|
||||||
|
// This API supports only one parameter: clear action function.
|
||||||
|
})
|
||||||
|
afterAll(function () {
|
||||||
|
// Presets a clear action, which is performed after all test cases of the test suite end.
|
||||||
|
// This API supports only one parameter: clear action function.
|
||||||
|
})
|
||||||
|
it('assertContain',0, function () {
|
||||||
|
// Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function.
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'it begin');
|
||||||
|
let a = 'abc'
|
||||||
|
let b = 'b'
|
||||||
|
// Defines a variety of assertion methods, which are used to declare expected boolean conditions.
|
||||||
|
expect(a).assertContain(b)
|
||||||
|
expect(a).assertEqual(a)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import abilityTest from './Ability.test'
|
||||||
|
|
||||||
|
export default function testsuite() {
|
||||||
|
abilityTest()
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import UIAbility from '@ohos.app.ability.UIAbility';
|
||||||
|
import AbilityDelegatorRegistry from '@ohos.app.ability.abilityDelegatorRegistry';
|
||||||
|
import hilog from '@ohos.hilog';
|
||||||
|
import { Hypium } from '@ohos/hypium';
|
||||||
|
import testsuite from '../test/List.test';
|
||||||
|
import window from '@ohos.window';
|
||||||
|
|
||||||
|
export default class TestAbility extends UIAbility {
|
||||||
|
onCreate(want, launchParam) {
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onCreate');
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'want param:' + JSON.stringify(want) ?? '');
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'launchParam:'+ JSON.stringify(launchParam) ?? '');
|
||||||
|
var abilityDelegator: any
|
||||||
|
abilityDelegator = AbilityDelegatorRegistry.getAbilityDelegator()
|
||||||
|
var abilityDelegatorArguments: any
|
||||||
|
abilityDelegatorArguments = AbilityDelegatorRegistry.getArguments()
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'start run testcase!!!');
|
||||||
|
Hypium.hypiumTest(abilityDelegator, abilityDelegatorArguments, testsuite)
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy() {
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onDestroy');
|
||||||
|
}
|
||||||
|
|
||||||
|
onWindowStageCreate(windowStage: window.WindowStage) {
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onWindowStageCreate');
|
||||||
|
windowStage.loadContent('testability/pages/Index', (err, data) => {
|
||||||
|
if (err.code) {
|
||||||
|
hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hilog.info(0x0000, 'testTag', 'Succeeded in loading the content. Data: %{public}s',
|
||||||
|
JSON.stringify(data) ?? '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onWindowStageDestroy() {
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onWindowStageDestroy');
|
||||||
|
}
|
||||||
|
|
||||||
|
onForeground() {
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onForeground');
|
||||||
|
}
|
||||||
|
|
||||||
|
onBackground() {
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onBackground');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import hilog from '@ohos.hilog';
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct Index {
|
||||||
|
aboutToAppear() {
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility index aboutToAppear');
|
||||||
|
}
|
||||||
|
@State message: string = 'Hello World'
|
||||||
|
build() {
|
||||||
|
Row() {
|
||||||
|
Column() {
|
||||||
|
Text(this.message)
|
||||||
|
.fontSize(50)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
Button() {
|
||||||
|
Text('next page')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
}.type(ButtonType.Capsule)
|
||||||
|
.margin({
|
||||||
|
top: 20
|
||||||
|
})
|
||||||
|
.backgroundColor('#0D9FFB')
|
||||||
|
.width('35%')
|
||||||
|
.height('5%')
|
||||||
|
.onClick(()=>{
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
import hilog from '@ohos.hilog';
|
||||||
|
import TestRunner from '@ohos.application.testRunner';
|
||||||
|
import AbilityDelegatorRegistry from '@ohos.app.ability.abilityDelegatorRegistry';
|
||||||
|
|
||||||
|
var abilityDelegator = undefined
|
||||||
|
var abilityDelegatorArguments = undefined
|
||||||
|
|
||||||
|
async function onAbilityCreateCallback() {
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'onAbilityCreateCallback');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAbilityMonitorCallback(err: any) {
|
||||||
|
hilog.info(0x0000, 'testTag', 'addAbilityMonitorCallback : %{public}s', JSON.stringify(err) ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class OpenHarmonyTestRunner implements TestRunner {
|
||||||
|
constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
onPrepare() {
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'OpenHarmonyTestRunner OnPrepare ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onRun() {
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'OpenHarmonyTestRunner onRun run');
|
||||||
|
abilityDelegatorArguments = AbilityDelegatorRegistry.getArguments()
|
||||||
|
abilityDelegator = AbilityDelegatorRegistry.getAbilityDelegator()
|
||||||
|
var testAbilityName = abilityDelegatorArguments.bundleName + '.TestAbility'
|
||||||
|
let lMonitor = {
|
||||||
|
abilityName: testAbilityName,
|
||||||
|
onAbilityCreate: onAbilityCreateCallback,
|
||||||
|
};
|
||||||
|
abilityDelegator.addAbilityMonitor(lMonitor, addAbilityMonitorCallback)
|
||||||
|
var cmd = 'aa start -d 0 -a TestAbility' + ' -b ' + abilityDelegatorArguments.bundleName
|
||||||
|
var debug = abilityDelegatorArguments.parameters['-D']
|
||||||
|
if (debug == 'true')
|
||||||
|
{
|
||||||
|
cmd += ' -D'
|
||||||
|
}
|
||||||
|
hilog.info(0x0000, 'testTag', 'cmd : %{public}s', cmd);
|
||||||
|
abilityDelegator.executeShellCommand(cmd,
|
||||||
|
(err: any, d: any) => {
|
||||||
|
hilog.info(0x0000, 'testTag', 'executeShellCommand : err : %{public}s', JSON.stringify(err) ?? '');
|
||||||
|
hilog.info(0x0000, 'testTag', 'executeShellCommand : data : %{public}s', d.stdResult ?? '');
|
||||||
|
hilog.info(0x0000, 'testTag', 'executeShellCommand : data : %{public}s', d.exitCode ?? '');
|
||||||
|
})
|
||||||
|
hilog.info(0x0000, 'testTag', '%{public}s', 'OpenHarmonyTestRunner onRun end');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
{
|
||||||
|
"module": {
|
||||||
|
"name": "entry_test",
|
||||||
|
"type": "feature",
|
||||||
|
"description": "$string:module_test_desc",
|
||||||
|
"mainElement": "TestAbility",
|
||||||
|
"deviceTypes": [
|
||||||
|
"phone"
|
||||||
|
],
|
||||||
|
"deliveryWithInstall": true,
|
||||||
|
"installationFree": false,
|
||||||
|
"pages": "$profile:test_pages",
|
||||||
|
"abilities": [
|
||||||
|
{
|
||||||
|
"name": "TestAbility",
|
||||||
|
"srcEntry": "./ets/testability/TestAbility.ets",
|
||||||
|
"description": "$string:TestAbility_desc",
|
||||||
|
"icon": "$media:icon",
|
||||||
|
"label": "$string:TestAbility_label",
|
||||||
|
"exported": true,
|
||||||
|
"startWindowIcon": "$media:icon",
|
||||||
|
"startWindowBackground": "$color:start_window_background",
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"actions": [
|
||||||
|
"action.system.home"
|
||||||
|
],
|
||||||
|
"entities": [
|
||||||
|
"entity.system.home"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"color": [
|
||||||
|
{
|
||||||
|
"name": "start_window_background",
|
||||||
|
"value": "#FFFFFF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "module_test_desc",
|
||||||
|
"value": "test ability description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TestAbility_desc",
|
||||||
|
"value": "the test ability"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TestAbility_label",
|
||||||
|
"value": "test label"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"src": [
|
||||||
|
"testability/pages/Index"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
{
|
||||||
|
"modelVersion": "5.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { injectNativeModules } from 'flutter-hvigor-plugin';
|
||||||
|
|
||||||
|
injectNativeModules(__dirname, path.dirname(__dirname))
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { appTasks } from '@ohos/hvigor-ohos-plugin';
|
||||||
|
import { flutterHvigorPlugin } from 'flutter-hvigor-plugin';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
|
||||||
|
plugins:[flutterHvigorPlugin(path.dirname(__dirname))] /* Custom plugin to extend the functionality of Hvigor. */
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"modelVersion": "5.1.0",
|
||||||
|
"name": "app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Please describe the basic information.",
|
||||||
|
"main": "",
|
||||||
|
"author": "",
|
||||||
|
"license": "",
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ohos/hypium": "1.0.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-13
@@ -5,7 +5,7 @@ version: 1.0.0+1
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_analytics: ^0.1.0
|
core_analytics: ^0.1.0
|
||||||
@@ -23,10 +23,9 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
intl: any
|
intl: any
|
||||||
native_scan: ^0.1.0
|
native_scan: ^0.1.0
|
||||||
sentry_flutter: ^9.26.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
@@ -34,19 +33,13 @@ dev_dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
integration_test:
|
integration_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
sentry_dart_plugin: ^3.4.0
|
|
||||||
# 只为测试拿 InMemorySharedPreferencesAsync,不在 lib/ 里出现。
|
# 只为测试拿 InMemorySharedPreferencesAsync,不在 lib/ 里出现。
|
||||||
shared_preferences_platform_interface: ^2.4.2
|
shared_preferences_platform_interface: ^2.4.2
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
generate: true
|
generate: true
|
||||||
|
assets:
|
||||||
# release 构建后由 CI 调用 `dart run sentry_dart_plugin` 上传符号表。
|
- env/dev.json
|
||||||
# 见 08 §release 构建 和 13 §崩溃上报。
|
- env/uat.json
|
||||||
sentry:
|
- env/prod.json
|
||||||
upload_debug_symbols: true
|
|
||||||
upload_source_maps: false
|
|
||||||
project: conti-retail-app
|
|
||||||
org: continental
|
|
||||||
# auth_token 只从 CI 变量 SENTRY_AUTH_TOKEN 读,绝不写进仓库
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ conti-app/
|
|||||||
feature_analytics/ # 经营分析:对账单、核销收入、返利、报表
|
feature_analytics/ # 经营分析:对账单、核销收入、返利、报表
|
||||||
feature_profile/ # 个人中心:地址、热线、客服
|
feature_profile/ # 个人中心:地址、热线、客服
|
||||||
feature_scan/ # 扫码业务入口(VIN/车牌/二维码/条码 → 分发到对应业务)
|
feature_scan/ # 扫码业务入口(VIN/车牌/二维码/条码 → 分发到对应业务)
|
||||||
native_scan/ # 原生插件包:扫码能力(android/ios 两端实现)
|
native_scan/ # 原生能力 Dart facade;Android/iOS 插件,OHOS 实现在 App entry
|
||||||
native_media/ # 相机、相册、文件选择/上传
|
native_media/ # 相机、相册、文件选择/上传
|
||||||
native_device/ # 拨号、设备信息、权限申请
|
native_device/ # 拨号、设备信息、权限申请
|
||||||
```
|
```
|
||||||
@@ -42,7 +42,7 @@ conti-app/
|
|||||||
- `app` 可以依赖所有 `core_*` 和 `feature_*`。
|
- `app` 可以依赖所有 `core_*` 和 `feature_*`。
|
||||||
- `feature_*` **只能**依赖 `core_*` 和 `native_*`,**不能**相互依赖(`feature_purchase` 的 `pubspec.yaml` 里不允许出现 `feature_inventory` 的 path dependency)。
|
- `feature_*` **只能**依赖 `core_*` 和 `native_*`,**不能**相互依赖(`feature_purchase` 的 `pubspec.yaml` 里不允许出现 `feature_inventory` 的 path dependency)。
|
||||||
- `core_*` 可以依赖 `native_*`(`core_webview` 的 JSBridge 需要调起扫码/相机/上传)。
|
- `core_*` 可以依赖 `native_*`(`core_webview` 的 JSBridge 需要调起扫码/相机/上传)。
|
||||||
- `native_*` 只依赖 Flutter SDK 和 [Pigeon](https://pub.dev/packages/pigeon) 生成的代码,不依赖任何 `core_*` / `feature_*`——保证原生插件包可以脱离业务单独编译、单独测试(详见 [07-native-integration.md](./07-native-integration.md))。
|
- `native_*` 只依赖 Flutter SDK 与自身的跨平台 transport,不依赖任何 `core_*` / `feature_*`。Android/iOS transport 使用 [Pigeon](https://pub.dev/packages/pigeon) 生成代码;OHOS transport 可在 facade 内使用 `MethodChannel`,其 ArkTS 落在 App 宿主 `ohos/entry`,仍不得泄漏给业务层(详见 [07-native-integration.md](./07-native-integration.md))。
|
||||||
|
|
||||||
`core_*` 之间原则上不互相依赖,允许的例外只有下面三条,多一条都要走评审:
|
`core_*` 之间原则上不互相依赖,允许的例外只有下面三条,多一条都要走评审:
|
||||||
|
|
||||||
@@ -74,16 +74,19 @@ conti-app/
|
|||||||
|
|
||||||
- `core_xxx`:基础设施层,不含具体业务逻辑。
|
- `core_xxx`:基础设施层,不含具体业务逻辑。
|
||||||
- `feature_xxx`:对应一个业务域(多数是原来的某个小程序,也有全新的,如首页工作台)。
|
- `feature_xxx`:对应一个业务域(多数是原来的某个小程序,也有全新的,如首页工作台)。
|
||||||
- `native_xxx`:原生能力插件包,首版含 `android/`、`ios/` 两套原生实现目录(OHOS 不在首版范围,见 [07-native-integration.md](./07-native-integration.md))。
|
- `native_xxx`:业务侧唯一可依赖的原生能力 facade。Android/iOS 原生实现位于插件包;`harmony` 分支首期 OHOS ArkTS 实现位于 App 宿主的 `ohos/entry`,见 [07-native-integration.md](./07-native-integration.md)。
|
||||||
|
|
||||||
## SDK 版本基线
|
## SDK 版本基线
|
||||||
|
|
||||||
| 项 | 版本 | 说明 |
|
| 项 | 版本 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Flutter | **3.44.9** | 用 [FVM](https://fvm.app/) 锁定,仓库根目录提交 `.fvmrc` |
|
| Flutter | **3.44.9** | 用 [FVM](https://fvm.app/) 锁定,仓库根目录提交 `.fvmrc` |
|
||||||
| Dart | 随 Flutter 3.44.9 附带(3.12.x) | 具体号以 `flutter --version` 实测为准;`environment: sdk: ^3.12.0` 对整个 3.12.x 都成立 |
|
| Dart | 随 Flutter 3.44.9 附带(3.12.x) | 具体号以 `flutter --version` 实测为准;`environment: sdk: ^3.11.0` 对整个 3.12.x 都成立 |
|
||||||
|
| Flutter_OH(仅 `harmony`) | **3.41.10-ohos-1.0.0 / Dart 3.11.5** | 仅用于 OHOS 构建;由 `enable-ohos.ps1` 临时注入当前 PowerShell PATH,不能替换 Android/iOS 的 FVM SDK |
|
||||||
|
|
||||||
**为什么不跟最新 stable(3.47.0 / Dart 3.13.0,2026-08-12 发布)**:鸿蒙(OpenHarmony)的 Flutter 分支适配落后于官方 stable 一段时间,虽然 OHOS 不在首版范围(见 [07-native-integration.md](./07-native-integration.md) 的「OHOS 后续演进」),但 SDK 基线要为后续接 OHOS 留出兼容窗口,所以刻意停在 3.44.9 而不是追最新。这条约束在决定升级 Flutter 版本时必须重新评估,不要因为"新版本有新特性"就单方面升。
|
**OHOS SDK 边界**:OpenHarmony 的 Flutter 分支与官方 stable 不同步。Android/iOS 仍以 FVM 锁定的 Flutter 3.44.9 为基线;`harmony` 分支已用 Flutter_OH 3.41.10-ohos-1.0.0 / Dart 3.11.5 完成 HAP 编译验证。两套 SDK 不可混用,任一侧升级都必须重新验证依赖解析、生成代码和对应平台构建(见 [07-native-integration.md](./07-native-integration.md))。
|
||||||
|
|
||||||
|
**未来收敛条件**:Flutter_OH 上游文档计划在 **2026 年 9 月**提供 Flutter **3.44.x / Dart 3.12.x** 对齐版本;发布实际 Tag/Commit 可获取后,可评审将 `harmony` 与 Android/iOS 收敛到同一 SDK 基线。计划日期或版本号相同都不是自动放行条件;必须重新完成依赖解析、Riverpod/Pigeon 生成物核验、HAP 构建及真机原生能力验证后才能切换。
|
||||||
|
|
||||||
**为什么必须用 FVM 锁**:monorepo 里各人本地 Flutter 版本不一致,会导致同一份代码有人 `flutter analyze` 过、有人不过,生成代码(`build_runner` 产物)也可能不一致——这类问题排查成本远高于装一次 FVM。CI 也用 `.fvmrc` 里的版本,保证本地和流水线一致。
|
**为什么必须用 FVM 锁**:monorepo 里各人本地 Flutter 版本不一致,会导致同一份代码有人 `flutter analyze` 过、有人不过,生成代码(`build_runner` 产物)也可能不一致——这类问题排查成本远高于装一次 FVM。CI 也用 `.fvmrc` 里的版本,保证本地和流水线一致。
|
||||||
|
|
||||||
@@ -109,7 +112,7 @@ Melos 7.0 起改用 Dart 官方原生的 **[Pub Workspaces](https://dart.dev/too
|
|||||||
name: conti_app
|
name: conti_app
|
||||||
publish_to: none
|
publish_to: none
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
workspace:
|
workspace:
|
||||||
- app
|
- app
|
||||||
@@ -263,4 +266,3 @@ dart pub global activate melos
|
|||||||
- [Pigeon | Dart package](https://pub.dev/packages/pigeon)
|
- [Pigeon | Dart package](https://pub.dev/packages/pigeon)
|
||||||
- [Drift | Dart package](https://pub.dev/packages/drift)
|
- [Drift | Dart package](https://pub.dev/packages/drift)
|
||||||
- [Lerna(JS 生态对标工具)](https://lerna.js.org/)
|
- [Lerna(JS 生态对标工具)](https://lerna.js.org/)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ final goRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它
|
navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它
|
||||||
initialLocation: '/home',
|
initialLocation: '/home',
|
||||||
observers: [NavigationObserver(ref.read(crashReporterProvider))], // 崩溃前的页面路径,见 13
|
observers: const [], // 路由观测由 app 层按需注入
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
// read 不是 watch:这里只要当前值,订阅由下面的 listen 负责
|
// read 不是 watch:这里只要当前值,订阅由下面的 listen 负责
|
||||||
final auth = ref.read(authStateProvider);
|
final auth = ref.read(authStateProvider);
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## 决策
|
## 决策
|
||||||
|
|
||||||
原生能力(扫码、支付、蓝牙等)统一封装成独立的 `native_*` Dart package(结构见 [01-project-structure.md](./01-project-structure.md)),跨语言接口用 **[Pigeon](https://pub.dev/packages/pigeon)**(`^27.3.0`,2026-08 快照)生成,不手写裸 `MethodChannel`/`invokeMethod` 字符串调用。
|
原生能力(扫码、支付、蓝牙等)统一由独立的 `native_*` Dart package 暴露稳定公共 API(结构见 [01-project-structure.md](./01-project-structure.md))。**Android/iOS** 跨语言接口使用 [Pigeon](https://pub.dev/packages/pigeon)(锁定 `26.3.1`)生成;`harmony` 分支的首期 **OHOS** 实现是经过约束的例外:`native_*` 内部通过手写 `MethodChannel` 调用 App 宿主 `app/ohos/entry` 中的 ArkTS,而不是将 OHOS 再包装成独立 Flutter plugin。
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
pigeon: ^27.3.0
|
pigeon: 26.3.1
|
||||||
```
|
```
|
||||||
|
|
||||||
## 包结构规则
|
## 包结构规则
|
||||||
@@ -32,7 +32,28 @@ native_scan/
|
|||||||
Classes/NativeScanPlugin.swift # 手写:插件注册入口
|
Classes/NativeScanPlugin.swift # 手写:插件注册入口
|
||||||
```
|
```
|
||||||
|
|
||||||
首版只有 `android/` 和 `ios/`(OHOS 不在首版范围,见文末「OHOS 后续演进」)。
|
插件包中的原生目录目前只有 `android/` 和 `ios/`;这不是遗漏。OHOS 的首期 ArkTS 实现位于宿主 App 的 `app/ohos/entry`,见下节。
|
||||||
|
|
||||||
|
### 平台实现归属:Android/iOS 插件,OHOS 宿主桥接
|
||||||
|
|
||||||
|
```
|
||||||
|
feature_* / core_webview(JSBridge)
|
||||||
|
↓ 只依赖公共 Dart API
|
||||||
|
native_scan
|
||||||
|
┌─────────┴──────────┐
|
||||||
|
│ │
|
||||||
|
Android / iOS OHOS(harmony)
|
||||||
|
Pigeon + plugin 手写 MethodChannel
|
||||||
|
Kotlin / Swift ↓
|
||||||
|
app/ohos/entry ArkTS
|
||||||
|
```
|
||||||
|
|
||||||
|
| 平台 | Dart 到原生 transport | 原生代码归属 | 原因 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Android / iOS | Pigeon 生成的强类型桩代码 | `packages/native_scan/android`、`ios` | 复用成熟的 Flutter plugin 注册和 Kotlin/Swift 生成链路 |
|
||||||
|
| OHOS(首期) | `native_scan` 内部的手写 `MethodChannel` | `app/ohos/entry/src/main/ets/` | Pigeon 不生成 ArkTS;不手抄其 `BasicMessageChannel` / 自定义 codec 协议,降低漂移风险 |
|
||||||
|
|
||||||
|
这不会增加 Dart `main` 文件,也不会新增 HAP module。`entry` 是已有 OHOS 应用模块;这里只是在其中增加本 App 的原生能力实现。业务代码和 H5 均不得直接调用 channel、ArkTS 或做 OHOS 平台判断。
|
||||||
|
|
||||||
### `pubspec.yaml` 必须声明 plugin platforms
|
### `pubspec.yaml` 必须声明 plugin platforms
|
||||||
|
|
||||||
@@ -44,7 +65,7 @@ name: native_scan
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
flutter: '>=3.44.0'
|
flutter: '>=3.44.0'
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
@@ -197,24 +218,35 @@ pigeon:
|
|||||||
- Dart 生成物在根 `analysis_options.yaml` 里排除 lint(`analyzer: exclude: - "**/*.g.dart"`)。
|
- Dart 生成物在根 `analysis_options.yaml` 里排除 lint(`analyzer: exclude: - "**/*.g.dart"`)。
|
||||||
- CI 要有一步"重新生成后 `git diff --exit-code`",防止有人改了 schema 但忘了提交生成物。
|
- CI 要有一步"重新生成后 `git diff --exit-code`",防止有人改了 schema 但忘了提交生成物。
|
||||||
|
|
||||||
## OHOS 后续演进
|
## OHOS 当前实现策略(`harmony`)
|
||||||
|
|
||||||
鸿蒙(OpenHarmony)**不在首版范围**,但基线决策是为它留了口子的,这里记录清楚,避免后面接的时候重新走一遍弯路。
|
OHOS 不是 Android 的另一种 plugin platform。当前 Flutter_OH 工具链没有可直接复用的 Pigeon ArkTS 生成链路;而现有 Pigeon API 在 Dart 侧使用 `BasicMessageChannel`、自定义 codec、类型 tag 和生成的错误/结果 envelope。让 ArkTS 人工复刻该私有 wire protocol,维护成本和接口漂移风险都不值得。
|
||||||
|
|
||||||
接 OHOS 需要处理三件事:
|
因此,OHOS 在 `native_scan` 的**内部**新增一条简洁、独立的 `MethodChannel` transport。它接收和返回仅用于该 transport 的基础 Map/List/标量,再映射回既有的 `ScanOptions`、`ScanResult` 与 `NativeScanException`;Android/iOS Pigeon schema、生成物与注册逻辑不因此改变。
|
||||||
|
|
||||||
1. **SDK 分支不同**:OHOS 用的是 OpenHarmony 社区维护的 Flutter 分支,版本落后于官方 stable 一段时间。这正是 [01-project-structure.md](./01-project-structure.md) 里 SDK 基线刻意停在 **3.44.9** 而不追 3.47.0 的原因——基线跑太前,OHOS 分支跟不上就接不进来。
|
ArkTS 按职责拆分,避免把业务实现写进默认入口:
|
||||||
2. **Pigeon 没有 ArkTS 生成器**:Pigeon 官方只生成 Kotlin/Java、Swift/Objective-C、C++、GObject,**没有 ArkTS/OHOS**。所以 OHOS 侧的 channel 代码只能**手写**,需要人工保证方法名、参数结构与 Pigeon 生成的 Dart 端编解码格式一致——这是一份实打实的额外维护成本,接 OHOS 时要预留出来。
|
|
||||||
3. **`native_*` 包要加 `ohos:` 平台声明**,并新增 `ohos/` 目录。
|
|
||||||
|
|
||||||
在此之前,`native_*` 的公共 API 类里遇到不支持的平台,一律抛明确的 `UnsupportedPlatformException`,不静默返回空值或占位假数据——静默返回会让"这个平台其实没实现"的问题一直藏到用户手里。
|
```
|
||||||
|
app/ohos/entry/src/main/ets/
|
||||||
|
entryability/EntryAbility.ets # 生命周期、Flutter engine 与 bridge 注册
|
||||||
|
native_scan/NativeScanChannel.ets # 方法分发、参数/结果序列化、错误映射
|
||||||
|
native_scan/OhosScanService.ets # 相机/扫码 API 调用
|
||||||
|
native_scan/ScanPermissionService.ets # 相机权限检查与申请
|
||||||
|
```
|
||||||
|
|
||||||
|
`NativeScanChannel` 必须集中维护 channel 名、方法名、参数、成功返回值与错误码;Dart facade 捕获 `PlatformException` 并继续转为 `NativeScanException`。取消、拒绝权限、能力不可用、超时和未知方法必须分别有明确错误码,不能返回空值或伪造扫描结果。
|
||||||
|
|
||||||
|
**延后抽取独立 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` 和真机原生能力回归;不能仅因计划日期或版本号相同就替换工具链。
|
||||||
|
|
||||||
|
|
||||||
## 使用规则
|
## 使用规则
|
||||||
|
|
||||||
- `pigeons/xxx_api.dart` 是**唯一手写**的接口定义文件,Dart 端和两端原生的桩代码全部由 `dart run pigeon --input pigeons/xxx_api.dart` 生成,生成产物不手动修改,改需求就改 schema 重新生成。
|
- Android/iOS 的 `pigeons/xxx_api.dart` 是**唯一手写**的 Pigeon 接口定义文件;Dart 端及 Kotlin/Swift 桩代码全部由 `dart run pigeon --input pigeons/xxx_api.dart` 生成,生成产物不手动修改。OHOS 不手抄 Pigeon wire protocol,而是维护独立、文档化的 `MethodChannel` 契约。
|
||||||
- Dart 调原生用 `@HostApi()`;原生主动推事件给 Dart(比如扫码结果的持续回调)用 `@FlutterApi()`——不允许为了图省事用 `@HostApi()` 硬凑双向通信。
|
- Dart 调原生用 `@HostApi()`;原生主动推事件给 Dart(比如扫码结果的持续回调)用 `@FlutterApi()`——不允许为了图省事用 `@HostApi()` 硬凑双向通信。
|
||||||
- **调用方只允许依赖 `native_*` 包 `lib/native_xxx.dart` 导出的公共 API 类**,不允许直接 import `src/generated/` 里的生成代码。调用方包括 `feature_*` 和 `core_webview`(JSBridge)。
|
- **调用方只允许依赖 `native_*` 包 `lib/native_xxx.dart` 导出的公共 API 类**,不允许直接 import `src/generated/` 里的生成代码。调用方包括 `feature_*` 和 `core_webview`(JSBridge)。
|
||||||
|
- `native_*` 是平台分派和异常收敛的唯一位置;Feature、`core_webview`、H5 与 Dart entrypoint 均不得直接创建 OHOS `MethodChannel` 或 import ArkTS 对应实现。
|
||||||
- 原生侧异常需要在生成的 host API 实现里捕获并转换成 Pigeon schema 里声明的错误类型,Dart 侧统一映射成 [05-networking.md](./05-networking.md) 里同一套 `AppException` 体系,不让原生异常类型(如 `PlatformException`)直接抛到业务代码里。
|
- 原生侧异常需要在生成的 host API 实现里捕获并转换成 Pigeon schema 里声明的错误类型,Dart 侧统一映射成 [05-networking.md](./05-networking.md) 里同一套 `AppException` 体系,不让原生异常类型(如 `PlatformException`)直接抛到业务代码里。
|
||||||
- 某一端暂未实现的能力,公共 API 类里对应平台分支抛明确的 `UnsupportedPlatformException`,不允许静默返回空值或占位假数据。
|
- 某一端暂未实现的能力,公共 API 类里对应平台分支抛明确的 `UnsupportedPlatformException`,不允许静默返回空值或占位假数据。
|
||||||
|
|
||||||
|
|||||||
+16
-33
@@ -17,11 +17,11 @@ App 侧维护 **3 个 flavor:`dev` / `uat` / `prod`**,环境划分与现有
|
|||||||
|
|
||||||
## 使用规则
|
## 使用规则
|
||||||
|
|
||||||
- 每个 flavor 对应一个独立的 Dart 入口文件(`main_dev.dart`/`main_uat.dart`/`main_prod.dart`),三者都只是设置好环境标识后调用同一个共享的 `bootstrap()` 启动函数,不允许在入口文件里写业务逻辑分支。
|
- 全项目只保留 `lib/main.dart` 一个 Dart 入口。Flutter 的 `appFlavor` 自动对应 `--flavor`,启动代码据此加载打包的 `env/{flavor}.json`;未传 flavor 时默认 dev,DevEco 不需要额外入口配置。
|
||||||
- 环境相关的可变配置(API base URL、是否开启日志等,见 [05-networking.md](./05-networking.md) 的 `appEnvProvider`)通过 `--dart-define-from-file=env/{flavor}.json` 注入,不写死在代码里、也不用 `if (flavor == 'dev')` 这种运行时字符串判断来分支配置。
|
- 环境配置只包含可公开随包发布的 API 地址、日志开关与 H5 host;不在 Dart 代码中写死,也不要求开发者为入口文件额外传 `-t` 或 `--dart-define-from-file`。
|
||||||
- `env/*.json` 只包含非敏感配置(API 地址等);密钥类配置(如第三方 SDK App Key)通过 CI 变量在构建时注入,不提交进仓库。
|
- `env/*.json` 只包含非敏感配置(API 地址等);密钥类配置(如第三方 SDK App Key)通过 CI 变量在构建时注入,不提交进仓库。
|
||||||
- Android 侧用 Gradle `productFlavors` 区分 `applicationIdSuffix`/图标/`versionNameSuffix`;iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier`/图标;两端 flavor 名称必须完全一致(`dev`/`uat`/`prod`),不允许两端用不同命名。
|
- 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`
|
## 用 `applicationIdSuffix` 而不是覆盖 `applicationId`
|
||||||
|
|
||||||
@@ -41,15 +41,13 @@ productFlavors {
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
fvm flutter build appbundle \
|
fvm flutter build appbundle \
|
||||||
--flavor prod --target lib/main_prod.dart \
|
--flavor prod \
|
||||||
--dart-define-from-file=env/prod.json \
|
|
||||||
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
|
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
|
||||||
```
|
```
|
||||||
|
|
||||||
- `--obfuscate` 混淆 Dart 符号名,`--split-debug-info` 把调试符号剥离到单独目录(同时显著减小包体)。
|
- `--obfuscate` 混淆 Dart 符号名,`--split-debug-info` 把调试符号剥离到单独目录(同时显著减小包体)。
|
||||||
- **两个参数必须一起用**,只写 `--obfuscate` 不写 `--split-debug-info` 会被工具链拒绝。
|
- **两个参数必须一起用**,只写 `--obfuscate` 不写 `--split-debug-info` 会被工具链拒绝。
|
||||||
- **符号表必须归档,并且构建完立刻上传到 Sentry**:混淆后崩溃堆栈是不可读的乱码。CI 在 build 之后紧跟一条 `fvm dart run sentry_dart_plugin`,把 Dart 符号表、Android mapping、iOS dSYM 一起传上去(见 [13-observability-analytics.md](./13-observability-analytics.md))。**上传时的 `release` 必须和 App 里 `options.release` 严格一致**,对不上的表现是"传了但堆栈还是混淆的",且后台不报错。
|
- 符号表必须按 `版本号+构建号` 归档为 CI artifact 保留至少 1 年,供后续排障或未来选择的崩溃平台使用。
|
||||||
- 同时把 `build/symbols/` 按 `版本号+构建号` 归档为 CI artifact 保留至少 1 年,作为 Sentry 侧数据过期或服务不可用时的兜底。**丢了符号表 = 那个版本的所有线上崩溃永远无法定位**,这是个不可逆的失误。
|
|
||||||
- 符号表目录按版本号区分(用 tag 或 `versionName+versionCode`),不能所有版本堆一个目录。
|
- 符号表目录按版本号区分(用 tag 或 `versionName+versionCode`),不能所有版本堆一个目录。
|
||||||
|
|
||||||
## 版本号规则
|
## 版本号规则
|
||||||
@@ -113,8 +111,7 @@ build_ios_uat:
|
|||||||
stage: build
|
stage: build
|
||||||
tags: [macos] # 只有 mac runner 有这个 tag
|
tags: [macos] # 只有 mac runner 有这个 tag
|
||||||
script:
|
script:
|
||||||
- fvm flutter build ipa --flavor uat --target lib/main_uat.dart \
|
- fvm flutter build ipa --flavor uat --export-options-plist=ios/ExportOptions-uat.plist
|
||||||
--dart-define-from-file=env/uat.json --export-options-plist=ios/ExportOptions-uat.plist
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 内测分发渠道:Firebase 在国内可达性存疑
|
## 内测分发渠道:Firebase 在国内可达性存疑
|
||||||
@@ -154,13 +151,11 @@ Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有
|
|||||||
|
|
||||||
- **iOS 构建 runner 方案(A/B/C 选哪个)**——阻塞 iOS 发版,优先级最高。
|
- **iOS 构建 runner 方案(A/B/C 选哪个)**——阻塞 iOS 发版,优先级最高。
|
||||||
- 内测分发渠道的最终选型与托管位置。
|
- 内测分发渠道的最终选型与托管位置。
|
||||||
- 是否引入 Firebase(影响崩溃上报选型,见 [13-observability-analytics.md](./13-observability-analytics.md))。
|
|
||||||
- Android 上架渠道清单(华为/小米/OPPO/vivo 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。
|
- Android 上架渠道清单(华为/小米/OPPO/vivo 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。
|
||||||
|
|
||||||
## 参考链接
|
## 参考链接
|
||||||
|
|
||||||
- [Flutter 官方 Flavors 文档](https://docs.flutter.dev/deployment/flavors)
|
- [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)
|
- [Flutter: 混淆 Dart 代码](https://docs.flutter.dev/deployment/obfuscate)
|
||||||
- [Android: 从命令行构建并签名](https://developer.android.com/build/building-cmdline)
|
- [Android: 从命令行构建并签名](https://developer.android.com/build/building-cmdline)
|
||||||
- [fastlane match(证书管理)](https://docs.fastlane.tools/actions/match/)
|
- [fastlane match(证书管理)](https://docs.fastlane.tools/actions/match/)
|
||||||
@@ -179,8 +174,7 @@ Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有
|
|||||||
|
|
||||||
1. **Android `productFlavors`**:在 `android/app/build.gradle` 里声明多套 `applicationId`/`versionNameSuffix`/资源目录,编译时用 `--flavor` 选择其中一套。
|
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。
|
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()` 一个环境枚举)。
|
3. **Dart 入口与环境配置**:Dart 固定从 `main.dart` 启动,Flutter 自动把 `--flavor` 暴露为 `appFlavor`;启动代码按其加载内置 JSON 配置。未提供 flavor 时用 dev,适配 DevEco 的默认构建。
|
||||||
4. **`--dart-define-from-file`**:flavor 解决的是原生层面的差异(图标、包名),但 API 地址这类 Dart 侧读取的配置,用编译期注入的 JSON 文件解决,避免打包进一个写死 `http://dev-api...` 的字符串常量。
|
|
||||||
|
|
||||||
### 使用示例
|
### 使用示例
|
||||||
|
|
||||||
@@ -225,30 +219,21 @@ android {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
共享启动入口 + 各 flavor 的 Dart 入口文件:
|
唯一 Dart 入口:
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
// lib/bootstrap.dart —— 三个 flavor 共用的启动逻辑
|
// lib/main.dart
|
||||||
Future<void> bootstrap(AppEnv env) async {
|
Future<void> main() async {
|
||||||
runApp(ProviderScope(
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
overrides: [appEnvProvider.overrideWithValue(env)],
|
await bootstrap(await loadBundledAppEnv(flavor: appFlavor));
|
||||||
child: const App(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```dart
|
|
||||||
// lib/main_dev.dart
|
|
||||||
void main() => bootstrap(AppEnv.fromDartDefine(name: 'dev'));
|
|
||||||
```
|
|
||||||
|
|
||||||
构建命令:
|
构建命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fvm flutter build apk \
|
fvm flutter build apk \
|
||||||
--flavor dev \
|
--flavor dev
|
||||||
--target lib/main_dev.dart \
|
|
||||||
--dart-define-from-file=env/dev.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上):
|
GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上):
|
||||||
@@ -266,7 +251,7 @@ build_android_dev:
|
|||||||
script:
|
script:
|
||||||
- melos run analyze
|
- melos run analyze
|
||||||
- melos run test
|
- 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:
|
artifacts:
|
||||||
paths: [build/app/outputs/flutter-apk/app-dev-release.apk]
|
paths: [build/app/outputs/flutter-apk/app-dev-release.apk]
|
||||||
rules:
|
rules:
|
||||||
@@ -276,8 +261,7 @@ build_android_prod:
|
|||||||
<<: *flutter_base
|
<<: *flutter_base
|
||||||
stage: build
|
stage: build
|
||||||
script:
|
script:
|
||||||
- flutter build appbundle --flavor prod --target lib/main_prod.dart
|
- flutter build appbundle --flavor prod
|
||||||
--dart-define-from-file=env/prod.json
|
|
||||||
--build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID
|
--build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID
|
||||||
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
|
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
|
||||||
artifacts:
|
artifacts:
|
||||||
@@ -292,8 +276,7 @@ build_ios_prod:
|
|||||||
stage: build
|
stage: build
|
||||||
tags: [macos] # 必须是 mac runner,Linux runner 跑不了,见上文「iOS 构建链路」
|
tags: [macos] # 必须是 mac runner,Linux runner 跑不了,见上文「iOS 构建链路」
|
||||||
script:
|
script:
|
||||||
- fvm flutter build ipa --flavor prod --target lib/main_prod.dart
|
- fvm flutter build ipa --flavor prod
|
||||||
--dart-define-from-file=env/prod.json
|
|
||||||
--build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID
|
--build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID
|
||||||
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
|
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
@@ -145,8 +145,7 @@ Future<void> switchStore(int targetStoreId) async {
|
|||||||
// ── 7. 记住这次选择,供下次冷启动预选 ────────────────
|
// ── 7. 记住这次选择,供下次冷启动预选 ────────────────
|
||||||
await ref.read(prefsProvider).setInt('last_store_id', newStore.storeId);
|
await ref.read(prefsProvider).setInt('last_store_id', newStore.storeId);
|
||||||
|
|
||||||
// ── 8. 同步观测上下文(见 13)────────────────────────
|
// ── 8. 同步埋点上下文 ──────────────────────────────────
|
||||||
ref.read(crashReporterProvider).setTag('storeId', '${newStore.storeId}');
|
|
||||||
ref.read(analyticsProvider).registerSuperProperties({'storeId': newStore.storeId});
|
ref.read(analyticsProvider).registerSuperProperties({'storeId': newStore.storeId});
|
||||||
// 切店事件本身由后端从 /stores/{id}/switch 的接口日志出,客户端不重复上报,见 13
|
// 切店事件本身由后端从 /stores/{id}/switch 的接口日志出,客户端不重复上报,见 13
|
||||||
}
|
}
|
||||||
@@ -158,7 +157,7 @@ Future<void> switchStore(int targetStoreId) async {
|
|||||||
| 3 在 5 之前 | H5 页面里可能有在途请求。先 `about:blank` 停掉,再换上下文,否则旧门店的 H5 请求会带着新门店的票据回来 |
|
| 3 在 5 之前 | H5 页面里可能有在途请求。先 `about:blank` 停掉,再换上下文,否则旧门店的 H5 请求会带着新门店的票据回来 |
|
||||||
| 4 在 5 之前 | 缓存表带 `storeId`(见 06),但**清理和新上下文之间不能有窗口期**:如果先落新上下文,provider 立刻失效并重新请求,可能在清理完成前就把新数据写进去,然后被 `clearBusinessCache()` 一起删掉 |
|
| 4 在 5 之前 | 缓存表带 `storeId`(见 06),但**清理和新上下文之间不能有窗口期**:如果先落新上下文,provider 立刻失效并重新请求,可能在清理完成前就把新数据写进去,然后被 `clearBusinessCache()` 一起删掉 |
|
||||||
| 6 在 5 之后 | 清栈时目标页面(首页)要用新上下文渲染 |
|
| 6 在 5 之后 | 清栈时目标页面(首页)要用新上下文渲染 |
|
||||||
| 8 在 5 之后 | 观测上下文要和业务上下文保持一致;漏了这一步的表现是**切店后的崩溃和埋点还挂在旧门店名下**,按门店维度分析时数据是错的,而且错得很隐蔽 |
|
| 8 在 5 之后 | 埋点上下文要和业务上下文保持一致;漏了这一步会让切店后的埋点还挂在旧门店名下,按门店维度分析时数据会悄悄出错 |
|
||||||
|
|
||||||
**关于步骤 0(未完成写操作)**:切店时用户可能正在提交订单或上传图片。默认策略是**阻止切换并提示**,而不是静默取消——取消一个已经发出去的下单请求,客户端不知道服务端到底成没成。`pendingWriteProvider` 由发起写操作的 feature 自己注册/注销。
|
**关于步骤 0(未完成写操作)**:切店时用户可能正在提交订单或上传图片。默认策略是**阻止切换并提示**,而不是静默取消——取消一个已经发出去的下单请求,客户端不知道服务端到底成没成。`pendingWriteProvider` 由发起写操作的 feature 自己注册/注销。
|
||||||
|
|
||||||
@@ -190,7 +189,6 @@ Future<void> logout({LogoutReason reason = LogoutReason.userInitiated}) async {
|
|||||||
ref.read(analyticsProvider)
|
ref.read(analyticsProvider)
|
||||||
..track(AnalyticsEvent.logout, {'reason': reason.name}) // 报完再 reset,顺序不能反
|
..track(AnalyticsEvent.logout, {'reason': reason.name}) // 报完再 reset,顺序不能反
|
||||||
..reset();
|
..reset();
|
||||||
ref.read(crashReporterProvider).setUser('');
|
|
||||||
|
|
||||||
// 6. 兜底:清掉所有 provider 缓存
|
// 6. 兜底:清掉所有 provider 缓存
|
||||||
ref.invalidate(...); // 或在 ProviderScope 层重建,见下文
|
ref.invalidate(...); // 或在 ProviderScope 层重建,见下文
|
||||||
|
|||||||
@@ -1,442 +1,22 @@
|
|||||||
# 13. 可观测性与埋点
|
# 13. 日志与埋点
|
||||||
|
|
||||||
## 为什么单独一篇
|
|
||||||
|
|
||||||
PRD §21.4 和 §22.1 有明确要求(主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、9 类埋点事件),但 01-12 里完全没有落点。同时,**App 侧的可观测性是排查线上问题唯一的手段**——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。
|
|
||||||
|
|
||||||
这一篇定三件事:**崩溃上报**、**日志规范**、**埋点规范**。
|
|
||||||
|
|
||||||
## 决策
|
## 决策
|
||||||
|
|
||||||
| 项 | 决策 |
|
首期不接入任何崩溃上报 SDK,也不向第三方传输异常、用户或门店上下文。业务代码通过
|
||||||
|---|---|
|
`core_logging` 输出经过脱敏的本地日志;行为分析继续通过 `core_analytics` 的抽象接口接入。
|
||||||
| 崩溃上报 | **[sentry_flutter](https://pub.dev/packages/sentry_flutter) `^9.26.0`**(官方 verified publisher),配套 [sentry_dart_plugin](https://pub.dev/packages/sentry_dart_plugin) `^3.4.0` 上传符号表 |
|
|
||||||
| Sentry 部署形态 | **自建优先**(`sentry.io` SaaS 是跨境上报),**待确认** |
|
|
||||||
| 本地日志 | **[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),写入本地日志并作为崩溃上报的自定义字段 |
|
|
||||||
|
|
||||||
## 一、崩溃上报:Sentry
|
## 日志规则
|
||||||
|
|
||||||
### 为什么不是 Bugly
|
- `AppLogger` 是唯一日志入口;请求日志和业务日志都必须经 `scrubber.dart` 脱敏。
|
||||||
|
- dev 输出 debug 级别日志,uat 输出 info 及以上,prod 不输出控制台日志。
|
||||||
|
- 日志不持久化、不上传;不保留无消费者的崩溃黑匣子缓存。
|
||||||
|
- Provider 失败和未知路由仅记录脱敏日志。业务拒绝、登录失效和主动取消属于预期流程,
|
||||||
|
不作为错误日志噪音处理。
|
||||||
|
|
||||||
Bugly 是团队过往项目用过的方案,国内可达性没问题,本来是很自然的默认选项。**否掉它的理由只有一条,但这一条是决定性的:Bugly 没有上传 Dart 符号表的能力。**
|
## 埋点
|
||||||
|
|
||||||
- Flutter App 的**绝大多数异常是 Dart 异常**(`setState` 期间抛错、null check、JSON 解析失败),不是原生崩溃。Bugly 只能把它们当"自定义异常"收下,存成一段字符串堆栈。
|
`Analytics` 仍由 app 层注入。会话建立时设置用户与角色维度,切店时更新门店维度,登出时
|
||||||
- [08-build-flavors.md](./08-build-flavors.md) 要求 release 必须 `--obfuscate --split-debug-info`。两者相加的结果是:**线上占比最大的那一半崩溃,在 Bugly 后台是一串读不出来的混淆符号**,只能人工把堆栈拷出来跑 `flutter symbolize` 还原。
|
先记录登出事件再 reset,避免共用门店设备上的用户上下文串联。
|
||||||
|
|
||||||
Bugly 在原生侧(Java/Kotlin 异常、SIGSEGV、ANR、iOS crash)确实做得好,能自动符号化。但它强的正好是我们占比小的那一半。
|
如未来需要崩溃上报,须单独完成合规、数据字段、脱敏、符号表保存和 SDK 选型评审;不得
|
||||||
|
直接恢复本项目已删除的第三方 SDK 配置。
|
||||||
其余差别一并记录在此,作为决策存档:
|
|
||||||
|
|
||||||
| | 腾讯 Bugly | Sentry |
|
|
||||||
|---|---|---|
|
|
||||||
| **Dart 异常堆栈还原** | **做不到** | **做得到**(`sentry_dart_plugin` 自动上传) |
|
|
||||||
| Flutter 官方 SDK | **没有**,只有 Android/iOS 原生 SDK,pub.dev 上只有 `flutter_bugly` 1.1.1、`bugly_pro_flutter` 0.4.21 两个 unverified 社区插件 | **有**,官方维护、13 天前刚发版 |
|
|
||||||
| 原生崩溃 | 强项,自动符号化 | 支持,mapping/dSYM 由同一个插件上传 |
|
|
||||||
| 接入成本 | 要自己写 `native_crash`(Pigeon + 几十行 Kotlin/Swift) | `pubspec.yaml` 加两行 |
|
|
||||||
| 国内可达性 | 无问题 | **自建无问题;SaaS 是跨境上报**,见下 |
|
|
||||||
| 运维成本 | 无 | 自建的话有(存储、升级、告警) |
|
|
||||||
| 账号/合同 | 本项目**没有**现成的,要新申请 | 同样要新建 |
|
|
||||||
|
|
||||||
代价是运维:Sentry 这条路把"接入成本"换成了"部署成本"。这是这次选型唯一真正付出的东西。
|
|
||||||
|
|
||||||
注意最后一行:**本项目在两边都没有既有账号或合同**,所以"沿用现成的"这个通常最有分量的理由,在这次选型里不成立——两条路的启动成本都要从零算。
|
|
||||||
|
|
||||||
**连带影响:不再需要 `native_crash` 这个包。** [07-native-integration.md](./07-native-integration.md) 里的 Pigeon 包只剩 `native_scan` / `native_media`。
|
|
||||||
|
|
||||||
### 唯一还没定的:自建还是 SaaS
|
|
||||||
|
|
||||||
这一条**必须在开工前定**,它决定的不只是可达性,还有合规:
|
|
||||||
|
|
||||||
- **自建(推荐)**:崩溃数据不出境,门店网络下上报可靠,长期成本可控。代价是要一套内网 K8s/VM 资源和运维承接方。
|
|
||||||
- **`sentry.io` SaaS**:零运维,但崩溃报告里带着 `userId`、`storeId`、面包屑和日志片段,属于**数据出境**,要走合规评估;同时门店网络访问境外服务的丢报率无法预估。
|
|
||||||
|
|
||||||
需要在讨论时明确的:有没有可用的内网资源、谁运维、以及法务对崩溃数据出境的口径。**在结论出来之前,`SENTRY_DSN` 走 `--dart-define-from-file`(见 08),代码里不写死任何地址——换 DSN 不需要改一行代码。**
|
|
||||||
|
|
||||||
### 依赖与初始化
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
dependencies:
|
|
||||||
sentry_flutter: ^9.26.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
|
||||||
sentry_dart_plugin: ^3.4.0
|
|
||||||
```
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// main.dart —— 崩溃上报必须在最早期初始化,晚一步就漏掉启动期崩溃
|
|
||||||
await SentryFlutter.init(
|
|
||||||
(options) {
|
|
||||||
options.dsn = env.sentryDsn; // 来自 --dart-define-from-file,见 08
|
|
||||||
options.environment = env.flavorName; // dev / uat / prod 分开看,否则测试数据污染线上崩溃率
|
|
||||||
options.release = '${env.appVersion}+${env.buildNumber}'; // 必须和符号表归档对得上
|
|
||||||
options.tracesSampleRate = 0.0; // 首版不开性能追踪,见下
|
|
||||||
options.sendDefaultPii = false; // 关键:默认不采集 IP / 请求头 / 用户信息
|
|
||||||
options.beforeBreadcrumb = scrubBreadcrumb; // 见「脱敏」
|
|
||||||
options.beforeSend = scrubEvent;
|
|
||||||
},
|
|
||||||
appRunner: () => runApp(ProviderScope(
|
|
||||||
retry: (_, __) => null,
|
|
||||||
observers: [ErrorObserver()], // 见 12
|
|
||||||
child: const ContiApp(),
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**`appRunner` 不是可选写法。** 传了它,Sentry 会自己接管 `FlutterError.onError` 和 `PlatformDispatcher.instance.onError` 并把 `runApp` 放进受保护的 error zone;**这时候再手写一遍这两个回调,结果是同一个异常上报两次**,线上崩溃数直接翻倍,是这个 SDK 最常见的接入错误。
|
|
||||||
|
|
||||||
业务代码仍然只依赖 `core_logging` 暴露的 `CrashReporter` 接口,不直接 import `sentry_flutter`:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// packages/core_logging/lib/src/crash_reporter.dart
|
|
||||||
abstract interface class CrashReporter {
|
|
||||||
void setUser(String userId);
|
|
||||||
void setTag(String key, String value);
|
|
||||||
void leaveBreadcrumb(String message);
|
|
||||||
void report(Object error, StackTrace? stack, {Map<String, String> extra = const {}});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
这一层不是为了"将来可能换 Sentry"——**是为了测试里能直接 mock 掉,不必真的初始化 SDK**,以及让 `feature_*` 不多一条对三方 SDK 的直接依赖(见 [01-project-structure.md](./01-project-structure.md) 的依赖规则)。
|
|
||||||
|
|
||||||
### 符号表:唯一必须打通的一步
|
|
||||||
|
|
||||||
`sentry_dart_plugin` 包装 `sentry-cli`,构建后一条命令把 Dart 符号表、Android mapping、iOS dSYM 一起传上去:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# pubspec.yaml
|
|
||||||
sentry:
|
|
||||||
upload_debug_symbols: true
|
|
||||||
upload_source_maps: false # 不做 Web
|
|
||||||
project: conti-retail-app
|
|
||||||
org: continental
|
|
||||||
# auth_token 走 CI 环境变量 SENTRY_AUTH_TOKEN,不写进仓库
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# CI:build 之后立刻跑,见 08
|
|
||||||
fvm flutter build appbundle --flavor prod --target lib/main_prod.dart \
|
|
||||||
--dart-define-from-file=env/prod.json \
|
|
||||||
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
|
|
||||||
fvm dart run sentry_dart_plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
三条硬约束:
|
|
||||||
|
|
||||||
- **`options.release` 必须和上传符号表时的 release 严格一致**,对不上的表现是"符号表传上去了,堆栈还是混淆的"——这是接入 Sentry 最常见的坑,且后台不会报错。统一由 `versionName+versionCode` 生成(见 08 的版本号规则)。
|
|
||||||
- **上传步骤必须在 CI 里、紧跟 build**,不能靠人工。漏传一次,那个版本的崩溃就永久读不出来。
|
|
||||||
- **本地符号表归档照旧保留**(08 要求 ≥1 年)。Sentry 能自动还原之后它不再是唯一手段,但仍是 Sentry 服务出问题/数据过期时的兜底。
|
|
||||||
|
|
||||||
### 必须关掉的默认行为
|
|
||||||
|
|
||||||
Sentry 的默认配置面向公网 C 端产品,有几项在门店场景下不能开:
|
|
||||||
|
|
||||||
| 项 | 结论 |
|
|
||||||
|---|---|
|
|
||||||
| `sendDefaultPii` | **false**。开了会自动带上 IP、请求头(含 `Authorization`)、用户信息 |
|
|
||||||
| Session Replay / 截图(`attachScreenshot`) | **关闭**。收银、经营分析页面上有金额和客户信息 |
|
|
||||||
| `attachViewHierarchy` | 关闭。控件树里会出现输入框内容 |
|
|
||||||
| `tracesSampleRate` | **0.0**,首版不开性能追踪。接口耗时后端已有(见 backend/08),开了只是多一份跨境流量 |
|
|
||||||
| HTTP 面包屑里的 URL | **必须脱敏**:H5 URL 的 query 里带着 `ticket`,原样进面包屑等于把 token 发出去,见「脱敏」一节 |
|
|
||||||
|
|
||||||
### 用户与门店上下文
|
|
||||||
|
|
||||||
会话状态变化时同步(见 [11-store-context-and-session.md](./11-store-context-and-session.md)):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
CrashReporter.instance
|
|
||||||
..setUser(session.user.userId) // 只传 ID,不传手机号/姓名
|
|
||||||
..setTag('storeId', '${session.store.storeId}')
|
|
||||||
..setTag('roleCode', session.user.roleCode)
|
|
||||||
..setTag('flavor', env.flavorName);
|
|
||||||
```
|
|
||||||
|
|
||||||
**`storeId` 一定要带。** 它能直接回答"这个崩溃是不是只发生在某几家门店"——门店设备型号和网络环境高度集中,很多崩溃是设备相关的,没有这个维度只能盲猜。
|
|
||||||
|
|
||||||
`traceId` 在网络相关的错误上报时作为自定义字段带上,这样一条崩溃能直接关联到后端 ELK 里的那次请求(见 [backend/08-observability.md](../../conti-backend/docs/08-observability.md))。
|
|
||||||
|
|
||||||
### 崩溃前的页面路径
|
|
||||||
|
|
||||||
崩溃报告里最有用的上下文之一是"崩之前用户在哪几个页面"。go_router 的 `observers` 挂一个 `NavigationObserver`(见 [04-routing.md](./04-routing.md)),把最近的路由变化写进环形缓冲,随崩溃一起上报:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
class NavigationObserver extends NavigatorObserver {
|
|
||||||
NavigationObserver(this._reporter);
|
|
||||||
final CrashReporter _reporter;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didPush(Route route, Route? previous) =>
|
|
||||||
_reporter.leaveBreadcrumb('nav: ${route.settings.name}');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
注意**记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据(见脱敏一节)。同理再补三处业务关键节点:H5 启动/失败、门店切换、扫码。这三条链路最长、最容易出问题。
|
|
||||||
|
|
||||||
### 上报什么、不上报什么
|
|
||||||
|
|
||||||
- **上报**:未捕获的 Dart 异常、原生崩溃、ANR、Riverpod provider 抛出的异常(通过 `ErrorObserver`,即使 UI 已经优雅处理了——"用户看到了漂亮的错误页"和"不需要知道有多少人看到"是两回事,见 12)。
|
|
||||||
- **不上报**:`RequestCancelledException`(用户正常退出页面)、`UnauthorizedException`(正常的登出流程)。这两类是业务流程的一部分,上报只会把真正的崩溃淹掉。
|
|
||||||
|
|
||||||
### 验证接入真的成功了
|
|
||||||
|
|
||||||
崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 App 很稳定。所以:
|
|
||||||
|
|
||||||
- `core_logging` 暴露一个 `throwTestException()`,**只在 dev flavor 下可调**,每次发版前在 dev 上验证一遍 Android 和 iOS 都能在 Sentry 里看到。
|
|
||||||
- **同时验证堆栈是不是可读的**——这一步比"能收到"更容易漏。用 `--obfuscate` 打一个 release 包、跑一遍 `sentry_dart_plugin`、再触发一次异常,确认后台显示的是 Dart 文件名行号而不是 `_x12`。`release` 对不上的话就是这个表现,见上文。
|
|
||||||
- uat 环境跑一个迭代后,对一下"Sentry 上的错误数"和"埋点里的 `api_failed` 数量级",如果差得离谱说明有一侧漏了。
|
|
||||||
|
|
||||||
## 二、日志规范
|
|
||||||
|
|
||||||
### `AppLogger`
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// packages/core_logging/lib/src/app_logger.dart
|
|
||||||
abstract interface class AppLogger {
|
|
||||||
void d(String message, {Map<String, Object?>? data});
|
|
||||||
void i(String message, {Map<String, Object?>? 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 退出即消失,避免日志文件成为新的泄漏面。实现上挂在 `beforeSend` 里作为 `contexts` 附加,单个事件体积有上限,所以实际带的是**最近 30 条**,不是全部 500 条。
|
|
||||||
|
|
||||||
### 脱敏(PRD §21.3「敏感字段脱敏」)
|
|
||||||
|
|
||||||
```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。
|
|
||||||
- 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志会随崩溃一起传上去。
|
|
||||||
|
|
||||||
**同一个脱敏器要挂到 Sentry 的两个钩子上**,这是上文 `SentryFlutter.init` 里 `beforeBreadcrumb` / `beforeSend` 的实现:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// packages/core_logging/lib/src/sentry_scrubber.dart
|
|
||||||
Breadcrumb? scrubBreadcrumb(Breadcrumb? crumb, Hint hint) {
|
|
||||||
if (crumb == null) return null;
|
|
||||||
// SDK 自动记录的 HTTP 面包屑里 url 是完整的,query 里可能带 ticket / token
|
|
||||||
final url = crumb.data?['url'];
|
|
||||||
if (url is String) {
|
|
||||||
final u = Uri.tryParse(url);
|
|
||||||
crumb.data?['url'] = u == null ? '<redacted>' : u.replace(query: '').toString();
|
|
||||||
}
|
|
||||||
return crumb;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`beforeBreadcrumb` 不能漏。** Sentry 默认会自动记录所有 HTTP 请求作为面包屑,我们在 05/10 里辛苦保证的"URL 不落日志",会被这条默认行为绕过去——它不走我们的 `AppLogger`。
|
|
||||||
|
|
||||||
### 不采集什么
|
|
||||||
|
|
||||||
出于合规(个人信息保护法「最小必要」原则)和 PRD §21.3:
|
|
||||||
|
|
||||||
| 项 | 结论 |
|
|
||||||
|---|---|
|
|
||||||
| 崩溃截图 / Session Replay / View Hierarchy | **关闭**。收银、经营分析页面上有金额和客户信息 |
|
|
||||||
| 精确位置 | 不采集。App 没有需要精确位置的功能 |
|
|
||||||
| IMEI / IDFA / MAC / AndroidID | **不采集**(见 05,`X-Device-Id` 用的是匿名安装 UUID)。**神策原生 SDK 默认会采集设备标识来生成 `distinct_id`,必须在初始化时逐项关掉**;Sentry 侧靠 `sendDefaultPii = false` |
|
|
||||||
| 通讯录、短信 | 不申请权限 |
|
|
||||||
| 用户输入的原文 | 不打日志(包括搜索关键词里可能出现的车牌、手机号) |
|
|
||||||
|
|
||||||
这份清单要和 App 的隐私政策(PRD §10.3,由 App Backend 下发)**逐条对齐**——隐私政策里没写的,代码里就不能采。**第三方 SDK 的默认采集行为是最容易在合规审查时出问题的地方**:神策和 Sentry 的隐私说明都要单独过一遍,并且要在**用户同意隐私政策之前不初始化**(两个 SDK 都支持延迟初始化),否则「同意前不采集」这条硬要求就破了。
|
|
||||||
|
|
||||||
## 三、埋点:以后端为主
|
|
||||||
|
|
||||||
**大部分业务埋点由后端从自己的请求日志和审计日志里出,客户端不重复做一遍。**
|
|
||||||
|
|
||||||
理由很直接:任何一个业务动作(登录、切店、下单、入库、打开 H5)都会打到 App Backend 的接口上,后端已经有 `traceId`、用户上下文、门店上下文和 `@Audited` 审计通道(见 [backend/08-observability.md](../../conti-backend/docs/08-observability.md))。客户端再报一遍,得到的是同一件事的两份数据——而且客户端那份还更不可靠(可能丢、可能延迟、可能被篡改)。
|
|
||||||
|
|
||||||
**这两份数据最好落到同一个地方。** 神策有服务端 SDK / 数据导入接口,后端把业务事件写进同一个神策项目的话,运营就能做「扫码失败的门店,后续下单转化率是不是更低」这种跨端漏斗;分成两套系统也能跑,但每次跨端分析都要人工对数。**这一条要和后端确认**,见待确认项。
|
|
||||||
|
|
||||||
### Metabase 不是神策的替代品
|
|
||||||
|
|
||||||
后端侧提到过 Metabase。**它和神策不冲突,也不是二选一**——两者根本不在一层:
|
|
||||||
|
|
||||||
| | 神策 | Metabase |
|
|
||||||
|---|---|---|
|
|
||||||
| 客户端采集 SDK | **有** | **没有**,它不采集任何数据 |
|
|
||||||
| 数据来源 | 自己的 SDK / 服务端导入 | 接已有的数据库、数仓 |
|
|
||||||
| 定位 | 采集 + 管道 + 分析平台 | BI / 看板层 |
|
|
||||||
|
|
||||||
Metabase 官网自己把 Mixpanel、PostHog、Amplitude 列为**上游集成**——由那些工具负责采集,Metabase 在导出的数据上出图。这就说明了它的位置。
|
|
||||||
|
|
||||||
所以合理的分工是:**神策收客户端事件;后端的业务埋点本来就在自己库里,Metabase 接上去出报表。** 后端如果已经在用 Metabase,那是个好消息而不是冲突信号——它意味着上面「两份数据落到一个地方」这条有了第二种解法:不把业务数据推进神策,而是反过来把神策的客户端事件导出到同一个库,用 Metabase 统一出图,还能直接和订单、门店主数据 join。哪一种更合适取决于后端的数仓现状,一并列进待确认项。
|
|
||||||
|
|
||||||
### 分工
|
|
||||||
|
|
||||||
| PRD §22.1 事件 | 谁来出 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| 登录成功/失败 | **后端** | 登录本身就是接口调用 |
|
|
||||||
| 首页曝光 | **后端** | 首页聚合接口的调用即曝光 |
|
|
||||||
| 门店切换 | **后端** | 切换接口 |
|
|
||||||
| 采购下单 | **后端** | |
|
|
||||||
| 入库成功 | **后端** | |
|
|
||||||
| 待办点击 | **后端** | 点击后会请求详情接口 |
|
|
||||||
| **扫码成功/失败** | **客户端** | 扫码是 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 里不多见(对比 Bugly 那两个 unverified 社区插件)。**团队过往项目用过,事件模型和数据接入的坑踩过一遍**,这是选它最实在的理由。
|
|
||||||
|
|
||||||
#### 神策不是"开箱即用",这些活一样要干
|
|
||||||
|
|
||||||
先把预期摆正,否则排期一定会低估。**接了神策之后,下面这些工作量和自建一套上报是完全一样的**:
|
|
||||||
|
|
||||||
- **事件方案设计**——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 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<String, Object?> params = const {}]);
|
|
||||||
void registerSuperProperties(Map<String, Object?> props); // 公共属性,注册一次全局附加
|
|
||||||
void identify(String userId); // 登录成功后调
|
|
||||||
void reset(); // 登出时调
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
理由和 `CrashReporter` 一样:测试里能 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) 的 12 项)里也没有埋点这一项。
|
|
||||||
|
|
||||||
### 命名约定
|
|
||||||
|
|
||||||
`snake_case`,`对象_动作` 或 `对象_动作_结果`。结果类用过去式(`succeeded`/`failed`),动作类用现在式(`clicked`)。
|
|
||||||
|
|
||||||
事件名和参数名一旦上线**不再改**——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(`AnalyticsEvent.scanSucceeded`),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。
|
|
||||||
|
|
||||||
## 四、性能指标
|
|
||||||
|
|
||||||
| 指标 | 怎么测 | 目标 |
|
|
||||||
|---|---|---|
|
|
||||||
| 冷启动到首帧 | `WidgetsBinding.instance.addTimingsCallback` | < 2s |
|
|
||||||
| 冷启动到首页可用 | `main()` → 首页数据渲染完成 | < 3s |
|
|
||||||
| H5 打开耗时 | `h5_first_paint` 的 `ticketMs + loadMs` | < 3s(PRD §21.1 要求有超时策略) |
|
|
||||||
| 接口耗时 | 后端侧统计即可,客户端不重复报 | P95 < 1s |
|
|
||||||
| 帧率 | 先不做自动采集,用 DevTools 人工测关键页面 | — |
|
|
||||||
|
|
||||||
**接口耗时不由客户端报**:后端有完整的请求日志和 Micrometer 指标(见 backend/08)。客户端唯一能补充的是"客户端观测到的耗时 - 服务端处理耗时 = 网络耗时",这个差值有价值但不是首版必须,先不做。
|
|
||||||
|
|
||||||
## 五、和后端审计日志的分工
|
|
||||||
|
|
||||||
`backend/08-observability.md` 已经有 `@Audited` 审计日志通道。**审计以服务端为准,客户端不做审计**——客户端日志可被篡改,不能作为审计依据。
|
|
||||||
|
|
||||||
| | 客户端埋点 | 服务端日志/审计 |
|
|
||||||
|---|---|---|
|
|
||||||
| 目的 | 补齐后端看不到的行为 | 业务分析、合规追溯 |
|
|
||||||
| 可信度 | 参考 | 权威 |
|
|
||||||
| 覆盖 | 纯客户端行为、请求失败 | 所有到达服务端的操作 |
|
|
||||||
|
|
||||||
## 待确认项
|
|
||||||
|
|
||||||
- **Sentry 是自建还是用 SaaS**——这是本篇最硬的阻塞项,决定可达性和数据出境合规口径。需要明确内网资源、运维承接方、法务意见。见上文「唯一还没定的」。
|
|
||||||
- Sentry 的 org/project 划分:dev/uat/prod 是三个 project 还是靠 `environment` 区分(建议 prod 单独一个 project,避免测试数据污染线上崩溃率告警)。
|
|
||||||
- **公司有没有在用的神策服务?** 本项目没有现成账号。有的话拿数据接收地址即可;**没有的话开通神策是采购流程,不是配置项**,周期可能比开发长。这一项是埋点唯一的外部依赖——**但它不阻塞开工**:事件方案设计和 `core_analytics` 接口先做,这两块工作量与最终用什么 SDK 无关。真的走不通,实现类换成最小的 `POST /api/v1/events/batch`,代价是可靠投递要自己补。
|
|
||||||
- 若确认用神策:数据接收地址是私有化部署还是神策云,以及 dev/uat/prod 的项目划分。地址走 `--dart-define-from-file`,代码里不写死。
|
|
||||||
- **客户端事件和后端业务数据怎么汇到一起**:是后端用神策服务端 SDK 写进同一个神策项目,还是把神策的客户端事件导出到后端数仓、统一用 Metabase 出图。取决于后端数仓现状和 Metabase 的实际使用情况,要和后端一起定。
|
|
||||||
- 神策原生 SDK 的默认设备信息采集项(`distinct_id` 的生成方式、是否取 AndroidID/IDFA),需逐项关闭并与隐私政策对齐(法务侧)。
|
|
||||||
- **后端的业务埋点写不写进同一个神策项目**(用神策服务端 SDK / 数据导入),还是留在自己的 ELK 里出报表。影响的是能不能做跨端漏斗分析。
|
|
||||||
- 后端从请求日志出业务埋点的具体口径(哪个接口对应哪个事件),需要和后端一起把 PRD §22.1 的 9 类事件逐条落到接口上。
|
|
||||||
- 神策和 Sentry 都要在**用户同意隐私政策之后**才初始化,具体的延迟初始化时机要和 `feature_auth` 的协议弹窗流程对齐。
|
|
||||||
- 性能指标目标值需在真机(门店常用的中低端 Android)实测后校准,上表是初始预期值。
|
|
||||||
|
|
||||||
## 参考链接
|
|
||||||
|
|
||||||
- [sentry_flutter | Dart package](https://pub.dev/packages/sentry_flutter)
|
|
||||||
- [sentry_dart_plugin | Dart package](https://pub.dev/packages/sentry_dart_plugin)(上传 Dart 符号表 / mapping / dSYM)
|
|
||||||
- [Sentry: Flutter Debug Symbols](https://docs.sentry.io/platforms/dart/guides/flutter/debug-symbols/)
|
|
||||||
- [Sentry: 自建(self-hosted)](https://develop.sentry.dev/self-hosted/)
|
|
||||||
- [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)
|
|
||||||
- [logger | Dart package](https://pub.dev/packages/logger)
|
|
||||||
- [backend/08-observability.md:traceId 与审计日志](../../conti-backend/docs/08-observability.md)
|
|
||||||
- [PRD §21.4 可观测性 / §22.1 埋点](../../conti-docs/Architecture-Diagram/202606-Continental-Retail-APP-PRD.md)
|
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# 15. Flutter SDK CLI:官方 SDK 与 OpenHarmony SDK 速查
|
||||||
|
|
||||||
|
本文是本仓库的 **Flutter 命令行总手册**:先记录官方 Flutter SDK 的通用开发命令,再标记 CPF-Flutter OpenHarmony(OHOS)SDK 所增加的命令、环境和限制。它不是只讲 OHOS 的手册。
|
||||||
|
|
||||||
|
同一套 Dart / Flutter 业务代码通常使用相同的 CLI;区别主要在可用目标平台、原生构建工具、构建产物及插件实现。开发前先选定本次终端使用的 SDK,避免官方 Flutter 与 CPF-Flutter 混用。
|
||||||
|
|
||||||
|
> 本文是开发操作指南,不替代产品上线验收。首版正式交付范围仍以 [README](./README.md) 所述 Android / iOS 为准;OHOS 的真机、插件和原生能力须分别验收。
|
||||||
|
|
||||||
|
## 1. 先选 SDK:官方 Flutter 还是 OHOS Flutter
|
||||||
|
|
||||||
|
| 目标 | 应使用的 SDK | 典型命令 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Android、iOS、Web、Windows、macOS、Linux | 官方 Flutter SDK | `flutter run`、`flutter build apk`、`flutter build appbundle`、`flutter build ipa`、`flutter build web` 等 |
|
||||||
|
| OpenHarmony | CPF-Flutter OHOS SDK | `flutter run -d <OHOS设备>`、`flutter build hap` |
|
||||||
|
| 同时维护 Android / iOS / OHOS | CPF-Flutter OHOS SDK(项目和插件须完成 OHOS 兼容验证) | `flutter run -d <对应设备>`、按目标使用 `build apk` / `build hap` |
|
||||||
|
|
||||||
|
检查当前终端实际使用哪个 SDK:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Command flutter -All
|
||||||
|
flutter --version
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把两个 SDK 的 `bin` 目录同时放在 `PATH` 前部并期待 Flutter 自动切换。`flutter` 命令只会使用 PATH 中排在最前的那个 SDK。需要明确切换时,调整 PATH、使用 FVM,或直接用目标 SDK 的 `flutter.bat` 完整路径执行。
|
||||||
|
|
||||||
|
### 官方 Flutter SDK 的通用命令
|
||||||
|
|
||||||
|
以下命令来自 Flutter CLI 本身,官方 Flutter SDK 与 CPF-Flutter OHOS SDK 都会保留(具体平台能力由当前 SDK 与目标设备决定):
|
||||||
|
|
||||||
|
| 场景 | 命令 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 查看版本 | `flutter --version` | 确认实际 SDK 与 Dart 版本 |
|
||||||
|
| 检查环境 | `flutter doctor -v` | 检查平台工具链与已连接设备 |
|
||||||
|
| 配置 SDK | `flutter config --<key> <value>` | 查看:`flutter config --list` |
|
||||||
|
| 新建 App | `flutter create --org <org> <appName>` | 默认创建当前 SDK 支持的平台 |
|
||||||
|
| 列出设备 | `flutter devices` | 获取 `deviceId` |
|
||||||
|
| 启动调试 | `flutter run -d <deviceId>` | 构建、安装、运行、连接调试器 |
|
||||||
|
| 连接已运行应用 | `flutter attach -d <deviceId>` | 不重新安装应用 |
|
||||||
|
| 截屏 | `flutter screenshot -d <deviceId> -o .\screen.png` | 默认截取设备完整屏幕 |
|
||||||
|
| 静态分析 | `flutter analyze` | 按 `analysis_options.yaml` 分析代码 |
|
||||||
|
| 执行测试 | `flutter test` | 执行 unit / widget 测试 |
|
||||||
|
| 获取依赖 | `flutter pub get` | 按 `pubspec.yaml` 解析依赖 |
|
||||||
|
| 更新依赖 | `flutter pub upgrade` | 更新允许范围内的依赖版本 |
|
||||||
|
| 查看依赖状态 | `flutter pub outdated` | 查看可升级依赖 |
|
||||||
|
| 清理项目 | `flutter clean` | 清理当前工程的构建与工具缓存 |
|
||||||
|
| 修复全局缓存 | `flutter pub cache repair` | 重新下载全局 Pub 缓存中的包 |
|
||||||
|
| 清空全局缓存 | `flutter pub cache clean` | 影响所有 Flutter / Dart 项目,最后手段 |
|
||||||
|
|
||||||
|
### 官方 Flutter SDK 的常见构建目标
|
||||||
|
|
||||||
|
这些是官方 SDK 的常用构建命令;是否可用取决于该平台的工具链与项目是否已生成对应目录。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
flutter build apk --debug # Android 测试 APK
|
||||||
|
flutter build apk --release # Android 正式 APK
|
||||||
|
flutter build appbundle --release # Android Play 上架 AAB
|
||||||
|
flutter build ipa --release # iOS IPA(仅 macOS + Xcode 环境)
|
||||||
|
flutter build web --release # Web 静态产物
|
||||||
|
flutter build windows --release # Windows 桌面应用(Windows + Visual Studio)
|
||||||
|
flutter build macos --release # macOS 桌面应用(仅 macOS)
|
||||||
|
flutter build linux --release # Linux 桌面应用(仅 Linux)
|
||||||
|
```
|
||||||
|
|
||||||
|
`flutter assemble` 是底层 Flutter 资源构建入口,通常由上述 `flutter build <target>` 自动调用;日常不应把它当作完整应用打包命令。
|
||||||
|
|
||||||
|
## 2. 当前项目状态
|
||||||
|
|
||||||
|
`app/` 已经是一个包含 OpenHarmony 平台目录的 Flutter 项目,**不要在 `app/` 目录重新执行 `flutter create`**。这会以模板补全/修复工程,可能改写已有原生配置。
|
||||||
|
|
||||||
|
| 项目 | 当前值 / 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| Flutter SDK | CPF-Flutter `3.41.10-ohos-1.0.0` |
|
||||||
|
| Flutter SDK 路径 | `C:\Users\A200477427\Developments\flutter_flutter` |
|
||||||
|
| OHOS 工程 | `app/ohos/` 已存在 |
|
||||||
|
| DevEco OHOS SDK | `C:\Program Files\Huawei\DevEco Studio\sdk` |
|
||||||
|
| 已验证模拟器 | `127.0.0.1:5555`,`ohos-x64` |
|
||||||
|
| Flutter OHOS 开关 | 已启用:`enable-ohos` |
|
||||||
|
|
||||||
|
## 3. OpenHarmony 专用:一次性环境配置(Windows)
|
||||||
|
|
||||||
|
CPF-Flutter 不是官方 Flutter release channel,因此 `flutter doctor` 可能提示非标准 remote / channel。这是该 OHOS SDK fork 的预期提示;不要为消除这条提示切回标准 Flutter SDK。
|
||||||
|
|
||||||
|
### Flutter 配置
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
flutter config --enable-ohos
|
||||||
|
flutter config --ohos-sdk "C:\Program Files\Huawei\DevEco Studio\sdk"
|
||||||
|
flutter config --ohpm-home "C:\Program Files\Huawei\DevEco Studio\tools\ohpm"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows 用户级 PATH
|
||||||
|
|
||||||
|
将下面两个目录加到**用户环境变量** `Path`,然后重启 PowerShell、VS Code / Android Studio:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\Program Files\Huawei\DevEco Studio\tools\ohpm\bin
|
||||||
|
C:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin
|
||||||
|
```
|
||||||
|
|
||||||
|
可选地设置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OHPM_HOME=C:\Program Files\Huawei\DevEco Studio\tools\ohpm
|
||||||
|
```
|
||||||
|
|
||||||
|
仅对当前 PowerShell 临时生效时:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:Path = "C:\Program Files\Huawei\DevEco Studio\tools\ohpm\bin;C:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin;$env:Path"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
flutter doctor -v
|
||||||
|
flutter devices
|
||||||
|
```
|
||||||
|
|
||||||
|
必须看到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[✓] HarmonyOS toolchain - develop for HarmonyOS devices
|
||||||
|
```
|
||||||
|
|
||||||
|
若未看到,优先检查 `ohpm` 和 `hvigorw` 是否能在新打开的 PowerShell 中直接执行,以及 DevEco Studio 是否安装了 OHOS SDK。
|
||||||
|
|
||||||
|
## 4. OpenHarmony 专用:日常运行、构建与安装
|
||||||
|
|
||||||
|
所有项目命令从 `app/`(包含 `pubspec.yaml` 的目录)运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\A200477427\Developers\Continental-App\conti-retail-app\app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模拟器运行
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
flutter pub get
|
||||||
|
flutter devices
|
||||||
|
flutter run -d 127.0.0.1:5555
|
||||||
|
```
|
||||||
|
|
||||||
|
`flutter run` 会构建、安装、启动应用,并连接调试器。运行期间:`r` 热重载、`R` 热重启、`q` 退出调试。
|
||||||
|
|
||||||
|
### HAP 构建
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# x64 模拟器测试包
|
||||||
|
flutter build hap --debug --target-platform ohos-x64
|
||||||
|
|
||||||
|
# ARM64 真机测试包(默认目标也为 ohos-arm64)
|
||||||
|
flutter build hap --debug --target-platform ohos-arm64
|
||||||
|
|
||||||
|
# ARM64 真机正式包
|
||||||
|
flutter build hap --release --target-platform ohos-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
日常使用 SDK 自带的 OHOS Engine,不需 `--local-engine`。仅在自行编译了兼容 OHOS 的 Flutter Engine 时才传入该路径:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
flutter build hap --debug --target-platform ohos-arm64 --local-engine="C:\path\to\ohos_debug_engine"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安装、附加调试和截图
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 构建并安装到指定设备(本机 SDK 的实际参数为 -d)
|
||||||
|
flutter install -d 127.0.0.1:5555 --debug
|
||||||
|
|
||||||
|
# 附加到已运行应用,不重新构建/安装
|
||||||
|
flutter attach -d 127.0.0.1:5555
|
||||||
|
|
||||||
|
# 截取完整设备屏幕
|
||||||
|
flutter screenshot -d 127.0.0.1:5555 -o .\ohos-screenshot.png
|
||||||
|
```
|
||||||
|
|
||||||
|
部分历史 OHOS SDK 文档将已有 HAP 的安装写为 `flutter install -t <deviceId> <hap路径>`。应以当前 SDK 的 `flutter help install` 为准;本项目当前 SDK 显示的是 `-d` / `--device-id`,且不将 HAP 路径作为该命令的位置参数。
|
||||||
|
|
||||||
|
## 5. OpenHarmony 扩展命令速查
|
||||||
|
|
||||||
|
`[]` 和 `<>` 均是文档占位符,实际执行时不要输入。
|
||||||
|
|
||||||
|
| 指令 | 作用 | 常用示例 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `doctor` | 检查开发环境 | `flutter doctor -v` |
|
||||||
|
| `config` | 配置 OHOS SDK 与工具 | `flutter config --enable-ohos` |
|
||||||
|
| `create`(App) | 创建独立多端应用 | `flutter create --platforms ohos,android,ios --org com.conti my_app` |
|
||||||
|
| `create`(Module) | 创建嵌入原生应用的 Flutter module | `flutter create -t module my_module` |
|
||||||
|
| `create`(Plugin) | 创建 MethodChannel 插件 | `flutter create -t plugin --platforms ohos,android,ios my_plugin` |
|
||||||
|
| `create`(Plugin FFI) | 创建基于 Dart FFI 的插件 | `flutter create -t plugin_ffi --platforms ohos,android,ios my_plugin_ffi` |
|
||||||
|
| `devices` | 查找已连接设备 | `flutter devices` |
|
||||||
|
| `assemble` | 构建 Flutter 底层资源 | `flutter assemble`(通常由 `build hap` 自动调用) |
|
||||||
|
| `build hap` | 构建 OHOS HAP 安装包 | `flutter build hap --debug` / `--release` |
|
||||||
|
| `run` | 构建、安装、启动并调试 | `flutter run -d <deviceId>` |
|
||||||
|
| `install` | 构建并安装到设备 | `flutter install -d <deviceId> --debug` |
|
||||||
|
| `attach` | 附加到已运行应用 | `flutter attach -d <deviceId>` |
|
||||||
|
| `screenshot` | 截取设备屏幕 | `flutter screenshot -d <deviceId> -o .\screen.png` |
|
||||||
|
| `pub get` | 获取当前项目依赖 | `flutter pub get` |
|
||||||
|
| `clean` | 清理当前项目构建缓存 | `flutter clean` |
|
||||||
|
| `pub cache` | 管理全局 Pub 缓存 | `flutter pub cache repair` |
|
||||||
|
| `pub cache clean` | 清空全局 Pub 缓存 | `flutter pub cache clean` |
|
||||||
|
|
||||||
|
### 依赖与缓存的正确清理顺序
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 首选:只影响当前项目
|
||||||
|
flutter clean
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
# 缓存确实损坏时:修复整机 Pub 缓存
|
||||||
|
flutter pub cache repair
|
||||||
|
|
||||||
|
# 最后手段:清空整机所有 Pub 缓存,再重新下载
|
||||||
|
flutter pub cache clean
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
`flutter pub cache clean` 会影响电脑上的所有 Flutter / Dart 项目,不能作为普通构建失败时的第一反应。
|
||||||
|
|
||||||
|
## 6. CPF-Flutter OHOS SDK 与标准 Flutter SDK 的区别
|
||||||
|
|
||||||
|
| 维度 | 标准 Flutter SDK | CPF-Flutter OHOS SDK |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 维护与发行 | Flutter 官方渠道及其 Engine | CPF-Flutter 维护的 OpenHarmony 适配发行版 |
|
||||||
|
| 新增目标平台 | Android、iOS、Web、桌面等 | 在 Flutter 工具链中增加 `ohos` |
|
||||||
|
| 原生工程目录 | `android/`、`ios/` 等 | 额外生成 `ohos/` |
|
||||||
|
| 产物 | Android APK/AAB、iOS IPA 等 | OpenHarmony `.hap` |
|
||||||
|
| 构建命令 | `flutter build apk`、`appbundle`、`ipa` 等 | `flutter build hap --debug` / `--release` |
|
||||||
|
| 原生构建工具 | Gradle、Xcode 等 | DevEco Studio SDK、`ohpm`、`hvigorw` |
|
||||||
|
| 设备发现/运行 | `flutter devices` / `flutter run` | 命令保持相同,但发现并运行 OHOS 实机/模拟器 |
|
||||||
|
| 插件实现 | 各平台的官方 embedding / API | 插件必须有 OHOS 适配实现;并非所有 pub.dev 插件天然可用 |
|
||||||
|
| SDK 版本标识 | 例如 `3.41.x stable` | 例如 `3.41.10-ohos-1.0.0`,带 OHOS fork 后缀 |
|
||||||
|
|
||||||
|
### 不变的部分
|
||||||
|
|
||||||
|
- Dart、Flutter Widget、`pubspec.yaml`、`flutter pub get`、Widget / 单元测试的开发体验基本相同。
|
||||||
|
- `flutter devices`、`flutter run`、`flutter attach`、`flutter screenshot` 仍是主要 CLI 入口。
|
||||||
|
- 应用业务层不应因为增加 OHOS 平台而把平台判断散落到 feature 中;平台差异应集中在插件或原生集成层。
|
||||||
|
|
||||||
|
### 需要额外注意的部分
|
||||||
|
|
||||||
|
1. **插件兼容性不是自动获得的。** 每个含原生能力的依赖(相机、扫码、WebView、推送、存储、权限等)都必须确认其是否有 OHOS implementation,并在 OHOS 模拟器与真机分别验证。
|
||||||
|
2. **OHOS 使用 DevEco 构建链。** `flutter build hap` 内部会准备 Flutter 资源,再由 `ohpm` / `hvigorw` 完成 HAP 组装;前述工具未在 PATH 时 `flutter doctor` 会失败。
|
||||||
|
3. **模拟器与真机架构不同。** 当前模拟器是 `ohos-x64`;大多数真机是 `ohos-arm64`。构建/测试时应明确目标。
|
||||||
|
4. **本地 Engine 是高级路径。** `--local-engine` 仅用于 SDK / Engine 开发或特定兼容性验证,普通业务开发使用随 CPF-Flutter SDK 提供的 Engine。
|
||||||
|
5. **Windows 路径约束。** CPF-Flutter 文档提示,在 Windows 上 Flutter 项目与依赖插件项目应位于同一磁盘分区,避免宿主构建/插件解析出现路径问题。
|
||||||
|
|
||||||
|
## 7. 故障排查顺序
|
||||||
|
|
||||||
|
```text
|
||||||
|
flutter doctor -v
|
||||||
|
↓
|
||||||
|
确认 HarmonyOS toolchain 为 ✓
|
||||||
|
↓
|
||||||
|
flutter devices
|
||||||
|
↓
|
||||||
|
确认目标设备 ID 与架构
|
||||||
|
↓
|
||||||
|
flutter clean && flutter pub get
|
||||||
|
↓
|
||||||
|
flutter run -d <deviceId>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ohpm` / `hvigorw` 缺失:确认第 3 节 PATH 配置,重开终端后再执行 `flutter doctor -v`。
|
||||||
|
- 设备未出现:确认模拟器已启动或真机已开启开发者模式/USB 调试,然后再次 `flutter devices`。
|
||||||
|
- 构建残留导致异常:先 `flutter clean`,再 `flutter pub get`;不要直接清除全局缓存。
|
||||||
|
- 某个原生功能不可用:先检查对应插件 OHOS 兼容性与 `ohos/` 侧实现,不要默认 Dart 层代码跨平台即代表原生能力可用。
|
||||||
|
|
||||||
|
## 8. 来源与版本原则
|
||||||
|
|
||||||
|
- [CPF-Flutter / flutter_flutter](https://gitcode.com/CPF-Flutter/flutter_flutter):OHOS Flutter SDK、支持命令与版本分支。
|
||||||
|
- [OpenHarmony-SIG flutter_flutter README](https://gitee.com/openharmony-sig/flutter_flutter/blob/master/README.en.md):环境要求、命令与 OpenHarmony 适配说明。
|
||||||
|
- 本机行为以 `flutter --version`、`flutter help <command>` 和 `flutter doctor -v` 为准。OHOS fork 版本演进很快,历史文档中的命令参数若与当前 CLI 帮助不一致,应以当前 CLI 为准。
|
||||||
+3
-2
@@ -4,7 +4,7 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读
|
|||||||
|
|
||||||
**这些文档是本仓库代码的约束来源。** 代码和文档不一致时,以文档为准改代码。
|
**这些文档是本仓库代码的约束来源。** 代码和文档不一致时,以文档为准改代码。
|
||||||
|
|
||||||
首版范围为 **Android / iOS**,鸿蒙 OHOS 不在首版内(但 SDK 基线锁 3.44.9 是为后续 OHOS 适配留窗口,见 01 和 07)。
|
首版正式交付范围仍为 **Android / iOS**。`harmony` 分支已用 Flutter_OH 完成 OHOS debug HAP 编译验证,但这不是生产可用结论;运行、Dart/ArkTS 桥接、相机扫码和真机验收仍须按 [07](./07-native-integration.md) 的 Gate 完成。
|
||||||
|
|
||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -14,7 +14,7 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读
|
|||||||
| [04-routing.md](./04-routing.md) | 路由方案(go_router) |
|
| [04-routing.md](./04-routing.md) | 路由方案(go_router) |
|
||||||
| [05-networking.md](./05-networking.md) | 网络层设计(dio) |
|
| [05-networking.md](./05-networking.md) | 网络层设计(dio) |
|
||||||
| [06-local-storage.md](./06-local-storage.md) | 本地存储方案(Drift / secure storage / shared_preferences) |
|
| [06-local-storage.md](./06-local-storage.md) | 本地存储方案(Drift / secure storage / shared_preferences) |
|
||||||
| [07-native-integration.md](./07-native-integration.md) | 原生能力集成方式(Pigeon) |
|
| [07-native-integration.md](./07-native-integration.md) | 原生能力集成方式(Android/iOS Pigeon;OHOS 宿主 MethodChannel) |
|
||||||
| [08-build-flavors.md](./08-build-flavors.md) | 多环境构建(dev/uat/prod flavor) |
|
| [08-build-flavors.md](./08-build-flavors.md) | 多环境构建(dev/uat/prod flavor) |
|
||||||
| [09-testing.md](./09-testing.md) | 测试策略(单元/Widget/集成测试) |
|
| [09-testing.md](./09-testing.md) | 测试策略(单元/Widget/集成测试) |
|
||||||
| [10-webview-h5.md](./10-webview-h5.md) | Embedded H5 容器与 JSBridge(PRD §7 核心链路) |
|
| [10-webview-h5.md](./10-webview-h5.md) | Embedded H5 容器与 JSBridge(PRD §7 核心链路) |
|
||||||
@@ -22,6 +22,7 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读
|
|||||||
| [12-error-and-api-contract.md](./12-error-and-api-contract.md) | 错误处理与 API 契约(`ApiResult`、异常体系、降级) |
|
| [12-error-and-api-contract.md](./12-error-and-api-contract.md) | 错误处理与 API 契约(`ApiResult`、异常体系、降级) |
|
||||||
| [13-observability-analytics.md](./13-observability-analytics.md) | 可观测性与埋点(Sentry 崩溃上报、神策客户端埋点、日志脱敏) |
|
| [13-observability-analytics.md](./13-observability-analytics.md) | 可观测性与埋点(Sentry 崩溃上报、神策客户端埋点、日志脱敏) |
|
||||||
| [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md) | 工程规范与 CI 门禁(lint、格式化、分支、流水线卡点) |
|
| [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md) | 工程规范与 CI 门禁(lint、格式化、分支、流水线卡点) |
|
||||||
|
| [15-flutter-sdk-cli.md](./15-flutter-sdk-cli.md) | Flutter SDK CLI 速查(官方通用命令、OHOS 环境/构建与 SDK 差异) |
|
||||||
|
|
||||||
## 这份副本从哪来、怎么更新
|
## 这份副本从哪来、怎么更新
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Enables the OpenHarmony Flutter toolchain for the current PowerShell session.
|
||||||
|
# This script does not change the system or user PATH permanently.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$flutterBin = 'C:\Users\A200477427\Developments\flutter_flutter\bin'
|
||||||
|
$devecoHome = 'C:\Program Files\Huawei\DevEco Studio'
|
||||||
|
$devecoSdk = "$devecoHome\sdk"
|
||||||
|
$ohpmBin = "$devecoHome\tools\ohpm\bin"
|
||||||
|
$hvigorBin = "$devecoHome\tools\hvigor\bin"
|
||||||
|
|
||||||
|
$requiredDirectories = @($flutterBin, $devecoSdk, $ohpmBin, $hvigorBin)
|
||||||
|
$missingDirectories = $requiredDirectories | Where-Object {
|
||||||
|
-not (Test-Path -LiteralPath $_ -PathType Container)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missingDirectories) {
|
||||||
|
throw "OpenHarmony tool directories were not found:`n$($missingDirectories -join "`n")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set these only for this terminal. This also makes the script work in a terminal
|
||||||
|
# that was opened before the corresponding Windows environment variables existed.
|
||||||
|
$env:DEVECO_HOME = $devecoHome
|
||||||
|
$env:DEVECO_SDK_HOME = $devecoSdk
|
||||||
|
|
||||||
|
# Put the OpenHarmony-specific commands first for this terminal only. Remove
|
||||||
|
# duplicate entries so repeated execution of this script leaves PATH clean.
|
||||||
|
$pathDirectories = @($flutterBin, $ohpmBin, $hvigorBin)
|
||||||
|
$pathEntries = $env:Path -split ';' | Where-Object {
|
||||||
|
$_ -and $_ -notin $pathDirectories
|
||||||
|
}
|
||||||
|
$env:Path = ($pathDirectories + $pathEntries) -join ';'
|
||||||
|
|
||||||
|
$commands = @('flutter', 'ohpm', 'hvigorw')
|
||||||
|
$unavailableCommands = foreach ($command in $commands) {
|
||||||
|
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
|
||||||
|
$command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($unavailableCommands) {
|
||||||
|
throw "The following commands could not be resolved from PATH: $($unavailableCommands -join ', ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'OpenHarmony Flutter environment is enabled for this PowerShell session.' -ForegroundColor Green
|
||||||
|
Get-Command $commands | Select-Object Name, Source, CommandType | Format-Table -AutoSize
|
||||||
@@ -3,7 +3,7 @@ library;
|
|||||||
|
|
||||||
/// 业务代码唯一可见的埋点接口。
|
/// 业务代码唯一可见的埋点接口。
|
||||||
///
|
///
|
||||||
/// 理由和 `CrashReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方
|
/// 这样测试里能 mock,`feature_*` 不多一条对三方
|
||||||
/// SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案
|
/// SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案
|
||||||
/// 先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件。
|
/// 先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件。
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ version: 0.1.0
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_foundation: ^0.1.0
|
core_foundation: ^0.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
/// 11 的 `switchStore` / `logout` 伪代码直接 `ref.read` 了
|
/// 11 的 `switchStore` / `logout` 伪代码直接 `ref.read` 了
|
||||||
/// `webViewSessionProvider`(core_webview)、`appDatabaseProvider`(core_storage)、
|
/// `webViewSessionProvider`(core_webview)、`appDatabaseProvider`(core_storage)、
|
||||||
/// `prefsProvider`(core_storage)、`goRouterProvider`(core_router)、
|
/// `prefsProvider`(core_storage)、`goRouterProvider`(core_router)、
|
||||||
/// `crashReporterProvider`(core_logging)、`analyticsProvider`(core_analytics)。
|
/// `analyticsProvider`(core_analytics)。
|
||||||
///
|
///
|
||||||
/// 但 01 的依赖约束里,`core_auth` 一条 `core_*` 出边都没有(连允许的三条例外
|
/// 但 01 的依赖约束里,`core_auth` 一条 `core_*` 出边都没有(连允许的三条例外
|
||||||
/// 都是别人指向它)。照抄伪代码会把 core_auth 变成整个 core 层的汇聚点,
|
/// 都是别人指向它)。照抄伪代码会把 core_auth 变成整个 core 层的汇聚点,
|
||||||
@@ -72,9 +72,8 @@ abstract interface class SessionScopedStore {
|
|||||||
|
|
||||||
/// 会话变化时需要同步的观测 / 埋点侧。
|
/// 会话变化时需要同步的观测 / 埋点侧。
|
||||||
///
|
///
|
||||||
/// 由 app 层用 core_logging 的 `CrashReporter` 和 core_analytics 的 `Analytics`
|
/// 由 app 层用 core_analytics 的 `Analytics` 实现。漏了这一步会让切店后的埋点
|
||||||
/// 实现。**漏了这一步的表现是切店后的崩溃和埋点还挂在旧门店名下**,按门店维度
|
/// 仍挂在旧门店名下,按门店维度分析时数据会悄悄出错。
|
||||||
/// 分析时数据是错的,而且错得很隐蔽(11 步骤 8)。
|
|
||||||
abstract interface class SessionObserver {
|
abstract interface class SessionObserver {
|
||||||
/// 门店上下文已更新。
|
/// 门店上下文已更新。
|
||||||
void onStoreChanged(StoreContext store);
|
void onStoreChanged(StoreContext store);
|
||||||
|
|||||||
@@ -11,16 +11,18 @@ resolution: workspace
|
|||||||
# 因此本包里的 token 刷新用的是一个**不带任何拦截器的裸 Dio**。
|
# 因此本包里的 token 刷新用的是一个**不带任何拦截器的裸 Dio**。
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_foundation: ^0.1.0
|
core_foundation: ^0.1.0
|
||||||
dio: ^5.11.0
|
dio: ^5.11.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
flutter_secure_storage: ^11.0.0
|
flutter_secure_storage: ^11.0.0
|
||||||
riverpod_annotation: ^4.0.3
|
# 4.0.3+ 的 generator 引入 analyzer ^12,要求 meta ^1.18;
|
||||||
|
# Harmony Flutter(Dart 3.11)固定 meta 1.17.0,故保持在兼容组合。
|
||||||
|
riverpod_annotation: 4.0.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.4.13
|
build_runner: ^2.4.13
|
||||||
@@ -28,4 +30,4 @@ dev_dependencies:
|
|||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
mocktail: ^1.0.5
|
mocktail: ^1.0.5
|
||||||
riverpod_generator: ^4.0.4
|
riverpod_generator: 4.0.3
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ void main() {
|
|||||||
flavor: AppFlavor.dev,
|
flavor: AppFlavor.dev,
|
||||||
apiBaseUrl: 'https://example.test',
|
apiBaseUrl: 'https://example.test',
|
||||||
enableLog: false,
|
enableLog: false,
|
||||||
sentryDsn: '',
|
|
||||||
h5AllowedHosts: <String>{},
|
h5AllowedHosts: <String>{},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+15
-23
@@ -22,41 +22,36 @@ enum AppFlavor {
|
|||||||
/// **所有环境差异都收敛在这一个类里**,业务代码里不允许出现
|
/// **所有环境差异都收敛在这一个类里**,业务代码里不允许出现
|
||||||
/// `if (kDebugMode)` 或 `if (host.contains('uat'))` 这类判断。
|
/// `if (kDebugMode)` 或 `if (host.contains('uat'))` 这类判断。
|
||||||
///
|
///
|
||||||
/// 值来自 `--dart-define-from-file=env/{flavor}.json`(见 08)。构建时不传这个
|
/// 值来自随 App 打包的 `env/{flavor}.json`。配置无效时会在启动瞬间暴露,
|
||||||
/// 参数会得到空的 baseUrl,[fromDartDefine] 会直接抛错而不是让 App 带着空配置跑起来——
|
/// 而不是等第一个网络请求 404。
|
||||||
/// 这种错误必须在启动瞬间暴露,而不是等第一个网络请求 404。
|
|
||||||
class AppEnv {
|
class AppEnv {
|
||||||
const AppEnv({
|
const AppEnv({
|
||||||
required this.flavor,
|
required this.flavor,
|
||||||
required this.apiBaseUrl,
|
required this.apiBaseUrl,
|
||||||
required this.enableLog,
|
required this.enableLog,
|
||||||
required this.sentryDsn,
|
|
||||||
required this.h5AllowedHosts,
|
required this.h5AllowedHosts,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 从 `--dart-define-from-file` 注入的值构造。
|
/// 从已解析的环境 JSON 构造,并在启动期验证所有必填字段。
|
||||||
///
|
factory AppEnv.fromConfig({required String flavor, required Map<String, Object?> values}) {
|
||||||
/// [flavor] 由各自的入口文件(main_dev.dart / main_uat.dart / main_prod.dart)
|
final Object? apiBaseUrl = values['API_BASE_URL'];
|
||||||
/// 硬编码传入,而不是从 dart-define 读——这样"用 dev 的入口配了 prod 的 json"
|
final Object? enableLog = values['ENABLE_LOG'];
|
||||||
/// 这种事故在代码里看得见。
|
final Object? h5AllowedHosts = values['H5_ALLOWED_HOSTS'];
|
||||||
factory AppEnv.fromDartDefine({required String flavor}) {
|
|
||||||
const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
|
|
||||||
const enableLog = bool.fromEnvironment('ENABLE_LOG');
|
|
||||||
const sentryDsn = String.fromEnvironment('SENTRY_DSN');
|
|
||||||
const h5AllowedHosts = String.fromEnvironment('H5_ALLOWED_HOSTS');
|
|
||||||
|
|
||||||
if (apiBaseUrl.isEmpty) {
|
if (apiBaseUrl is! String || apiBaseUrl.trim().isEmpty) {
|
||||||
throw StateError(
|
throw StateError('环境 $flavor 的 API_BASE_URL 必须是非空字符串。');
|
||||||
'API_BASE_URL 为空。启动时必须带上 --dart-define-from-file=env/$flavor.json,'
|
}
|
||||||
'见 README「怎么跑起来」。',
|
if (enableLog is! bool) {
|
||||||
);
|
throw StateError('环境 $flavor 的 ENABLE_LOG 必须是布尔值。');
|
||||||
|
}
|
||||||
|
if (h5AllowedHosts is! String) {
|
||||||
|
throw StateError('环境 $flavor 的 H5_ALLOWED_HOSTS 必须是字符串。');
|
||||||
}
|
}
|
||||||
|
|
||||||
return AppEnv(
|
return AppEnv(
|
||||||
flavor: AppFlavor.parse(flavor),
|
flavor: AppFlavor.parse(flavor),
|
||||||
apiBaseUrl: apiBaseUrl,
|
apiBaseUrl: apiBaseUrl,
|
||||||
enableLog: enableLog,
|
enableLog: enableLog,
|
||||||
sentryDsn: sentryDsn,
|
|
||||||
h5AllowedHosts: _parseHosts(h5AllowedHosts),
|
h5AllowedHosts: _parseHosts(h5AllowedHosts),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,9 +70,6 @@ class AppEnv {
|
|||||||
/// 是否输出网络日志和 debug 级日志。prod 必须为 false(见 13)。
|
/// 是否输出网络日志和 debug 级日志。prod 必须为 false(见 13)。
|
||||||
final bool enableLog;
|
final bool enableLog;
|
||||||
|
|
||||||
/// 为空表示不启用 Sentry(dev 默认不上报,避免把开发期噪音混进线上数据)。
|
|
||||||
final String sentryDsn;
|
|
||||||
|
|
||||||
/// H5 域名白名单。WebView 只允许加载这些域名及其子域,见 10。
|
/// H5 域名白名单。WebView 只允许加载这些域名及其子域,见 10。
|
||||||
final Set<String> h5AllowedHosts;
|
final Set<String> h5AllowedHosts;
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ version: 0.1.0
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|||||||
@@ -44,12 +44,48 @@ void main() {
|
|||||||
flavor: AppFlavor.dev,
|
flavor: AppFlavor.dev,
|
||||||
apiBaseUrl: 'https://api-dev.conti.com',
|
apiBaseUrl: 'https://api-dev.conti.com',
|
||||||
enableLog: true,
|
enableLog: true,
|
||||||
sentryDsn: '',
|
|
||||||
h5AllowedHosts: <String>{'f6.conti.com'},
|
h5AllowedHosts: <String>{'f6.conti.com'},
|
||||||
);
|
);
|
||||||
expect(env.isProd, isFalse);
|
expect(env.isProd, isFalse);
|
||||||
expect(env.flavorSuffixVisible, isTrue);
|
expect(env.flavorSuffixVisible, isTrue);
|
||||||
expect(env.h5AllowedHosts, contains('f6.conti.com'));
|
expect(env.h5AllowedHosts, contains('f6.conti.com'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('从内置环境配置读取字段并规范化 H5 域名', () {
|
||||||
|
final AppEnv env = AppEnv.fromConfig(
|
||||||
|
flavor: 'uat',
|
||||||
|
values: <String, Object?>{
|
||||||
|
'API_BASE_URL': 'https://api-uat.conti.com',
|
||||||
|
'ENABLE_LOG': true,
|
||||||
|
'H5_ALLOWED_HOSTS': ' H5.Conti.com, shop.conti.com ',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(env.flavor, AppFlavor.uat);
|
||||||
|
expect(env.apiBaseUrl, 'https://api-uat.conti.com');
|
||||||
|
expect(env.enableLog, isTrue);
|
||||||
|
expect(env.h5AllowedHosts, <String>{'h5.conti.com', 'shop.conti.com'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('缺失或类型错误的内置配置在启动期失败', () {
|
||||||
|
expect(
|
||||||
|
() => AppEnv.fromConfig(
|
||||||
|
flavor: 'dev',
|
||||||
|
values: <String, Object?>{'API_BASE_URL': '', 'ENABLE_LOG': true, 'H5_ALLOWED_HOSTS': ''},
|
||||||
|
),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => AppEnv.fromConfig(
|
||||||
|
flavor: 'dev',
|
||||||
|
values: <String, Object?>{
|
||||||
|
'API_BASE_URL': 'https://api-dev.conti.com',
|
||||||
|
'ENABLE_LOG': 'true',
|
||||||
|
'H5_ALLOWED_HOSTS': '',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
/// 日志与崩溃上报的统一入口。来源:conti-docs/13-observability-analytics.md。
|
/// 日志与敏感信息脱敏的统一入口。
|
||||||
///
|
///
|
||||||
/// 边界约定:
|
/// 边界约定:
|
||||||
/// - 业务代码只用 [AppLogger] / [CrashReporter] 两个接口,**不直接 import
|
/// - 业务代码只用 [AppLogger],不直接 import `logger`,也不用 `print` /
|
||||||
/// `logger` 或 `sentry_flutter`**,也不用 `print` / `debugPrint`;
|
/// `debugPrint`。
|
||||||
/// - 全仓库只有本包的 `sentry_*.dart` 和 `app/bootstrap.dart` 可以见到
|
|
||||||
/// Sentry SDK。
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/app_logger.dart';
|
export 'src/app_logger.dart';
|
||||||
export 'src/crash_breadcrumb_observer.dart';
|
|
||||||
export 'src/crash_reporter.dart';
|
|
||||||
export 'src/log_buffer.dart';
|
|
||||||
export 'src/logger_app_logger.dart';
|
export 'src/logger_app_logger.dart';
|
||||||
export 'src/providers.dart';
|
export 'src/providers.dart';
|
||||||
export 'src/scrubber.dart';
|
export 'src/scrubber.dart';
|
||||||
export 'src/sentry_crash_reporter.dart';
|
|
||||||
export 'src/sentry_scrubber.dart';
|
|
||||||
export 'src/test_exception.dart';
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
/// 路由面包屑观察者。来源:13 §一「崩溃前的页面路径」。
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
import 'crash_reporter.dart';
|
|
||||||
|
|
||||||
/// 把路由变化写进 Sentry 面包屑。
|
|
||||||
///
|
|
||||||
/// **记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据
|
|
||||||
/// (见 10 与本包的 `scrubber.dart`)。
|
|
||||||
///
|
|
||||||
/// 挂载位置:`app/` 在构造 GoRouter 时通过 `navigatorObserversProvider`
|
|
||||||
/// 注入。`core_router` 不依赖 `core_logging`(那不在 01 允许的三条 core 间
|
|
||||||
/// 依赖里),所以这个类住在这里而不是路由包。详见 SCAFFOLD-NOTES.md §G。
|
|
||||||
class CrashBreadcrumbObserver extends NavigatorObserver {
|
|
||||||
/// [reporter] 通常是 `SentryCrashReporter`。
|
|
||||||
CrashBreadcrumbObserver(this._reporter);
|
|
||||||
|
|
||||||
final CrashReporter _reporter;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didPush(Route<Object?> route, Route<Object?>? previousRoute) => _leave('push', route);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didPop(Route<Object?> route, Route<Object?>? previousRoute) => _leave('pop', route);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didReplace({Route<Object?>? newRoute, Route<Object?>? oldRoute}) {
|
|
||||||
if (newRoute != null) _leave('replace', newRoute);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _leave(String action, Route<Object?> route) {
|
|
||||||
// name 为空时用 '<unnamed>',绝不回退到 route.settings.arguments 或
|
|
||||||
// 完整 location——那两个都可能带业务参数。
|
|
||||||
_reporter.leaveBreadcrumb('nav: $action ${route.settings.name ?? '<unnamed>'}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/// 崩溃上报门面。来源:13 §一。
|
|
||||||
library;
|
|
||||||
|
|
||||||
/// 业务代码唯一可见的崩溃上报接口。
|
|
||||||
///
|
|
||||||
/// 这一层**不是**为了"将来可能换 Sentry"——是为了测试里能直接 mock 掉、
|
|
||||||
/// 不必真的初始化 SDK,以及让 `feature_*` 不多一条对三方 SDK 的直接依赖
|
|
||||||
/// (见 01 的依赖规则)。
|
|
||||||
abstract interface class CrashReporter {
|
|
||||||
/// 只传后端的 `userId`,**不传手机号 / 姓名**(13 §一「用户与门店上下文」)。
|
|
||||||
void setUser(String userId);
|
|
||||||
|
|
||||||
/// 设置一个自定义维度。`storeId` 一定要带——门店设备型号和网络环境高度
|
|
||||||
/// 集中,很多崩溃是设备相关的,没有这个维度只能盲猜。
|
|
||||||
void setTag(String key, String value);
|
|
||||||
|
|
||||||
/// 清空用户与门店维度。登出时调用,否则共用设备上会串号。
|
|
||||||
void clearUser();
|
|
||||||
|
|
||||||
/// 面包屑。记路由名而不是完整 URL——URL 的 query 里带着票据。
|
|
||||||
void leaveBreadcrumb(String message);
|
|
||||||
|
|
||||||
/// 上报一个异常。
|
|
||||||
///
|
|
||||||
/// [extra] 用于带 `traceId` 这类能直接关联到后端 ELK 的字段。
|
|
||||||
void report(
|
|
||||||
Object error,
|
|
||||||
StackTrace? stack, {
|
|
||||||
Map<String, String> extra = const <String, String>{},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试与未接入环境用的空实现。
|
|
||||||
class NoopCrashReporter implements CrashReporter {
|
|
||||||
/// 创建一个什么都不做的上报器。
|
|
||||||
const NoopCrashReporter();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void setUser(String userId) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void setTag(String key, String value) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void clearUser() {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void leaveBreadcrumb(String message) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void report(
|
|
||||||
Object error,
|
|
||||||
StackTrace? stack, {
|
|
||||||
Map<String, String> extra = const <String, String>{},
|
|
||||||
}) {}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
/// 内存环形缓冲——崩溃时的"黑匣子"。来源:13 §二「级别与环境」。
|
|
||||||
library;
|
|
||||||
|
|
||||||
/// 一条已经脱敏、可以直接外发的日志。
|
|
||||||
class LogRecord {
|
|
||||||
/// 构造一条日志记录。[message] 必须是脱敏之后的内容。
|
|
||||||
const LogRecord({required this.level, required this.message, required this.timestamp});
|
|
||||||
|
|
||||||
/// 级别缩写:`D` / `I` / `W` / `E`。
|
|
||||||
final String level;
|
|
||||||
|
|
||||||
/// 已脱敏的消息体。
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
/// 记录时刻(本地时区)。
|
|
||||||
final DateTime timestamp;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '${timestamp.toIso8601String()} [$level] $message';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 固定容量的环形缓冲。
|
|
||||||
///
|
|
||||||
/// 只在内存里,**不落磁盘**——日志文件会成为新的泄漏面,且门店设备上
|
|
||||||
/// `logcat` 与外部存储都不是可信边界。App 退出即消失。
|
|
||||||
class LogBuffer {
|
|
||||||
/// [capacity] 默认 500,与 13 §二的表格一致。
|
|
||||||
LogBuffer({this.capacity = 500}) : assert(capacity > 0, 'capacity 必须为正');
|
|
||||||
|
|
||||||
/// 最多保留的条数。
|
|
||||||
final int capacity;
|
|
||||||
|
|
||||||
final List<LogRecord> _records = <LogRecord>[];
|
|
||||||
|
|
||||||
/// 追加一条,超出容量时丢弃最旧的。
|
|
||||||
void add(LogRecord record) {
|
|
||||||
_records.add(record);
|
|
||||||
if (_records.length > capacity) {
|
|
||||||
_records.removeRange(0, _records.length - capacity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 取最近 [count] 条,按时间正序。
|
|
||||||
///
|
|
||||||
/// 崩溃上报时实际带走的是 30 条而不是全部 500 条——单个 Sentry 事件的
|
|
||||||
/// 体积有上限,超了整条事件会被丢弃,那比少带日志更糟。
|
|
||||||
List<LogRecord> recent([int count = 30]) {
|
|
||||||
if (_records.length <= count) return List<LogRecord>.unmodifiable(_records);
|
|
||||||
return List<LogRecord>.unmodifiable(_records.sublist(_records.length - count));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前条数。
|
|
||||||
int get length => _records.length;
|
|
||||||
|
|
||||||
/// 清空。登出时调用,避免上一个账号的日志被下一个账号的崩溃带走。
|
|
||||||
void clear() => _records.clear();
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import 'package:core_foundation/core_foundation.dart';
|
|||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
|
|
||||||
import 'app_logger.dart';
|
import 'app_logger.dart';
|
||||||
import 'log_buffer.dart';
|
|
||||||
import 'scrubber.dart';
|
import 'scrubber.dart';
|
||||||
|
|
||||||
/// 日志级别,按严重程度递增。
|
/// 日志级别,按严重程度递增。
|
||||||
@@ -33,14 +32,12 @@ enum LogSeverity {
|
|||||||
/// 三条硬约束(13 §二):
|
/// 三条硬约束(13 §二):
|
||||||
/// 1. **prod 不输出到控制台**——Android 的 `logcat` 是全局可读的,门店设备上
|
/// 1. **prod 不输出到控制台**——Android 的 `logcat` 是全局可读的,门店设备上
|
||||||
/// 这不是理论风险;
|
/// 这不是理论风险;
|
||||||
/// 2. 所有内容进环形缓冲前**必须过一遍脱敏器**,因为缓冲会随崩溃外发;
|
/// 2. 结构化日志输出前**必须过一遍脱敏器**;
|
||||||
/// 3. 级别按 flavor 分档:dev=debug / uat=info / prod=warning。
|
/// 3. 级别按 flavor 分档:dev=debug / uat=info / prod=warning。
|
||||||
class LoggerAppLogger implements AppLogger {
|
class LoggerAppLogger implements AppLogger {
|
||||||
/// 按 [env] 推导级别和控制台开关。
|
/// 按 [env] 推导级别和控制台开关。
|
||||||
///
|
///
|
||||||
/// [buffer] 由调用方持有并同时交给 `CrashReporter`,两边必须是同一个实例,
|
LoggerAppLogger({required AppEnv env, Logger? logger})
|
||||||
/// 否则崩溃上报里带的是一份空日志。
|
|
||||||
LoggerAppLogger({required AppEnv env, required this.buffer, Logger? logger})
|
|
||||||
: _minSeverity = _severityFor(env.flavor),
|
: _minSeverity = _severityFor(env.flavor),
|
||||||
// enableLog 是 dart-define 里的开关,prod 即使误开也不打控制台。
|
// enableLog 是 dart-define 里的开关,prod 即使误开也不打控制台。
|
||||||
_console = env.enableLog && !env.isProd,
|
_console = env.enableLog && !env.isProd,
|
||||||
@@ -64,9 +61,6 @@ class LoggerAppLogger implements AppLogger {
|
|||||||
AppFlavor.prod => LogSeverity.warning,
|
AppFlavor.prod => LogSeverity.warning,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 崩溃时随事件外发的"黑匣子"。与 `beforeSend` 读的必须是同一个实例。
|
|
||||||
final LogBuffer buffer;
|
|
||||||
|
|
||||||
final LogSeverity _minSeverity;
|
final LogSeverity _minSeverity;
|
||||||
final bool _console;
|
final bool _console;
|
||||||
final Logger _logger;
|
final Logger _logger;
|
||||||
@@ -98,17 +92,6 @@ class LoggerAppLogger implements AppLogger {
|
|||||||
|
|
||||||
final String line = data == null || data.isEmpty ? message : '$message ${scrubMap(data)}';
|
final String line = data == null || data.isEmpty ? message : '$message ${scrubMap(data)}';
|
||||||
|
|
||||||
buffer.add(
|
|
||||||
LogRecord(
|
|
||||||
level: severity.tag,
|
|
||||||
// error 的 toString 无法结构化脱敏,所以约定反过来:仓库内的异常类型
|
|
||||||
// (见 core_foundation 的 AppException)只带 code / traceId,不带任何
|
|
||||||
// 凭据或用户输入原文。三方异常同理,发现例外要在这里显式拦。
|
|
||||||
message: error == null ? line : '$line | error=$error',
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!_console) return;
|
if (!_console) return;
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case LogSeverity.debug:
|
case LogSeverity.debug:
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ library;
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'app_logger.dart';
|
import 'app_logger.dart';
|
||||||
import 'crash_reporter.dart';
|
|
||||||
import 'log_buffer.dart';
|
|
||||||
|
|
||||||
/// 环形缓冲的唯一实例。
|
|
||||||
///
|
|
||||||
/// `AppLogger` 往里写、`beforeSend` 从里读,两边必须是同一个对象,
|
|
||||||
/// 否则崩溃上报里带的是一份空日志。
|
|
||||||
final Provider<LogBuffer> logBufferProvider = Provider<LogBuffer>((Ref ref) => LogBuffer());
|
|
||||||
|
|
||||||
/// 全局日志入口。
|
/// 全局日志入口。
|
||||||
///
|
///
|
||||||
@@ -21,8 +13,3 @@ final Provider<LogBuffer> logBufferProvider = Provider<LogBuffer>((Ref ref) => L
|
|||||||
final Provider<AppLogger> appLoggerProvider = Provider<AppLogger>(
|
final Provider<AppLogger> appLoggerProvider = Provider<AppLogger>(
|
||||||
(Ref ref) => const NoopAppLogger(),
|
(Ref ref) => const NoopAppLogger(),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 全局崩溃上报入口。默认空实现,同 [appLoggerProvider]。
|
|
||||||
final Provider<CrashReporter> crashReporterProvider = Provider<CrashReporter>(
|
|
||||||
(Ref ref) => const NoopCrashReporter(),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// 脱敏器。来源:13 §二「脱敏」、PRD §21.3。
|
/// 脱敏器。来源:PRD §21.3。
|
||||||
///
|
///
|
||||||
/// 这个文件是全仓库唯一的脱敏实现,`AppLogger`、Sentry 的两个钩子、以及
|
/// 这个文件是全仓库唯一的脱敏实现,`AppLogger` 与网络层的日志拦截器都必须
|
||||||
/// 网络层的日志拦截器都必须走这里,不允许各写各的。
|
/// 走这里,不允许各写各的。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
/// 命中即整体替换为 [redacted] 的 key(大小写不敏感)。
|
/// 命中即整体替换为 [redacted] 的 key(大小写不敏感)。
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
/// `CrashReporter` 的 Sentry 实现。来源:13 §一。
|
|
||||||
///
|
|
||||||
/// **本文件是全仓库唯一允许 `import 'package:sentry_flutter/...'` 的地方**
|
|
||||||
/// (另加 app/ 里的 `SentryFlutter.init`)。
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
|
||||||
|
|
||||||
import 'crash_reporter.dart';
|
|
||||||
|
|
||||||
/// 把 [CrashReporter] 转接到 Sentry SDK。
|
|
||||||
///
|
|
||||||
/// 所有方法都是"发射后不管":上报本身绝不能阻塞或影响业务流程,
|
|
||||||
/// 因此统一 `unawaited` 并吞掉自身异常。
|
|
||||||
class SentryCrashReporter implements CrashReporter {
|
|
||||||
/// 创建一个转接到全局 `Sentry` 的上报器。
|
|
||||||
const SentryCrashReporter();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void setUser(String userId) => _configure((Scope scope) async {
|
|
||||||
// 只传 ID。手机号、姓名、IP 一律不带(sendDefaultPii 也已关闭)。
|
|
||||||
await scope.setUser(SentryUser(id: userId));
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
void setTag(String key, String value) => _configure((Scope scope) => scope.setTag(key, value));
|
|
||||||
|
|
||||||
@override
|
|
||||||
void clearUser() => _configure((Scope scope) async {
|
|
||||||
await scope.setUser(null);
|
|
||||||
await scope.removeTag('storeId');
|
|
||||||
await scope.removeTag('roleCode');
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
void leaveBreadcrumb(String message) {
|
|
||||||
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: message)).catchError((_) {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void report(
|
|
||||||
Object error,
|
|
||||||
StackTrace? stack, {
|
|
||||||
Map<String, String> extra = const <String, String>{},
|
|
||||||
}) {
|
|
||||||
unawaited(
|
|
||||||
Sentry.captureException(
|
|
||||||
error,
|
|
||||||
stackTrace: stack,
|
|
||||||
withScope: (Scope scope) async {
|
|
||||||
if (extra.isNotEmpty) {
|
|
||||||
// traceId 放这里——一条崩溃能直接关联到后端 ELK 里的那次请求。
|
|
||||||
await scope.setContexts('app_extra', extra);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
).then<void>((_) {}).catchError((_) {}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _configure(FutureOr<void> Function(Scope) callback) {
|
|
||||||
unawaited(() async {
|
|
||||||
try {
|
|
||||||
await Sentry.configureScope(callback);
|
|
||||||
} on Object {
|
|
||||||
// 上报链路自身的异常不能外溢到业务流程。
|
|
||||||
}
|
|
||||||
}());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/// Sentry 的两个脱敏钩子。来源:13 §二「脱敏」。
|
|
||||||
///
|
|
||||||
/// 这两个钩子是 `SentryFlutter.init` 里 `beforeBreadcrumb` / `beforeSend`
|
|
||||||
/// 的实现,**一个都不能漏**:Sentry 默认会自动记录所有 HTTP 请求作为面包屑,
|
|
||||||
/// 我们在 05/10 里辛苦保证的"URL 不落日志"会被这条默认行为绕过去——
|
|
||||||
/// 它不走我们的 `AppLogger`。
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
|
||||||
|
|
||||||
import 'log_buffer.dart';
|
|
||||||
import 'scrubber.dart';
|
|
||||||
|
|
||||||
/// 面包屑脱敏:剥掉 URL 的 query(里面可能带 `ticket` / `token`)。
|
|
||||||
Breadcrumb? scrubBreadcrumb(Breadcrumb? crumb, Hint hint) {
|
|
||||||
if (crumb == null) return null;
|
|
||||||
final Object? url = crumb.data?['url'];
|
|
||||||
if (url is String) {
|
|
||||||
crumb.data?['url'] = scrubUrl(url);
|
|
||||||
}
|
|
||||||
return crumb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 事件脱敏 + 挂载"黑匣子"。
|
|
||||||
///
|
|
||||||
/// 返回的 `beforeSend` 闭包做两件事:
|
|
||||||
/// 1. 把环形缓冲里**最近 30 条**日志作为 context 附上——不是全部 500 条,
|
|
||||||
/// 单个事件有体积上限,超了整条事件会被丢弃;
|
|
||||||
/// 2. 剥掉请求 URL 的 query。
|
|
||||||
BeforeSendCallback buildScrubEvent(LogBuffer buffer) {
|
|
||||||
return (SentryEvent event, Hint hint) {
|
|
||||||
final SentryRequest? request = event.request;
|
|
||||||
final String? url = request?.url;
|
|
||||||
if (request != null && url != null) {
|
|
||||||
request.url = scrubUrl(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return event
|
|
||||||
..contexts['app_logs'] = <String, Object?>{
|
|
||||||
'recent': buffer.recent().map((LogRecord r) => r.toString()).toList(growable: false),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/// 上报链路的自检入口。来源:13 §一「验证接入真的成功了」。
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:core_foundation/core_foundation.dart';
|
|
||||||
|
|
||||||
/// 崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为
|
|
||||||
/// App 很稳定。所以每次发版前在 dev 上调一次这个函数,确认 Android 和 iOS
|
|
||||||
/// 都能在 Sentry 里看到,**并且堆栈是可读的 Dart 文件名行号而不是 `_x12`**。
|
|
||||||
///
|
|
||||||
/// 只在 dev flavor 下可调;uat/prod 调用会直接抛 [StateError],
|
|
||||||
/// 这样误留在代码里的调用会在测试环境就暴露,而不是污染线上崩溃率。
|
|
||||||
Never throwTestException(AppEnv env) {
|
|
||||||
if (env.flavor != AppFlavor.dev) {
|
|
||||||
throw StateError('throwTestException 只允许在 dev flavor 调用');
|
|
||||||
}
|
|
||||||
throw Exception('Sentry 接入自检:这是一条人为触发的测试异常');
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
name: core_logging
|
name: core_logging
|
||||||
description: 日志与崩溃上报的统一入口(AppLogger / CrashReporter),含敏感信息脱敏。
|
description: 应用日志与敏感信息脱敏。
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_foundation: ^0.1.0
|
core_foundation: ^0.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
logger: ^2.7.0
|
logger: ^2.7.0
|
||||||
sentry_flutter: ^9.26.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|||||||
@@ -53,85 +53,19 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('LogBuffer', () {
|
|
||||||
test('超出容量丢最旧的', () {
|
|
||||||
final LogBuffer buffer = LogBuffer(capacity: 3);
|
|
||||||
for (int i = 0; i < 5; i++) {
|
|
||||||
buffer.add(LogRecord(level: 'I', message: '$i', timestamp: DateTime(2026)));
|
|
||||||
}
|
|
||||||
expect(buffer.length, 3);
|
|
||||||
expect(buffer.recent().map((LogRecord r) => r.message), <String>['2', '3', '4']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recent 默认只取 30 条', () {
|
|
||||||
final LogBuffer buffer = LogBuffer();
|
|
||||||
for (int i = 0; i < 500; i++) {
|
|
||||||
buffer.add(LogRecord(level: 'I', message: '$i', timestamp: DateTime(2026)));
|
|
||||||
}
|
|
||||||
expect(buffer.length, 500);
|
|
||||||
expect(buffer.recent().length, 30);
|
|
||||||
expect(buffer.recent().first.message, '470');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('LoggerAppLogger', () {
|
group('LoggerAppLogger', () {
|
||||||
AppEnv envOf(AppFlavor flavor) => AppEnv(
|
AppEnv envOf(AppFlavor flavor) => AppEnv(
|
||||||
flavor: flavor,
|
flavor: flavor,
|
||||||
apiBaseUrl: 'https://example.com',
|
apiBaseUrl: 'https://example.com',
|
||||||
enableLog: true,
|
enableLog: true,
|
||||||
sentryDsn: '',
|
|
||||||
h5AllowedHosts: const <String>{},
|
h5AllowedHosts: const <String>{},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('prod 只收 warning 及以上', () {
|
test('各环境均可创建日志器', () {
|
||||||
final LogBuffer buffer = LogBuffer();
|
for (final AppFlavor flavor in AppFlavor.values) {
|
||||||
final AppLogger log = LoggerAppLogger(env: envOf(AppFlavor.prod), buffer: buffer);
|
final AppLogger log = LoggerAppLogger(env: envOf(flavor));
|
||||||
|
log.i('login', data: <String, Object?>{'phone': '13812345678'});
|
||||||
log
|
}
|
||||||
..d('debug')
|
|
||||||
..i('info')
|
|
||||||
..w('warn')
|
|
||||||
..e('error');
|
|
||||||
|
|
||||||
expect(buffer.recent().map((LogRecord r) => r.message), <String>['warn', 'error']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dev 收全部级别', () {
|
|
||||||
final LogBuffer buffer = LogBuffer();
|
|
||||||
LoggerAppLogger(env: envOf(AppFlavor.dev), buffer: buffer)
|
|
||||||
..d('debug')
|
|
||||||
..i('info');
|
|
||||||
|
|
||||||
expect(buffer.length, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('结构化 data 进缓冲前已脱敏', () {
|
|
||||||
final LogBuffer buffer = LogBuffer();
|
|
||||||
LoggerAppLogger(
|
|
||||||
env: envOf(AppFlavor.uat),
|
|
||||||
buffer: buffer,
|
|
||||||
).i('login', data: <String, Object?>{'phone': '13812345678'});
|
|
||||||
|
|
||||||
expect(buffer.recent().single.message, contains('138****5678'));
|
|
||||||
expect(buffer.recent().single.message, isNot(contains('13812345678')));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('throwTestException', () {
|
|
||||||
AppEnv envOf(AppFlavor flavor) => AppEnv(
|
|
||||||
flavor: flavor,
|
|
||||||
apiBaseUrl: 'https://example.com',
|
|
||||||
enableLog: false,
|
|
||||||
sentryDsn: '',
|
|
||||||
h5AllowedHosts: const <String>{},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('dev 抛测试异常', () {
|
|
||||||
expect(() => throwTestException(envOf(AppFlavor.dev)), throwsA(isA<Exception>()));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prod 拒绝调用', () {
|
|
||||||
expect(() => throwTestException(envOf(AppFlavor.prod)), throwsA(isA<StateError>()));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ version: 0.1.0
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_auth: ^0.1.0
|
core_auth: ^0.1.0
|
||||||
@@ -13,7 +13,7 @@ dependencies:
|
|||||||
dio: ^5.11.0
|
dio: ^5.11.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
uuid: ^4.5.1
|
uuid: ^4.5.1
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ resolution: workspace
|
|||||||
# 注意:本包**不**依赖任何 feature_*。feature 路由由 app/ 通过 appRoutesProvider 注入,
|
# 注意:本包**不**依赖任何 feature_*。feature 路由由 app/ 通过 appRoutesProvider 注入,
|
||||||
# 详见 lib/src/app_router.dart 顶部注释与 SCAFFOLD-NOTES.md §G。
|
# 详见 lib/src/app_router.dart 顶部注释与 SCAFFOLD-NOTES.md §G。
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_auth: ^0.1.0
|
core_auth: ^0.1.0
|
||||||
core_foundation: ^0.1.0
|
core_foundation: ^0.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
go_router: ^17.5.0
|
go_router: ^17.5.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
@@ -12,14 +12,22 @@ resolution: workspace
|
|||||||
# 连带收益:去掉 drift_dev 后 melos 可以留在 8.x(drift_dev 2.34.0 依赖
|
# 连带收益:去掉 drift_dev 后 melos 可以留在 8.x(drift_dev 2.34.0 依赖
|
||||||
# cli_util ^0.4.0,与 melos 8 的 ^0.5.0 互斥)。详见 SCAFFOLD-NOTES.md §A。
|
# cli_util ^0.4.0,与 melos 8 的 ^0.5.0 互斥)。详见 SCAFFOLD-NOTES.md §A。
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_foundation: ^0.1.0
|
core_foundation: ^0.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
shared_preferences: ^2.5.5
|
# Pub.dev 的 shared_preferences 不声明 OHOS 实现,Flutter 因而不会把它
|
||||||
|
# 注册进 GeneratedPluginRegistrant.ets。固定到 CPF-Flutter 的 OHOS 适配
|
||||||
|
# 分支;该主包会解析 shared_preferences_ohos。
|
||||||
|
shared_preferences:
|
||||||
|
git:
|
||||||
|
url: https://gitcode.com/CPF-Flutter/flutter_packages.git
|
||||||
|
# 固定 commit,避免 Pub 在 Windows Git cache 中错误复用 master 工作树。
|
||||||
|
ref: 19bd50ff6d5eaa96f18c63796c51e1b4e78a7480
|
||||||
|
path: packages/shared_preferences/shared_preferences
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ version: 0.1.0
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_foundation: ^0.1.0
|
core_foundation: ^0.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ resolution: workspace
|
|||||||
# 「用 target 换真实 URL」的接口调用**不在本包**——那需要 core_network,不是允许的边。
|
# 「用 target 换真实 URL」的接口调用**不在本包**——那需要 core_network,不是允许的边。
|
||||||
# 换票由调用方(feature)完成后把 URL 传进来,本包只负责校验和承载。
|
# 换票由调用方(feature)完成后把 URL 传进来,本包只负责校验和承载。
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_auth: ^0.1.0
|
core_auth: ^0.1.0
|
||||||
core_foundation: ^0.1.0
|
core_foundation: ^0.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
webview_flutter: ^4.14.1
|
webview_flutter: ^4.14.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ resolution: workspace
|
|||||||
# - 这里不出现任何另一个 feature_*(feature 间禁止互相依赖,见 01)
|
# - 这里不出现任何另一个 feature_*(feature 间禁止互相依赖,见 01)
|
||||||
# - 这里不直接出现 go_router(路由类型由 core_router re-export,见 04)
|
# - 这里不直接出现 go_router(路由类型由 core_router re-export,见 04)
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_auth: ^0.1.0
|
core_auth: ^0.1.0
|
||||||
@@ -18,8 +18,9 @@ dependencies:
|
|||||||
core_ui: ^0.1.0
|
core_ui: ^0.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
riverpod_annotation: ^4.0.3
|
# 与 riverpod_generator 4.0.3 配套,兼容 Harmony Flutter 固定的 meta 1.17.0。
|
||||||
|
riverpod_annotation: 4.0.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.4.13
|
build_runner: ^2.4.13
|
||||||
@@ -27,4 +28,4 @@ dev_dependencies:
|
|||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
mocktail: ^1.0.5
|
mocktail: ^1.0.5
|
||||||
riverpod_generator: ^4.0.4
|
riverpod_generator: 4.0.3
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ resolution: workspace
|
|||||||
# "需要" 登录信息,但它拿的是 core_auth 的会话状态,不是 feature_auth 的页面
|
# "需要" 登录信息,但它拿的是 core_auth 的会话状态,不是 feature_auth 的页面
|
||||||
# - 这里不直接出现 go_router(路由类型由 core_router re-export,见 04)
|
# - 这里不直接出现 go_router(路由类型由 core_router re-export,见 04)
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
core_analytics: ^0.1.0
|
core_analytics: ^0.1.0
|
||||||
@@ -20,8 +20,9 @@ dependencies:
|
|||||||
core_ui: ^0.1.0
|
core_ui: ^0.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_riverpod: ^3.3.2
|
flutter_riverpod: 3.2.1
|
||||||
riverpod_annotation: ^4.0.3
|
# 与 riverpod_generator 4.0.3 配套,兼容 Harmony Flutter 固定的 meta 1.17.0。
|
||||||
|
riverpod_annotation: 4.0.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.4.13
|
build_runner: ^2.4.13
|
||||||
@@ -29,4 +30,4 @@ dev_dependencies:
|
|||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
mocktail: ^1.0.5
|
mocktail: ^1.0.5
|
||||||
riverpod_generator: ^4.0.4
|
riverpod_generator: 4.0.3
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ publish_to: none
|
|||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ resolution: workspace
|
|||||||
# 因此本包抛的是包内自定义的 NativeScanException,由调用方转成 NativeException。
|
# 因此本包抛的是包内自定义的 NativeScanException,由调用方转成 NativeException。
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
@@ -20,7 +20,9 @@ dev_dependencies:
|
|||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
pigeon: ^27.3.0
|
# 27.x 需要 analyzer >=10,与 Harmony 兼容的 riverpod_generator 4.0.3
|
||||||
|
# (analyzer ^9)不能共存;26.3.1 同时兼容 Dart 3.11 与 analyzer 9。
|
||||||
|
pigeon: 26.3.1
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
plugin:
|
plugin:
|
||||||
|
|||||||
+51
-129
@@ -5,26 +5,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
|
sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "99.0.0"
|
version: "92.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
|
sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.1.0"
|
version: "9.0.0"
|
||||||
analyzer_buffer:
|
analyzer_buffer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer_buffer
|
name: analyzer_buffer
|
||||||
sha256: "445b77e2054fa3e8c8a8ef1b5e9e6b23bb8028fffd34b5e60eaef315b7750674"
|
sha256: ff4bd291778c7417fe53fe24ee0d0a1f1ffe281a2d4ea887e7094f16e36eace7
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.3"
|
version: "0.3.0"
|
||||||
ansi_styles:
|
ansi_styles:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -33,14 +33,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.2+1"
|
version: "0.3.2+1"
|
||||||
ansicolor:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: ansicolor
|
|
||||||
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.3"
|
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -229,10 +221,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
|
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.8"
|
version: "3.1.3"
|
||||||
dio:
|
dio:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -316,10 +308,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_riverpod
|
name: flutter_riverpod
|
||||||
sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e"
|
sha256: e2026c72738a925a60db30258ff1f29974e40716749f3c9850aabf34ffc1a14c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.2"
|
version: "3.2.1"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -407,14 +399,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
version: "2.1.3"
|
||||||
globbing:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: globbing
|
|
||||||
sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
go_router:
|
go_router:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -463,14 +447,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
injector:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: injector
|
|
||||||
sha256: ed389bed5b48a699d5b9561c985023d0d5cc88dd5ff2237aadcce5a5ab433e4e
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.0"
|
|
||||||
integration_test:
|
integration_test:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -492,14 +468,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
jni:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: jni
|
|
||||||
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.14.2"
|
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -584,10 +552,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -644,22 +612,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
package_info_plus:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: package_info_plus
|
|
||||||
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "10.2.1"
|
|
||||||
package_info_plus_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: package_info_plus_platform_interface
|
|
||||||
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.1.0"
|
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -728,10 +680,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pigeon
|
name: pigeon
|
||||||
sha256: f90254ef7b22db026fd8d5fd44718fd60697d50968a144a8bc251e5e95d79e10
|
sha256: "7afad261c00755491a69de151009fb41fda003e69205e16ba0c354c352c2de62"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "27.3.0"
|
version: "26.3.1"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -772,14 +724,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
properties:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: properties
|
|
||||||
sha256: "333f427dd4ed07bdbe8c75b9ff864a1e70b5d7a8426a2e8bdd457b65ae5ac598"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.1"
|
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -816,74 +760,51 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: riverpod
|
name: riverpod
|
||||||
sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76"
|
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.2"
|
version: "3.2.1"
|
||||||
riverpod_analyzer_utils:
|
riverpod_analyzer_utils:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: riverpod_analyzer_utils
|
name: riverpod_analyzer_utils
|
||||||
sha256: "3e275138862ccc22ed61444a1f9a840f753094c367f28f4123f50289cd204d68"
|
sha256: e55bc08c084a424e1bbdc303fe8ea75daafe4269b68fd0e0f6f1678413715b66
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0-dev.10"
|
version: "1.0.0-dev.9"
|
||||||
riverpod_annotation:
|
riverpod_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: riverpod_annotation
|
name: riverpod_annotation
|
||||||
sha256: "674dbb26e2db3d9253166faf4758c796af14146b8fbcf5e7102bc8a04cd359b8"
|
sha256: "16471a1260b94e939394d78f1c63a9350936ac4a68c9fbdab40be47268c0b04f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.3"
|
version: "4.0.2"
|
||||||
riverpod_generator:
|
riverpod_generator:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: riverpod_generator
|
name: riverpod_generator
|
||||||
sha256: "54d790c3fee1ae281c448801bfdbfaa9fd961a9d3998494e0fcd9ee32184d7eb"
|
sha256: "6f9220534d7a353b53c875ea191a84d28cb4e52ac420a66a1bd7318329d977b0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.4"
|
version: "4.0.3"
|
||||||
sentry:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sentry
|
|
||||||
sha256: c2ecd8abe82e63cdcb6947f71320612ced56e10ba94db7529d85cc02be47cb3b
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.27.0"
|
|
||||||
sentry_dart_plugin:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sentry_dart_plugin
|
|
||||||
sha256: da9c1d0b3c87a251bfc36301f16af090a88c2d59128fe9a6f908f5ac20340c97
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.4.0"
|
|
||||||
sentry_flutter:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sentry_flutter
|
|
||||||
sha256: ec89cc6ba939ca19155ea83900d9740a36544f50b3b6baf265518e3348fb0f50
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.27.0"
|
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences
|
path: "packages/shared_preferences/shared_preferences"
|
||||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
ref: "19bd50ff6d5eaa96f18c63796c51e1b4e78a7480"
|
||||||
url: "https://pub.dev"
|
resolved-ref: "19bd50ff6d5eaa96f18c63796c51e1b4e78a7480"
|
||||||
source: hosted
|
url: "https://gitcode.com/CPF-Flutter/flutter_packages.git"
|
||||||
version: "2.5.5"
|
source: git
|
||||||
|
version: "2.5.4"
|
||||||
shared_preferences_android:
|
shared_preferences_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
|
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.27"
|
version: "2.4.23"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -900,6 +821,15 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
|
shared_preferences_ohos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
path: "packages/shared_preferences/shared_preferences_ohos"
|
||||||
|
ref: "br_shared_preferences-v2.5.4_ohos"
|
||||||
|
resolved-ref: "19bd50ff6d5eaa96f18c63796c51e1b4e78a7480"
|
||||||
|
url: "https://gitcode.com/openharmony-tpc/flutter_packages.git"
|
||||||
|
source: git
|
||||||
|
version: "2.5.4"
|
||||||
shared_preferences_platform_interface:
|
shared_preferences_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1041,14 +971,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.1"
|
version: "0.3.1"
|
||||||
system_info2:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: system_info2
|
|
||||||
sha256: b937736ecfa63c45b10dde1ceb6bb30e5c0c340e14c441df024150679d65ac43
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.1.0"
|
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1061,26 +983,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.31.0"
|
version: "1.30.0"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.10"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.17"
|
version: "0.6.16"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1173,10 +1095,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_android
|
name: webview_flutter_android
|
||||||
sha256: b98656fa4461f8cc05c48a778b4d4883e60ec63e1778348f363f9bb9a477745d
|
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.14.0"
|
version: "4.12.0"
|
||||||
webview_flutter_platform_interface:
|
webview_flutter_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1189,10 +1111,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_wkwebview
|
name: webview_flutter_wkwebview
|
||||||
sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d
|
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.26.0"
|
version: "3.25.1"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1234,5 +1156,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.4"
|
version: "2.2.4"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.0 <4.0.0"
|
dart: ">=3.11.0 <4.0.0"
|
||||||
flutter: ">=3.44.0"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ name: conti_retail_app
|
|||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.0
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 工作区成员。新增包时必须同步加到这里,否则 melos bootstrap 看不到它。
|
# 工作区成员。新增包时必须同步加到这里,否则 melos bootstrap 看不到它。
|
||||||
|
|||||||
Reference in New Issue
Block a user