☰
Compose 整合 YOLO:Android 端离线目标检测的完整实现指南
2026/10/14 17:59:10 网站建设 项目流程

去年有个项目需求:在完全离线的条件下,用手机拍一张桌面,几秒内识别出里面有哪些物体、各自在什么位置。我们选型时几乎没犹豫就定了 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 上完成图像识别,我的建议就一句话:选 YOLOv8n(nano)或 YOLOv8s(small)。

原因很直接:

  • 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 model=yolov8n.pt format=onnx opset=12 simplify=True

执行完会在同级目录生成yolov8n.onnx,大约 12MB(FP32)。这里几个参数值得解释一下:

  • opset=12:ONNX Runtime 对 opset 12 的支持最稳妥,太新的 opset 可能导致某些内置算子不被当前版本的 ORT 支持
  • simplify=True:会调用 onnxsim 对计算图做简化,减少冗余节点,推理时能省一点时间
  • 如果你要导出 TFLite,ultralytics 也支持,但它在内部会先转 ONNX 再转 TF,然后转 TFLite,中间依赖 TensorFlow 环境,比较折腾。我当时试过在 Windows 上导出 TFLite,卡了两个小时才发现是 TF 版本冲突。如果必须要 TFLite 格式,建议在干净的 Linux Docker 环境里做。

2.2 推理引擎选型:ONNX Runtime 还是 TFLite

这是所有人都会纠结的问题,我直接给出我的结论和理由。

ONNX Runtime(ORT)适合这样的场景:

  • 你主要用 YOLOv8 官方导出的模型
  • 不想被 TensorFlow Lite 的转换链路折磨
  • 需要调试模型内部某个节点的输出
  • 需要在多个平台(Windows、Linux、Android)用同一份模型文件

TFLite 适合这样的场景:

  • 你非常在意推理速度,需要走 NNAPI delegate 或者 GPU delegate
  • 你的模型是 TensorFlow 生态里训练的
  • 你需要用InterpreterAPI 而不是OrtSession

TFLite 在低端机上的 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模式在某些机型上会用 SurfaceView,SurfaceView 不参与 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发布到 Compose
  • Compose 的 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)的张量。这里关键数字的含义:

  • 1:batch size,这里固定为 1
  • 84:4(边界框的 x、y、w、h)+ 80(COCO 数据集类别数)
  • 8400:候选框数量,这是模型在不同尺度特征图上生成的锚框总数

这个输出结构跟 YOLOv5 不同。v5 的输出是(1, 25200, 85),其中最后一维是候选框属性,一个框的所有信息排成一行。v8 则是通道优先的排列,前 4 个通道是坐标,5 到 84 通道是类别置信度,最后一个维度是候选框索引。

所以在 ONNX Runtime 里,我们要把输出转换为(8400, 84)的形状,然后对每一行做处理:

private fun parseOutput(output: Array<FloatArray>): List<DetectedObject> { val result = mutableListOf<DetectedObject>() 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: MutableList<DetectedObject>, iouThreshold: Float, scoreThreshold: Float ): List<DetectedObject> { val filtered = objects.filter { it.score >= scoreThreshold } .sortedByDescending { it.score } val keep = mutableListOf<DetectedObject>() 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 随识别结果重绘,我定义了一个数据类和一个 State:

data class DetectedObject( val box: RectF, val classId: Int, val className: String, val score: Float ) var detectedObjects by mutableStateOf<List<DetectedObject>>(emptyList())

分析线程每完成一帧识别,就把新列表赋值给detectedObjects。Compose 会检测到 State 变化,触发 Canvas 所在区域的 recomposition,然后调用Canvas的绘制代码。

这个写法很符合 Compose 的声明式思想:UI 是状态的函数。我们不需要手动维护"检测结果更新了,去调用某个 View 的 invalidate",状态变了,UI 自动变。

5.2 Canvas 绘制矩形和标签

在 Compose 里绘制一个带标签的矩形,我封装成了一个@Composable:

@Composable fun DetectionOverlay( objects: List<DetectedObject>, 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 里直接用drawIntoCanvas+nativeCanvas.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的处理可能不同。我在某款国产机上就遇到过rotationDegrees=90但实际画面并没有旋转的情况。所以测试时建议多拿几台机子试,真机矩阵最好覆盖:中端、低端、不同 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 怎么做、生命周期怎么管理。模型本身已经是"开箱即用"的范畴了,把这些工程细节吃透,你的识别功能才能真正稳定落地。如果这篇文章能帮你少走几个弯路,那这半天的整理就算值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询