Files
conti-docs/flutter-app/07-native-integration.md
T

405 lines
23 KiB
Markdown
Raw Normal View History

# 07. 原生能力集成方式
## 决策
原生能力(扫码、支付、蓝牙等)统一封装成独立的 `native_*` Dart package(结构见 [01-project-structure.md](./01-project-structure.md)),跨语言接口用 **[Pigeon](https://pub.dev/packages/pigeon)**`^27.3.0`,2026-08 快照)生成,不手写裸 `MethodChannel`/`invokeMethod` 字符串调用。
## 依赖
```yaml
dev_dependencies:
pigeon: ^27.3.0
```
## 包结构规则
```
native_scan/
pubspec.yaml # 必须有 flutter: plugin: platforms: 声明,见下文
pigeons/
scan_api.dart # 接口 schema 定义,唯一手写的源文件
lib/
native_scan.dart # 对外导出:封装好的公共 API 类(调用方只调这个)
src/
generated/ # pigeon 生成的 Dart 端代码,不手动修改
android/
src/main/kotlin/.../ScanApi.g.kt # pigeon 生成
src/main/kotlin/.../ScanApiImpl.kt # 手写:生成的 Kotlin host API 接口的实现
src/main/kotlin/.../NativeScanPlugin.kt # 手写:插件注册入口
ios/
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 的 JSBridgeH5 页面调起扫码,见 10-webview-h5.md
```
这也是 [01-project-structure.md](./01-project-structure.md) 里"`core_*` 允许依赖 `native_*`"这条例外存在的原因——如果只允许 `feature_* → native_*``core_webview` 的 JSBridge 就没法调起扫码,只能退化成"复制一份扫码实现"或者"让 core_webview 反向依赖 feature_scan",两条都不可接受。
> **与前期材料的冲突(已裁决)**:前期草稿和 `202606-Conti-Retail-APP-Component-data-source.md` 里把扫码写成"嵌入 F6 扫码页",与此处不一致。已按本文档裁决(扫码是 App 原生做的),现行 PRD 的 **REQ-INT-003** 已校正。
### VIN 码与车牌识别:车牌走阿里云 OCR,其余在端上解
PRD 要求扫描 **VIN 码**和**车牌**。但通用扫码库(`mobile_scanner`、ZXing、MLKit Barcode Scanning)解的是**二维码/条形码**,识别不了车牌这种自然场景文字;VIN 虽然常以 Code 39 条码形式印在车身铭牌上,但也大量存在"只有印刷字符、没有条码"的情况。这两个都需要 **OCR**
**结论:车牌用付费的[阿里云视觉智能开放平台车牌识别](https://help.aliyun.com/zh/viapi/developer-reference/api-u92rj0)`RecognizeLicensePlate`),上传图片换识别结果,不做端侧模型。**
| 需求 | 能力 | 方案 | 在哪跑 |
|---|---|---|---|
| 二维码 / 条形码(商品、库位) | Barcode | MLKit Barcode ScanningAndroid/ VisioniOS | **端上**,离线 |
| VIN 条码 | BarcodeCode 39 | 同上 | **端上**,离线 |
| VIN 印刷字符 | OCR + 校验位算法 | MLKit Text Recognition / Vision 通用 OCR**用 VIN 第 9 位校验码过滤误识别** | **端上**,离线 |
| **车牌** | **云端 OCR** | **阿里云 `RecognizeLicensePlate`** | **云端**,联网 |
这张表最重要的是最后一列:**只有车牌这一路需要联网**,其余三路都在端上离线完成。下面的约束全部由这个差异推出来。
#### 车牌这一路和其它三路完全不是一回事
它不再是 `native_scan` 的一种扫码模式,而是「**拍照 → 上传 → 等结果**」的网络请求。三个直接后果:
**1. 交互从"取景框自动识别"变成"按快门"。** 端侧方案可以逐帧识别、对准就出结果;云端 API 按次计费且有网络往返,**不允许连续帧调用**。所以车牌入口的交互是拍一张照、上传、等一个明确的结果。设计稿如果画的是扫码式取景框自动识别,需要按这条调整。
**2. 弱网下这个功能直接不可用。** 门店地下车库、施工区网络条件差,而接车是高频动作。所以:
- **手工输码是常驻的并列入口,不是识别失败后的降级路径**(PRD 首页「扫码 / 车牌」本来就是两个按钮)。
- 上传前**必须压缩**:API 限制单图 ≤ 4 MB、分辨率 15×15 ~ 4096×4096,而手机原图动辄十几 MB。压到长边 1920 左右、JPEG 质量 80 通常既满足识别又能在弱网下传得动。
- 超时和重试上限要设死。**失败就退回手工输码,不要自动重试第二次** —— 每次调用都要花钱,而且用户已经在等了。
**3. 图片要离开设备,隐私政策必须写到。** 阿里云是境内服务,**不涉及数据出境**,但"车辆照片上传至第三方进行识别"属于必须告知的处理行为,要进隐私政策,并计入 `REQ-NFR-023`
#### 客户端不直连阿里云
**AK/SK 绝对不能进客户端。** 打进 APK 的密钥等同于公开,反编译就能拿到,之后任何人都能拿我们的账号刷调用量。而且 `RecognizeLicensePlate` 收的是 `ImageURL`(OSS 链接),不是图片二进制 —— 客户端直连还得自己处理 OSS 上传凭证,更没必要。
**客户端只调我们后端的一个接口**,阿里云的存在对客户端完全透明:
```
App ──① 压缩后的图片──▶ 后端 ──② 落 OSS──▶ 阿里云 OSS
└──③ RecognizeLicensePlate(ImageURL)──▶ 阿里云 OCR
App ◀───────④ { plateNumber, confidence } ──────┘
```
好处是换供应商、加缓存、加调用量管控都只动后端。代价是图片多走一跳(门店 → 我们的后端 → OSS)。**如果实测上传耗时不可接受**,再换成"后端签发 STS 临时凭证、客户端直传 OSS、只把 URL 交给后端"的两段式,但首版不必要 —— 别为还没测出来的问题先加一层复杂度。
后端侧的接法(超时、熔断、错误码段)按 [../backend/05-integration-layer.md](../backend/05-integration-layer.md) 的规矩走,阿里云 OCR 是一个和 F6、Mini 同级的外部依赖。
#### 置信度要用起来
响应里的 `Confidence` 不是装饰。约定:
- **低于阈值不直接填进表单**,而是把识别结果作为"待确认"展示,让用户点一下确认或改。阈值实测后定。
- 识别出的字符串还要过一次**车牌格式校验**(省份简称 + 字母 + 5~6 位、新能源 8 位),不合规一律当失败处理。API 返回一个高置信度的非法车牌,比返回失败更危险 —— 它会被直接写进工单。
#### 为什么不自己训模型
评估过"自训练 YOLO 定位 + PaddleOCR 识别"的端侧方案,没有采用:
| | 阿里云 OCR(采用) | 自训练 YOLO + PaddleOCR |
|---|---|---|
| 准确率 | **供应商负责**,开箱可用 | 要自己调到可用,工程风险集中在这里 |
| 投入 | 按次付费 | 算法工程 + 数据标注 + 持续调优的人力 |
| 离线可用 | ❌ **必须联网** | ✅ 完全离线 |
| 数据合规 | 图片上传第三方(境内),需写进隐私政策 | 图片不出设备 |
| 包体积 | 无增量 | 模型文件增量 |
| 迭代 | 供应商升级即受益 | 每次优化都要发版 |
**取舍**:用调用费和联网依赖,换掉一整条算法工程链路和"准确率自负"的风险。对一个门店业务 APP 来说这笔账是划算的 —— 车牌识别不是我们的核心竞争力,没有理由自己养一套模型。离线不可用由手工输码入口兜住,这本来就是必须有的。
VIN 印刷字符**继续在端上用通用 OCR + 校验位过滤**,不一并上云:VIN 是标准印刷字符,通用 OCR 本来就擅长,第 9 位校验码能把误识别挡在外面 —— 这是车牌没有的优势,白白花钱和牺牲离线能力没有道理。真到实测准确率不够,阿里云同一套 OCR 里也有 VIN 识别接口可以顶上。
## 权限与合规
`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 重新生成。
- Dart 调原生用 `@HostApi()`;原生主动推事件给 Dart(比如扫码结果的持续回调)用 `@FlutterApi()`——不允许为了图省事用 `@HostApi()` 硬凑双向通信。
- **调用方只允许依赖 `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`,不允许静默返回空值或占位假数据。
## 待确认项
- **车牌识别的置信度阈值**:低于多少不直接回填、改成"待确认"让用户核对,需实测后定。
- **图片压缩参数**(长边、JPEG 质量):要同时满足阿里云 ≤ 4 MB 的限制、弱网可传、以及识别准确率不明显下降,实测后固化。
- **上传路径首版走"经我们后端中转"还是"STS 直传 OSS"**:默认中转(简单、密钥不出服务端),实测上传耗时不可接受再改,见上文。
- **调用量管控与计费口径**:单次接车允许几次识别、失败是否计费、月度用量上限与告警,和后端一起定。
- VIN 印刷字符 OCR 的实际准确率,需要拿真实车辆铭牌照片做一轮验证;不达标则改用阿里云的 VIN 识别接口。
- 三方 SDK 的 iOS 隐私清单覆盖情况,首次提交 TestFlight 前核完。
## 参考链接
- [Pigeon 官方文档](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 是什么,日常怎么用
给还没接触过跨语言原生集成的同学看的入门说明。
### 要解决的问题
Flutter 原生的 [`MethodChannel`](https://docs.flutter.dev/platform-integration/platform-channels) 机制本质是"字符串方法名 + 弱类型参数"的消息传递:
```dart
// 手写 MethodChannel,容易出的问题:
final result = await MethodChannel('scan_channel').invokeMethod('startScan', {'timeout': 5000});
// 1. 'startScan' 是字符串,原生那边方法名打错了,运行时才报 "not implemented"
// 2. 参数是 Map,字段名/类型对不上,运行时才崩,编译期完全看不出来
// 3. 返回值类型是 dynamic,还要自己强转、自己判断 null
```
三个问题的共性是:**Dart 和原生代码之间没有共享的类型系统**,接口的一致性完全靠开发者手动保证、runtime 才能发现错误。
**Pigeon** 用一个 Dart 文件定义"接口 schema"(有哪些方法、参数和返回值类型),然后生成 Dart 端 + Android(Kotlin) + iOS(Swift) 三端的强类型桩代码——方法名、参数、返回类型三端保持一致,改了 schema 忘记同步实现,编译期就会报错(生成的原生接口是抽象类/协议,没实现完整会编译不过),彻底消灭"方法名打错""参数字段对不上"这类只有运行时才发现的问题。
### 核心概念
1. **Schema 文件**`pigeons/xxx_api.dart`):用普通 Dart 类和注解描述接口,不是真的可执行代码,只是给 pigeon 生成器读的"接口契约"。
2. **`@HostApi()`**:声明一个"Dart 调用原生"的接口,pigeon 生成 Dart 端可直接调用的类,以及原生端需要实现的抽象类/协议。
3. **`@FlutterApi()`**:声明一个"原生调用 Dart"的接口(方向相反),用于原生侧主动推送事件(比如蓝牙扫描持续上报发现的设备)。
4. **生成命令**`dart run pigeon --input pigeons/xxx_api.dart` 会同时生成 Dart、Kotlin、Swift 三份代码,开发者只需要去实现原生那两个抽象类/协议里的方法体。
### 使用示例(`native_scan`:扫码能力)
```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
ScanResult startScan(ScanOptions options);
void stopScan();
}
/// 端上能解的两类。**车牌不在这里** —— 它是"拍照 + 调后端接口"
/// 不是取景框里的实时识别,见上文「VIN 码与车牌识别」
enum ScanMode { barcode, vin }
class ScanOptions {
ScanOptions({required this.mode, required this.timeoutMs});
final ScanMode mode;
final int timeoutMs;
}
class ScanResult {
ScanResult({required this.value, required this.format});
final String value;
final String format; // QR_CODE / CODE_39 / OCR_TEXT ...
}
```
```bash
# 配置已写进 @ConfigurePigeon,命令里不用再重复一遍输出路径
fvm dart run pigeon --input pigeons/scan_api.dart
```
Android 端实现生成的抽象类(`ScanApiImpl.kt`,非生成代码,是需要手写的实现):
```kotlin
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(value = "123456", format = "QR_CODE")))
}
override fun stopScan() {
// 停止扫码 SDK
}
}
```
Dart 端对外的公共 API`native_scan.dart`,调用方唯一能用的入口):
```dart
class NativeScan {
final ScanHostApi _api = ScanHostApi();
Future<ScanResult> startScan({
ScanMode mode = ScanMode.barcode,
Duration timeout = const Duration(seconds: 30),
}) async {
try {
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);
}
}
Future<void> stopScan() => _api.stopScan();
}
```
`feature_scan``core_webview` 的 JSBridge 都只 import `NativeScan` 这一个类,完全不知道底层是 Pigeon 生成的还是手写 `MethodChannel`——这也是把原生能力做成独立 `native_*` package(而不是散落在各 feature 里直接写平台通道代码)的意义:原生实现细节被这一层完全封装,将来换扫码 SDK 或补 OHOS 实现,调用方一行都不用动。