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

405 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 实现,调用方一行都不用动。