feat: add engineering conventions and CI gates documentation
- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks. - Updated README to include the new conventions document. - Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability. - Adjusted global exception handling to return numeric error codes. - Updated tests to reflect changes in error code handling.
This commit is contained in:
+237
-27
@@ -15,33 +15,222 @@ dev_dependencies:
|
||||
|
||||
```
|
||||
native_scan/
|
||||
pubspec.yaml # 必须有 flutter: plugin: platforms: 声明,见下文
|
||||
pigeons/
|
||||
scan_api.dart # 接口 schema 定义,唯一手写的源文件
|
||||
lib/
|
||||
native_scan.dart # 对外导出:封装好的公共 API 类(feature 只调这个)
|
||||
native_scan.dart # 对外导出:封装好的公共 API 类(调用方只调这个)
|
||||
src/
|
||||
generated/ # pigeon 生成的 Dart 端代码,不手动修改
|
||||
android/
|
||||
src/main/kotlin/.../ScanApiImpl.kt # 生成的 Kotlin host API 接口的具体实现
|
||||
src/main/kotlin/.../ScanApi.g.kt # pigeon 生成
|
||||
src/main/kotlin/.../ScanApiImpl.kt # 手写:生成的 Kotlin host API 接口的实现
|
||||
src/main/kotlin/.../NativeScanPlugin.kt # 手写:插件注册入口
|
||||
ios/
|
||||
Classes/ScanApiImpl.swift # 生成的 Swift host API 接口的具体实现
|
||||
ohos/
|
||||
src/main/ets/ScanApiImpl.ets # 生成的 ArkTS host API 接口的具体实现
|
||||
Classes/ScanApi.g.swift # pigeon 生成
|
||||
Classes/ScanApiImpl.swift # 手写:生成的 Swift host API 协议的实现
|
||||
Classes/NativeScanPlugin.swift # 手写:插件注册入口
|
||||
```
|
||||
|
||||
首版只有 `android/` 和 `ios/`(OHOS 不在首版范围,见文末「OHOS 后续演进」)。
|
||||
|
||||
### `pubspec.yaml` 必须声明 plugin platforms
|
||||
|
||||
这是最容易漏、漏了最难排查的一条:**`native_*` 包如果没有 `flutter: plugin:` 声明,`android/`、`ios/` 下的原生代码根本不会被编译进宿主 App**。表现是 Dart 侧调用直接抛 `MissingPluginException`,而代码看上去哪里都没问题。
|
||||
|
||||
```yaml
|
||||
# packages/native_scan/pubspec.yaml
|
||||
name: native_scan
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.0
|
||||
flutter: '>=3.44.0'
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
android:
|
||||
package: com.conti.native_scan
|
||||
pluginClass: NativeScanPlugin
|
||||
ios:
|
||||
pluginClass: NativeScanPlugin
|
||||
```
|
||||
|
||||
`pluginClass` 指向的类需要实现 `FlutterPlugin`(Android)/ `FlutterPlugin` 协议(iOS),在 `onAttachedToEngine` 里把 `ScanApiImpl` 注册到 pigeon 生成的 `setUp` 方法上:
|
||||
|
||||
```kotlin
|
||||
// android/src/main/kotlin/com/conti/native_scan/NativeScanPlugin.kt
|
||||
class NativeScanPlugin : FlutterPlugin, ActivityAware {
|
||||
private var impl: ScanApiImpl? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
impl = ScanApiImpl()
|
||||
ScanHostApi.setUp(binding.binaryMessenger, impl) // pigeon 生成的注册方法
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
ScanHostApi.setUp(binding.binaryMessenger, null)
|
||||
impl = null
|
||||
}
|
||||
|
||||
// 扫码需要 Activity(起 CameraX 预览页),通过 ActivityAware 拿
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { impl?.activity = binding.activity }
|
||||
override fun onDetachedFromActivity() { impl?.activity = null }
|
||||
override fun onReattachedToActivityForConfigChanges(b: ActivityPluginBinding) = onAttachedToActivity(b)
|
||||
override fun onDetachedFromActivityForConfigChanges() = onDetachedFromActivity()
|
||||
}
|
||||
```
|
||||
|
||||
> `ActivityAware` 不能省。扫码、相册选择、拨号这类能力都需要 `Activity`(起页面、申请权限、收 `onActivityResult`),只在 `onAttachedToEngine` 里拿 `applicationContext` 是不够的。而且 `onDetachedFromActivity` 里必须把引用置空,否则横竖屏切换或后台回收后会持有已销毁的 Activity,导致内存泄漏和崩溃。
|
||||
|
||||
## 扫码的归属:App 原生实现
|
||||
|
||||
**扫码由 App 原生实现(`native_scan`),不是 F6 的功能。**
|
||||
|
||||
`native_scan` 同时服务两个调用方:
|
||||
|
||||
```
|
||||
feature_scan(App 内的扫码页:扫码入库、扫码查件)
|
||||
↘
|
||||
native_scan → 原生相机 + 解码
|
||||
↗
|
||||
core_webview 的 JSBridge(H5 页面调起扫码,见 10-webview-h5.md)
|
||||
```
|
||||
|
||||
这也是 [01-project-structure.md](./01-project-structure.md) 里"`core_*` 允许依赖 `native_*`"这条例外存在的原因——如果只允许 `feature_* → native_*`,`core_webview` 的 JSBridge 就没法调起扫码,只能退化成"复制一份扫码实现"或者"让 core_webview 反向依赖 feature_scan",两条都不可接受。
|
||||
|
||||
> **与 PRD 的已知冲突**:PRD §11.5 和 `202606-Conti-Retail-APP-Component-data-source.md` 里把扫码写成"嵌入 F6 扫码页",与此处不一致。以本文档为准(扫码是 App 做的),**PRD 需要回头修订**。
|
||||
|
||||
### 待确认:VIN 码与车牌识别的技术路径
|
||||
|
||||
这是一个**还没解决的能力缺口**,必须在开工前定下来。
|
||||
|
||||
PRD 要求扫描 **VIN 码**和**车牌**。但通用扫码库(`mobile_scanner`、ZXing、MLKit Barcode Scanning)解的是**二维码/条形码**,识别不了车牌这种自然场景文字;VIN 虽然常以 Code 39 条码形式印在车身铭牌上,但也大量存在"只有印刷字符、没有条码"的情况。这两个都需要 **OCR**。
|
||||
|
||||
| 需求 | 能力 | 候选方案 |
|
||||
|---|---|---|
|
||||
| 二维码 / 条形码(商品、库位) | Barcode | MLKit Barcode Scanning(Android)/ Vision(iOS),或 `mobile_scanner` |
|
||||
| VIN 条码 | Barcode(Code 39) | 同上 |
|
||||
| VIN 印刷字符 | OCR + 校验位算法 | MLKit Text Recognition / Vision;VIN 有第 9 位校验码,可用来过滤误识别 |
|
||||
| 车牌 | 专用 OCR | MLKit/Vision 通用 OCR 准确率偏低;或接第三方车牌识别 SDK(如车牌识别专用商用 SDK) |
|
||||
|
||||
**建议路径**:条码走 MLKit/Vision(免费、离线、成熟);VIN 印刷字符用通用 OCR + VIN 校验位过滤,先验证准确率;**车牌单独做一次技术验证**,通用 OCR 达不到可用准确率就要评估采购商用 SDK(涉及成本、离线授权、包体积、以及是否上传图片到第三方服务器的合规问题)。
|
||||
|
||||
在验证结论出来之前,**`native_scan` 的 Pigeon schema 要预留 `ScanMode` 参数**(`barcode` / `vin` / `plate`),避免后面加识别类型时要改接口签名。
|
||||
|
||||
## 权限与合规
|
||||
|
||||
`native_*` 涉及的运行时权限:
|
||||
|
||||
| 能力 | Android 权限 | iOS `Info.plist` key |
|
||||
|---|---|---|
|
||||
| 扫码 / 拍照 | `CAMERA` | `NSCameraUsageDescription` |
|
||||
| 相册选择 | `READ_MEDIA_IMAGES`(API 33+) | `NSPhotoLibraryUsageDescription` |
|
||||
| 保存图片 | `WRITE_EXTERNAL_STORAGE`(API ≤ 28) | `NSPhotoLibraryAddUsageDescription` |
|
||||
| 拨号 | 无需权限(`ACTION_DIAL` 不需要 `CALL_PHONE`) | 无(`tel:` scheme) |
|
||||
|
||||
规则:
|
||||
|
||||
- **权限申请必须在用到的那一刻发起,不在启动时批量申请。** 启动就要相机权限是应用商店审核和用户流失的双重风险。
|
||||
- **被拒绝后要有引导**:拒绝一次 → 说明为什么需要 + 再次申请;选了"不再询问" → 提示并提供跳转系统设置的入口。不能只是 toast 一句"没有权限"然后什么也做不了。
|
||||
- iOS 用途说明文案要写具体("用于扫描商品条码入库"),写"需要相机权限"这种会被审核打回。
|
||||
- 拨号用 `ACTION_DIAL` / `tel:` **拉起拨号盘让用户自己按拨出**,不用 `CALL_PHONE` 直接拨号——后者要额外的危险权限,还容易被审核质疑。
|
||||
|
||||
### iOS 隐私清单 `PrivacyInfo.xcprivacy`(上架强制)
|
||||
|
||||
苹果自 2024 年起强制要求 App 及其使用的三方 SDK 提供隐私清单,**没有会直接被拒**。每个 `native_*` 包如果访问了需要声明的 API,要在 `ios/Resources/PrivacyInfo.xcprivacy` 里声明:
|
||||
|
||||
```xml
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array><string>C617.1</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
|
||||
同时确认三方依赖(相机/图片压缩/崩溃上报 SDK)是否自带隐私清单——不带的需要我们在主 App 里替它声明,或者换一个带的。这条要在**首次提交 TestFlight 前**验证,别留到上架当天。
|
||||
|
||||
**我们不采集设备唯一标识**(IMEI/IDFA/MAC),所以不需要声明 `NSPrivacyTracking`(见 [05-networking.md](./05-networking.md) 的 `X-Device-Id` 约定)。
|
||||
|
||||
## Pigeon 的工程化
|
||||
|
||||
生成命令不写在 README 里让人手敲,而是把配置写进 schema、动作做成 melos script。
|
||||
|
||||
```dart
|
||||
// native_scan/pigeons/scan_api.dart
|
||||
@ConfigurePigeon(PigeonOptions(
|
||||
dartOut: 'lib/src/generated/scan_api.g.dart',
|
||||
dartOptions: DartOptions(),
|
||||
kotlinOut: 'android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'com.conti.native_scan'),
|
||||
swiftOut: 'ios/Classes/ScanApi.g.swift',
|
||||
swiftOptions: SwiftOptions(),
|
||||
dartPackageName: 'native_scan',
|
||||
))
|
||||
library;
|
||||
|
||||
@HostApi()
|
||||
abstract class ScanHostApi { /* ... */ }
|
||||
```
|
||||
|
||||
配置写进 `@ConfigurePigeon` 之后,生成命令就退化成一行,不会出现"某人生成时路径敲错,生成物落到别的目录":
|
||||
|
||||
```bash
|
||||
fvm dart run pigeon --input pigeons/scan_api.dart
|
||||
```
|
||||
|
||||
melos script(见 [01-project-structure.md](./01-project-structure.md)):
|
||||
|
||||
```yaml
|
||||
pigeon:
|
||||
run: melos exec --scope="native_*" -- dart run pigeon --input pigeons/
|
||||
```
|
||||
|
||||
生成产物的处理:
|
||||
|
||||
- `*.g.dart` / `*.g.kt` / `*.g.swift` **入 git**(同 riverpod/drift 的生成物,理由见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。
|
||||
- Dart 生成物在根 `analysis_options.yaml` 里排除 lint(`analyzer: exclude: - "**/*.g.dart"`)。
|
||||
- CI 要有一步"重新生成后 `git diff --exit-code`",防止有人改了 schema 但忘了提交生成物。
|
||||
|
||||
## OHOS 后续演进
|
||||
|
||||
鸿蒙(OpenHarmony)**不在首版范围**,但基线决策是为它留了口子的,这里记录清楚,避免后面接的时候重新走一遍弯路。
|
||||
|
||||
接 OHOS 需要处理三件事:
|
||||
|
||||
1. **SDK 分支不同**:OHOS 用的是 OpenHarmony 社区维护的 Flutter 分支,版本落后于官方 stable 一段时间。这正是 [01-project-structure.md](./01-project-structure.md) 里 SDK 基线刻意停在 **3.44.9** 而不追 3.47.0 的原因——基线跑太前,OHOS 分支跟不上就接不进来。
|
||||
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`,不静默返回空值或占位假数据——静默返回会让"这个平台其实没实现"的问题一直藏到用户手里。
|
||||
|
||||
|
||||
## 使用规则
|
||||
|
||||
- `pigeons/xxx_api.dart` 是**唯一手写**的接口定义文件,Dart 端和三端原生的桩代码全部由 `dart run pigeon --input pigeons/xxx_api.dart` 生成,生成产物不手动修改,改需求就改 schema 重新生成。
|
||||
- `pigeons/xxx_api.dart` 是**唯一手写**的接口定义文件,Dart 端和两端原生的桩代码全部由 `dart run pigeon --input pigeons/xxx_api.dart` 生成,生成产物不手动修改,改需求就改 schema 重新生成。
|
||||
- Dart 调原生用 `@HostApi()`;原生主动推事件给 Dart(比如扫码结果的持续回调)用 `@FlutterApi()`——不允许为了图省事用 `@HostApi()` 硬凑双向通信。
|
||||
- `feature_*` 只允许依赖对应 `native_*` 包 `lib/native_xxx.dart` 导出的公共 API 类,不允许直接 import `src/generated/` 里的生成代码。
|
||||
- 原生侧异常需要在生成的 host API 实现里捕获并转换成 Pigeon schema 里声明的错误类型,Dart 侧统一映射成 [05-networking.md](./05-networking.md) 里同一套 `AppException` 体系,不让原生异常类型(如 `PlatformException`)直接抛到 `feature_*` 业务代码里。
|
||||
- 三端(Android/iOS/OHOS)中若某一端暂未实现,公共 API 类里对应平台分支返回明确的 `UnimplementedError`,不允许静默返回空值或占位假数据。
|
||||
- **调用方只允许依赖 `native_*` 包 `lib/native_xxx.dart` 导出的公共 API 类**,不允许直接 import `src/generated/` 里的生成代码。调用方包括 `feature_*` 和 `core_webview`(JSBridge)。
|
||||
- 原生侧异常需要在生成的 host API 实现里捕获并转换成 Pigeon schema 里声明的错误类型,Dart 侧统一映射成 [05-networking.md](./05-networking.md) 里同一套 `AppException` 体系,不让原生异常类型(如 `PlatformException`)直接抛到业务代码里。
|
||||
- 某一端暂未实现的能力,公共 API 类里对应平台分支抛明确的 `UnsupportedPlatformException`,不允许静默返回空值或占位假数据。
|
||||
|
||||
## 待确认项
|
||||
|
||||
- **车牌识别的技术路径**(通用 OCR 是否够用,还是要采购商用 SDK)——见上文,开工前必须有结论。
|
||||
- VIN 印刷字符 OCR 的实际准确率,需要拿真实车辆铭牌照片做一轮验证。
|
||||
- 三方 SDK 的 iOS 隐私清单覆盖情况,首次提交 TestFlight 前核完。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Pigeon 官方文档](https://pub.dev/packages/pigeon)
|
||||
- [pigeon | Dart package](https://pub.dev/packages/pigeon)
|
||||
- [Flutter 平台通道官方文档](https://docs.flutter.dev/platform-integration/platform-channels)
|
||||
- [编写 Flutter plugin package](https://docs.flutter.dev/packages-and-plugins/developing-packages#plugin-platforms)
|
||||
- [Apple: 隐私清单文件](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files)
|
||||
- [Android 运行时权限最佳实践](https://developer.android.com/training/permissions/requesting)
|
||||
|
||||
## 附录:Pigeon 是什么,日常怎么用
|
||||
|
||||
@@ -74,6 +263,15 @@ final result = await MethodChannel('scan_channel').invokeMethod('startScan', {'t
|
||||
|
||||
```dart
|
||||
// native_scan/pigeons/scan_api.dart —— 唯一手写的 schema 文件
|
||||
@ConfigurePigeon(PigeonOptions(
|
||||
dartOut: 'lib/src/generated/scan_api.g.dart',
|
||||
kotlinOut: 'android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'com.conti.native_scan'),
|
||||
swiftOut: 'ios/Classes/ScanApi.g.swift',
|
||||
dartPackageName: 'native_scan',
|
||||
))
|
||||
library;
|
||||
|
||||
@HostApi()
|
||||
abstract class ScanHostApi {
|
||||
@async
|
||||
@@ -81,33 +279,38 @@ abstract class ScanHostApi {
|
||||
void stopScan();
|
||||
}
|
||||
|
||||
/// 预留识别类型,避免后面加车牌/VIN 识别时改接口签名
|
||||
enum ScanMode { barcode, vin, plate }
|
||||
|
||||
class ScanOptions {
|
||||
ScanOptions({required this.timeoutMs});
|
||||
ScanOptions({required this.mode, required this.timeoutMs});
|
||||
final ScanMode mode;
|
||||
final int timeoutMs;
|
||||
}
|
||||
|
||||
class ScanResult {
|
||||
ScanResult({required this.code, required this.format});
|
||||
final String code;
|
||||
final String format;
|
||||
ScanResult({required this.value, required this.format});
|
||||
final String value;
|
||||
final String format; // QR_CODE / CODE_39 / OCR_TEXT ...
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dart run pigeon \
|
||||
--input pigeons/scan_api.dart \
|
||||
--dart_out lib/src/generated/scan_api.g.dart \
|
||||
--kotlin_out android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt \
|
||||
--swift_out ios/Classes/ScanApi.g.swift
|
||||
# 配置已写进 @ConfigurePigeon,命令里不用再重复一遍输出路径
|
||||
fvm dart run pigeon --input pigeons/scan_api.dart
|
||||
```
|
||||
|
||||
Android 端实现生成的抽象类(`ScanApiImpl.kt`,非生成代码,是需要手写的实现):
|
||||
|
||||
```kotlin
|
||||
class ScanApiImpl(private val activity: Activity) : ScanHostApi {
|
||||
class ScanApiImpl : ScanHostApi {
|
||||
var activity: Activity? = null // 由 NativeScanPlugin 的 ActivityAware 回调注入
|
||||
|
||||
override fun startScan(options: ScanOptions, callback: (Result<ScanResult>) -> Unit) {
|
||||
val act = activity ?: return callback(Result.failure(
|
||||
FlutterError("NO_ACTIVITY", "扫码需要前台 Activity", null)))
|
||||
// 调用具体的扫码 SDK,拿到结果后:
|
||||
callback(Result.success(ScanResult(code = "123456", format = "QR_CODE")))
|
||||
callback(Result.success(ScanResult(value = "123456", format = "QR_CODE")))
|
||||
}
|
||||
|
||||
override fun stopScan() {
|
||||
@@ -116,17 +319,24 @@ class ScanApiImpl(private val activity: Activity) : ScanHostApi {
|
||||
}
|
||||
```
|
||||
|
||||
Dart 端对外的公共 API(`native_scan.dart`,`feature_scan` 唯一能调用的入口):
|
||||
Dart 端对外的公共 API(`native_scan.dart`,调用方唯一能用的入口):
|
||||
|
||||
```dart
|
||||
class NativeScan {
|
||||
final ScanHostApi _api = ScanHostApi();
|
||||
|
||||
Future<ScanResult> startScan({Duration timeout = const Duration(seconds: 5)}) async {
|
||||
Future<ScanResult> startScan({
|
||||
ScanMode mode = ScanMode.barcode,
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
try {
|
||||
return await _api.startScan(ScanOptions(timeoutMs: timeout.inMilliseconds));
|
||||
} on PlatformException catch (e) {
|
||||
throw NativeCapabilityException('扫码失败: ${e.message}');
|
||||
return await _api.startScan(
|
||||
ScanOptions(mode: mode, timeoutMs: timeout.inMilliseconds),
|
||||
);
|
||||
} on PlatformException catch (e, st) {
|
||||
// 原生异常不外泄,统一转成 05 里的 AppException 体系
|
||||
Error.throwWithStackTrace(
|
||||
NativeCapabilityException('扫码失败: ${e.message}', code: e.code), st);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,4 +344,4 @@ class NativeScan {
|
||||
}
|
||||
```
|
||||
|
||||
`feature_scan` 只 import `NativeScan` 这一个类,完全不知道底层是 Pigeon 生成的还是手写 `MethodChannel`——这也是把原生能力做成独立 `native_*` package(而不是散落在各 feature 里直接写平台通道代码)的意义:原生实现细节被这一层完全封装。
|
||||
`feature_scan` 和 `core_webview` 的 JSBridge 都只 import `NativeScan` 这一个类,完全不知道底层是 Pigeon 生成的还是手写 `MethodChannel`——这也是把原生能力做成独立 `native_*` package(而不是散落在各 feature 里直接写平台通道代码)的意义:原生实现细节被这一层完全封装,将来换扫码 SDK 或补 OHOS 实现,调用方一行都不用动。
|
||||
|
||||
Reference in New Issue
Block a user