1. 项目概述与方案选型
1.1 项目背景与核心需求
文档扫描器在移动端一直是个高频需求——合同归档、报销凭证、白板拍照转PDF、学生扫笔记,本质上都是同一件事:用手机摄像头把一张真实的纸变成一张干净、端正的电子图片。我这次要做的,就是在React Native项目里完整实现这个闭环:打开相机自动识别文档边界、实时画出四边形框、按下快门后做透视裁剪矫正、最后导出图片或PDF。
为什么要自己完整实现一遍,而不是直接接一个扫描SDK?我调研过不少第三方的文档扫描SDK,大多数要么是原生iOS/Android库,要么是闭源商用产品,接入成本高、定制困难,而且对React Native的支持全靠个人维护的桥接库,质量参差不齐。更关键的是,文档扫描的核心体验——自动检测那一块——如果完全黑盒依赖SDK,遇到特殊拍摄角度、反光、阴影场景,想调试优化都无从下手。自己做一套,至少能彻底搞清楚每一帧图像是怎么被处理的,出了问题也能定位到具体环节。
1.2 技术选型:为什么我选了这套组合
核心选型是“react-native-vision-camera + OpenCV + 少量原生代码”。VisionCamera负责流畅的相机预览和帧回调,OpenCV负责边缘检测、轮廓查找、透视变换这类图像处理重活,原生部分只用来做相机权限、相册保存等系统能力桥接。
这套组合的优势很直接。VisionCamera是目前React Native社区维护最活跃的相机库,支持帧处理器(Frame Processors),可以直接在C++/JSI层面拿到视频帧,避免每一帧都往JS线程传Bitmap导致的卡顿;OpenCV则是图像处理领域的标准库,Canny边缘检测、findContours、warpPerspective这些算法都是被无数场景验证过的,没必要自己造轮子。
这里有一个常见的选型误区:不少朋友会用纯JS的图像处理库,在Canvas上做边缘检测,好处是跨端一致、不用原生编译,但性能差很多。一张1200x1600的图片在JS线程上做高斯滤波和Canny,实测耗时可能要几百毫秒,完全达不到实时预览“框住文档”的效果。而OpenCV的C++实现同样操作基本在10-20毫秒内完成,这种量级的差距直接决定了最终能不能做到实时框选。
网上也有现成的库,比如react-native-document-scanner,我也试过,但这类库多数基于旧版Camera,Android端依赖的是OpenCV旧版本,在RN 0.7x以上经常出现原生模块不兼容、编译失败的问题。如果只是做demo玩玩可以,真要往生产环境上推,我建议还是自己组合一套。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 相机模块与实时帧处理链路
2.1 VisionCamera的接入与配置
先装依赖,我用的是当前最新稳定版本,React Native 0.72+环境,iOS和Android都能正常跑。
bash复制npm install react-native-vision-camera
cd ios && pod install
Android需要在AndroidManifest.xml里声明相机权限:
xml复制<uses-permission android:name="android.permission.CAMERA" />
iOS需要在Info.plist里加上NSCameraUsageDescription,否则启动时直接崩溃。
相机组件的核心代码很简单,但有几个关键点要注意:
tsx复制import { Camera, useCameraDevice, useFrameProcessor } from 'react-native-vision-camera';
import { useRef } from 'react';
function ScannerView() {
const device = useCameraDevice('back');
const frameProcessor = useFrameProcessor((frame) => {
'worklet';
// 这里每帧都会被调用,注意不要做耗时操作
}, []);
if (!device) {
return null; // 没有后置相机时给出提示
}
return (
<Camera
device={device}
style={{ flex: 1 }}
isActive={true}
pixelFormat="yuv"
frameProcessor={frameProcessor}
onError={(e) => console.warn('Camera error:', e)}
/>
);
}
pixelFormat这里我选了yuv而不是rgb,原因有两点。第一是YUV是相机输出的原生格式,少一次RGB转换;第二是YUV的Y通道本身就是灰度信息,直接做边缘检测时可以省掉一次cvtColor。不过如果你的帧处理器要展示到界面上做预览,可能还是需要rgb,这个要看具体场景,没有绝对答案。
另一个容易忽略的点是:VisionCamera是从v4开始把帧处理器的Worklet拆到了react-native-worklets-core里,如果你还在用v3,需要依赖react-native-reanimated。安装完必须跑一次原生编译,别漏掉这一步。
2.2 帧回调与处理流水线设计
帧处理器跑在Worklet线程,意味着这段代码不会阻塞JS线程。但即使如此,你也不应该在每一帧上都做完整的OpenCV pipeline。以常见的30fps相机帧率来说,每帧处理时间超过33毫秒,就会造成持续掉帧,预览画面会明显变卡。我实测下来,把文档检测控制在每秒处理10-15帧左右,既能保证框选平滑,又不会让手机发烫。
帧回调的伪代码如下:
typescript复制const lastProcessTime = useRef(0);
const frameProcessor = useFrameProcessor((frame) => {
'worklet';
const now = global.performance.now();
if (now - lastProcessTime.current < 100) {
return; // 100ms节流,即每秒处理约10帧
}
lastProcessTime.current = now;
// 1. 把帧转成Mat对象
const input = frameToMat(frame);
// 2. 缩小分辨率再检测,减少计算量
const small = resizeToWidth(input, 640);
// 3. 执行文档检测,得到四边形的角点
const corners = detectDocumentCorners(small);
// 4. 把角点坐标映射回原图分辨率
const scaledCorners = scaleToOriginal(corners, frame.width, small.width);
// 5. 通过Worklet的广播机制发到UI层画框
global.broadcastCorners(scaledCorners);
}, []);
这里我有一个很重要的经验:千万不要拿原始分辨率去做边缘检测。相机帧分辨率通常是1920x1080甚至3840x2160,直接处理不仅慢,而且图像里的噪点、纹理细节过多,反而干扰轮廓查找。先把图缩到640-720宽,检测到角点之后再把坐标等比映射回原分辨率,这样无论是速度还是准确率都明显提升。
UI层要画那个实时追踪的四边形框时,我建议直接用另一个Animated View或者SVG Overlay去渲染四个角和四条边,不要用Camera组件的View去做动画,否则每帧setState会让整个预览界面重绘,卡顿很明显。把角点数据通过useSharedValue这样的方式放进共享值里,再在动画帧里读取,体验会顺滑很多。
3. 自动检测:如何让机器“看到”文档边界
3.1 图像预处理:灰度、滤波与归一化
自动检测文档边界是整个扫描器最核心的算法环节。传统CV路线不依赖任何机器学习模型,只靠图像特征来找边缘,虽然不如深度学习那样能识别“这到底是什么物体”,但在文档扫描这个特定场景下,效率和稳定性反而更好。
第一步是转灰度。无论原始帧是RGBA还是YUV,都要先拿到单通道灰度图。这一步没有太多技巧,标准做法就是cvtColor:
cpp复制cv::Mat gray;
cv::cvtColor(input, gray, cv::COLOR_RGBA2GRAY);
第二步是高斯滤波。这一步很容易被新手跳过,但如果不滤波,Canny边缘检测会同时提取出纸张表面的细小纹理、打印文字的笔画、甚至镜头的噪点,导致后续轮廓查找找到一堆乱七八糟的边缘。我用的是5x5高斯核:
cpp复制cv::GaussianBlur(gray, gray, cv::Size(5, 5), 0);
高斯滤波的sigma取0表示由OpenCV根据核大小自动计算,实际效果已经足够。这里有一个认知:高斯滤波不是为了让图像变模糊,而是为了“去伪边缘”,把孤立噪点提前压掉,让后续的Canny只响应真正的结构边缘。
预处理还涉及一个归一化的问题。不同手机、不同光线条件下,灰度图的明暗分布差异很大。我习惯在滤波之后做一次直方图均衡化(如果需要的话),因为在高光或者阴影特别重的场景下,灰度对比度不够,会影响Canny的阈值设定。当然这也增加了一点计算成本,所以在实时检测环节我通常不做,只在静态拍完照后处理高清图时才启用。
3.2 Canny边缘检测与轮廓查找
边缘检测用的是Canny算子,两个阈值我固定取50和150。这个组合在绝大多数室内外光照条件下都表现稳定。如果环境太暗或者逆光,可以适当降低到30/100,但不要低于20,否则会产生大量噪点。
cpp复制cv::Mat edges;
cv::Canny(gray, edges, 50, 150);
Canny内部做的其实是一个“先找梯度极值、再双阈值滞后连接”的过程。高阈值决定了哪些边缘是真正的强边缘,低阈值决定了强边缘周围哪些弱边缘可以被连接起来。所以阈值不是随便填的,它直接决定了你的轮廓连续性。
拿到边缘图后,下一步是找轮廓。这一步有个关键参数选择:我用RETR_EXTERNAL,也就是只取最外层轮廓。文档扫描里,我们关心的是纸张的外边界,而不是纸上的印刷文字、二维码或者手写字,如果用了RETR_LIST或者RETR_TREE,你会得到大量内部轮廓,对四边形筛选毫无帮助,反而拖慢速度。
cpp复制std::vector<std::vector<cv::Point>> contours;
cv::findContours(edges, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);
然后对每个轮廓求面积并做排序,只保留面积最大的几个候选,避免后面无谓的遍历。
3.3 四边形筛选与角落排序
找到轮廓之后,最关键的一步是判断哪些轮廓“看起来像一张纸”。我用两条规则:
第一,轮廓必须是凸的。一张正常的纸在相机视角下应该是一个凸四边形,虽然透视变形会导致边看起来不平行,但不会变成凹多边形。用isContourConvex能干掉很多不规则的轮廓。
第二,轮廓的多边形近似必须只有4个顶点。这里用到approxPolyDP,epsilon参数我取轮廓周长的2%:
cpp复制std::vector<cv::Point> approx;
double epsilon = 0.02 * cv::arcLength(contour, true);
cv::approxPolyDP(contour, approx, true);
if (approx.size() != 4) continue;
epsilon的取值值得细说。太大,会把一个实际是六边形或圆形的物体“压”成四边形;太小,又容易把一个带锯齿的边缘拟合成很多个点,判断不出四边形。我试过1%、2%、3%三档,2%在正常拍摄距离下最稳。如果你发现有些时候框选区域比纸张本身大了一圈,可以尝试把epsilon降到1.5%——这通常是采样点太密导致的拟合偏移。
找到四个角点之后,还要做一件事:把它们的顺序排好。透视变换要求输入的四个点必须按照“左上、右上、右下、左下”的顺序,否则变换出来的图像会旋转或扭曲。朴素的排序方式是按x+y最小为左上、最大为右下,但遇到纸倾斜超过45度时就会出错。我用的方法是以四个点的几何中心为原点,按对应方位角排序:
javascript复制function orderPoints(points) {
const center = points.reduce(
(acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }),
{ x: 0, y: 0 }
);
center.x /= points.length;
center.y /= points.length;
return [...points].sort((a, b) => {
const angleA = Math.atan2(a.y - center.y, a.x - center.x);
const angleB = Math.atan2(b.y - center.y, b.x - center.x);
return angleA - angleB;
});
}
这样无论纸张朝着哪个方向旋转,四个角的顺序都是稳定的。这个排序我至少在十几个项目里用过,从没出过问题。
3.4 检测平滑与防抖策略
实时预览阶段,如果你真的每帧都去识别四边形,你会发现一个很现实的问题:检测结果会抖动。不是因为算法不稳定,而是因为相机帧本身就有微小的噪声,手部的抖动会让边缘像素上下浮动几个像素,检测出的角点坐标也跟着跳。
我的解决方案是引入一个简单的低通滤波,对当前帧检测到的角点与上一帧有效角点做插值:
typescript复制function smoothCorners(prev, cur, alpha = 0.4) {
return cur.map((p, i) => ({
x: prev[i].x + (p.x - prev[i].x) * alpha,
y: prev[i].y + (p.y - prev[i].y) * alpha,
}));
}
alpha越大响应越快、越跟手,但抖动也越明显;越小越平滑,但会显得框选有延迟。我建议取0.3-0.5之间。同时可以加一个置信度判断:如果当前帧没有检测到四边形,不要立刻清空UI上的框,而是保留上一帧有效结果300毫秒左右,这样即使某几帧检测失败,用户也不会有“框一下消失一下”的割裂感。
另外我还会判断一下角点坐标是否发生了突变。如果上一帧和当前帧检测出来的四个角点距离突然变得非常大,大概率是检测到了背景里的其他物体,这种时候应该放弃当前结果,而不是让框直接跳走。
4. 拍照采集与透视裁剪矫正
4.1 为什么需要透视变换
从斜上方拍一张纸,得到的图像在数学上等价于把一张正面的平面图做了一个单应变换(Homography)。纸的左右两边在图像里不再平行,面积也不等。我们要把这种斜视图还原成俯视的正视图,就需要做透视矫正。
透视变换的数学基础是3x3单应矩阵H。它把原始图像坐标(u, v)映射到矫正后坐标(x, y):
code复制[x'] [h11 h12 h13] [u]
[y'] = [h21 h22 h23] [v]
[w'] [h31 h32 h33] [1]
这里得到的是齐次坐标,最后归一化得到实际像素坐标:x = x'/w',y = y'/w'。h31和h32非零时,意味着变换会给坐标增加一个与u、v相关的缩放分量,这就是“透视”的感觉来源。
OpenCV里不需要你手动求逆矩阵,直接调用两个函数:getPerspectiveTransform计算矩阵,warpPerspective完成重采样。前者只需要4组对应点,后者生成最终图像。
4.2 计算目标尺寸与变换矩阵
目标尺寸的确定有很多细节。直接用检测到的四个角的欧氏距离来估算宽高,是最常见也最稳妥的做法。我一般这样算目标宽高:
javascript复制// 原始角点顺序:左上、右上、右下、左下
const topWidth = Math.hypot(
ordered[1].x - ordered[0].x,
ordered[1].y - ordered[0].y
);
const bottomWidth = Math.hypot(
ordered[2].x - ordered[3].x,
ordered[2].y - ordered[3].y
);
const leftHeight = Math.hypot(
ordered[3].x - ordered[0].x,
ordered[3].y - ordered[0].y
);
const rightHeight = Math.hypot(
ordered[2].x - ordered[1].x,
ordered[2].y - ordered[1].y
);
const targetWidth = Math.round(Math.max(topWidth, bottomWidth));
const targetHeight = Math.round(Math.max(leftHeight, rightHeight));
上下宽或左右高取较大的值,是为了避免纸张本身有倾斜时矫正后内容被裁切。实测中用max比用min或平均都更好,你用min会发现纸张边缘被切掉一点,用平均在某些拍摄角度下也会出现裁剪。
然后填充对应的源点和目标点,计算变换矩阵:
cpp复制cv::Point2f src[4] = {
cv::Point2f(ordered[0].x, ordered[0].y),
cv::Point2f(ordered[1].x, ordered[1].y),
cv::Point2f(ordered[2].x, ordered[2].y),
cv::Point2f(ordered[3].x, ordered[3].y)
};
cv::Point2f dst[4] = {
cv::Point2f(0, 0),
cv::Point2f(targetWidth, 0),
cv::Point2f(targetWidth, targetHeight),
cv::Point2f(0, targetHeight)
};
cv::Mat M = cv::getPerspectiveTransform(src, dst);
cv::Mat result;
cv::warpPerspective(srcImage, result, M, cv::Size(targetWidth, targetHeight));
这样一来,不管原图里纸斜成什么样,输出都是一张端端正正的矩形图。
4.3 拍照模式下的高清处理链路
实时预览用的是缩小的帧,但最终导出一定要用相机拍照后的高清图。所以拍照那一瞬间,我会在native层保存一个当前帧的原始分辨率引用,然后用静态图片重新走一遍完整的检测+矫正流程。如果用户是在预览时已经看到了检测框,那么其实可以直接把预览阶段的角点坐标映射到拍照后的高清图上,无需重新检测,大大加快响应速度。
这里有一个容易踩的坑:预览帧的坐标和拍照后图像的坐标分辨率不一样。我处理的方式是记录一个宽高比例系数:
typescript复制const scaleX = photoWidth / previewWidth;
const scaleY = photoHeight / previewHeight;
const highResCorners = previewCorners.map((p) => ({
x: p.x * scaleX,
y: p.y * scaleY,
}));
但要注意,如果预览模式是裁剪过的(比如用了cover的缩放模式),这个比例就不是简单的等比放大了。我建议在预览界面把Camera的画面设置成“充满(cover)”风格,然后通过onPreviewSize拿到实际预览分辨率,再和成像分辨率做换算。这块并没有统一API,不同版本的VisionCamera字段名还会变,需要以实际版本为准。
拍照后的高清矫正,我建议额外做一次颜色增强。很多扫描App会提供“增强”“黑白”“灰度”等滤镜,实际上就是对矫正后的图像做一次自适应阈值或者对比度增强。我一般在静态图阶段调用OpenCV的adaptiveThreshold生成纯黑白效果,用户可以在界面上切换原图/增强/黑白三种预览。
5. 导出功能设计:图片、PDF与多页文档
5.1 导出静态图片
矫正完成后,最直接的导出方式就是保存为PNG或JPEG。PNG适合文字、单据、白板这种边缘清晰的图,无损但有体积;JPEG体积小,适合照片类的资料。我的做法是默认导出JPEG,质量设90,这样在体积和清晰度之间比较均衡。
保存到相册,Android和iOS两端的处理方式不同。iOS可以直接用CameraRoll库或者PHPhotoLibrary原生API,Android需要先写入公共媒体目录,再通过MediaScanner通知系统刷新相册。我用的是react-native-blob-util把文件写入临时目录,再调用原生方法保存到相册,流程上比较稳定。
typescript复制import { writeFile } from 'react-native-fs';
import { CameraRoll } from '@react-native-camera-roll/camera-roll';
const jpegPath = `${tempDir}/scan_${timestamp}.jpg`;
await writeFile(jpegPath, base64, 'base64');
await CameraRoll.save(jpegPath, { type: 'photo' });
5.2 生成PDF
如果用户需要把多张扫描结果合并成一个PDF,我推荐用react-native-pdf-lib。它可以直接创建PDF文档、分页、把图片插入页面,API非常简单:
typescript复制import { PDFDocument, PDFPage } from 'react-native-pdf-lib';
const pdf = await PDFDocument.create('/path/to/output.pdf');
const page = PDFPage.create().setMediaBox(595, 842); // A4大小,单位磅
pdf.addPages(page);
const path = await pdf.write();
需要注意,PDF页面的尺寸和图片尺寸需要做换算。A4纸是595x842磅(72dpi),如果你想把拍摄出来的A4纸图片原尺寸放进去,需要根据图片像素和屏幕dpi换算一下缩放比例。实际项目中,我给用户提供了A4和原始尺寸两种模式,PDF页面按照图片比例动态设置MediaBox。
5.3 多页扫描与会话管理
一个完整的扫描流程,通常需要连续拍多张,最后一起导出。我的方案是引入一个“扫描会话”(ScanSession)的概念:每次检测成功并确认后,把矫正后的图片路径加入会话数组,界面上显示一个缩略图列表。用户可以删除某一张、调整顺序,最后统一导出为多页PDF。
状态管理用Zustand就足够了,不需要Redux这种重框架。核心状态大概是:
typescript复制type ScanSession = {
pages: string[]; // 图片路径数组
currentPage: number | null;
addPage: (path: string) => void;
removePage: (index: number) => void;
reorderPages: (from: number, to: number) => void;
clear: () => void;
};
这种设计的好处是解耦。相机预览、图像处理、导出都只依赖一个简单的pages数组,用户操作路径清晰,也不容易出状态混乱的bug。
6. 性能优化、白屏排查与跨端适配
6.1 处理分辨率与内存控制
我在前面反复强调缩小检测分辨率,这里再算一笔具体的账。假设相机输出1080p的帧,每帧RGB数据大概是1920x1080x3约等于6MB。如果你把这帧数据转成Mat并且在JS层持有引用,连续几帧就可能导致内存暴涨,Android上用了一会就OOM。这是RN文档扫描器最常见的崩溃原因之一。
控制手段有三个:第一,帧处理里做完检测立刻释放Mat和中间变量,用完了就delete;第二,预览阶段只保留缩小后的frame用于检测,不把原图长期持有;第三,静态高清图处理完,也要主动把Bitmap recycle,在RN里就是释放引用并移除临时文件。
我在OpenCV桥接层做了一套弱引用管理,每一次从帧创建Mat都会在native侧注册一个唯一ID,JS侧通过ID访问,当JS侧不再引用时由native GC统一回收。这个设计初看繁琐,但对避免内存泄漏非常有效。
6.2 启动白屏问题排查实录
项目做到后期,测试同学反馈了一个痛点问题:在部分Android低端机上,App冷启动后要白屏2-3秒才进入扫描页。排查下来,发现白屏的根因链是这样:VisionCamera和OpenCV的原生库都在应用启动阶段被加载和初始化,再加上RN的JS bundle解析,三条耗时叠加,导致首帧渲染被拖住。
这类“React Native启动白屏”问题,解决方案有几个层次。首先是确认启动的阶段,用Android Profile和系统日志看白屏期间到底在执行什么。我遇到的情况是OpenCV的native初始化在首次调用时才执行,而相机模块又在页面挂载后才初始化,两者串行加起来浪费了时间。优化做法是让OpenCV的初始化提前到Application的onCreate里,并且通过线程池预热,不要等到扫描页真正打开的时候才懒加载。
其次是UI层面的兜底。给扫描页设置一个原生SplashScreen,并且确保RN首帧渲染完成后再隐藏Splash。react-native-screens开启enableScreens之后,页面切换的加载压力也会小一点。还有一个很隐蔽的问题:在Android上如果Camera组件在权限未授予时就挂载,是有可能卡住首帧的,官方文档里也没写得很清楚。我的处理方式是:权限未授予时先显示一个权限引导页,用户点“允许”之后再去挂载Camera组件,决不提前创建Camera实例。
6.3 Android/iOS差异与OpenHarmony适配思路
Native层的OpenCV路径,iOS端通过CocoaPods集成,Android端通过CMake构建,两者在OpenCV的调用方式上基本一致。真正的差异点在相机权限、相册权限、保存文件路径这三个方面。iOS的相册写入使用PHPhotoLibrary的权限模型,Android 10以后是分区存储,不能随意往公共目录写文件。这些细节需要单独看系统版本处理,没有一个放之四海而皆准的写法。
最近也有团队在评估React Native for OpenHarmony(RNOH)的移植。从我的角度,扫描器这种强依赖原生能力的组件要跑在OpenHarmony上,最大的问题是相机模块和OpenCV库是否有适配版本。VisionCamera目前没有OpenHarmony官方的原生实现,OpenCV也需要编译成OHOS可用的so库。如果你正在考虑后续兼容OpenHarmony,架构上最重要的一点是:把图像处理算法抽成独立的C++层,不要直接嵌入到各端的原生模块代码里。这样未来要适配RNOH时,JS层和UI层基本不动,只要重写原生桥接和相机采集层即可。
6.4 扩展:循环滚轮选择器在扫描设置中的应用
扫描器界面通常需要一个“文档类型”或者“滤镜模式”的选择器。如果你希望做一个类似iOS原生滚轮的选择体验,这里可以分享一个简单的React Native实现思路:用ScrollView配合snapToInterval来实现对齐到每一项,onMomentumScrollEnd时计算当前选中索引。如果想要无限循环,就在数据列表前后增加虚拟占位项,滚动到边界时用scrollTo瞬间跳回对应项,从视觉上看起来就是循环的。这个思路不复杂,但处理回跳动画时容易出bug,建议先理清索引映射关系再动手。
7. 常见问题速查与我的踩坑记录
以下是文档扫描器开发过程中我遇到过的典型问题,整理成速查表供大家参考:
| 问题 | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 预览卡顿 | 相机画面明显掉帧 | 每帧都做完整检测且未释放Mat | 帧处理节流到10-15fps,缩小检测分辨率,及时释放中间Mat |
| 检测框消失 | 移动手机时框突然消失 | 某帧检测不到四边形后立即清空UI | 保留上一帧有效结果300ms,对帧间角点做平滑插值 |
| 输出图像倾斜 | 矫正后的图还是斜的 | 四个角点排序错误 | 使用中心点+方位角排序,不能用简单的x+y判断 |
| 拍照后图片模糊 | 高清图比预览模糊 | 用的是预览帧而非拍照帧做矫正 | 拍照后用拍照高清图重新计算或映射角点 |
| 白屏/启动慢 | 冷启动白屏2-3秒 | 原生库初始化阻塞首帧 | 提前初始化OpenCV,权限页和Camera分离,使用SplashScreen |
| Android保存失败 | 写入相册无响应 | 分区存储权限处理不当 | Android 10+使用MediaStore API或兼容库 |
| 内存持续增长 | 使用一段时间后OOM | Mat和Bitmap未释放 | 用native侧引用计数管理Mat生命周期 |
还有一个我特别想强调的坑:不要在检测回调里直接setState来更新角点。如果你把角点数据通过setState放进React组件,UI线程和帧处理线程的调度会让画面变得非常卡,甚至Android会频繁GC。正确做法是用共享值或者事件分批传递,比如每100毫秒把4个角点打包发送给UI,UI层只用动画驱动绘制。
另外,处理静态高清图时,如果用户拍的是反光严重的纸张,OpenCV的轮廓查找有时候会把台灯、窗户的倒影也算进去。我加了一道防线:在候选四边形筛选时,增加一个“面积占比”条件,检测到的框面积至少要占整张图的15%以上,否则视为无效。这个阈值可以根据实际场景调整,但大体能过滤掉很多背景杂物。
最后说说开发过程中的体会。文档扫描器不是一个“只要能用就行”的功能,用户对它的评价很大程度上取决于细节——框选是否跟手、矫正是否端正、导出是否清晰、冷启动是否够快。每个环节看起来都可以将就,但合在一起就成了产品体验的分水岭。我在开发时最常做的一件事,就是拿不同材质、不同颜色的纸在不同光线下反复测试,发现问题就回到对应环节修正参数。这个过程虽然枯燥,但积累下来的参数和判断逻辑,才是这个功能真正的竞争力所在。
这次从方案选型到前后端协作、再到性能优化的经验,基本都覆盖了。如果你也正在做类似功能,建议先把预览阶段的检测链路打通,再考虑高清矫正和导出,一步一步来,比一次性追求完美要靠谱得多。
