Harmony 3.41.9 #1

Merged
guangfei.zhao merged 5 commits from harmony_3.41.9 into main 2026-09-03 16:58:35 +08:00
99 changed files with 1262 additions and 1266 deletions
+1 -7
View File
@@ -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
+19 -11
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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")
} }
} }
+1 -2
View File
@@ -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"
} }
+1 -2
View File
@@ -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"
} }
-1
View File
@@ -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"
} }
+11 -2
View File
@@ -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
View File
@@ -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 看不出关系。
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+8 -50
View File
@@ -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()),
);
} }
+1 -1
View File
@@ -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;
} }
+1 -1
View File
@@ -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 -1
View File
@@ -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 定了方案再统一迁入。"
} }
+15
View File
@@ -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));
}
-14
View File
@@ -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'));
-14
View File
@@ -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'));
-14
View File
@@ -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'));
+1 -1
View File
@@ -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,
+29
View File
@@ -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}');
}
}
+23 -4
View File
@@ -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('=', '');
}
+2 -6
View File
@@ -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});
} }
} }
+5 -21
View File
@@ -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},
);
} }
} }
+20
View File
@@ -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
+10
View File
@@ -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

+52
View File
@@ -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"
]
}
]
}
]
}
+7
View File
@@ -0,0 +1,7 @@
/node_modules
/oh_modules
/.preview
/build
/.cxx
/.test
GeneratedPluginRegistrant.ets
+15
View File
@@ -0,0 +1,15 @@
{
"apiType": 'stageMode',
"buildOption": {
},
"targets": [
{
"name": "default",
"runtimeOS": "HarmonyOS"
},
{
"name": "ohosTest",
}
]
}
+7
View File
@@ -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. */
}
+11
View File
@@ -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
}
}
+40
View File
@@ -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');
}
}
+37
View File
@@ -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"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"modelVersion": "5.1.0",
"dependencies": {
}
}
+4
View File
@@ -0,0 +1,4 @@
import path from 'path'
import { injectNativeModules } from 'flutter-hvigor-plugin';
injectNativeModules(__dirname, path.dirname(__dirname))
+8
View File
@@ -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. */
}
+13
View File
@@ -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
View File
@@ -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 读,绝不写进仓库
+9 -7
View File
@@ -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
View File
@@ -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);
+44 -12
View File
@@ -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
View File
@@ -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:
+2 -4
View File
@@ -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 层重建,见下文
+14 -434
View File
@@ -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)
+273
View File
@@ -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
View File
@@ -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 差异) |
## 这份副本从哪来、怎么更新 ## 这份副本从哪来、怎么更新
+46
View File
@@ -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` 也只改一个文件。
/// ///
+2 -2
View File
@@ -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);
+6 -4
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -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,
);
});
}); });
} }
+3 -11
View File
@@ -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(),
);
+3 -3
View File
@@ -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 接入自检:这是一条人为触发的测试异常');
}
+3 -4
View File
@@ -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>()));
}); });
}); });
} }
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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:
+11 -3
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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:
+5 -4
View File
@@ -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
+5 -4
View File
@@ -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
+1 -1
View File
@@ -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:
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 看不到它。