uni-app x iOS 扫码模糊怎么办?AVFoundation 灰尘级别摄像头助你超广角聚焦

uni-app x iOS 扫码模糊怎么办?从 <camera> 到 AVFoundation 原生扫码插件实战

前言

最近在一个 uni-app x 项目中遇到了一个比较典型的跨平台问题:

Android 使用 <camera mode="scanCode"> 扫描设备底部的二维码时基本正常,但在 iOS 上,近距离拍摄出来的画面非常模糊,二维码始终无法识别。

<camera>是因为可以自定义扫码样式

一开始很容易把问题归因于:

  • 相机分辨率不够 ?
  • 没有开启自动对焦 ?
  • 二维码尺寸太小 ?
  • uni-app x 的 <camera> 渲染不清晰 ?

但经过多轮排查后发现,问题的核心并不是分辨率,而是:

iPhone 普通广角镜头存在最短对焦距离,而 <camera> 组件没有提供选择虚拟多摄设备和控制微距镜头切换的能力。

最终方案是为 iOS 单独实现一个基于 AVFoundation 的原生扫码插件。

原来的实现

页面最初使用 uni-app x 的 <camera> 组件:

html 复制代码
<camera
  class="camera-view"
  mode="scanCode"
  device-position="back"
  @scancode="onScanCode"
  @error="onCameraError"
/>

这种方式的优点很明显:

  • 跨平台
  • 接入成本低
  • 自带扫码事件
  • 不需要编写原生代码

但是它暴露的相机配置非常有限。

业务层可以选择前后摄像头、闪光灯和扫码模式,却无法控制:

  • 使用哪一个后置物理镜头
  • 是否使用多摄虚拟设备
  • 是否允许自动切换到超广角微距镜头
  • 对焦点和曝光点
  • 自动对焦策略
  • 扫码码制
  • AVFoundation Session 配置

对于普通距离的二维码,这些限制可能没有影响;但当二维码很小、必须把手机贴近设备时,问题就出现了。

为什么提高分辨率没有用

排查过程中尝试过这些方案:

  • 使用 1080p
  • 使用 .high 预设
  • 降到 720p 提高对焦速度
  • 开启 continuousAutoFocus
  • 每隔一段时间重新触发 autoFocus
  • 设置中心对焦点
  • 对画面进行数字变焦

这些修改有时会让画面稍微改善,但始终无法从根本上解决问题。

原因是:

如果拍摄距离已经小于当前镜头的最短对焦距离,那么镜头物理上就无法合焦。

此时无论输出 720p、1080p 还是更高分辨率,得到的都只是分辨率更高的模糊图像。

数字变焦同样不能解决问题,因为它只是裁切并放大模糊画面。

定时触发自动对焦甚至可能产生副作用:镜头还没有完成一次对焦,就被下一次对焦操作打断,最终出现反复"拉风箱"的现象。

为什么系统相机可以拍清楚

同一台 iPhone,系统相机靠近二维码时却可以正常拍清楚。

这是因为部分多摄 iPhone 会在近距离场景下自动切换镜头:

  1. 默认使用普通广角镜头;
  2. 系统发现当前距离无法合焦;
  3. 自动切换到包含超广角镜头的微距方案;
  4. 超广角镜头完成近距离对焦。

因此,真正需要解决的问题不是"如何让普通广角镜头对焦更快",而是:

如何让扫码页面像系统相机一样,使用支持镜头切换的虚拟多摄设备。

使用 UTS + Swift 实现原生插件

插件目录结构如下:

text 复制代码
uni_modules/qr-scanner/
├── package.json
└── utssdk/app-ios/
    ├── config.json
    ├── Info.plist
    ├── index.uts
    └── QRScannerView.swift

各文件职责:

  • package.json:声明这是一个 uni_modules 插件
  • config.json:配置 iOS deployment target 和系统框架
  • Info.plist:声明相机权限用途
  • QRScannerView.swift:实现 AVFoundation 相机及扫码逻辑
  • index.uts:连接 Swift 原生视图和 uni-app x 页面

这里踩到的第一个打包问题就是遗漏了 package.json

text 复制代码
Cannot find module 'uni_modules/qr-scanner/package.json'

因此,即使是项目内部使用的本地 UTS 插件,也需要完整的 uni_modules 元数据。

将原生 UIView 嵌入页面

iOS 页面改用 <native-view>

html 复制代码
<!-- #ifdef APP-IOS -->
<native-view
  class="camera-view"
  @init="onNativeScanInit"
/>
<!-- #endif -->

Android 则继续保留原来的 <camera>

html 复制代码
<!-- #ifndef APP-IOS -->
<camera
  class="camera-view"
  mode="scanCode"
  device-position="back"
  @scancode="onScanCode"
  @error="onCameraError"
/>
<!-- #endif -->

