别急着打开 Android Studio 或 Xcode,先想清楚一件事:你接到的这个需求,是“要一个能扫护照的 App”,还是“要一个能稳定、快速、跨平台扫护照的 App”?如果只是前者,随便接个扫码 SDK 糊弄一下也行,但如果你要在酒店自助入住、银行开户、机场自助值机这类真实业务里用 React Native 做 MRZ 护照扫描仪,那么从架构选型到相机参数,每一步都可能决定你的功能是“能跑”还是“能用”。
这篇文章我从实战角度梳理整个构建过程,包括 MRZ 格式细节、识别方案选型、RN 和原生之间的桥接设计、双端权限与相机差异,以及我实际踩过的坑。无论你打算用现成库快速集成,还是想自研底层识别模块,这篇文章都能给你一条清晰的技术路线。
1. 技术方案选型:别急着写代码,先想清楚这几件事
1.1 MRZ 扫描的业务需求与技术闭环
MRZ 的全称是 Machine Readable Zone,也就是护照资料页底部那两行(或三行)由字母、数字和 < 符号组成的区域。它之所以重要,是因为这一小块区域包含了护照持有人的关键身份信息:姓名、护照号码、国籍、出生日期、性别、有效期等。传统做法是人工录入,不仅慢,还容易出错,尤其在高峰期排队办理业务时,体验非常糟糕。
做 MRZ 扫描仪,本质上要做的事可以拆成一个闭环:相机实时捕捉画面、定位画面中的 MRZ 区域、自动触发拍照或连续帧识别、对识别出的文本做 OCR 后处理、再按 MRZ 规范解析出结构化字段,最后把数据交给你上层业务逻辑。整个过程看起来简单,但每个环节都有不少细节,比如环境光线变化、护照表面的反光、手机握持角度、MRZ 区域在画面中的占比,这些都会直接影响识别成功率。
在 React Native 生态里做这件事,还有一个额外的挑战:React Native 本身不提供相机能力,更不提供 OCR 能力。你必须通过原生模块桥接或第三方库来补足这两块能力。所以技术方案选型的关键,不是“哪个库更好用”,而是“你的团队能接受多大的原生开发成本”,以及“你的业务对识别率和延迟的要求有多高”。
1.2 三大技术路线:原生桥接、现成库、自研模块
我梳理了三条主流路线,各有各的适用场景。
第一条路线是用原生 SDK 写一个自定义视图,通过 React Native 的 Native Component 桥接给 JS 层调用。比如 iOS 端用 Vision 框架里的 VNDetectHumanRectangles 或 VNRecognizeTextRequest,配合 AVFoundation 做相机采集;Android 端用 CameraX 或 Camera2 采集帧,配合 Google ML Kit 的文本识别。这条路线的优点是可控性强、识别率上限高,缺点是原生代码量不小,而且你还要自己写桥接层、处理双端生命周期差异,开发周期比较长。
第二条路线是直接用社区封装好的现成库。例如 react-native-mrz-scanner、react-native-mlkit-mrz-scanner,或者更上层的 react-native-document-scanner 这类方案。它们通常已经把相机采集、OCR、MRZ 解析都封装好了,集成成本很低,适合产品快速验证、公司内部工具类 App。缺点是维护质量参差不齐,有些库已经很久不更新,在新版本 RN、Android 高版本或 iOS 新系统上可能出现相机权限、渲染线程兼容问题。
第三条路线是混合方案:相机采集交给成熟的视频流库,比如 react-native-vision-camera,帧数据通过原生模块送入 OCR 引擎,解析逻辑放到 JS/TS 层来做。这也是我推荐大多数团队采用的方式。react-native-vision-camera 社区活跃、API 设计合理,且支持帧处理器(Frame Processor),你可以直接在 JS 层拿到每一帧的像素数据,然后调用原生侧已封装好的 OCR 能力。这样做的好处是,相机这一块不用自己碰原生,OCR 和解析部分又能完全掌控。
我自己最终采用了第三条路线,即 react-native-vision-camera 加原生侧基于 ML Kit / Vision 的封装,解析逻辑放在 TypeScript 层。这样团队里不熟悉原生的前端同学也能参与迭代,而且出了问题,定位起来比黑盒 SDK 要快得多。
1.3 为什么在 React Native 里做这件事,以及边界在哪
有些朋友会问,既然要碰原生,为什么不用 Flutter,或者干脆写两个原生 App?放在今天的业务环境下,React Native 依然是很多公司的现实选择:团队技术栈是 JS/TS,已有业务代码大量复用,或者公司规定移动端必须由 RN 一套代码覆盖双端。在这些约束下,MRZ 扫描作为一个子模块嵌入现有 RN App 是合理的,没必要为了这种边角功能重写整个应用。
但你也得清楚边界:React Native 是 UI 框架和业务逻辑框架,不是图像处理框架。所有涉及相机硬件、图像帧获取、AI 推理的活儿,最终都必须落到原生侧。你的 RN 代码能做的是:调用原生暴露的接口、处理返回的文本数据、组织和渲染 UI、管理业务流程。所以正确的技术心态是:把 RN 当“前台指挥”,把原生当“后台能力中台”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 吃透 MRZ:识别之前,先把数据格式搞明白
2.1 MRZ 的三种常见版式与字段布局
很多人第一次接触 MRZ 会觉得它像乱码,实际上它遵循非常严格的国际标准。常见的版式有三种:TD1、TD2 和 TD3。TD3 是护照最常用的格式,两行各 44 个字符;TD2 也是两行,但每行 36 个字符,常见于部分签证贴纸;TD1 是三行、每行 30 个字符,常见于身份证,在护照项目里遇到相对少。
以 TD3 为例,第一行通常以 P 开头,代表 Passport 类型,后面跟着签发国代码,再后面是姓名。姓名部分会有很多 < 符号,这些 < 就是填充位,用来把名字的分隔和补齐都规范到固定位置。注意这里的姓名规则是“姓氏”加两个 < 再加“名字”,名字内部如果有多个部分,用单个 < 分隔。
第二行则以护照号码开头,紧接着一位校验位,然后是国籍、出生日期、性别、有效期、个人号码等字段。每个关键字段后面都跟着一位校验位,最后一个位置还有整体校验位。可以说,MRZ 格式的设计初衷,就是让机器在不同光照和识别条件下,依然能通过校验位去判别读取结果是否正确。
如果你要自己写解析器,建议先把这三种版式的字段布局做成一张表,明确每个字段的起始位置和长度,后面写代码就不会乱。我把 TD3 的关键字段位置整理如下:
| 字段 | 起始位置 | 长度 | 说明 |
|---|---|---|---|
| 证件类型 | 0 | 1 | 通常为 P |
| 签发国 | 2 | 3 | 三位国家代码 |
| 姓名 | 5 | 39 | 姓氏+<<+名字 |
| 护照号码 | 44 | 9 | 第二行开头 |
| 护照号码校验位 | 53 | 1 | 对前 9 位计算 |
| 国籍 | 54 | 3 | 三位国家代码 |
| 出生日期 | 57 | 6 | YYMMDD 格式 |
| 出生日期校验位 | 63 | 1 | 对前 6 位计算 |
| 性别 | 64 | 1 | M / F / < |
| 有效期 | 65 | 6 | YYMMDD 格式 |
| 有效期校验位 | 71 | 1 | 对前 6 位计算 |
| 个人号码 | 72 | 14 | 可选字段 |
| 个人号码校验位 | 86 | 1 | 对前 14 位计算 |
| 总校验位 | 87 | 1 | 对上述所有含校验位字段计算 |
这个表格基本就是 TD3 的“地图”,解析时按图索骥即可。
2.2 校验位算法:这个 10 行代码的价值超乎你想象
MRZ 的校验位算法并不复杂,我给团队新人讲过很多次,只要三步:字符转数值、加权求和、取模。
字符转数值的规则是:< 当作 0,数字 0 到 9 保持原值,字母 A 到 Z 分别对应 10 到 35。加权求和的权重固定是 7、3、1、7、3、1……这样循环下去。最终和值对 10 取模,得到的结果就是校验位。
举个例子,假设护照号前 9 位是 L898902C3,逐个转换并加权:
- L 对应 21,乘以权重 7,得 147
- 8 对应 8,乘以权重 3,得 24
- 9 对应 9,乘以权重 1,得 9
- 8 对应 8,乘以权重 7,得 56
- 9 对应 9,乘以权重 3,得 27
- 0 对应 0,乘以权重 1,得 0
- 2 对应 2,乘以权重 7,得 14
- C 对应 12,乘以权重 3,得 36
- 3 对应 3,乘以权重 1,得 3
总和是 316,对 10 取模得到 6,所以校验位就是 6。这在真实护照样本里对应的就是第二行那个位置上的数字。校验位验算的价值在于:OCR 识别出错时,很可能被校验逻辑拦下来,而不是把错误的护照号直接交给业务系统。这个设计能极大降低“看似成功、实则诈骗”的隐蔽错误。
2.3 真实护照样本的解析演练
我拿一个经典测试样本做完整演示。第一行是:
code复制P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<
拆分一下:P 是证件类型,UTO 是签发国(这是 ICAO 测试用虚构国家代码,实际业务里遇到这种要留意),ERIKSSON 是姓氏,两个 < 分隔符,ANNA 和 MARIA 是名字的两个部分,剩下的 < 是补位。解析后姓名字段字符串为 ERIKSSON<<ANNA<MARIA,再按 << 和 < 切分就能拿到完整的姓和名列表。
第二行是:
code复制L898902C36UTO7408122F1204159ZE184226B<<<<<10
按前面的表格切分,护照号是 L898902C3,校验位是 6;国籍是 UTO;出生日期是 740812,表示 1974 年 8 月 12 日,校验位是 2;性别是 F;有效期是 120415,表示 2012 年 4 月 15 日,校验位是 9;个人号码是 ZE184226B,校验位是 <,这意味着该字段没有启用校验;最后一位 0 是总校验位。
注意一个细节:如果出生日期或有效期在解析时出现两位数年份,比如 740812,你需要根据业务场景决定是否补齐为完整日期。大多数情况下,护照有效期不会早于 2000 年,出生年份则可能在 2000 年前后都有,所以最好结合系统当前时间做一次合理性判断,而不是盲目拼接 19 或 20 前缀。
3. 实操落地:从项目初始化到扫描功能跑通
3.1 环境准备与依赖选型
项目基础环境方面,我假设你已经装好了 Node.js、React Native CLI 或 Expo(如果使用 Expo,需要确认你用的版本是否支持 custom native code,否则 MRZ 扫描这类原生能力很难集成)。我用的是 React Native 0.72 以上版本,原生侧分别用 Xcode 14+ 和 Android Studio Hedgehog 2023.1.1(AGP 8.x)来做双端编译。
依赖选型上,核心是两个库:react-native-vision-camera 负责相机采集,版本建议 3.x 以上,因为 3.x 对 Frame Processor 的支持更稳定;另一个是原生侧 OCR 能力,Android 推荐 Google ML Kit 的 com.google.mlkit:text-recognition,iOS 推荐使用 Vision 框架的 VNRecognizeTextRequest,这不需要额外安装第三方 SDK。
如果你不想自己封装原生 OCR,也有一个折中方案:使用 @react-native-ml-kit/text-recognition 这类库,它把 ML Kit 的文本识别能力封装成了 RN 原生模块。这样你在 Frame Processor 里拿到帧后,可以直接调用该库识别文本,然后自己解析 MRZ。这个方案的好处是省掉你写原生模块的工作量,坏处是中间隔了一层,遇到性能瓶颈时优化空间有限。
我实际用的组合是:react-native-vision-camera + Android 端 ML Kit 自封装 + iOS 端 Vision 自封装。这样在做帧预处理时,能针对两个平台做差异化调优。
需要注意一个问题:新版 React Native 默认启用了 New Architecture,有些原生库还没完全适配。如果你打算开启新架构,务必提前确认所选库的版本是否支持 Fabric 和 TurboModule,否则会出现编译错误或运行崩溃。我建议稳妥起见,MRZ 扫描功能跑通之前先关掉新架构,等稳定后再考虑迁移。
3.2 iOS/Android 原生权限与配置
iOS 端,你必须在 Info.plist 里添加相机权限描述,否则应用会直接崩溃,而不只是弹窗拒绝。这个描述的文案有讲究,不要写得太泛,审核和用户体验都建议写清楚用途,比如:
xml复制<key>NSCameraUsageDescription</key>
<string>我们需要使用相机来扫描护照信息,用于身份核验。</string>
Android 端,需要在 AndroidManifest.xml 里声明相机权限:
xml复制<uses-permission android:name="android.permission.CAMERA" />
如果相机权限是运行时获取,动态申请的代码建议放在原生侧完成,或者使用社区的 react-native-permissions 统一管理。这里我特别提醒一点:Android 13 及以上的运行时权限模型里,相机权限依然属于危险权限,但如果你用 react-native-vision-camera,它内部会自动处理一部分权限流程,你只需在 JS 层调用 Camera.requestCameraPermission() 这样的 API 触发申请即可。
另外,如果你的 App 还要做护照照片存档,Android 11 及以上版本对存储权限收紧得很厉害,建议不要依赖传统的 WRITE_EXTERNAL_STORAGE 权限,而是优先使用 MediaStore API 或 App 私有目录。
3.3 相机扫描核心流程封装
我把整体流程设计成三个阶段:初始化相机、实时帧处理、结果回调。用 react-native-vision-camera 实现时长这样:
tsx复制import { Camera, useFrameProcessor, useCameraDevice } from 'react-native-vision-camera';
function MrzScanner() {
const device = useCameraDevice('back');
const frameProcessor = useFrameProcessor((frame) => {
'worklet';
// 将帧数据传给原生侧 OCR
const result = runMrzOcr(frame);
if (result?.texts?.length) {
// 解析 MRZ 文本
const parsed = parseMrz(result.texts);
if (parsed) {
// 回调给业务层
onMrzDetected(parsed);
}
}
}, []);
useEffect(() => {
Camera.requestCameraPermission().then((res) => {
if (res !== 'granted') {
// 提示用户授权
}
});
}, []);
if (device == null) {
return <NoCameraView />;
}
return (
<Camera
device={device}
isActive={true}
style={{ flex: 1 }}
frameProcessor={frameProcessor}
pixelFormat="yuv"
outputOrientation="device"
/>
);
}
这里需要解释一个关键点:frameProcessor 是 worklet 环境,也就是你在里面写的 JS 函数其实会被同步到原生侧的渲染线程执行。这意味着你不能在里面做太重的计算,也不要直接访问 React 组件里的普通闭包变量,否则要么性能很差,要么直接报错。正确做法是,把耗时的 OCR 识别放到原生侧,JS 层只做轻量的文本解析和数据结构化。
runMrzOcr 这个方法在双端原生实现,通过 TurboModule 或 Native Module 暴露给 JS。以 iOS 为例,你在原生侧拿到 CMSampleBuffer,转换成 VNImageRequestHandler 的输入,然后跑 VNRecognizeTextRequest:
swift复制let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.recognitionLanguages = ["en-US"]
request.usesLanguageCorrection = false
注意 usesLanguageCorrection 要设成 false,因为 MRZ 里的字符很多是 <、数字和字母混排,语言校正反而会把正确结果“纠正”成错误的单词。Android 端 ML Kit 也是一样的道理,用 TextRecognizer 时直接识别,不启用任何语言模型强化。
3.4 MRZ 解析模块的 TypeScript 实现
识别出的原始文本一般是一整串带换行的字符串,比如:
code复制P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
解析器首先要判断这是哪种类别。判断规则非常简单:去掉换行符和空白后,如果是两行 44 字符,就是 TD3;如果是两行 36 字符,是 TD2;如果是三行 30 字符,是 TD1。我在 parseMrz 函数里先做这个判断,再按对应版式去切字段。
ts复制function parseMrz(lines: string[]): MrzData | null {
const cleaned = lines.map(line => line.trim());
if (cleaned.length === 2 && cleaned[0].length === 44 && cleaned[1].length === 44) {
return parseTd3(cleaned[0], cleaned[1]);
}
if (cleaned.length === 2 && cleaned[0].length === 36 && cleaned[1].length === 36) {
return parseTd2(cleaned[0], cleaned[1]);
}
if (cleaned.length === 3 && cleaned[0].length === 30 && cleaned[1].length === 30 && cleaned[2].length === 30) {
return parseTd1(cleaned[0], cleaned[1], cleaned[2]);
}
return null;
}
接着是字段提取和校验。TD3 的提取逻辑可以这样写:
ts复制function parseTd3(line1: string, line2: string): MrzData {
const surname = line1.substring(5, 44).split('<<')[0].replace(/</g, ' ');
const givenNames = line1.substring(5, 44).split('<<')[1].split('<').filter(Boolean).join(' ');
const passportNumber = line2.substring(0, 9).replace(/</g, '');
return {
documentType: line1[0],
issuingCountry: line1.substring(2, 5),
surname,
givenNames,
passportNumber,
passportNumberCheckDigit: line2[9],
nationality: line2.substring(10, 13),
birthDate: line2.substring(13, 19),
birthDateCheckDigit: line2[19],
sex: line2[20],
expiryDate: line2.substring(21, 27),
expiryDateCheckDigit: line2[27],
personalNumber: line2.substring(28, 42).replace(/</g, ''),
personalNumberCheckDigit: line2[42],
finalCheckDigit: line2[43],
};
}
写解析器时的经验教训:一定不要直接对整行字符串做简单的 replace(/</g, '') 后再按坐标切分,因为 << 也是字段内容的一部分,姓名部分尤其依赖分隔符来区分姓氏和名字。先按坐标切分,再清洗,这个顺序不能反。
3.5 扫描交互细节:取景框、闪光灯、自动对焦
很多第一版实现的产品,把相机全屏铺开,然后希望用户自己对准护照。结果就是识别率惨不忍睹。原因很简单:MRZ 区域太小,用户不知道要对多近、多远、横着放还是竖着放。你需要给用户明确的扫描引导。
我在实现时,在 UI 层画了一个取景框,比例按照实际 MRZ 区域的长宽比来设定,一般是宽度接近满屏、高度约为宽度的五分之一到四分之一。然后我会把相机的 frameProcessor 里传出的识别结果带上坐标信息,当 MRZ 区域离取景框中心较远时,提示用户调整手机位置;当区域内文本已可识别但置信度不高时,提示用户保持稳定。
闪光灯的处理也要谨慎。护照表面通常是光滑的,近距离开闪光灯会导致严重反光,识别率反而下降。我的策略是:默认关闭闪光灯,但提供手动开关,同时在光线不足时的确需要补光时,建议用户把手机略微倾斜,避免光线垂直打在护照表面。
对焦方面,react-native-vision-camera 可以通过 focus(point) 方法在用户点击画面时手动对焦。但扫描 MRZ 时,我更推荐开启连续自动对焦,并设置一个比较近的对焦距离,因为距离一般在 20 到 40 厘米之间,超过这个范围,文字会模糊,识别率会断崖式下降。
4. 双端兼容与性能调优:Android/iOS 实测差异
4.1 两大平台在相机与权限模型上的差异
iOS 端的相机模型比较统一,设备就那么几款,AVFoundation 的行为可预期性很高,只要处理了权限描述,基本不会出大问题。Android 端则不然,厂商定制 ROM 五花八门,权限策略各有差异,相机 HAL 实现也参差不齐。
我遇到过最典型的场景是:同一套代码,在 Pixel 上识别流畅,在某个国产机型上却出现预览卡顿、对焦迟缓。原因通常不是 CPU 性能不够,而是底层相机的 preview 分辨率和 Frame Processor 的分辨率配置不合理。react-native-vision-camera 允许配置 frameProcessor 接收的帧分辨率,我建议不要用全分辨率,因为 OCR 并不需要 4K 细节,1080p 甚至 720p 已经足够,而且能显著降低内存带宽压力。
还有格式问题:Android 端 Frame Processor 默认会输出 YUV 格式,iOS 端可能是 BGRA。你在原生侧做 OCR 之前,需要针对不同格式做转换。ML Kit 可以直接处理 InputImage 从 YUV 或 Bitmap 创建,但有些封装库只接受某种特定格式,这里非常容易踩坑,建议在原生侧统一封装一个“帧转 OCR 输入”的函数,把双端差异隔离掉。
4.2 性能与识别率的平衡点
MRZ 扫描的性能瓶颈几乎都出现在 OCR 环节,而不是相机预览。iOS 的 Vision 框架在 A12 芯片及以后跑文本识别很快,基本能做到实时。Android 的 ML Kit 文本识别在主流机型上也很快,但在中低端机型上,单帧识别可能要 100 到 200 毫秒,如果每帧都识别,用户会感觉明显的卡顿,而且耗电也高。
我的做法是做帧采样。也就是不是每一帧都送去做 OCR,而是每隔 N 帧或者当画面变化超过一定阈值时才触发识别。实现上,你可以在 Frame Processor 里用一个计数器,比如每 3 帧识别一次,或者用一个简单的“关键帧检测”:如果当前帧和上一帧的灰度直方图差异不大,就跳过。这个策略能极大降低 CPU 占用,同时几乎不影响用户体验,因为用户在移动手机时,画面变化很快,关键帧会被自然捕捉到。
识别率方面,影响最大的因素还是护照摆放。我建议你在扫描引导 UI 里明确告诉用户:“请将护照平放于桌面或握住边缘,将下方 MRZ 区域完整放入取景框内,避免倾斜和反光。”同时做一个动态提示,当算法检测到 MRZ 区域有倾斜时,在 UI 上显示“请放正护照”或“请调整角度”。
4.3 实战踩坑记录与排查清单
我整理几个实际项目中高频出现的问题和排查思路,供你对照。
第一个是 Android 端运行时报 Camera is not available 或黑屏。常见原因包括:权限没有在运行时申请成功、相机设备被其他应用占用、isActive 状态没有和页面生命周期关联。排查时先用系统相机 App 验证硬件正常,然后确认 Camera.requestCameraPermission() 的返回值,最后检查页面 onPause 时是否把 isActive 设成了 false。从实
