资讯动态

Compose 整合 YOLO:Android 端离线目标检测的完整实现指南

发布时间:2026/9/10 6:57:24 来源:尧图企业网站定制
去年有个项目需求在完全离线的条件下用手机拍一张桌面几秒内识别出里面有哪些物体、各自在什么位置。我们选型时几乎没犹豫就定了 Jetpack Compose 做界面层、YOLO 做检测模型Android 端本地推理全程不碰云服务。当时 Compose 已经比较成熟YOLO 的生态也足够开放这套组合非常适合做实时图像识别类 App。这篇文章把我从模型导出、推理引擎选择到 Compose 端绘制检测框的完整过程写出来主要面向已经会用 Android 和 Compose 基础组件、但没做过模型集成的开发者也欢迎被各种教程坑过、想搞清楚里面原理的人来拍砖。其实Compose 整合 YOLO这句话听起来简单实际落地时牵扯的问题非常多模型文件怎么选、推理引擎用 ONNX Runtime 还是 TFLite、CameraX 的帧怎么喂给模型、YOLO 输出的张量怎么翻译成坐标、Compose 的 Canvas 怎么跟相机预览对齐。这些问题任何一个环节卡住整个项目就转不起来。我尽量把完整的链路讲清楚并且把我在实测中踩到的坑一并交代了。1. 为什么是 Compose YOLO这个组合解决什么问题1.1 离线识别的需求本质先聊一个很多教程不会提的问题为什么要自己集成 YOLO而不是直接用云服务或者某些现成的 SDK我的项目是用于工厂生产线的辅助质检工具场景决定了数据不能出设备。生产线周围网络信号不稳定现场人员也不希望设备里的照片上传到任何外部服务。此外识别结果响应速度和可定制性都很关键云识别一次往返要 1 到 3 秒而本地推理在手机上能做到 200 到 500 毫秒云服务的模型固定想加一个缺陷类别要等厂商排期本地模型自己训练、自己导出、想怎么改就怎么改。所以在选型阶段就把远程调用方案排除了。剩下的问题是在 Android 端用什么 UI 框架、什么推理框架、什么模型架构。在 2024 到 2025 年这个时间点答案基本是确定的新项目用 Jetpack Compose 写界面不用再去碰 Fragment XML推理框架在 ONNX Runtime 和 TFLite 之间选模型用 YOLOv8n 或 YOLOv8s 这类轻量模型。这套组合的最大优势是生态完整、资料多、踩坑成本低。提示如果你是给已有项目做增量功能而且项目里已经大量使用 View 体系没必要为了这个功能把整个 App 重写成 Compose。Compose 和 View 可以在同一个 Activity 里共存用AndroidView把相机预览和绘制视图包进去完全可行。1.2 YOLO 模型怎么选版本与量化形态YOLO 现在已经迭代了好几代网上随便一搜能看到 v5、v7、v8、v9、v10 各种版本。如果你不是做算法研究只是希望在 Android 上完成图像识别我的建议就一句话选 YOLOv8nnano或 YOLOv8ssmall。原因很直接v8 的官方仓库ultralytics导出 ONNX 和 TFLite 非常方便一条命令就能完成v8n 的模型只有 6MB 左右v8s 大约 22MB在手机上占用可控社区资料最多遇到问题随便一搜都有答案检测头输出格式简单后处理代码好写v5 的导出也成熟但它的 Python 环境和依赖管理相对混乱对只做集成的人来说没必要折腾。v9、v10 等新版本虽然精度指标好看但 Android 端的推理支持还没那么完善导出后往往需要手写不少处理逻辑。至于模型的量化形态要分场景看形态优点缺点适用场景FP32 ONNX兼容性最好部署简单模型大CPU 推理偏慢快速验证、中高端手机FP16 ONNX体积减半部分设备更快部分 ARM CPU 支持差中高端机型为主INT8 TFLite体积最小CPU 推理快精度可能轻微下降需要量化数据集低端机型、追求帧率我在项目里用的是 yolov8n 转出的 FP32 ONNX。原因很简单目标设备是一台骁龙 778G 中端机FP32 单次推理约 250 到 350 毫秒能满足单张拍照识别而不是每秒 30 帧实时检测的需求。如果你的场景需要连续视频流里做实时检测建议优先考虑 INT8 量化后的 TFLite 模型或者干脆用 NNAPI/GPU delegate 走硬件加速。1.3 Compose 在这个技术栈里的定位Compose 在我这个项目里承担三件事相机预览的承载、检测结果的可视化、识别历史记录的展示。前两件事是核心。相机预览这块我用的是 CameraX 的PreviewView。它本质上还是一个 View所以需要用AndroidView把它嵌到 Compose 里。这个桥接方式不复杂但要注意生命周期、尺寸变化、旋转等细节后面第 3 节我会展开。检测结果的可视化是 Compose 真正让人爽的地方。YOLO 输出的是一组边界框坐标、类别、置信度我只需要把这些数据放到一个mutableStateOf里然后用 Canvas 组件去绘制矩形和文字就可以自动重绘。不用像 View 体系那样手动拿到onDraw、invalidate声明式 UI 在这个场景下体验非常好。2. 模型准备从 YOLO 权重到 Android 可用的推理文件2.1 导出 ONNX 的具体操作如果你训练好了自己的 YOLOv8 模型或者直接用官方预训练权重导出 ONNX 的操作如下。我假设本地 Python 环境里已经装好了ultralytics包yolo export modelyolov8n.pt formatonnx opset12 simplifyTrue执行完会在同级目录生成yolov8n.onnx大约 12MBFP32。这里几个参数值得解释一下opset12ONNX Runtime 对 opset 12 的支持最稳妥太新的 opset 可能导致某些内置算子不被当前版本的 ORT 支持simplifyTrue会调用 onnxsim 对计算图做简化减少冗余节点推理时能省一点时间如果你要导出 TFLiteultralytics 也支持但它在内部会先转 ONNX 再转 TF然后转 TFLite中间依赖 TensorFlow 环境比较折腾。我当时试过在 Windows 上导出 TFLite卡了两个小时才发现是 TF 版本冲突。如果必须要 TFLite 格式建议在干净的 Linux Docker 环境里做。2.2 推理引擎选型ONNX Runtime 还是 TFLite这是所有人都会纠结的问题我直接给出我的结论和理由。ONNX RuntimeORT适合这样的场景你主要用 YOLOv8 官方导出的模型不想被 TensorFlow Lite 的转换链路折磨需要调试模型内部某个节点的输出需要在多个平台Windows、Linux、Android用同一份模型文件TFLite 适合这样的场景你非常在意推理速度需要走 NNAPI delegate 或者 GPU delegate你的模型是 TensorFlow 生态里训练的你需要用InterpreterAPI 而不是OrtSessionTFLite 在低端机上的 CPU 推理通常比 ORT 快一些原因是 TFLite 针对 ARM CPU 做了很多算子级优化。但 ORT 的劣势没有大到无法接受而且 ORT 在较新版本里也支持了 NNAPI速度差距正在缩小。我的建议是先用 ONNX Runtime 跑通整个链路等识别精度和效果验证没问题了再去尝试 TFLite 做锦上添花。这样能把问题最小化不用一开始就陷入格式转换的泥潭。2.3 Android 端集成 ONNX Runtime 依赖在build.gradle.kts里面加一行implementation(com.microsoft.onnxruntime:onnxruntime-android:1.17.1)请注意版本。我最早用过 1.10 版本发现有些 YOLOv8 的算子跑不了报错信息还不明不白升级到 1.16 之后一切正常。建议直接用最新稳定版不要用太旧的。模型文件放在assets目录下运行时这样加载val session OrtSession.SessionOptions().let { options - options.setNumThreads(4) options.addOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) OrtSession(env, yolov8n.onnx, options) }其中env是OrtEnvironment.getEnvironment()。这里有个容易忽略的点OrtSession 创建比较重不要在每帧识别时重复创建应该在整个 App 生命周期内保持单例。线程数设 4 是根据我的测试结果来的在 8 核手机上设 4 线程比设 8 线程稳定性更好耗时也差不多设太多反而因为线程切换导致帧率不稳定。3. Compose 中接入相机与图像分析3.1 CameraX 与 Compose 的桥接方式CameraX 在 Android 上是 Jetpack 家族的库天然支持 Compose 项目。但相机预览组件PreviewView是个 View所以 Compose 侧必须用AndroidView包一层。下面是我项目里最核心的相机预览代码Composable fun CameraPreview( modifier: Modifier Modifier, analyzer: (ImageProxy) - Unit ) { val context LocalContext.current val lifecycleOwner LocalLifecycleOwner.current val previewView remember { PreviewView(context).apply { implementationMode PreviewView.ImplementationMode.COMPATIBLE scaleType PreviewView.ScaleType.FILL_CENTER } } AndroidView( modifier modifier, factory { previewView }, update { view - val cameraProviderFuture ProcessCameraProvider.getInstance(context) cameraProviderFuture.addListener({ val cameraProvider cameraProviderFuture.get() val previewUseCase Preview.Builder() .build() .also { it.setSurfaceProvider(view.surfaceProvider) } val analysisUseCase ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setTargetResolution(Size(640, 640)) .build() .also { it.setAnalyzer(Executors.newSingleThreadExecutor(), analyzer) } cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, previewUseCase, analysisUseCase ) }, ContextCompat.getMainExecutor(context)) } ) }这里我要重点说明三个关键细节第一ImplementationMode.COMPATIBLE。默认的PERFORMANCE模式在某些机型上会用 SurfaceViewSurfaceView 不参与 Compose 的动画和 Transformation叠加绘制检测框时会很别扭。COMPATIBLE模式用的是 TextureView虽然性能略低但能和 Compose 的层级正常协作。实时识别场景下帧率本身就不是顶级这里优先保证绘制正确。第二setTargetResolution(Size(640, 640))。这是为了让 CameraX 输出的分析帧分辨率接近 YOLO 的输入尺寸省去后续缩放的开销。注意 CameraX 不保证一定输出完全 640x640它只会选择最接近的尺寸所以预处理时还是要妥善处理缩放。第三STRATEGY_KEEP_ONLY_LATEST。这个背压策略非常关键。如果分析速度跟不上相机的出帧速度默认的BLOCK_PRODUCER会把相机管线阻塞住导致预览卡顿。KEEP_ONLY_LATEST会丢弃来不及处理的帧保证分析器始终处理最新的一帧这样预览流畅性优先识别结果稍有延迟可以接受。3.2 YUV 帧转 Bitmap 的常见坑CameraX 的ImageAnalysis默认输出ImageProxy格式是YUV_420_888。YOLO 的输入需要 RGB 数据所以中间必须做一次颜色空间转换。一个非常常见的坑是直接用 Bitmap 的getPixels或 canvas 把 ImageProxy 旋转后送进模型结果识别率低得离谱或者颜色完全不对。这是因为 YUV_420_888 的rowStride和pixelStride在不同手机上不一样就算同样标记为 YUV_420_888内存排列也有差别。只按固定偏移取值在某些机型上会拿到错位的数据。我验证了一个稳定的做法先用YuvImage把 YUV 数据编码成 JPEG再用 BitmapFactory 解码成 Bitmap。这个方案虽然多用一次编解码但省去了处理各种 stride 的麻烦兼容性极好private fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap { val nv21 convertYuv420888ToNv21(imageProxy) // 处理 rowStride / pixelStride val yuvImage YuvImage(nv21, ImageFormat.NV21, imageProxy.width, imageProxy.height, null) val outputStream ByteArrayOutputStream() yuvImage.compressToJpeg(Rect(0, 0, imageProxy.width, imageProxy.height), 100, outputStream) val byteArray outputStream.toByteArray() return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) }convertYuv420888ToNv21这个函数网上很多版本核心是用plane.rowStride和pixelStride正确地拷贝 Y、U、V 三个平面然后交错成 NV21。我在多个机型上测试包括一些冷门国产机这个方案没有出现过颜色错乱。如果你非常在意性能可以考虑只在字节层面做 YUV 到 RGB 的转换然后用Bitmap.createBitmap接收 RGB 数组同时在转换时利用imageProxy.imageInfo.rotationDegrees做一次旋转。不过这套代码需要自己维护调试成本较高我建议第一版先走 JPEG 中转跑通全链路后再优化。3.3 在 Compose 侧维护推理生命周期推理不能阻塞 UI 线程但 Compose 的 recomposition 又需要在主线程触发。所以我的架构是相机分析器的线程池负责把ImageProxy转成 Bitmap然后交给模型推理线程做前处理、推理、后处理推理结果通过MutableState发布到 ComposeCompose 的 Canvas 组件订阅这个 State自动绘制示意图大概是这样class YoloAnalyzer( private val detector: YoloDetector, private val onResult: (YoloResult) - Unit ) : ImageAnalysis.Analyzer { override fun analyze(imageProxy: ImageProxy) { try { val bitmap imageProxyToBitmap(imageProxy) val result detector.detect(bitmap) onResult(result) } catch (e: Exception) { Log.e(YoloAnalyzer, analyze failed, e) } finally { imageProxy.close() } } }detector.detect(bitmap)是同步阻塞的所以我必须保证分析器线程不在主线程。CameraX 的setAnalyzer传的 Executor 是单线程恰好满足了每帧排队执行、不会并发调用模型的要求。提醒一定不要忘记imageProxy.close()否则分析器很快耗尽相机缓冲区画面会直接卡死。我在联调时因为这个疏忽排障排了一整个下午。4. 推理与结果解码不只是把图片喂给模型4.1 YOLOv8 输出张量结构解析YOLOv8 的检测头输出是一个形状为(1, 84, 8400)的张量。这里关键数字的含义1batch size这里固定为 1844边界框的 x、y、w、h 80COCO 数据集类别数8400候选框数量这是模型在不同尺度特征图上生成的锚框总数这个输出结构跟 YOLOv5 不同。v5 的输出是(1, 25200, 85)其中最后一维是候选框属性一个框的所有信息排成一行。v8 则是通道优先的排列前 4 个通道是坐标5 到 84 通道是类别置信度最后一个维度是候选框索引。所以在 ONNX Runtime 里我们要把输出转换为(8400, 84)的形状然后对每一行做处理private fun parseOutput(output: ArrayFloatArray): ListDetectedObject { val result mutableListOfDetectedObject() val rows output[0].size / 4 // 8400 val channelSize 64 // 类别数 4这里是 84 - 4 80 类别 4 坐标 84但遍历时需要处理 // 实际更稳妥的写法是直接拿到 (1, 84, 8400) 然后转置。 ... }保险做法是拿到 ORT 的TensorInfo确认 shape再用FloatArray接收后手动转置。建议写一个专门的函数把输出张量归一化成一个结构列表每个元素包含cx, cy, w, h, classId, score。这里有一个很重要的数学细节YOLOv8 输出的坐标是相对于输入图像尺寸比如 640x640的归一化中心点坐标和宽高比值不是像素坐标。所以转成像素坐标时需要乘以对应的缩放系数。如果输入图像经过了 letterbox 缩放还需要扣除 padding 偏移。这一步很多教程含糊带过实际上是最容易出错的地方。4.2 NMS 去重解决重叠框问题YOLO 每个目标会生成大量候选框模型输出 8400 个预测中只有极少数是有效目标。我们需要做两步过滤第一步置信度阈值过滤。把低于 0.5 的候选框直接丢掉。阈值设置的学问很大设太高可能漏检设太低大量误检框会拖慢 NMS 的速度。我通常设为 0.5如果某个类别特别难检再单独调低到 0.3 左右。第二步NMS 非极大值抑制。对同一类别的重叠框保留置信度最高的那个把与之 IOU 超过一定阈值的框全部删掉。NMS 的 IOU 阈值常见值是 0.45。这个值越低删除的框越多对遮挡严重的物体容易误删值越高重叠的候选框保留越多可能出现一个物体两个框的情况。NMS 的标准实现并不复杂fun nms( objects: MutableListDetectedObject, iouThreshold: Float, scoreThreshold: Float ): ListDetectedObject { val filtered objects.filter { it.score scoreThreshold } .sortedByDescending { it.score } val keep mutableListOfDetectedObject() val suppressed BooleanArray(filtered.size) for (i in filtered.indices) { if (suppressed[i]) continue keep.add(filtered[i]) for (j in i 1 until filtered.size) { if (suppressed[j]) continue if (filtered[i].classId ! filtered[j].classId) continue val iou computeIou(filtered[i].box, filtered[j].box) if (iou iouThreshold) { suppressed[j] true } } } return keep }注意类别不同的时候不应该互相抑制。比如一个人站在自行车旁边人和自行车的框大面积重叠是合理的不能用人的框抑制自行车的框。很多教程里的 NMS 实现没判断 classId会导致检测结果少掉一个类别的框。computeIou的计算也很直接两个矩形的交集面积除以并集面积。实际开发中还要处理矩形坐标不是整数、可能越界等问题在这些地方加上保护判断可以避免后续 Canvas 绘制时的崩溃。4.3 从模型空间到 UI 坐标的换算这是整个项目里最隐蔽的坑。YOLO 处理的是缩放后的 640x640 正方形图像而 Compose 里的Canvas是PreviewView的实际显示区域两者宽高比完全不同。直接把模型输出的坐标映射到 Canvas框的位置会对不上。正确做法分三步第一步letterbox 反向映射。假设原图尺寸是 W x H缩放后模型输入是 640 x 640。缩放时我会用 letterbox 保持宽高比等比缩放后用灰色填充横幅方向的空间。检测框坐标要按这个公式换算回原图val ratio min(640.0 / bitmap.width, 640.0 / bitmap.height) val newWidth bitmap.width * ratio val newHeight bitmap.height * ratio val dx (640 - newWidth) / 2 val dy (640 - newHeight) / 2 // 模型输出 cx, cy, w, h 是相对于 640 输入图的 val x1 (cx - w / 2 - dx) / ratio val y1 (cy - h / 2 - dy) / ratio val x2 (cx w / 2 - dx) / ratio val y2 (cy h / 2 - dy) / ratio第二步原图坐标适配 Compose Canvas 尺寸。Canvas 的宽高跟PreviewView的实际显示尺寸一致如果PreviewView设置的是FILL_CENTER那相机画面会有裁剪。需要根据裁剪后的显示区域把原图坐标等比映射到 Canvas 坐标系。上面这两步最容易出错的地方是把裁剪和缩放混在一起。很多 DEMO 直接写了一个scaleX / scaleY就完事但在 FILL_CENTER、COORDINATE 旋转的叠加下结果一定会偏。我当时遇到的问题就是识别框比实际物体偏左上且大小整体偏小排查了一圈发现是省略了 letterbox 的dx/dy偏移。第三步镜像和旋转补偿。前置摄像头有镜像效果后置摄像头在默认情况下没有镜像但ImageAnalysis输出的旋转角度随机型不同可能是 0、90、180、270 度。CameraX 的imageInfo.rotationDegrees告诉我们正确的旋转方向转换时要先旋转再换算坐标。我建议先不用前置摄像头调这个逻辑后置摄像头调通了再处理前置。5. 结果可视化用 Compose Canvas 绘制检测框5.1 把检测结果变成 State要让 Canvas 随识别结果重绘我定义了一个数据类和一个 Statedata class DetectedObject( val box: RectF, val classId: Int, val className: String, val score: Float ) var detectedObjects by mutableStateOfListDetectedObject(emptyList())分析线程每完成一帧识别就把新列表赋值给detectedObjects。Compose 会检测到 State 变化触发 Canvas 所在区域的 recomposition然后调用Canvas的绘制代码。这个写法很符合 Compose 的声明式思想UI 是状态的函数。我们不需要手动维护检测结果更新了去调用某个 View 的 invalidate状态变了UI 自动变。5.2 Canvas 绘制矩形和标签在 Compose 里绘制一个带标签的矩形我封装成了一个ComposableComposable fun DetectionOverlay( objects: ListDetectedObject, modifier: Modifier Modifier ) { Canvas(modifier modifier.fillMaxSize()) { objects.forEach { obj - val left obj.box.left val top obj.box.top val right obj.box.right val bottom obj.box.bottom drawRect( color Color.Red, topLeft Offset(left, top), size Size(right - left, bottom - top), style Stroke(width 4.dp.toPx()) ) val text ${obj.className} ${(obj.score * 100).toInt()}% drawText( textMeasurer textMeasurer, text text, topLeft Offset(left, top - 20.dp.toPx()), style TextStyle(color Color.White) ) } } }注意drawText在 Compose 1.5 之后从drawIntoCanvas改成了TextMeasurer方案。需要先创建一个TextMeasurer然后传入drawText。在较旧版本的 Compose 里直接用drawIntoCanvasnativeCanvas.drawText也能凑合但字体渲染质量较差建议跟随新版 API。绘制的时候我没有用原生drawRoundRect加阴影因为实时场景下每帧都在重绘复杂的绘制指令会拖慢帧率。一个简单矩形加一条粗边就够了。5.3 绘制性能优化避免不必要的重绘在实际项目里我遇到过一个明显的卡顿问题识别结果一出来Canvas 重绘时整个预览画面会抖一下。排查后发现原因在 Compose 的重组范围太大。DetectionOverlay的父级包含了相机预览的AndroidView当detectedObjects更新时父级整个 recompose连相机预览组件也被重建了。解决方案是把 CameraPreview 和 DetectionOverlay 拆到两个独立的组件里用Box叠放并确保它们之间没有共享状态。这样结果更新时只有 DetectionOverlay 重组PreviewView 完全不受影响。写法上大概是这样Box(modifier Modifier.fillMaxSize()) { CameraPreview(analyzer analyzer) DetectionOverlay(objects detectedObjects) }另外如果检测结果每帧都在变比如视频流实时识别Canvas 重绘每一帧是必要的。但如果只是拍一张识别一张的静态场景可以加一个逻辑只有检测框的位置变化超过一定阈值才更新 State。这样可以显著减少无意义的重组。6. 性能优化实录CPU 推理慢与掉帧排查6.1 先量化耗时再谈优化很多人在集成 YOLO 后第一反应是我的模型太慢了然后陷入盲目调参。正确的第一步是给每个环节打点。我加了一个简单的计时器class YoloDetector { fun detect(bitmap: Bitmap): YoloResult { val t1 System.currentTimeMillis() val input preprocess(bitmap) // letterbox 归一化 val t2 System.currentTimeMillis() val outputs session.run(input) // onnx 推理 val t3 System.currentTimeMillis() val results postprocess(outputs) // NMS 等 val t4 System.currentTimeMillis() Log.d(YoloDetector, pre: ${t2 - t1}ms, infer: ${t3 - t2}ms, post: ${t4 - t3}ms) return results } }我当时的实测数据骁龙 778G、yolov8n、输入 640x640、FP32 ONNX、4 线程预处理约 30ms推理约 300ms后处理约 10ms。推理占了绝对大头所以在后面优化中我把重点放在推理环节。6.2 推理优化的三板斧第一板斧降低输入分辨率。这是立竿见影的办法。把输入从 640 降到 320推理耗时大约降为原来的四分之一从 300ms 降到 80ms 左右。代价是检测小物体的能力变差。如果你的业务场景是识别桌面上的大物体320 分辨率完全够用。我在项目里最后的折中是 416 分辨率推理约 130ms检测的稳定性还能接受。第二板斧调整线程数与轮询亲和性。ONNX Runtime 的setNumThreads在大多数设备上设 4 就能跑满性能再往上性能提升很小。更进阶的玩法是设置 CPU 亲和性把小核任务调度到大核上但 Android 上这种操作依赖厂商的调度策略收益不稳定我最终没有使用。第三板斧尝试 NNAPI delegate 或 TFLite INT8 量化。NNAPI 在部分手机上能调用 NPU/DSP推理速度会有量级提升。但 NNAPI 的兼容性参差不齐同一台设备不同系统版本的效果都可能不同。我实测在骁龙 778G 上NNAPI 加载 ONNX 模型后偶发崩溃最后还是回到 CPU 推理。TFLite INT8 量化是更稳妥的选择配合 NNAPI 或 GPU delegate 效果更好。如果你是纯 CPU 推理TFLite INT8 通常比 ONNX FP32 快 2 到 3 倍精度下降对于常见目标检测任务来说可接受。6.3 关于多进程加速的实验结论网上有一种说法是多进程跑 YOLO 可以加速还有人分享过CPU 多进程慢 1.4 秒的反向案例。我用实际项目做过一个简单的多进程实验把一张 640x640 图像切成左右两半分别跑两个 YOLO 进程期望用两核并行缩短推理时间。结论很明确完全不可行反而更慢了。原因是 YOLO 的卷积计算很难把一张图切成两半独立推理因为感受野跨越了切分边界直接切图会严重损失检测精度。如果要做数据并行一个进程测一张多个进程测不同图片那也只有在批处理场景下才有意义。单个相机帧逐帧分析多进程光是序列化输入、拷贝内存、进程间通信的开销就超过单进程推理时间了。这个实验的中间数据我记得很清楚单进程推理 640x640 的图大约 300ms用两个进程分别处理左右半图每个进程也要 200ms 左右加起来 400 多 ms而且在 Android 上额外维护进程生命周期、传递 Bitmap 内存复杂度增加不少。所以不要在移动端为了看起来并行去引入多进程那是从根上没理解瓶颈在哪儿。7. 完整集成中容易被忽略的坑7.1 镜像与旋转检测框偏移的元凶我项目里第一次真机测试时检测框往左上方偏出了大约 15% 的距离而且是所有框一致偏移。后来排查发现是PreviewView在FILL_CENTER缩放模式下相机画面被裁剪而我的坐标映射没有把这部分裁剪计算进去。还有一个更隐蔽的问题前置摄像头默认有镜像但 ImageAnalysis 输出的帧并不是镜像的。检测结果拿到的是一个相对原始帧的坐标如果 UI 层显示的是镜像后的画面那框就会左右对调。所以前置摄像头场景必须先判断LENS_FACING_FRONT然后手动做水平翻转。后置摄像头虽然默认不镜像但不同品牌的 ROM 对rotationDegrees的处理可能不同。我在某款国产机上就遇到过rotationDegrees90但实际画面并没有旋转的情况。所以测试时建议多拿几台机子试真机矩阵最好覆盖中端、低端、不同 SoC、不同 Android 版本。7.2 模型冷启动与内存分配ONNX Runtime 加载 ONNX 模型文件后会额外分配一部分图优化内存。模型越大这部分开销越高。在实际测试中yolov8n 冷启动加载大约耗时 500ms 到 1s占用内存大约 50MB 到 80MB。不要在点击开始识别按钮时才去创建 OrtSession最好在 App 启动后预加载。如果 App 有启动页把模型加载放在启动阶段是很好的选择。推理过程中还有一个容易被忽略的内存问题每帧都创建 Bitmap、FloatArray、ByteArray如果不回收或复用GC 压力会很大导致卡顿。我的做法是维护一个ArrayDeque做对象池或者更简单一点直接复用一块FloatArray缓冲区避免每帧都 new 大数组导致 GC。在 Compose 里detectedObjects频繁更新也可能触发大量对象分配所以每帧识别完尽量复用上一次的 List 对象或使用不可变对象减少 GC 压力。7.3 模型文件与包体积控制assets 目录下放一个十几 MB 的 ONNX 模型对 APK 体积影响不小。如果只做一个 demo 无所谓但正式产品建议用Split APK或者App Bundle按 ABI 分发只打包 arm64-v8a 的 so 库然后在应用内做完模型完整性校验。ONNX Runtime 的 Android so 库本身就包含 x86、armv7、arm64 多架构体积不小裁剪后可以省出一部分空间。此外我还建议在 App 首次启动时把模型从 assets 拷贝到私有目录然后在运行时从文件路径加载 OrtSession而不是直接要从 assets 流读取。这样可以避免每次启动都去流式解压也方便后续做模型热更新把新模型下载到私有目录后直接替换。7.4 关于二维码和嵌套滚动事件的干扰我们的实际场景里相机识别的同时页面可以做下拉刷新历史记录列表和缩放手势。这个需求引入了另一个坑Compose 的手势冲突。如果直接把DetectionOverlay叠在相机预览上那手势事件会被预览层截获页面就拉不动了。我的方案是给DetectionOverlay设置Modifier.pointerInput(Unit) { awaitPointerEventScope { while (true) { val event awaitPointerEvent() } } }然后把事件穿透给父级。这样检测框只负责显示不消费手势。代码很简洁但属于那种不踩坑根本不会去考虑的问题。写在后面把我这次集成过程中积累的小经验放在最后吧。如果你想在自己的 Compose 项目里快速跑起来我建议顺序是先用官方的 yolov8n.pt 导出 ONNX用 Android 端 ONNX Runtime 跑一张静态图从相册选图确认坐标换算没问题然后再接 CameraX 预览和分析实时显示检测框最后再考虑性能优化和模型量化。这个顺序能把变量控制到最小每一步都容易验证。我最大的体会是YOLO 集成真正的难点不在模型本身而在前后处理的细节——坐标怎么映射、NMS 怎么做、生命周期怎么管理。模型本身已经是开箱即用的范畴了把这些工程细节吃透你的识别功能才能真正稳定落地。如果这篇文章能帮你少走几个弯路那这半天的整理就算值了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价