这样可以把改动控制在 iOS,避免影响已经正常工作的 Android 扫码逻辑。

UTS 层负责创建 Swift 视图并绑定到 native-view

uts 复制代码
export class NativeQRScanner {
  private scannerView: QRScannerView | null = null

  constructor(element: UniNativeViewElement) {
    const view = new QRScannerView()
    this.scannerView = view
    element.bindIOSView(view as UIView)
  }

  @UTSJS.keepAlive
  start(onResult: (code: string) => void): void {
    if (this.scannerView == null) return

    this.scannerView!.onScan = (code: string): void => {
      onResult(code)
    }

    this.scannerView!.start()
  }

  resume(): void {
    this.scannerView?.resume()
  }

  stop(): void {
    this.scannerView?.stop()
  }
}

@UTSJS.keepAlive 很重要,它保证扫码结果回调在 start() 方法执行结束后仍然有效。

选择支持微距切换的虚拟相机

原先使用的是:

swift 复制代码
AVCaptureDevice.default(for: .video)

这种写法能获得默认后置摄像头,但不能保证拿到支持超广角切换的虚拟设备。

修改后优先选择:

swift 复制代码
private func preferredBackCamera() -> AVCaptureDevice? {
    if let device = AVCaptureDevice.default(
        .builtInTripleCamera,
        for: .video,
        position: .back
    ) {
        return device
    }

    if let device = AVCaptureDevice.default(
        .builtInDualWideCamera,
        for: .video,
        position: .back
    ) {
        return device
    }

    return AVCaptureDevice.default(
        .builtInWideAngleCamera,
        for: .video,
        position: .back
    )
}

选择顺序为:

  1. 三摄虚拟设备
  2. 双广角虚拟设备
  3. 普通广角设备兜底

需要注意,builtInDualCamera 通常是广角加长焦,不一定包含超广角,因此并不能直接解决近距离微距问题。

开启虚拟设备自动镜头切换

拿到虚拟设备后,还需要允许系统自动选择合适的物理镜头:

swift 复制代码
if device.isVirtualDevice,
   device.activePrimaryConstituentDeviceSwitchingBehavior != .unsupported {
    device.setPrimaryConstituentDeviceSwitchingBehavior(
        .auto,
        restrictedSwitchingBehaviorConditions: []
    )
}

.auto 表示系统可以根据以下条件自动选择镜头:

  • 当前变焦倍率
  • 光照条件
  • 对焦距离
  • 当前镜头能否完成曝光和对焦

当普通广角镜头无法在近距离合焦时,系统便有机会切换到超广角镜头。

这一步才是解决 iOS 近距离扫码模糊的核心。

恢复连续自动对焦

对焦策略改为系统式连续对焦:

swift 复制代码
if device.isFocusPointOfInterestSupported {
    device.focusPointOfInterest = CGPoint(x: 0.5, y: 0.5)
}

if device.isFocusModeSupported(.continuousAutoFocus) {
    device.focusMode = .continuousAutoFocus
} else if device.isFocusModeSupported(.autoFocus) {
    device.focusMode = .autoFocus
}

同时设置中心曝光点:

swift 复制代码
if device.isExposurePointOfInterestSupported,
   device.isExposureModeSupported(.continuousAutoExposure) {
    device.exposurePointOfInterest = CGPoint(x: 0.5, y: 0.5)
    device.exposureMode = .continuousAutoExposure
}

最终删除了定时重新触发对焦的方案,因为它会不断打断对焦收敛过程。

PS:图一的二维码模糊是我手动打了码,可清晰看到摄像头里面细小灰尘,已经是实现超广角聚焦了

清晰后为什么仍然扫不到

下面是我自己项目的坑(可忽略)

完成镜头切换后,画面已经明显清晰,但又出现了一个新问题:

二维码已经清楚可见,页面却没有任何反应。

这里分别遇到了两个问题。

问题一:识别区域坐标错误

为了只扫描白色框内的内容,最初设置了 rectOfInterest

但是 metadataOutputRectConverted 的调用时机太早:Session 尚未启动、Preview Layer 的连接和方向还没有完全建立,导致换算出的识别区域位置错误。

最终表现就是:

  • 相机预览正常
  • 二维码清晰
  • AVFoundation 永远检测不到二维码

为了提高稳定性,最终移除了识别区域限制,恢复全画面检测。页面上的白框只作为视觉引导。

对于扫码性能来说,全画面检测一个二维码的开销通常可以接受,比错误的坐标限制更可靠。

问题二:只开启了 QR 码

原生实现最初只配置了:

swift 复制代码
output.metadataObjectTypes = [.qr]

但工业标签不一定都是 QR Code,也可能是:

  • DataMatrix
  • Aztec
  • PDF417
  • Code 128
  • Code 39
  • EAN

