# Unif HMS Scan — 全文文档聚合 > 单文件聚合版。每段都带源路径与标题,方便整体粘贴给 LLM。 ## 目录 - 函数 - HmsScanView - Scanner - 类型 - 安装 - 快速上手 - 图片识别 - 底层 headless 组件 - 权限处理 - 成品扫一扫页 - 介绍 - 平台差异 - AI Skill - 测试(Mock) - 常见问题 ## 函数 *Source: `docs/api/functions.md` · Slug: `/api/functions`* # 函数 `@unif/react-native-hms-scan` 导出的所有函数。 ```tsx import { decodeImage, getCameraPermissionStatus, requestCameraPermission, coerceFormat, coerceContentType, formatsToCsv, } from '@unif/react-native-hms-scan'; ``` | 函数 | 作用 | 触碰原生 | | --- | --- | --- | | [`decodeImage`](#decode-image) | 从本地图片解码条码 / 二维码 | ✅ | | [`getCameraPermissionStatus`](#get-status) | 查询相机权限状态(不弹窗) | ✅ | | [`requestCameraPermission`](#request) | 请求相机权限(必要时弹窗) | ✅ | | [`coerceFormat`](#coerce-format) | 字符串安全收敛为 `BarcodeFormat` | ❌ 纯函数 | | [`coerceContentType`](#coerce-content-type) | 字符串安全收敛为 `BarcodeContentType` | ❌ 纯函数 | | [`formatsToCsv`](#formats-to-csv) | `BarcodeFormat[]` → 逗号分隔 CSV | ❌ 纯函数 | --- ## decodeImage {#decode-image} 从**本地**图片解码条码 / 二维码(华为 Bitmap 模式)。**不走相机**,是独立的识图路径。 ### 签名 ```ts function decodeImage( uri: string, options?: DecodeImageOptions ): Promise ``` ### 参数 | 参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `uri` | `string` | ✅ | 本地图片 URI / 路径,见下方[接受的 URI](#accepted-uri)。**不下载远程 URL** | | `options` | `DecodeImageOptions` | — | 可选配置 | | `options.formats` | `BarcodeFormat[]` | — | 限定识别码制;不传 = 全部 | ### 接受的 URI {#accepted-uri} 只接受**本地** URI;两端接受的形式略有差异(源自各自原生实现): | 形式 | Android | iOS | | --- | --- | --- | | `file:///...`(文件 URI) | ✅ | ✅ | | 绝对路径(无 scheme,如 `/data/.../a.jpg`) | ✅ | ✅ | | `data:...`(base64 等) | — | ✅ | | `content://...`(Android Content URI) | ✅ | ❌ | | `android.resource://...` | ✅ | ❌ | | `ph://...`(iOS 相册)/ `assets-library://` | — | ❌ | | `http(s)://...`(远程 URL) | ❌ | ❌ | :::tip 跨平台最稳的输入:`file://` 或绝对路径 两端都稳的输入是 **`file://` 或绝对路径**。`content://` **仅 Android**;iOS **不接受 `ph://`**(相册 URI)。从相册选图时,让图片选择器(如 `react-native-image-picker`)返回**本地文件路径**再传入,最省心。 ::: ### 返回值 `Promise` — 命中的结果数组。**图中无码时 resolve 空数组 `[]`,不抛错**(最易踩的坑:别把 `!results.length` 当失败抛异常)。 ### 错误 {#errors} 真正的失败才抛 `HmsScanError`(带 `code`): | `code` | 触发场景 | | --- | --- | | `E_IMAGE_LOAD_FAILED` | 路径无效 / 非本地 uri(如远程 URL、iOS 的 `ph://`)/ 格式不支持 | | `E_DECODE_FAILED` | 解码过程异常 | | `E_NO_READ_PERMISSION` | 缺少相册读取权限(Android) | | `E_UNKNOWN` | 其他未知错误(非上述 code 的原生异常统一收敛于此) | | 场景 | 结果 | | --- | --- | | 图里没码 | resolve `[]`(**不抛错**) | | 传了远程 URL / 非本地 uri / 路径无效 | 抛 `E_IMAGE_LOAD_FAILED` | | 解码过程异常 | 抛 `E_DECODE_FAILED` | ### 示例 ```tsx import { decodeImage, HmsScanError } from '@unif/react-native-hms-scan'; try { const results = await decodeImage('file:///var/mobile/photo.jpg', { formats: ['QR_CODE', 'EAN_13'], }); if (results.length > 0) { const { value, format } = results[0]; // 处理识别结果 } else { // 空数组 = 图里没码(正常,不是错误) } } catch (e) { if (e instanceof HmsScanError) { // e.code: E_IMAGE_LOAD_FAILED / E_DECODE_FAILED / E_NO_READ_PERMISSION / E_UNKNOWN } } ``` 更多用法见[指南 → 图片识别](/docs/guides/decode-image)。 --- ## getCameraPermissionStatus {#get-status} 查询当前相机权限状态(**不弹**系统授权框)。 ### 签名 ```ts function getCameraPermissionStatus(): Promise ``` ### 返回值 `Promise`: | 值 | 说明 | | --- | --- | | `'granted'` | 已授权 | | `'denied'` | 用户拒绝(可再次请求) | | `'blocked'` | 永久拒绝(需引导去系统设置) | | `'undetermined'` | 尚未请求过权限 | :::note Android 查询时只给 granted / denied Android 在查询时无法可靠区分 `blocked` 与 `undetermined`,对任何未授权状态返回 `denied`;精确区分由 `requestCameraPermission`(请求后)给出。iOS 查询即返回完整四态。**判断流程请以 `requestCameraPermission` 的返回为准。** ::: ### 示例 ```tsx const status = await getCameraPermissionStatus(); if (status === 'granted') { // 可以打开相机 } ``` --- ## requestCameraPermission {#request} 发起相机权限请求(必要时弹系统授权框),返回请求后的状态。 ### 签名 ```ts function requestCameraPermission(): Promise ``` ### 返回值 `Promise` — 请求后的权限状态(取值同 [`getCameraPermissionStatus`](#get-status))。若返回 `blocked`,系统不再弹框,需引导去系统设置。 ### 示例 ```tsx import { Linking } from 'react-native'; import { requestCameraPermission } from '@unif/react-native-hms-scan'; const status = await requestCameraPermission(); if (status === 'granted') { // 权限已获取 } else if (status === 'blocked') { Linking.openSettings(); // 引导去设置 } ``` 完整权限流见[指南 → 权限处理](/docs/guides/permissions)。 --- ## 码制工具(纯函数){#format-utils} 以下三个为纯 JS 工具,不触碰原生,在测试 mock 中也保留**真实实现**。多数业务无需直接调用——`` / `decodeImage` 内部已自动使用。 ### coerceFormat {#coerce-format} 把任意值安全收敛为 `BarcodeFormat`;不是已知码制(含 `UNKNOWN`)则返回 `'UNKNOWN'`。 ```ts function coerceFormat(value: unknown): BarcodeFormat coerceFormat('EAN_13'); // 'EAN_13' coerceFormat('FOO'); // 'UNKNOWN' coerceFormat(123); // 'UNKNOWN' ``` ### coerceContentType {#coerce-content-type} 把任意值安全收敛为 `BarcodeContentType`;未知 / 缺省返回 `undefined`。 ```ts function coerceContentType(value: unknown): BarcodeContentType | undefined coerceContentType('URL'); // 'URL' coerceContentType('FOO'); // undefined ``` ### formatsToCsv {#formats-to-csv} 把 `BarcodeFormat[]` 转成传给原生的逗号分隔 CSV;空数组 / 未传 → `''`(= 识别全部码制)。 ```ts function formatsToCsv(formats?: readonly BarcodeFormat[]): string formatsToCsv(['QR_CODE', 'EAN_13']); // 'QR_CODE,EAN_13' formatsToCsv([]); // '' formatsToCsv(); // '' ``` --- ## 平台兼容性 | 函数 | iOS | Android | Web | | --- | --- | --- | --- | | `decodeImage` | ✅ | ✅ | ❌ | | `getCameraPermissionStatus` | ✅ | ✅ | ❌ | | `requestCameraPermission` | ✅ | ✅ | ❌ | | `coerceFormat` / `coerceContentType` / `formatsToCsv` | ✅ | ✅ | ✅(纯函数) | --- ## 相关 - [API 参考 → 类型](/docs/api/types) — `ScanResult` / `DecodeImageOptions` / `CameraPermissionStatus` / `HmsScanError` - [指南 → 图片识别](/docs/guides/decode-image) — `decodeImage` 使用场景与 URI 坑 - [指南 → 权限处理](/docs/guides/permissions) — 完整权限管理流程 - [平台差异](/docs/platform-differences) — `decodeImage` URI / 权限的两端差异 ## HmsScanView *Source: `docs/api/hms-scan-view.md` · Slug: `/api/hms-scan-view`* # HmsScanView 底层 headless 扫码相机组件:**只渲染相机预览并发出扫码事件**,取景框 / 扫描线 / 手电按钮等 UI 由上层(如 [``](/docs/api/scanner))用普通 RN 视图叠加绘制。需要自定义扫码界面时用它;要开箱即用的整屏扫码页用 ``。 ```tsx import { HmsScanView } from '@unif/react-native-hms-scan'; ``` 原生实现:Android = 华为 `RemoteView`(Scan SDK-Plus),iOS = `HmsCustomScanViewController`(ScanKitFrameWork)。 --- ## 签名 ```tsx const HmsScanView: ForwardRefExoticComponent< HmsScanViewProps & RefAttributes<...> > ``` `HmsScanViewProps` 继承自 `ViewProps`——所有标准 `View` props(如 `style`)均可传入。 --- ## Props {#props} | Prop | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `style` | `StyleProp` | — | 布局样式,通常设 `StyleSheet.absoluteFill` 或 `{ flex: 1 }`(继承自 `ViewProps`) | | `formats` | `BarcodeFormat[]` | — | 限定识别码制;不传 = 全部([14 种](/docs/api/types#barcode-format)) | | `continuous` | `boolean` | `true` | 连续扫码模式;`false` 命中后停止 | | `paused` | `boolean` | `false` | 暂停 / 恢复扫码;命中后置 `true` 可停在结果画面 | | `torch` | `boolean` | `false` | 手电筒开关(**iOS 为 best-effort**,见[平台差异](/docs/platform-differences#torch)) | | `onScanResult` | `(results: ScanResult[]) => void` | — | 命中一个或多个码时回调(原生 JSON 已解析为强类型) | | `onScanError` | `(error: { code: string; message: string }) => void` | — | 相机 / 解码出错时回调,见 [error.code](#error-codes) | | `onTorchStatus` | `(status: TorchStatus) => void` | — | 手电 / 暗光状态变化时回调,见 [TorchStatus](#torch-status) | :::note formats 变更会重建相机 两端都在初始化时按 `formats` 创建扫码器,运行中改变 `formats`(或 `continuous`)会**重建**底层相机视图。若需频繁切换码制,建议传一个稳定的全集而非频繁变更。 ::: --- ## TorchStatus {#torch-status} `onTorchStatus` 回调参数类型。 | 字段 | 类型 | 说明 | | --- | --- | --- | | `available` | `boolean` | **暗光提示**:环境暗到建议显示手电按钮(**仅 Android 据环境光上报**) | | `on` | `boolean` | 手电当前是否点亮 | :::warning available 的暗光语义仅 Android `available` 作为「**环境暗、建议显示手电**」的提示**只有 Android 会据环境光上报**(来自华为 `OnLightVisibleCallBack`)。iOS 上 `onTorchStatus` **仅在你改变 `torch` prop 时**触发,其 `available` 反映的是「设备是否有手电硬件」、**不是**环境光信号——**不要**把它当跨平台的暗光提示。详见[平台差异](/docs/platform-differences#torch)。 ::: --- ## error.code {#error-codes} `onScanError` 回调的 `error.code`(字符串;**不是** `HmsScanError` 实例): | `code` | 平台 | 含义 | | --- | --- | --- | | `E_CAMERA_INIT` | Android | 相机 / 预览初始化失败(如无宿主 Activity) | | `E_NO_RESULT` | iOS | 解码结果为空或无法解析(软错误) | > `` **不含权限请求逻辑**——渲染前需先确认已获得相机权限(见[指南 → 权限处理](/docs/guides/permissions))。`` 会自动处理权限。 --- ## 示例 ```tsx import { useState } from 'react'; import { View, StyleSheet } from 'react-native'; import { HmsScanView, type ScanResult } from '@unif/react-native-hms-scan'; function CustomScan() { const [paused, setPaused] = useState(false); const [torch, setTorch] = useState(false); return ( { const code = results[0]?.value; if (code) setPaused(true); // 命中后停帧 }} onScanError={(e) => { // e.code: 'E_CAMERA_INIT'(Android)/ 'E_NO_RESULT'(iOS) }} onTorchStatus={(status) => { // Android: status.available 为暗光提示;iOS 仅 torch 变更时触发 }} /> {/* 自定义取景框 / 按钮叠加在这里 */} ); } ``` 更多模式见[指南 → 底层 headless 组件](/docs/guides/headless)。 --- ## 注意事项 - `paused` 置 `true` 时相机画面保持但停止解码,适合命中后停帧展示结果。 - `continuous` 模式下同一帧可能多次回调,业务侧需自行去重(如置 `paused`)。 - `torch` 在 iOS 端为 best-effort 实现,详见[平台差异](/docs/platform-differences#torch)。 --- ## 平台兼容性 | 平台 | 支持 | 备注 | | --- | --- | --- | | iOS(真机) | ✅ | `torch` best-effort;`onTorchStatus.available` 非暗光信号;模拟器无法扫码 | | Android | ✅ | 全功能支持 | | Web | ❌ | — | --- ## 相关 - [平台差异](/docs/platform-differences) — 手电筒与暗光提示的详细对比 - [指南 → 底层 headless 组件](/docs/guides/headless) — 使用示例与典型模式 - [API 参考 → Scanner](/docs/api/scanner) — 开箱即用的成品扫码页 - [API 参考 → 类型](/docs/api/types) — `ScanResult` / `BarcodeFormat` ## Scanner *Source: `docs/api/scanner.md` · Slug: `/api/scanner`* # Scanner 成品「扫一扫」界面(聚焦款,浅色)。底层使用 [``](/docs/api/hms-scan-view) 出相机画面,取景框 / 工具栏 / 结果卡全用 `@unif/react-native-design` 的主题令牌与组件绘制。**自带 `ThemeProvider` + `ToastHost` + 权限流**,可直接整屏接入。 ```tsx import { Scanner } from '@unif/react-native-hms-scan'; ``` --- ## 签名 ```tsx function Scanner(props: ScannerProps): JSX.Element ``` --- ## Props {#props} | Prop | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `title` | `string` | `'扫一扫'` | 顶栏标题 | | `formats` | `BarcodeFormat[]` | — | 限定识别码制;不传 = 全部([14 种](/docs/api/types#barcode-format)) | | `hintText` | `string` | `'将条码 / 二维码放入框内,自动扫描'` | 取景态提示文案 | | `topInset` | `number` | `54` | 顶部安全区高度(px)。用 `react-native-safe-area-context` 时传 `insets.top` | | `bottomInset` | `number` | `34` | 底部安全区高度(px)。用 `react-native-safe-area-context` 时传 `insets.bottom` | | `showTorch` | `boolean` | `true` | 是否显示手电筒按钮。手电由库内自管:Android 可编程控制,**iOS 为 best-effort**(见[平台差异](/docs/platform-differences#torch)),可在 iOS 传 `false` 隐藏 | | `onClose` | `() => void` | — | 返回按钮回调(退出扫码页;按钮在底部工具栏,与手电筒并排) | | `resolveProduct` | `(result: ScanResult) => ScanProduct \| null \| undefined \| Promise` | — | 扫到条码后由宿主解析商品信息(用于浮层确认卡)。返回 `null` / `undefined` **或抛错** = 未识别 → 进入 fail 重扫层。不传则以 `result.value` 作为商品名 | | `onConfirm` | `(product: ScanProduct, result: ScanResult) => void` | — | 用户点"确定"时回调(宿主通常在此导航返回)。`autoConfirm` 为真时由库自动触发 | | `autoConfirm` | `boolean` | `false` | 扫到并解析成功后**不显示结果卡**,直接触发 `onConfirm(product, result)`。适合"扫到即用、无需二次确认"。回调后相机暂停、不自动重扫(宿主通常在 `onConfirm` 里导航离开;再扫由宿主控制)。未识别(`resolveProduct` 返回 `null` / 抛错)仍走 fail 重扫,不会误触发 | | `pickImage` | `() => Promise` | — | 点"相册":宿主用自己的图片选择器选图并返回本地 uri(取消返回 `null`)。**库不内置图片选择器:传了才显示相册按钮,不传则隐藏** | :::note 返回 / 手电 / 相册按钮的显隐 工具栏在取景态显示,只要 `showTorch` 为真、传了 `pickImage`、**或**传了 `onClose` 任一即出现:返回按钮在传了 `onClose` 时显示,手电按钮受 `showTorch` 控制,相册按钮仅在传了 `pickImage` 时显示。`pickImage` 返回的本地 uri 会交给 `decodeImage` 识别(受同样的 [URI 规则](/docs/api/functions#accepted-uri) 约束)。 ::: --- ## 回调用到的类型 {#types} ### ScanResult {#scan-result} `resolveProduct` / `onConfirm` 收到的扫码结果。完整定义见 [类型 → ScanResult](/docs/api/types#scan-result)。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `value` | `string` | ✅ | 原始解码文本 | | `format` | `BarcodeFormat` | ✅ | 码制 | | `contentType` | `BarcodeContentType` | — | 内容语义类型(可能缺省) | | `cornerPoints` | `ScanCornerPoint[]` | — | 条码四角点(可能缺省) | ### ScanProduct {#scan-product} `resolveProduct` 返回、用于浮层确认卡展示的商品信息。仅 `name` 必填。完整定义见 [类型 → ScanProduct](/docs/api/types#scan-product)。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `name` | `string` | ✅ | 商品名 | | `brand` | `string` | — | 品牌 | | `brandChar` | `string` | — | 字母牌字符;缺省取 `brand` / `name` 首字 | | `barcode` | `string` | — | 条码;缺省取扫到的 `value` | | `spec` | `string` | — | 规格 | | `stockShort` | `string` | — | 库存短描述 | | `price` | `string` | — | 价格展示串 | | `priceCaption` | `string` | — | 价格副标题,默认 "建议零售" | --- ## 示例 ```tsx import { Scanner, type ScanResult, type ScanProduct } from '@unif/react-native-hms-scan'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; function ScanScreen({ navigation }) { const insets = useSafeAreaInsets(); return ( navigation.goBack()} resolveProduct={async (r: ScanResult): Promise => { const p = await api.lookupByBarcode(r.value); return p ? { name: p.name, price: `¥${p.price}` } : null; }} onConfirm={(product, result) => { navigation.navigate('Order', { barcode: result.value, product }); }} pickImage={async () => { const res = await launchImageLibrary({ mediaType: 'photo' }); return res.assets?.[0]?.uri ?? null; }} /> ); } ``` --- ## 注意事项 - 挂载时**自动请求相机权限**:已授权直接进入取景;永久拒绝(`blocked`)展示引导去系统设置的遮罩。无需自行写权限流。 - 内部状态机:`init → scan → detecting → success / fail / denied`,**一次扫一个**(扫到 `results[0]` 进 detecting,确定或重扫后回 scan)。 - `resolveProduct` **抛错与返回 `null` / `undefined` 效果相同**,均进入 fail 重扫层。 - 自带 `ThemeProvider`;放进宿主已有的 `ThemeProvider` 里也兼容(嵌套不报错)。 - `@unif/react-native-design` 是 peer 依赖,`` 的 UI 依赖它(及其链上的 `react-native-reanimated` / `react-native-gesture-handler`)。 --- ## 平台兼容性 | 平台 | 支持 | 备注 | | --- | --- | --- | | iOS(真机) | ✅ | 手电 best-effort;模拟器无法扫码(见[平台差异](/docs/platform-differences)) | | Android | ✅ | 全功能支持 | | Web | ❌ | — | --- ## 相关 - [指南 → 成品扫一扫页](/docs/guides/scanner) — 使用场景与配置示例 - [API 参考 → HmsScanView](/docs/api/hms-scan-view) — 底层 headless 组件 - [API 参考 → 类型](/docs/api/types) — `ScanResult` / `ScanProduct` / `BarcodeFormat` - [平台差异](/docs/platform-differences) — 手电筒 / 真机限制 ## 类型 *Source: `docs/api/types.md` · Slug: `/api/types`* # 类型 `@unif/react-native-hms-scan` 所有公开类型的完整定义。除 `HmsScanError`(运行时类)与 `ALL_BARCODE_FORMATS`(运行时常量)外,本页类型均为纯 TS 类型,不含运行时代码,可在任意平台引用。 ```ts import type { BarcodeFormat, BarcodeContentType, ScanCornerPoint, ScanResult, DecodeImageOptions, CameraPermissionStatus, HmsScanErrorCode, ScanProduct, HmsScanViewProps, TorchStatus, ScannerProps, } from '@unif/react-native-hms-scan'; import { HmsScanError, ALL_BARCODE_FORMATS } from '@unif/react-native-hms-scan'; ``` :::note 单一真相源 本页所有类型逐字取自 `src/types.ts`(外加 `HmsScanViewProps` / `TorchStatus` 来自 `src/HmsScanView.tsx`、`ScannerProps` 来自 `src/Scanner/Scanner.tsx`)。`HmsScanViewProps` / `TorchStatus` / `ScannerProps` 的字段表见各自的 [HmsScanView](/docs/api/hms-scan-view) 与 [Scanner](/docs/api/scanner) 页。 ::: --- ## BarcodeFormat {#barcode-format} 统一码制枚举(两端归一)。共 **14 种实际码制** 加一个 `UNKNOWN` 兜底值: ```ts type BarcodeFormat = | 'QR_CODE' | 'AZTEC' | 'DATA_MATRIX' | 'PDF417' | 'CODABAR' | 'CODE_39' | 'CODE_93' | 'CODE_128' | 'EAN_8' | 'EAN_13' | 'UPC_A' | 'UPC_E' | 'ITF14' | 'MULTI_FUNCTIONAL' | 'UNKNOWN'; ``` - `UNKNOWN` 是兜底值,由 `coerceFormat` 在原生回传无法识别时产生,**不应**主动传给 `formats`。 - `ALL_BARCODE_FORMATS` 常量包含上述 **14 种**(不含 `UNKNOWN`);不传 `formats` 等价于"识别全部码制",无需手动传它。 - **iOS 平台差异**:`MULTI_FUNCTIONAL` 在 HUAWEI iOS Scan Kit 无对应码制,作为 `formats` 过滤项在 iOS 上不生效(若 `formats` 只含 `MULTI_FUNCTIONAL` / `UNKNOWN`,iOS 会回退为识别全部码制)。详见[平台差异](/docs/platform-differences#formats)。 ```ts import { ALL_BARCODE_FORMATS } from '@unif/react-native-hms-scan'; // ALL_BARCODE_FORMATS: readonly BarcodeFormat[](14 项,不含 UNKNOWN) ``` --- ## BarcodeContentType {#barcode-content-type} 码内容语义类型(尽力归一)。Android 来自 `HmsScan.getScanTypeForm()`,iOS 来自 `sceneType`(精度有限,未知归 `OTHER` / 缺省)。 ```ts type BarcodeContentType = | 'TEXT' | 'URL' | 'EMAIL' | 'PHONE' | 'SMS' | 'WIFI' | 'CONTACT' | 'EVENT' | 'LOCATION' | 'DRIVER' | 'ISBN' | 'ARTICLE' | 'OTHER'; ``` :::note iOS 精度有限 iOS 的 `sceneType` 没有公开枚举,本库只映射少数已知场景,其余返回缺省(`contentType` 被省略)。**不要**把 `contentType` 当作两端一致的精确信号,业务判断建议以 `value` 内容为准。 ::: --- ## ScanCornerPoint {#scan-corner-point} 取景框 / 解码命中的角点(图像坐标系,单位 px)。 | 字段 | 类型 | 说明 | | --- | --- | --- | | `x` | `number` | 横坐标(px) | | `y` | `number` | 纵坐标(px) | --- ## ScanResult {#scan-result} 一次扫码 / 解码命中的结果。`` 的 `onScanResult` 与 `decodeImage` 均返回 `ScanResult[]`。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `value` | `string` | ✅ | 原始解码文本(Android `getOriginalValue` / iOS `text`) | | `format` | `BarcodeFormat` | ✅ | 码制 | | `contentType` | `BarcodeContentType` | — | 内容语义类型(可能缺省) | | `cornerPoints` | `ScanCornerPoint[]` | — | 条码四角点(可能缺省) | :::note 仅有 `value` 与 `format` 必有 原生回传经 `parseResultsJson` / `coerceResult` 防御性收敛:无有效 `value` 的命中被丢弃;`contentType` / `cornerPoints` 缺省时不出现在结果对象上。 ::: --- ## DecodeImageOptions {#decode-image-options} `decodeImage` 的可选配置。 | 字段 | 类型 | 说明 | | --- | --- | --- | | `formats` | `BarcodeFormat[]` | 限定识别码制;不传 = 全部 | --- ## CameraPermissionStatus {#camera-permission-status} 相机权限状态。`getCameraPermissionStatus` / `requestCameraPermission` 均返回此类型。 | 值 | 说明 | | --- | --- | | `'granted'` | 已授权 | | `'denied'` | 用户拒绝(可再次请求) | | `'blocked'` | 永久拒绝(需引导去系统设置) | | `'undetermined'` | 尚未请求过权限 | :::note Android 查询时只给 granted / denied Android 在**查询时**无法可靠区分 `blocked` 与 `undetermined`,故 `getCameraPermissionStatus` 对任何未授权状态返回 `denied`;`blocked` / `undetermined` 的精确区分由 `requestCameraPermission`(请求后)给出。iOS 查询即可返回完整四态。详见[平台差异](/docs/platform-differences#permission) 与[权限处理](/docs/guides/permissions#get-status)。 ::: --- ## HmsScanErrorCode {#hms-scan-error-code} 库统一错误码(`HmsScanError.code`)。 | 值 | 说明 | 来源 | | --- | --- | --- | | `'E_IMAGE_LOAD_FAILED'` | 图片加载失败(路径无效 / 非本地 uri / 格式不支持) | `decodeImage`(两端) | | `'E_DECODE_FAILED'` | 解码过程异常 | `decodeImage`(两端) | | `'E_NO_READ_PERMISSION'` | 缺少相册读取权限 | `decodeImage`(Android) | | `'E_CAMERA_INIT'` | 相机 / 预览初始化失败 | `` `onScanError`(Android) | | `'E_NO_RESULT'` | 解码结果为空或无法解析 | `` `onScanError`(iOS) | | `'E_NO_CAMERA_PERMISSION'` | 缺少相机权限 | `` 内部据此切到拒权遮罩 | | `'E_UNKNOWN'` | 其他未知错误 | `decodeImage` 兜底 | :::note 错误码分布 `decodeImage` 抛 `HmsScanError`(`code` 取上表 `decodeImage` 行;非这些 code 的原生异常统一收敛为 `E_UNKNOWN`)。`` 的相机 / 解码错误经 `onScanError` 回调以 `{ code, message }` 形式上报,**不是** `HmsScanError` 实例。各 code 的具体平台来源见上表"来源"列与[平台差异](/docs/platform-differences)。 ::: --- ## HmsScanError {#hms-scan-error} `decodeImage` 失败时抛出的错误类,继承自内建 `Error`。 | 字段 | 类型 | 说明 | | --- | --- | --- | | `code` | `HmsScanErrorCode` | 错误码(只读) | | `message` | `string` | 错误描述(默认等于 `code`) | | `name` | `string` | 固定为 `'HmsScanError'` | ```ts import { decodeImage, HmsScanError } from '@unif/react-native-hms-scan'; try { await decodeImage(uri); } catch (e) { if (e instanceof HmsScanError) { // e.code / e.message } } ``` --- ## ScanProduct {#scan-product} 业务层商品信息——`` 扫到条码后,由宿主通过 `resolveProduct` 解析返回,用于浮层确认卡展示。仅 `name` 必填,其余可缺省。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `name` | `string` | ✅ | 商品名 | | `brand` | `string` | — | 品牌(如 "统一") | | `brandChar` | `string` | — | 字母牌字符;缺省时取 `brand` 或 `name` 的首字 | | `barcode` | `string` | — | 条码;缺省时取扫到的 `value` | | `spec` | `string` | — | 规格(如 "500ml × 15 瓶/箱") | | `stockShort` | `string` | — | 库存短描述(如 "充足") | | `price` | `string` | — | 价格展示串(如 "¥5.50") | | `priceCaption` | `string` | — | 价格下方副标题,默认 "建议零售" | --- ## 平台兼容性 类型定义本身(不含 `HmsScanError` 运行时行为)在所有平台均可引用。 | 平台 | 支持 | | --- | --- | | iOS | ✅ | | Android | ✅ | | Web | ✅(仅类型,无运行时扫码能力) | --- ## 相关 - [API 参考 → Scanner](/docs/api/scanner) — `ScannerProps` / `ScanProduct` 使用场景 - [API 参考 → HmsScanView](/docs/api/hms-scan-view) — `HmsScanViewProps` / `TorchStatus` - [API 参考 → 函数](/docs/api/functions) — 使用这些类型的函数签名 - [平台差异](/docs/platform-differences) — 码制 / 权限 / 手电的两端差异 ## 安装 *Source: `docs/getting-started/installation.md` · Slug: `/getting-started/installation`* # 安装 装齐 `@unif/react-native-hms-scan` 的全部同伴包,配置原生权限,完成编译。**peerDeps 缺一即崩** —— 本页以 `package.json` 的 `peerDependencies` 为准逐项列出。 ## 环境要求 | 要求 | 版本 | | --- | --- | | React Native | **新架构(Fabric + TurboModules)必须开启** | | React | 19+ | | Android | **minSdkVersion ≥ 24**(Android 7.0) | | iOS | 随宿主 RN 工程最低版本(扫码相机**仅真机**) | :::danger 仅支持新架构 本库是 Fabric 组件 + TurboModule 桥,**仅支持新架构**。旧架构(Bridge)不受支持。安装前确认宿主已启用新架构(`android/gradle.properties` 的 `newArchEnabled=true` 等)。 ::: --- ## 1. 安装依赖 {#安装依赖} 以下同伴包**全部必装,缺一即崩**(以 `package.json` 的 `peerDependencies` 为准): ```sh yarn add @unif/react-native-hms-scan \ @unif/react-native-design react-native-svg \ @sbaiahmed1/react-native-blur \ react-native-gesture-handler react-native-reanimated \ react-native-reanimated-carousel react-native-safe-area-context \ react-native-worklets ``` 各包的作用与版本约束: | 包 | 版本约束 | 作用 | | --- | --- | --- | | `@unif/react-native-design` | `>=0.8.0` | `` 的主题、取景框、工具栏、结果卡、`toast` 全用它绘制 | | `react-native-svg` | `>=15` | `` 图标 | | `@sbaiahmed1/react-native-blur` | `>=4` | design 界面毛玻璃 | | `react-native-gesture-handler` | `>=2.21.0` | design / 手势 | | `react-native-reanimated` | `>=4.0.0` | design 动画 | | `react-native-reanimated-carousel` | `>=5.0.0-beta.0` | design 组件依赖 | | `react-native-safe-area-context` | `>=5.0.0` | 安全区适配 | | `react-native-worklets` | `*` | reanimated 4 的 worklet 运行时 | :::note 为什么扫码库要装这么多 UI 包 这些 peerDeps 几乎都是**成品 ``** 间接需要的:`` 的取景框 / 工具栏 / 结果卡 / `toast` 全部复用 [`@unif/react-native-design`](https://www.npmjs.com/package/@unif/react-native-design),而 design 自身依赖 `react-native-reanimated` / `react-native-gesture-handler` 等。即便你只用 headless `` 或 `decodeImage`,这些仍是声明的 peer —— 装齐即可,通常项目里已有大半。 ::: --- ## 2. Android 配置 ### 零配置:华为 Maven 与依赖已内置 {#android-zero-config} :::tip 无需改宿主 gradle、无需 agconnect 华为 Maven 仓库(`https://developer.huawei.com/repo/`)与 `com.huawei.hms:scanplus` 依赖**已写在本库自己的 `android/build.gradle` 里**,宿主**不用改任何 gradle**。也**不需要** `agconnect-services.json` / AppGallery Connect 插件 / API Key —— Scan SDK-Plus 是内置引擎,非华为机型也能用。 ::: 唯一硬要求是 **minSdkVersion ≥ 24**。若宿主低于 24,在 `android/build.gradle` 提升: ```gradle title="android/build.gradle" buildscript { ext { minSdkVersion = 24 // 本库要求 ≥ 24(Android 7.0) } } ``` ### 权限声明 {#android-permissions} 本库的 `AndroidManifest.xml` **已声明** `CAMERA`、`READ_MEDIA_IMAGES`、`READ_EXTERNAL_STORAGE`(`maxSdkVersion="32"`)以及 `android.hardware.camera`(`required="false"`),它们会通过 manifest 合并自动并入宿主 App。**通常无需在宿主重复声明。** 若宿主的清单合并策略覆盖了它们,或你想显式声明,可在 `android/app/src/main/AndroidManifest.xml` 的 `` 节点下补: | 权限 | 说明 | | --- | --- | | `android.permission.CAMERA` | 相机扫码所需权限 | | `android.permission.READ_MEDIA_IMAGES` | API 33+ 读相册图(**仅 `decodeImage` 解相册图时**需要) | | `android.permission.READ_EXTERNAL_STORAGE` | `maxSdkVersion="32"`,API ≤ 32 读相册图(**仅 `decodeImage` 时**) | ```xml title="android/app/src/main/AndroidManifest.xml" ``` > `CAMERA` / 相册读取属**运行时权限**,声明之外还要在运行时请求。`` 已自动处理相机权限;用 `` / `decodeImage` 时自行请求,见[权限处理](/docs/guides/permissions)。 --- ## 3. iOS 配置 ### pod install {#ios-pod-install} ```sh cd ios && bundle exec pod install ``` `pod install` 会自动集成华为 `ScanKitFrameWork`(podspec 的 `prepare_command` 用 Apple 官方 `vtool` 把真机 arm64 切片改写补出 arm64 模拟器切片,打成 xcframework),**无需额外配置**,同样**不需要 AppGallery Connect / API Key**。 :::warning iOS 扫码仅真机 `ScanKitFrameWork` 是华为老式 framework。podspec 已补出 arm64 模拟器切片让模拟器**能编译**,但相机扫码**只能在真机上跑**。在 Apple 芯片模拟器上若见 `ld: building for 'iOS-simulator', but linking in object file built for 'iOS'`,**是预期**,切真机即可。`pod install` 输出的 `ScanKitFrameWork` LICENSE 警告**无害**。详见[常见问题](/docs/troubleshooting)。 ::: ### Info.plist 权限 {#ios-permissions} 在宿主 `ios//Info.plist` 中添加: | Key | 说明 | | --- | --- | | `NSCameraUsageDescription` | 相机使用说明(**必须**,展示给用户的文案) | | `NSPhotoLibraryUsageDescription` | **仅当**宿主自己的图片选择器需要读相册时(本库 `decodeImage` 不直接读相册,见下) | ```xml title="ios//Info.plist" NSCameraUsageDescription 用于扫描商品条码与门店二维码 NSPhotoLibraryUsageDescription 用于从相册选取图片识别条码 ``` :::note iOS 上 decodeImage 与相册权限 本库 iOS 端的 `decodeImage` 只接受 `file://` / 绝对路径 / `data:`,**不直接读相册 URI(`ph://`)**。从相册选图通常由宿主的图片选择器(如 `react-native-image-picker`)完成 —— 是**那个库**决定是否需要 `NSPhotoLibraryUsageDescription`,选完图它给你一个本地路径再传给 `decodeImage`。详见[图片识别](/docs/guides/decode-image)。 ::: --- ## 4. 最小示例 安装完成后,参阅[快速上手](/docs/getting-started/quick-start)用 `` 跑通第一个扫码页。 --- ## 下一步 - [快速上手](/docs/getting-started/quick-start) —— 5 分钟跑通第一个扫码页 - [指南 → 成品扫一扫页](/docs/guides/scanner) —— `` 完整使用说明 - [API 参考 → Scanner](/docs/api/scanner) —— Scanner 完整 props 文档 ## 快速上手 *Source: `docs/getting-started/quick-start.md` · Slug: `/getting-started/quick-start`* # 快速上手 5 分钟跑通第一个扫码页:把成品 `` 丢进一个路由,传 `onClose` / `resolveProduct` / `onConfirm`,它自带取景 → 识别 → 确认的完整流程。 :::warning iOS 扫码仅真机 扫码依赖原生相机,**iOS 模拟器 / Android 模拟器 / Web 都跑不起来**(属预期行为)。请在真机上验证。先完成[安装](/docs/getting-started/installation)(peerDeps + iOS `pod install` + `NSCameraUsageDescription`)再运行本例。 ::: --- ## 最小可跑示例 ```tsx import { Scanner, type ScanResult, type ScanProduct } from '@unif/react-native-hms-scan'; function ScanScreen({ navigation }) { return ( navigation.goBack()} // ② 返回(底部工具栏,与手电筒并排) // ③ 扫到条码后由你解析商品(查接口 / 本地库)。返回 null = 未识别 → 重扫弹层 resolveProduct={async (r: ScanResult): Promise => { const p = await api.lookupByBarcode(r.value); return p ? { name: p.name, brand: p.brand, price: `¥${p.price}`, spec: p.spec } : null; }} // ④ 用户点"确定":把结果带回上一级(通常在此导航返回) onConfirm={(product, result) => { navigation.navigate('Order', { barcode: result.value, product }); }} // ⑤ (可选)点"相册":用你自己的图片选择器返回本地 uri,取消返回 null pickImage={async () => { const res = await launchImageLibrary({ mediaType: 'photo' }); return res.assets?.[0]?.uri ?? null; }} /> ); } ``` 跑起来后进入该页,把条码 / 二维码放进取景框即自动识别;识别成功弹出浮层确认卡,点「确定」即通过 `onConfirm` 带回上一级。 --- ## 逐步讲解 ### ① 整屏直接用 `` 是**整屏**组件:它**自带 `ThemeProvider` + `ToastHost` + 权限流 + 状态机**,直接作为一个路由页即可,不必再包主题。(放进宿主已有的 `ThemeProvider` 里也兼容。) ### ② 返回 `onClose` 在用户点底部工具栏的返回按钮(与手电筒并排)、或在无权限遮罩上点关闭时触发,宿主通常在此 `navigation.goBack()`。 ### ③ 解析商品 `resolveProduct` 扫到条码后,`` 把 `ScanResult` 交给你的 `resolveProduct`,你查接口 / 本地库,返回一个 `ScanProduct`(用于浮层确认卡展示): - 返回 `null` / `undefined` **或抛错** → 视为「未识别」,进入重扫弹层。 - **不传 `resolveProduct`** → 默认用扫到的原文(`result.value`)当商品名。 ### ④ 确认回调 `onConfirm` 用户在浮层确认卡点「确定」时触发,签名是 `(product, result)`:`product` 是你在 ③ 返回的商品,`result` 是扫码原始结果(`result.value` 是码内容)。 ### ⑤ 相册识别 `pickImage`(可选) **库不内置图片选择器**(遵循 RN 惯例):**传了 `pickImage` 才显示「相册」按钮**,不传则隐藏。你用自己的图片选择器选图、返回本地 `uri`(取消返回 `null`),`` 内部对它调 `decodeImage`。 --- ## 内部流程(自动) `` 内部维护一个状态机,你不用管: ``` 取景 → 识别中 → 识别成功(浮层确认卡)→ onConfirm → 带回上一级 ↘ 未识别(重扫弹层)→ 重扫 → 取景 ↘ 无相机权限(引导去设置遮罩) ``` **一次扫一个**:扫到 `results[0]` 即暂停继续扫,确定或重扫后才复位。相机权限在挂载时自动请求,永久拒绝则展示引导去系统设置的遮罩。 --- ## 下一步 - [指南 → 成品扫一扫页](/docs/guides/scanner) —— `` 完整配置(安全区、`formats`、手电、相册) - [指南 → 底层 headless 组件](/docs/guides/headless) —— 需要自定义 UI 时用 `` - [API 参考 → Scanner](/docs/api/scanner) —— Scanner 完整 props 表 ## 图片识别 *Source: `docs/guides/decode-image.md` · Slug: `/guides/decode-image`* # 图片识别 `decodeImage(uri, options?)` 从一张**本地**图片解码条码 / 二维码,返回命中的结果数组(可能为空)。它**不走相机**,是独立的识图路径。 ```tsx import { decodeImage } from '@unif/react-native-hms-scan'; const results = await decodeImage('file:///path/to/photo.jpg'); if (results.length > 0) { const code = results[0].value; // 处理识别结果 ... } else { // 空数组 = 图里没有码(正常,不是错误) } ``` --- ## 限定码制 {#formats} 传 `formats` 限定识别哪些码制,**不传 = 识别全部 14 种**: ```tsx const results = await decodeImage('file:///path/photo.jpg', { formats: ['QR_CODE', 'EAN_13'], }); ``` --- ## 接受的 URI:本地路径,不下载远程 {#accepted-uri} `decodeImage` 只接受**本地** uri。两端接受的形式略有差异(源自各自原生实现): | 形式 | Android | iOS | | --- | --- | --- | | `file:///...`(文件 URI) | ✅ | ✅ | | 绝对路径(无 scheme,如 `/data/.../a.jpg`) | ✅ | ✅ | | `content://...`(Android Content URI) | ✅ | ❌ | | `data:...`(base64 等) | —— | ✅ | | `ph://...`(iOS Photos)/ `assets-library://` | —— | ❌ | | `http(s)://...`(远程 URL) | ❌ | ❌ | :::tip 跨平台安全输入:`file://` 或绝对路径 要两端都稳的输入,用 **`file://` 或绝对路径**。`content://` **仅 Android**;iOS **不接受 `ph://`**(相册 URI)。从相册选图时,让你的图片选择器(如 `react-native-image-picker`)返回**本地文件路径**(它通常已落地为 `file://` / 路径)再传给 `decodeImage`,跨平台最省心。 ::: :::warning 不下载远程 URL `decodeImage` **不会下载远程 URL**(`http(s)://` 两端都不支持)。如需识别网络图片,请宿主**先下载到本地**,再把本地 uri 传进来。 ::: ```ts // ❌ Incorrect:传远程 URL,decodeImage 不下载,解不出(会抛 E_IMAGE_LOAD_FAILED) const results = await decodeImage('https://example.com/qr.png'); // ✅ Correct:先下到本地,再传本地 uri(file:// / 绝对路径) const local = await downloadToLocal('https://example.com/qr.png'); const results = await decodeImage(local); ``` --- ## 空数组是正常结果,不是错误 {#empty-array} 图里**没有检测到码**时,`decodeImage` resolve 一个**空数组 `[]`**,**不会抛错**。这是最容易踩的坑: ```ts // ❌ Incorrect:把空数组当失败抛异常 —— 把"图里没码"误判成错误 const results = await decodeImage(localUri); if (!results.length) throw new Error('decode failed'); // 错:[] 是正常结果 // ✅ Correct:空数组 = 图里没码(正常),据此提示即可 const results = await decodeImage(localUri); if (results.length === 0) { // 图里没码,正常 —— 提示"未识别到条码"之类 } else { use(results[0].value); } ``` --- ## 错误处理:真正的失败才抛 `HmsScanError` {#error-handling} 只有**图片加载失败 / 读权限缺失 / 解码异常**等真正的失败才抛 `HmsScanError`(带 `code`): ```tsx import { decodeImage, HmsScanError } from '@unif/react-native-hms-scan'; try { const results = await decodeImage(uri); // results 可能为空数组(图中无码)—— 不会走到 catch } catch (e) { if (e instanceof HmsScanError) { switch (e.code) { case 'E_IMAGE_LOAD_FAILED': /* 路径无效 / 非本地 uri / 格式不支持 */ break; case 'E_NO_READ_PERMISSION': /* 缺相册读取权限(Android)*/ break; case 'E_DECODE_FAILED': /* 解码过程异常 */ break; default: /* E_UNKNOWN 等 */ break; } } } ``` | 场景 | 结果 | | --- | --- | | 图里没码 | resolve `[]`(**不抛错**) | | 传了远程 URL / 非本地 uri / 路径无效 | 抛 `E_IMAGE_LOAD_FAILED` | | 缺相册读取权限(Android) | 抛 `E_NO_READ_PERMISSION` | | 解码过程异常 | 抛 `E_DECODE_FAILED` | 完整错误码见 [API → HmsScanErrorCode](/docs/api/types)。 --- ## 相关 - [API 参考 → 函数](/docs/api/functions) —— `decodeImage` 完整签名与错误码表 - [API 参考 → 类型](/docs/api/types) —— `ScanResult` / `HmsScanError` 类型定义 - [指南 → 权限处理](/docs/guides/permissions) —— 相册读取权限处理 ## 底层 headless 组件 *Source: `docs/guides/headless.md` · Slug: `/guides/headless`* # 底层 headless 组件 `` 是底层 headless 扫码相机组件:**只渲染相机预览并抛出扫码事件**,取景框 / 扫描线 / 手电按钮等 UI 完全由你用普通 RN 视图叠加绘制。 需要完全自定义扫码界面时用它;如果只想要现成的扫一扫页,直接用 [``](/docs/guides/scanner)。 :::tip 它只负责相机 + 事件 `` 不画任何覆盖层(没有取景框、没有手电按钮)。你把它铺满容器,然后在它上面叠自己的 UI。`` 正是这样在它之上叠了一整套界面。 ::: --- ## 基本用法 {#basic} ```tsx import { useState } from 'react'; import { View, StyleSheet } from 'react-native'; import { HmsScanView, type ScanResult } from '@unif/react-native-hms-scan'; function CustomScanScreen() { const [paused, setPaused] = useState(false); const [torchOn, setTorchOn] = useState(false); const handleResult = (results: ScanResult[]) => { const code = results[0]?.value; if (code) { setPaused(true); // 命中后暂停继续扫 // 处理扫码结果 ... } }; return ( { // e.code / e.message }} /> {/* 在这里叠加取景框、按钮等自定义 UI */} ); } const styles = StyleSheet.create({ container: { flex: 1 }, }); ``` `` 继承 `ViewProps`,`style` 等标准视图属性可直接传。务必给它一个有尺寸的容器(如 `flex: 1` + `StyleSheet.absoluteFill`),否则相机预览不可见。 --- ## 限定码制 `formats` {#formats} `formats` 限定识别哪些码制,**不传 = 识别全部 14 种**: ```tsx ``` > 内部把 `formats[]` 转成 CSV 传给原生(`formatsToCsv`),空 / 未传 → 识别全部。全部码制清单见 [API → BarcodeFormat](/docs/api/types)。 --- ## 扫码结果 `onScanResult` {#on-scan-result} 命中一个或多个码时回调,参数是**已解析为强类型**的 `ScanResult[]`(原生回传的 JSON 在组件内已解析、并对脏数据做了防御性过滤): ```tsx { const first = results[0]; if (!first) return; console.log(first.value, first.format); // 码内容、码制 }} /> ``` 每个 `ScanResult` 含 `value`(原始文本)、`format`(码制)、可选 `contentType`(内容语义)、可选 `cornerPoints`(四角点)。详见 [API → ScanResult](/docs/api/types)。 --- ## 连续扫码与暂停 `continuous` / `paused` {#continuous-paused} - **`continuous`**(默认 `true`)—— 连续扫码,每次命中都触发 `onScanResult`。 - **`paused`**(默认 `false`)—— 暂停扫码。命中后把 `paused` 设为 `true` 可停在结果画面,处理完再置 `false` 恢复。 ```tsx // 扫到后暂停,展示结果,用户确认后恢复 const [paused, setPaused] = useState(false); { setPaused(true); handleCode(results[0]?.value); }} /> ``` --- ## 手电筒 `torch` / `onTorchStatus` {#torch} ```tsx const [torch, setTorch] = useState(false); { // status.available: 环境暗到建议显示手电按钮(仅 Android 上报) // status.on: 手电当前是否点亮 }} /> ``` :::warning 手电筒平台差异 - **Android** —— `torch` 可编程控制;`onTorchStatus.available` 会在暗光时上报 `true`,可据此决定是否显示手电按钮。 - **iOS** —— HMS 无公开手电 API,本库走 `AVCaptureDevice` **尽力而为**,不保证点亮;`onTorchStatus.available` **永不上报**(始终不触发)。 所以**别把 `onTorchStatus.available` 当作跨平台的暗光信号** —— iOS 上它不会来。详见[平台差异 → 手电筒](/docs/platform-differences#torch)。 ::: --- ## 出错回调 `onScanError` {#on-scan-error} 相机 / 解码出错时回调,参数为 `{ code, message }`: ```tsx { if (e.code === 'E_NO_CAMERA_PERMISSION') { // 引导请求 / 去设置;权限 API 见下方链接 } // 其它:查 e.message }} /> ``` > headless 模式下相机权限**由你自己管**(不像 `` 自动处理):进入扫码页前先用 `requestCameraPermission` 确保已授权。见[权限处理](/docs/guides/permissions)。 --- ## 相关 - [API 参考 → HmsScanView](/docs/api/hms-scan-view) —— 完整 props 表 - [平台差异](/docs/platform-differences) —— 手电筒、暗光提示的平台行为对比 - [指南 → 权限处理](/docs/guides/permissions) —— headless 模式自行请求相机权限 - [指南 → 成品扫一扫页](/docs/guides/scanner) —— 需要开箱即用的完整页面 ## 权限处理 *Source: `docs/guides/permissions.md` · Slug: `/guides/permissions`* # 权限处理 扫码用相机,需要相机权限;`decodeImage` 解相册图另需读相册权限。本库提供两个权限工具函数供手动管理。 :::info `` 自动处理相机权限 用 [``](/docs/guides/scanner) 时,**相机权限已在内部自动处理**:挂载时请求,永久拒绝时展示引导去系统设置的遮罩。本页内容适用于用 [``](/docs/guides/headless) 或 `decodeImage` 时**自行管理**权限的场景。 ::: --- ## 原生权限声明 {#native-declarations} 运行时请求之前,先确保原生权限键已声明(详见[安装](/docs/getting-started/installation)): | 平台 | 权限 | 何时需要 | | --- | --- | --- | | iOS | `NSCameraUsageDescription` | 相机扫码(必须) | | Android | `android.permission.CAMERA` | 相机扫码(库清单已声明,通常自动合并) | | Android | `READ_MEDIA_IMAGES`(API 33+)/ `READ_EXTERNAL_STORAGE`(≤32) | **仅 `decodeImage` 解相册图**(库清单已声明) | > Android 端这些权限**已在本库 `AndroidManifest.xml` 里声明**,通过清单合并并入宿主。运行时请求仍需你做(下文)。 --- ## 查询权限状态 `getCameraPermissionStatus` {#get-status} ```tsx import { getCameraPermissionStatus } from '@unif/react-native-hms-scan'; const status = await getCameraPermissionStatus(); // 不弹窗,仅查询 // 'granted' | 'denied' | 'blocked' | 'undetermined' ``` - **`granted`** —— 已授权,可直接用相机 - **`denied`** —— 用户拒绝(可再次请求) - **`blocked`** —— 用户永久拒绝(必须引导去系统设置开启) - **`undetermined`** —— 尚未请求过权限 :::note Android 的状态区分发生在「请求后」 Android 在**查询时**无法可靠区分「永久拒绝」与「从未请求」,因此 `getCameraPermissionStatus` 对任何未授权状态都返回 **`denied`**;`blocked` / `undetermined` 的精确区分由 **`requestCameraPermission`**(请求后)给出。iOS 则查询时即可返回完整四态。所以**判断流程请以 `requestCameraPermission` 的返回为准**,别只靠查询结果去区分 `blocked`。 ::: --- ## 请求权限 `requestCameraPermission` {#request} ```tsx import { Linking } from 'react-native'; import { requestCameraPermission } from '@unif/react-native-hms-scan'; const status = await requestCameraPermission(); // 必要时弹系统授权框 if (status === 'granted') { // 权限已获取,可打开相机 } else if (status === 'blocked') { // 永久拒绝:引导去系统设置(系统不再弹框) Linking.openSettings(); } ``` --- ## 拒权降级处理(推荐封装) {#fallback} headless 场景进入扫码页前,先用这个模式确保权限: ```tsx import { Linking } from 'react-native'; import { getCameraPermissionStatus, requestCameraPermission, } from '@unif/react-native-hms-scan'; async function ensureCameraPermission(): Promise { let status = await getCameraPermissionStatus(); if (status === 'undetermined' || status === 'denied') { status = await requestCameraPermission(); } if (status === 'granted') return true; if (status === 'blocked') { Linking.openSettings(); // 永久拒绝,只能去系统设置 } return false; } ``` > 这正是 `` 内部的权限流;用 `` 时无需自己写。 --- ## decodeImage 的相册读取权限 {#decode-image-permission} `decodeImage` 解相册图时需要读相册权限: - **Android** —— `READ_MEDIA_IMAGES`(API 33+)/ `READ_EXTERNAL_STORAGE`(≤32)。缺权限时 `decodeImage` 抛 `HmsScanError`,`code` 为 `E_NO_READ_PERMISSION`。 - **iOS** —— 本库 `decodeImage` 只接受 `file://` / 绝对路径 / `data:`,**不直接读相册 URI(`ph://`)**;读相册由宿主的图片选择器负责,是**那个库**申请 `NSPhotoLibraryUsageDescription`。 ```ts import { decodeImage, HmsScanError } from '@unif/react-native-hms-scan'; try { const results = await decodeImage(localUri); } catch (e) { if (e instanceof HmsScanError && e.code === 'E_NO_READ_PERMISSION') { // 引导授予相册读取权限(Android) } } ``` 详见[图片识别](/docs/guides/decode-image)。 --- ## 相关 - [API 参考 → 函数](/docs/api/functions) —— `getCameraPermissionStatus` / `requestCameraPermission` 完整签名 - [API 参考 → 类型](/docs/api/types) —— `CameraPermissionStatus` 类型定义 - [指南 → 图片识别](/docs/guides/decode-image) —— `decodeImage` 与相册读取权限 ## 成品扫一扫页 *Source: `docs/guides/scanner.md` · Slug: `/guides/scanner`* # 成品扫一扫页 `` 是开箱即用的成品扫码界面(聚焦款,浅色)。底层用 `` 出相机画面,取景框 / 工具栏 / 结果卡全用 [`@unif/react-native-design`](https://www.npmjs.com/package/@unif/react-native-design) 的主题令牌与组件绘制。 它**自带 `ThemeProvider` + `ToastHost` + 权限流 + 状态机**,可直接作为一个路由整屏接入;放进宿主已有的 `ThemeProvider` 里也兼容。 :::info 何时用 `` vs `` 要现成的扫一扫页 → 用 ``(本页)。要完全自绘 UI(自定义取景框 / 工具栏布局)→ 用底层 [``](/docs/guides/headless)。 ::: --- ## 内部状态机 {#state-machine} ``` init → scan(取景)→ detecting(识别中) ↓ success(浮层确认卡)→ onConfirm → 带回上一级 ↓ fail(未识别弹层)→ 重扫 → scan ↓ denied(无权限遮罩)→ 去系统设置 ``` `` 挂载时**自动请求相机权限**:已授权直接进入取景;永久拒绝则展示引导去系统设置的遮罩(`denied`)。**一次扫一个** —— 扫到 `results[0]` 即进入 `detecting`,确定或重扫后才复位回 `scan`(内部 `handlingRef` 防重入)。 --- ## 商品解析 `resolveProduct` {#resolve-product} 扫到条码后,`` 把 `ScanResult` 交给 `resolveProduct`,由你解析成商品信息(用于浮层确认卡): ```tsx { const product = await api.lookupByBarcode(result.value); if (!product) return null; // null = 未识别 → 进入 fail 重扫弹层 return { name: product.name, // 仅 name 必填 brand: product.brand, price: `¥${product.price}`, spec: product.spec, stockShort: product.stockText, }; }} /> ``` - 返回 `null` / `undefined` **或抛错**,均视为「未识别」,进入 `fail` 重扫弹层。 - **不传 `resolveProduct`** 时,默认以扫到的原文(`result.value`)作为商品名。 - 返回的 `ScanProduct` 中只有 `name` 必填,其余(`brand` / `price` / `spec` / `stockShort` / `brandChar` / `priceCaption` 等)可缺省;`barcode` 缺省时自动取扫到的 `value`。完整字段见 [API → ScanProduct](/docs/api/types)。 --- ## 确认回调 `onConfirm` {#on-confirm} 用户在浮层确认卡点「确定」时触发,签名 `(product, result)`: ```tsx { navigation.navigate('Order', { barcode: result.value, // 扫码原始内容 product, // resolveProduct 返回的商品 }); }} /> ``` > 点确定后 `` 会 toast「已确定」并自动复位回取景态,宿主通常在 `onConfirm` 里导航离开本页。 --- ## 相册扫码 `pickImage` {#pick-image} **库不内置图片选择器**(遵循 RN 惯例):**传了 `pickImage` 才显示「相册」按钮**,不传则隐藏。宿主用自己的选择器选图,返回本地 `uri`(取消返回 `null`),`` 内部对它调 `decodeImage`: ```tsx import { launchImageLibrary } from 'react-native-image-picker'; { const res = await launchImageLibrary({ mediaType: 'photo' }); return res.assets?.[0]?.uri ?? null; // 取消返回 null }} /> ``` ```tsx // ❌ Incorrect:没传 pickImage 却期望出现相册按钮 // 工具栏不会有"相册" // ✅ Correct:传了 pickImage,相册按钮才显示 /* 选图返回本地 uri */} /> ``` > 相册识图走 `decodeImage`,它**只接受本地 uri、不下载远程 URL**。`react-native-image-picker` 返回的就是本地路径,可直接用;细节见[图片识别](/docs/guides/decode-image)。 --- ## 手电筒 `showTorch` {#show-torch} 底部工具栏默认显示手电筒按钮(`showTorch` 默认 `true`),手电状态由库内自管: - **Android** —— 可编程控制,稳定可用。 - **iOS** —— HMS 未提供公开手电 API,本库通过 `AVCaptureDevice` **尽力而为**,不保证点亮。 不想在 iOS 上呈现一个可能无效的按钮,可关掉: ```tsx import { Platform } from 'react-native'; ``` 详见[平台差异 → 手电筒](/docs/platform-differences#torch)。 --- ## 安全区 `topInset` / `bottomInset` {#insets} `` 默认 `topInset=54` / `bottomInset=34`。用 `react-native-safe-area-context` 时,把 `insets.top/bottom` 传入更精准: ```tsx import { useSafeAreaInsets } from 'react-native-safe-area-context'; function ScanScreen() { const insets = useSafeAreaInsets(); return ( navigation.goBack()} // ... /> ); } ``` --- ## 限定码制 `formats` {#formats} `formats` 限定识别哪些码制,**不传 = 识别全部 14 种**: ```tsx ``` > 限定到业务真正需要的码制(如只扫商品条码),能减少误识、提升识别速度。全部码制清单见 [API → BarcodeFormat](/docs/api/types)。 --- ## 自定义提示文案 `hintText` {#hint-text} 取景态默认提示「将条码 / 二维码放入框内,自动扫描」,可用 `hintText` 覆盖: ```tsx ``` --- ## 相关 - [API 参考 → Scanner](/docs/api/scanner) —— 完整 props 表 - [指南 → 底层 headless 组件](/docs/guides/headless) —— 需要自定义 UI 时用 `` - [指南 → 图片识别](/docs/guides/decode-image) —— `decodeImage` 单独使用 - [指南 → 权限处理](/docs/guides/permissions) —— `` 自动处理;手动场景的 API ## 介绍 *Source: `docs/intro.md` · Slug: `/intro`* # @unif/react-native-hms-scan 华为 **HMS 统一扫码服务**(HUAWEI Scan Kit)的 **React Native 新架构封装**:丢一个成品 `` 进路由就有完整扫一扫页,或用底层 `` 自绘 UI,或用 `decodeImage(uri)` 从一张本地图里解出条码 / 二维码。 [![npm](https://img.shields.io/npm/v/@unif/react-native-hms-scan.svg?color=cb3837&logo=npm)](https://www.npmjs.com/package/@unif/react-native-hms-scan) [![CI](https://github.com/unif-design/react-native-hms-scan/actions/workflows/ci.yml/badge.svg)](https://github.com/unif-design/react-native-hms-scan/actions/workflows/ci.yml) [![License](https://img.shields.io/npm/l/@unif/react-native-hms-scan.svg?color=blue)](https://github.com/unif-design/react-native-hms-scan/blob/main/LICENSE) [![Docs](https://img.shields.io/badge/docs-unif--design.github.io-orange.svg)](https://unif-design.github.io/react-native-hms-scan/) ## 这个库是什么 它在华为 Scan Kit 之上,封装出一套**新架构(Fabric + TurboModule)友好、UI 文案中文**的 React Native 扫码接口,把相机预览、码制识别、权限流、结果回调都收敛好,对外只暴露三个用法: - **成品 `` 页** —— 开箱即用的「扫一扫」整屏界面(聚焦款,浅色),取景框 / 工具栏 / 结果卡都用 [`@unif/react-native-design`](https://www.npmjs.com/package/@unif/react-native-design) 的主题令牌绘制,权限流自动处理。 - **底层 ``** —— headless 相机组件,只出相机预览 + 抛扫码事件,取景框 / 手电按钮等 UI 由你用普通 RN 视图自己叠加。 - **`decodeImage(uri)`** —— 从一张**本地**图片解码,不走相机,返回命中的结果数组。 ## 解决什么问题 直接接华为原生 Scan Kit,你要分别处理 iOS / Android 的相机视图接入、码制枚举的两端映射、运行时权限、暗光手电、图片识别的 Bitmap 路径。本库把这些收敛成声明式调用: ```tsx import { Scanner } from '@unif/react-native-hms-scan'; // 一个组件就是完整的「扫一扫」页:取景 → 识别 → 确定带回 navigation.goBack()} onConfirm={(product, result) => navigation.navigate('Order', { barcode: result.value })} /> ``` ## 核心概念 - **三种用法分层** —— ``(整屏直接用)内部用 ``(headless,自定义 UI 用它),两者底层都过同一套原生 TurboModule / Fabric 组件;`decodeImage` 是**独立的图片识别路径,不走相机**。需求由轻到重对应:成品页 → 自绘 UI → 仅识图。 - **Android 用内置引擎,非华为机也能用** —— Android 端走 **Scan SDK-Plus**(`com.huawei.hms:scanplus`),识别引擎**内置在库里**,**不依赖设备安装 HMS Core APK**,普通非华为机型也能扫。华为 Maven 源与依赖**已写在库自己的 gradle 里,宿主零配置**;**不需要 AppGallery Connect / `agconnect-services.json` / API Key**。 - **iOS 用 ScanKitFrameWork,仅真机** —— iOS 端集成华为 `ScanKitFrameWork`(自包含,同样无需 AppGallery Connect / API Key)。该 framework 的相机能力**只能在真机上跑**(详见下方平台支持)。 - **码制两端归一** —— 原生侧(Android `HmsScan.*` / iOS `HMSScanFormatTypeCode`)已统一映射成 14 种字符串码制枚举再回 JS;`formats` **省略 = 识别全部码制**。 - **`decodeImage` 空数组是正常结果** —— 图里没有码时返回**空数组 `[]`,不是错误、不会 throw**;真正的失败(图片加载失败 / 读权限缺失)才抛 `HmsScanError`。 ## 能力 - **成品「扫一扫」页** —— `` 自带状态机 + 权限流 + 主题,一次扫一个,扫到后弹浮层确认卡,确定带回上一级。 - **headless 自定义扫码** —— `` 只出预览 + 抛事件,UI 完全自绘;支持 `formats` / `paused` / `continuous` / `torch`。 - **图片识别** —— `decodeImage(localUri)` 从本地图片解码,可限定码制。 - **相机权限工具** —— `getCameraPermissionStatus` / `requestCameraPermission`,返回归一化的四态状态。 - **14 种码制** —— 二维码与一维码全覆盖,省略 `formats` 即识别全部。 - **官方 Jest mock** —— 随包导出 `./mock`,测试里整包替换,无需手写桩。 ## 何时使用 | 适用 | 不适用 | | --- | --- | | 扫二维码 / 条码(商品条码、门店码、付款码等) | 拍照 / 录像 —— 用 [`@unif/react-native-camera`](https://www.npmjs.com/package/@unif/react-native-camera) | | 想要一套现成的中文扫一扫页 UI(``) | —— | | 完全自定义的扫码界面(`` 自绘) | 需要内置图片选择器(本库不内置,由宿主提供) | | 从**本地**图片识别条码 / 二维码(`decodeImage`) | 识别**远程 URL** 图片 —— 本库不下载,需宿主先下到本地 | ## 平台支持 | 平台 | 支持 | | --- | --- | | iOS | ✅ 真机 | | Android(API 24+) | ✅ | | Web / 模拟器 | ❌ | :::warning iOS 扫码必须真机运行 iOS 的 `ScanKitFrameWork` 是华为老式 framework,相机扫码**只能在真机上跑**。Apple 芯片模拟器虽能编译(podspec 已补 arm64-sim 切片),但相机功能不可用。**这是预期行为,不是 bug** —— iOS 扫码改动一律真机验证。Android 模拟器同样不提供真实相机。 ::: :::info 仅支持新架构 本库是 Fabric 组件 + TurboModule 桥,**仅支持 React Native 新架构**。旧架构(Bridge)不在目标范围。Android 还要求 **minSdkVersion ≥ 24**(Android 7.0)。 ::: ## 下一步 - [安装](/docs/getting-started/installation) —— 装 peerDeps、Android 零配置、iOS `pod install` 与权限键 - [快速上手](/docs/getting-started/quick-start) —— 5 分钟用 `` 跑通第一个扫码页 - [指南 → 成品扫一扫页](/docs/guides/scanner) —— `` 完整用法 - [指南 → 底层 headless 组件](/docs/guides/headless) —— 用 `` 自绘 UI - [API 参考 → Scanner](/docs/api/scanner) —— 完整 API 文档 ## 平台差异 *Source: `docs/platform-differences.md` · Slug: `/platform-differences`* # 平台差异 `@unif/react-native-hms-scan` 两端 API 接口统一,但底层是不同的华为原生实现,**部分能力存在差异,务必知悉**。 | 维度 | Android | iOS | | --- | --- | --- | | 原生实现(相机) | 华为 `RemoteView`(Scan SDK-Plus) | `HmsCustomScanViewController`(ScanKitFrameWork) | | 原生实现(图片识别) | `ScanUtil.decodeWithBitmap` | `HmsBitMap` | | 接入配置 | **零配置**:华为 Maven + `scanplus` 已写在库 gradle,**无需 agconnect / API Key** | `pod install` 自动处理;**无需 AppGallery Connect** | | 运行环境 | 真机 + 模拟器均可 | **仅真机**(模拟器只能编译,不能扫码) | | 最低版本 | minSdkVersion ≥ 24(Android 7.0) | 见 podspec `min_ios_version_supported` | | 码制 `MULTI_FUNCTIONAL` 作为过滤项 | ✅ 支持 | ❌ 无对应码制(见[码制差异](#formats)) | | 手电筒 `torch` | ✅ 可编程控制 | ⚠️ best-effort,不保证(见[手电筒](#torch)) | | 暗光提示 `onTorchStatus.available` | ✅ 据环境光上报 | ❌ 非暗光信号(见[手电筒](#torch)) | | 权限查询四态精度 | 查询时只给 `granted` / `denied` | 查询即返回完整四态(见[相机权限](#permission)) | | `decodeImage` 接受的 URI | `file://` / 绝对路径 / `content://` / `android.resource://` | `file://` / 绝对路径 / `data:`(**不接受 `ph://` / `content://`**,见[图片识别 URI](#decode-image-uri)) | :::warning iOS 仅真机 iOS 的 `ScanKitFrameWork` 是华为老式 framework(仅含 arm64 真机 + x86_64 Intel 模拟器切片)。podspec 的 `prepare_command` 已用 Apple 官方 `vtool` 补出 arm64 模拟器切片让模拟器**能编译**,但相机扫码能力**只能在真机上跑**。Apple 芯片模拟器上见到 `ld: building for 'iOS-simulator', but linking in object file built for 'iOS'` **是预期**,切真机即可。 ::: --- ## 码制差异 {#formats} [`BarcodeFormat`](/docs/api/types#barcode-format) 共 **14 种**(不含 `UNKNOWN`),两端枚举值统一。差异在于把它作为 `formats` **过滤项**时: - `MULTI_FUNCTIONAL` 在 HUAWEI iOS Scan Kit **无对应码制**,作为过滤项在 iOS 上不生效。 - 若传入的 `formats` 只含 iOS 无法识别的项(`MULTI_FUNCTIONAL` / `UNKNOWN`),iOS 会**回退为识别全部码制**而非"什么都不扫"。 - `ITF14` 在 iOS 底层映射到华为的 `ITF` 码制(对外仍是 `ITF14`,无需关心)。 > 不传 `formats`(= 全部码制)时两端行为一致,是最省心的做法。 --- ## 相机权限 {#permission} [`CameraPermissionStatus`](/docs/api/types#camera-permission-status) 取值两端一致(`granted` / `denied` / `blocked` / `undetermined`),但**查询时的精度**不同: | | `getCameraPermissionStatus`(查询) | `requestCameraPermission`(请求后) | | --- | --- | --- | | iOS | 完整四态 | 完整四态 | | Android | 仅 `granted` / `denied` | 完整四态(`blocked` / `denied` 据请求后 rationale 区分) | Android 在**查询时**无法可靠区分「永久拒绝」与「从未请求」,故对任何未授权状态返回 `denied`。 :::tip 判断流程以请求后结果为准 要区分 `blocked`(永久拒绝、不再弹框)与 `undetermined`,**以 `requestCameraPermission` 的返回为准**,别只靠查询结果。`` 内部已用这个流程,用它时无需自己写。详见[指南 → 权限处理](/docs/guides/permissions#get-status)。 ::: --- ## 手电筒 {#torch} ### Android `torch` prop 直接映射到 Scan SDK-Plus 的手电控制接口,**行为稳定、可编程**。`onTorchStatus` 的 `available` 字段会在**环境光线暗**时上报 `true`(来自华为 `OnLightVisibleCallBack`),可据此决定是否显示手电按钮。 ### iOS iOS 端华为 Scan Kit **未提供公开的手电控制接口**(`HmsCustomScanViewController` 自带手电按钮与暗光自检)。本库通过 `AVCaptureDevice` 直接操作手电,属 **best-effort** 实现: - `torch={true}` **不保证**点亮——华为可能独占相机会话 / 持有配置锁导致操作无效,或设备无手电。 - `onTorchStatus` **仅在 `torch` prop 变更时**触发(不据环境光),其 `available` 反映「设备是否有手电硬件」、**不是**暗光信号。**不要**把它当跨平台的暗光提示。 :::tip iOS 上把手电当"提示"而非"保证" 建议 iOS 上手电按钮以「提示」呈现;或在成品 [``](/docs/api/scanner#props) 上用 `showTorch={false}` 直接隐藏。 ::: --- ## 图片识别 URI {#decode-image-uri} [`decodeImage`](/docs/api/functions#decode-image) 只接受**本地** URI,两端接受形式不同: | 形式 | Android | iOS | | --- | --- | --- | | `file:///...`(文件 URI) | ✅ | ✅ | | 绝对路径(无 scheme) | ✅ | ✅ | | `data:...`(base64 等) | — | ✅ | | `content://...` | ✅ | ❌ | | `android.resource://...` | ✅ | ❌ | | `ph://...` / `assets-library://`(iOS 相册) | — | ❌ | | `http(s)://...`(远程 URL) | ❌ | ❌ | - 不支持的 URI(含远程 URL、iOS 的 `ph://`)→ 抛 `E_IMAGE_LOAD_FAILED`(**不是**返回空数组)。 - 跨平台最稳的输入是 **`file://` 或绝对路径**。 > 完整 URI 规则与错误码见[函数 → decodeImage](/docs/api/functions#accepted-uri) 与[指南 → 图片识别](/docs/guides/decode-image#accepted-uri)。 --- ## 相关 - [指南 → 底层 headless 组件](/docs/guides/headless) — `` 使用说明 - [API 参考 → HmsScanView](/docs/api/hms-scan-view) — 完整 props 表,含 `torch` / `onTorchStatus` - [API 参考 → 函数](/docs/api/functions) — `decodeImage` URI 规则、权限函数 - [常见问题](/docs/troubleshooting) — iOS 真机 / 手电 / 权限相关排障 ## AI Skill *Source: `docs/skills.md` · Slug: `/skills`* # AI Skill:unif-hms-scan ## 这是什么 `unif-hms-scan` 是一个 **Agent Skill**,教 AI 编码助手(Claude Code / Cursor / Codex)正确调用 `@unif/react-native-hms-scan` 的 API、避免常见幻觉。 它把这个华为 HMS 扫码库的关键约定、易错点和参考索引打包给 AI,让助手在你的项目里写代码时按真实 API 来,而不是凭记忆瞎猜。 ## 覆盖什么 **何时会触发:** 用 `@unif/react-native-hms-scan` 扫二维码 / 条码——现成扫码页 / headless 自定义扫码 UI / 从图片解码,或排查 iOS 模拟器链接错误 / Android 依赖 / `decodeImage` 返回空数组。 **覆盖的能力:** - 三种用法:现成 `` 屏、headless ``、从图片 `decodeImage`。 - `decodeImage` 输入约定:不下载远程 URL、iOS 仅 `file:///` / `data:`、空数组 = 图里没码(不是错误)。 - 条码格式(14 种)、`formats` 省略 = 全部。 - 易错点:用远程 URL 调 `decodeImage`、把空数组当错误、把 iOS 模拟器链接错误当 bug。 > 内置引擎非华为机也能用、无需 agconnect;拍照 / 录像请走 camera skill。 ## 如何安装 **Claude Code 插件市场:** ```bash /plugin marketplace add unif-design/skills /plugin install unif@unif-skills ``` **或用 skills CLI:** ```bash npx skills add unif-design/skills ``` ## 在 GitHub 查看 skills 全部开源,发布在插件市场仓库 `unif-design/skills`。本 skill 的源码与参考文档: 👉 **[github.com/unif-design/skills · unif-hms-scan](https://github.com/unif-design/skills/tree/main/skills/unif-hms-scan)** --- 装了之后,在你的项目里让 AI 写 `@unif/react-native-hms-scan` 代码会更准。 ## 测试(Mock) *Source: `docs/testing.md` · Slug: `/testing`* # 测试(Mock) 本库依赖 `HmsScan` TurboModule 与 `HmsScanView` Fabric 组件,Jest 环境无法直接加载原生模块。库内置一份官方 mock,消费者在测试里用它替换本库: ```ts jest.mock('@unif/react-native-hms-scan', () => require('@unif/react-native-hms-scan/mock') ); ``` --- ## mock 后的行为 {#behavior} | 导出 | mock 行为 | | --- | --- | | `decodeImage` | `jest.fn`,默认 resolve **`[]`** | | `getCameraPermissionStatus` | `jest.fn`,默认 resolve **`'granted'`** | | `requestCameraPermission` | `jest.fn`,默认 resolve **`'granted'`** | | `` | 渲染为 **`null`**(不触碰原生) | | `` | 渲染为 **`null`**(不触碰原生) | | `coerceFormat` / `coerceContentType` / `formatsToCsv` | **保留真实实现**(纯函数,不碰原生) | | 类型 / 常量 / `HmsScanError` / `ALL_BARCODE_FORMATS` | **保留真实实现**(从 `./types` 原样导出) | :::note 纯函数与类型不被打桩 码制工具(`coerceFormat` / `coerceContentType` / `formatsToCsv`)以及所有类型、`HmsScanError`、`ALL_BARCODE_FORMATS` 在 mock 中是**真实实现**——它们不触碰原生,可在测试里直接断言其真实行为。被打桩的只有触碰原生的部分(`decodeImage`、两个权限函数、两个组件)。 ::: --- ## 覆盖单次返回 {#override} 默认值不够时,用 `jest.fn` 的 `mockResolvedValueOnce` 等覆盖: ```ts import { decodeImage, getCameraPermissionStatus } from '@unif/react-native-hms-scan'; jest.mock('@unif/react-native-hms-scan', () => require('@unif/react-native-hms-scan/mock') ); // 让 decodeImage 返回一个模拟命中 (decodeImage as jest.Mock).mockResolvedValueOnce([ { value: '6901028018999', format: 'EAN_13' }, ]); // 让权限查询返回 blocked (getCameraPermissionStatus as jest.Mock).mockResolvedValueOnce('blocked'); ``` --- ## 完整示例 {#example} ```ts import { decodeImage, requestCameraPermission } from '@unif/react-native-hms-scan'; jest.mock('@unif/react-native-hms-scan', () => require('@unif/react-native-hms-scan/mock') ); describe('扫码流程', () => { it('图片识别返回结果', async () => { (decodeImage as jest.Mock).mockResolvedValueOnce([ { value: 'https://example.com', format: 'QR_CODE' }, ]); const results = await decodeImage('file:///photo.jpg'); expect(results).toHaveLength(1); expect(results[0].value).toBe('https://example.com'); }); it('图里没码(默认空数组)', async () => { const results = await decodeImage('file:///blank.jpg'); expect(results).toEqual([]); // mock 默认 resolve [] }); it('权限被永久拒绝', async () => { (requestCameraPermission as jest.Mock).mockResolvedValueOnce('blocked'); const status = await requestCameraPermission(); expect(status).toBe('blocked'); }); }); ``` :::tip 不要在模拟器里测真实扫码 iOS 扫码仅真机、相机能力依赖硬件。逻辑层(识图 / 权限分支 / 结果处理)用本页的 `jest.mock` 方案在无硬件环境跑通,相机本身留到真机手测。详见[常见问题](/docs/troubleshooting)。 ::: --- ## 相关 - [API 参考 → 函数](/docs/api/functions) — `decodeImage` / 权限函数 / 码制工具签名 - [API 参考 → 类型](/docs/api/types) — `ScanResult` / `CameraPermissionStatus` / `HmsScanError` - [常见问题](/docs/troubleshooting) — iOS 真机限制与排障 ## 常见问题 *Source: `docs/troubleshooting.md` · Slug: `/troubleshooting`* # 常见问题 按**症状 → 原因 → 解法**排查。多数问题集中在「iOS 在模拟器上跑」「Android minSdk / 权限」「把 `decodeImage` 空数组当错误」三类。 --- ## 症状:iOS 模拟器编译 / 链接报错 ``` ld: building for 'iOS-simulator', but linking in object file built for 'iOS' ``` ✅ **这是预期行为,不是 bug —— 请用真机调试。** `ScanKitFrameWork` 是华为老式 framework。podspec 的 `prepare_command` 已用 Apple 官方 `vtool` 补出 arm64 模拟器切片让模拟器**能编译**,但相机扫码能力**只能在真机上跑**。在 Apple 芯片模拟器上见到上面这行,切真机即可。 :::tip 在 CI / 模拟器里测逻辑 不要在模拟器里测真实扫码。单元测试用[测试(Mock)](/docs/testing)页的 `jest.mock` 方案,在无硬件环境跑通扫码 / 识图流程逻辑。 ::: --- ## 症状:`pod install` 报 `ScanKitFrameWork` LICENSE 警告 ``` [!] The `ScanKitFrameWork` pod ... has a license ... which doesn't provide any official binaries... ``` ✅ **无害,可忽略。** 这是 CocoaPods 对部分非标准 / 私有 LICENSE 的提示,不影响编译和运行。 --- ## 症状:Android 编译报错 `minSdkVersion < 24` ``` uses-sdk:minSdkVersion 21 cannot be smaller than version 24 declared in library ``` ✅ 本库要求 **minSdkVersion ≥ 24**(Android 7.0)。在宿主 `android/build.gradle` 提升: ```gradle title="android/build.gradle" buildscript { ext { minSdkVersion = 24 } } ``` --- ## 症状:Android 扫码无响应 / 画面黑屏 逐一排查: ### 因 1:缺 `CAMERA` 权限声明 ✅ 确认 `AndroidManifest.xml` 有 `android.permission.CAMERA`。本库清单已声明该权限并自动合并;若宿主合并策略覆盖了它,显式补回。见[安装 → 权限声明](/docs/getting-started/installation#android-permissions)。 ### 因 2:运行时未授权相机 ✅ `CAMERA` 是运行时权限,声明之外还要在运行时请求。用 `` 会自动处理;用 `` 时自己先调 `requestCameraPermission`。见[权限处理](/docs/guides/permissions)。 :::note 无需 agconnect / API Key 华为 Maven 源与 `scanplus` 依赖已内置在本库 gradle 里,Android **零配置**;**不需要** `agconnect-services.json` / AppGallery Connect 插件 / API Key。若你在为「漏配 agconnect」排查 —— 不必,本库不依赖它。 ::: --- ## 症状:`decodeImage` 返回空数组 `[]` ✅ **空数组是正常结果,不是错误,也不会抛异常。** 可能原因: 1. **图里确实没码** —— 图片内容不含有效条码 / 二维码,返回 `[]` 完全正常。**别把 `!results.length` 当失败抛异常。** 2. **传了远程 URL** —— `decodeImage` **不下载网络图**;远程 URL 实际会抛 `E_IMAGE_LOAD_FAILED`,而非返回空数组。请宿主先下到本地再传。 3. **传了不支持的 uri** —— iOS 不接受 `ph://`(相册 URI)、`content://`;两端都不接受 `http(s)://`。用 `file://` / 绝对路径最稳。 4. **码制被 `formats` 限掉** —— 若传了 `formats`,确认目标码制在列表里。 详见[图片识别](/docs/guides/decode-image)。 --- ## 症状:`decodeImage` 抛 `E_NO_READ_PERMISSION` ✅ 读相册图片需要权限(Android):`READ_MEDIA_IMAGES`(API 33+)/ `READ_EXTERNAL_STORAGE`(API ≤ 32)。本库清单已声明,但仍需**在运行时请求**后再调 `decodeImage`。 > iOS 端 `decodeImage` 不直接读相册(只收 `file://` / 绝对路径 / `data:`),相册读取由宿主图片选择器负责。见[权限处理 → decodeImage 的相册读取权限](/docs/guides/permissions#decode-image-permission)。 --- ## 症状:手电筒在 iOS 上不生效 / 暗光提示不来 ✅ **这是已知限制,不是 bug。** iOS 端 HMS 无公开手电 API,本库走 `AVCaptureDevice` **尽力而为**: - `torch={true}` **不保证**点亮(设备 / 系统差异)。 - `onTorchStatus.available`(暗光提示)**仅 Android 上报,iOS 永不上报** —— 别把它当跨平台的暗光信号。 建议 iOS 上把手电按钮以「提示」而非「保证」呈现,或在 `` 上用 `showTorch={false}` 直接隐藏。详见[平台差异 → 手电筒](/docs/platform-differences#torch)。 --- ## 症状:相机权限为 `blocked`,无法再弹授权框 ✅ 用户已**永久拒绝**,系统不再允许弹框。引导去系统设置手动开启: ```ts import { Linking } from 'react-native'; Linking.openSettings(); ``` `` 在无权限时会自动展示引导去设置的遮罩。Android 上 `blocked` 的精确判断发生在 `requestCameraPermission` 之后,见[权限处理](/docs/guides/permissions#get-status)。 --- ## 症状:打包 / 运行报 `Unable to resolve module ...` ✅ 缺同伴包。`peerDependencies` **缺一即崩**,逐项核对[安装 → 安装依赖](/docs/getting-started/installation#安装依赖)是否装齐 —— 尤其 `@unif/react-native-design` 及其链上的 `react-native-reanimated` / `react-native-gesture-handler` 等(`` 的 UI 依赖它们)。补齐后 iOS 重新 `cd ios && bundle exec pod install`。