Files
conti-docs/flutter-app/07-native-integration.md
T
Guangfei.Zhao 2704ba5445 refactor: update README and project structure for native integration and OHOS support
- Enhanced README to clarify native integration methods for Android/iOS and OHOS.
- Updated project structure documentation to reflect changes in native capabilities and dependencies.
- Revised native integration documentation to specify transport methods for Android/iOS and OHOS.
- Documented the current implementation strategy for OHOS, emphasizing the use of MethodChannel for ArkTS.
- Adjusted SDK versioning notes to clarify the separation between Android/iOS and OHOS SDKs.
2026-09-02 23:32:16 +08:00

25 KiB
Raw Blame History

07. 原生能力集成方式

决策

原生能力(扫码、支付、蓝牙等)统一由独立的 native_* Dart package 暴露稳定公共 API(结构见 01-project-structure.md)。Android/iOS 跨语言接口使用 Pigeon(锁定 26.3.1)生成;harmony 分支的首期 OHOS 实现是经过约束的例外:native_* 内部通过手写 MethodChannel 调用 App 宿主 app/ohos/entry 中的 ArkTS,而不是将 OHOS 再包装成独立 Flutter plugin。

依赖

dev_dependencies:
  pigeon: 26.3.1

包结构规则

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 的首期 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

这是最容易漏、漏了最难排查的一条:native_* 包如果没有 flutter: plugin: 声明,android/、ios/ 下的原生代码根本不会被编译进宿主 App。表现是 Dart 侧调用直接抛 MissingPluginException,而代码看上去哪里都没问题。

# 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 方法上:

// 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 里"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。

结论:车牌用付费的阿里云视觉智能开放平台车牌识别(RecognizeLicensePlate),上传图片换识别结果,不做端侧模型。

需求 能力 方案 在哪跑
二维码 / 条形码(商品、库位) Barcode MLKit Barcode Scanning(Android)/ Vision(iOS) 端上,离线
VIN 条码 Barcode(Code 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 的规矩走,阿里云 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 里声明:

<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 的 X-Device-Id 约定)。

Pigeon 的工程化

生成命令不写在 README 里让人手敲,而是把配置写进 schema、动作做成 melos script。

// 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 之后,生成命令就退化成一行,不会出现"某人生成时路径敲错,生成物落到别的目录":

fvm dart run pigeon --input pigeons/scan_api.dart

melos script(见 01-project-structure.md):

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)。
  • Dart 生成物在根 analysis_options.yaml 里排除 lint(analyzer: exclude: - "**/*.g.dart")。
  • CI 要有一步"重新生成后 git diff --exit-code",防止有人改了 schema 但忘了提交生成物。

OHOS 当前实现策略(harmony)

OHOS 不是 Android 的另一种 plugin platform。当前 Flutter_OH 工具链没有可直接复用的 Pigeon ArkTS 生成链路;而现有 Pigeon API 在 Dart 侧使用 BasicMessageChannel、自定义 codec、类型 tag 和生成的错误/结果 envelope。让 ArkTS 人工复刻该私有 wire protocol,维护成本和接口漂移风险都不值得。

因此,OHOS 在 native_scan 的内部新增一条简洁、独立的 MethodChannel transport。它接收和返回仅用于该 transport 的基础 Map/List/标量,再映射回既有的 ScanOptions、ScanResult 与 NativeScanException;Android/iOS Pigeon schema、生成物与注册逻辑不因此改变。

ArkTS 按职责拆分,避免把业务实现写进默认入口:

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 出现经验证可用的 Flutter 3.44.x / Dart 3.12.x 对齐版本时,可评审统一基线。升级前必须以该候选 SDK 完成依赖解析、Pigeon 与 Riverpod 生成物核验、flutter build hap --debug -t lib\\main_dev.dart 和真机原生能力回归;不能仅因版本号相同就替换工具链。

使用规则

  • 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() 硬凑双向通信。
  • 调用方只允许依赖 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 里同一套 AppException 体系,不让原生异常类型(如 PlatformException)直接抛到业务代码里。
  • 某一端暂未实现的能力,公共 API 类里对应平台分支抛明确的 UnsupportedPlatformException,不允许静默返回空值或占位假数据。

待确认项

  • 车牌识别的置信度阈值:低于多少不直接回填、改成"待确认"让用户核对,需实测后定。
  • 图片压缩参数(长边、JPEG 质量):要同时满足阿里云 ≤ 4 MB 的限制、弱网可传、以及识别准确率不明显下降,实测后固化。
  • 上传路径首版走"经我们后端中转"还是"STS 直传 OSS":默认中转(简单、密钥不出服务端),实测上传耗时不可接受再改,见上文。
  • 调用量管控与计费口径:单次接车允许几次识别、失败是否计费、月度用量上限与告警,和后端一起定。
  • VIN 印刷字符 OCR 的实际准确率,需要拿真实车辆铭牌照片做一轮验证;不达标则改用阿里云的 VIN 识别接口。
  • 三方 SDK 的 iOS 隐私清单覆盖情况,首次提交 TestFlight 前核完。

参考链接

附录:Pigeon 是什么,日常怎么用

给还没接触过跨语言原生集成的同学看的入门说明。

要解决的问题

Flutter 原生的 MethodChannel 机制本质是"字符串方法名 + 弱类型参数"的消息传递:

// 手写 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:扫码能力)

// 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 ...
}
# 配置已写进 @ConfigurePigeon,命令里不用再重复一遍输出路径
fvm dart run pigeon --input pigeons/scan_api.dart

Android 端实现生成的抽象类(ScanApiImpl.kt,非生成代码,是需要手写的实现):

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,调用方唯一能用的入口):

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 实现,调用方一行都不用动。