Android <camera mode="scanCode"> 默认可能支持多种码制,而原生 iOS 实现如果只开启 .qr,遇到 DataMatrix 时就不会产生任何回调。

最终改为根据设备支持情况动态启用:

swift 复制代码
let desiredTypes: [AVMetadataObject.ObjectType] = [
    .qr,
    .dataMatrix,
    .aztec,
    .pdf417,
    .code128,
    .code39,
    .code93,
    .ean13,
    .ean8,
    .itf14,
    .upce,
    .interleaved2of5
]

let supportedTypes = desiredTypes.filter {
    output.availableMetadataObjectTypes.contains($0)
}

output.metadataObjectTypes = supportedTypes

不能直接把所有类型全部设置进去,因为向 metadataObjectTypes 写入设备不支持的类型可能产生异常。

页面生命周期管理

相机会占用系统硬件资源,因此必须跟随页面生命周期启停:

uts 复制代码
onShow(() => {
  isProcessing.value = false
  startNativeScan()
})

onHide(() => {
  stopNativeScan()
})

onUnload(() => {
  stopNativeScan()
  qrScanner = null
})

扫描成功后暂停 Session,防止同一个码连续触发多次。

如果业务匹配失败,则延迟恢复扫描:

uts 复制代码
setTimeout(() => {
  isProcessing.value = false
  qrScanner?.resume()
}, 2000)

减少自定义基座打包次数

UTS 原生插件的一个现实问题是:每次修改 Swift 或原生 UTS 代码,都需要重新打包自定义基座。

为了避免等到云端打包时才发现 Swift 语法错误,项目增加了一个本地检查脚本:

bash 复制代码
./scripts/check-ios-plugin.sh qr-scanner

核心命令是:

bash 复制代码
swiftc -typecheck QRScannerView.swift \
  -sdk "$(xcrun --sdk iphoneos --show-sdk-path)" \
  -target arm64-apple-ios15.0

它不能替代真机测试,也不能验证 UTS 桥接的运行时行为,但可以提前发现:

  • Swift 语法错误
  • API 名称错误
  • 只读属性误赋值
  • iOS Deployment Target 不兼容

例如,自动镜头切换不能写成:

swift 复制代码
device.primaryConstituentDeviceSwitchingBehavior = .auto

因为该属性只读,正确方式是:

swift 复制代码
device.setPrimaryConstituentDeviceSwitchingBehavior(
    .auto,
    restrictedSwitchingBehaviorConditions: []
)

这个问题就是通过本地 swiftc -typecheck 提前发现的。

总结

这次问题最重要的经验是:

iOS 扫码模糊不一定是分辨率问题,也可能是当前镜头已经超过物理对焦范围。

如果业务需要扫描非常小、距离很近的二维码,仅仅调整分辨率、数字变焦和自动对焦模式,很可能无法解决。

最终方案的关键点包括:

  1. 使用 AVFoundation 获取相机控制权;
  2. 优先选择包含超广角的虚拟多摄设备;
  3. 允许系统根据对焦距离自动切换镜头;
  4. 使用连续自动对焦,而不是定时重置对焦;
  5. 正确配置设备支持的扫码码制;
  6. 谨慎处理 rectOfInterest 的坐标和调用时机;
  7. 完整管理相机会话生命周期;
  8. 使用本地 Swift 类型检查减少无效打包。

<camera> 组件并不是不能扫码,而是它提供的是通用、跨平台能力。当业务开始依赖镜头切换、微距和精细对焦策略时,就需要下沉到原生层。

这也是跨平台开发中一个很典型的边界:

通用组件负责覆盖大多数场景,原生插件负责解决平台特有的深度需求。

相关推荐
柒和远方1 小时前
V062:WebGPU 端侧推理与单例模式:从 Transformers.js 把大模型装进浏览器,到 Worker 线程的流式对话
前端
huabuyu1 小时前
INP 自动化归因与自愈:如何自动定位、修复并验证一次卡顿
前端·javascript
何时梦醒1 小时前
第一篇:项目概览 — 在浏览器里跑大模型,端侧 AI 的革命来了
前端·人工智能
何时梦醒1 小时前
第二篇:工程化搭建 — Vite + React + TypeScript + TailwindCSS 全解析
前端·人工智能
只一1 小时前
React 性能优化精讲:useCallback 与 useMemo 彻底吃透(附实战案例)
前端·react.js
橘子星1 小时前
浏览器也能跑大模型:WebGPU + Transformers.js 本地运行 DeepSeek-R1
前端·人工智能
用户33144195556732 小时前
Rush Monorepo 构建缓存指南
前端
windliang2 小时前
Claude Code 源码分析(八):Memory 如何被写入、整理与按需召回
前端·算法·面试
木公子2 小时前
Vue3源码精读03:响应式核心依赖追踪机制|track与trigger底层源码全解析
前端·vue.js