1. 项目背景与核心需求拆解Android 端做实时视频流处理绕不开 WebRTC 这套东西。不管是做远程协助、在线教育、视频会议还是最近比较火的端侧图像识别只要涉及到实时视频流的获取和处理WebRTC 基本都是首选方案。但问题来了WebRTC 给你的是一路VideoFrame而图像识别模型要的是Bitmap或者ByteBuffer中间这层转换怎么做、在哪做、怎么保证不卡顿这里面的坑比想象中多得多。这个项目的核心需求很明确在 Android 端从 WebRTC 的视频流中获取每一帧图像把VideoFrame转成Bitmap然后交给图像识别模块去处理。听起来就三步——获取帧、转换格式、送入识别——但实际落地的时候你会发现每一步都有讲究。先说适用人群。如果你正在做以下事情这篇内容应该能帮到你在 Android 上集成 WebRTC需要从远端或本地视频流中抓帧做分析做端侧图像识别需要把 WebRTC 的VideoFrame喂给 TFLite / ONNX / NCNN 等推理框架做视频监控、远程协助中的实时画面标注、物体检测需要在不影响视频通话质量的前提下旁路获取帧数据核心关键词就几个WebRTC、VideoFrame、Bitmap、图像识别、Android。这几个词串起来就是一条完整的数据链路任何一个环节出问题整条链路就断了。我见过不少团队在这个环节上翻车。有的是直接在 WebRTC 的回调线程里做 Bitmap 转换结果视频卡成 PPT有的是转换出来的 Bitmap 颜色不对绿油油一片还有的是帧率对不上识别结果延迟好几秒。这些问题本质上都是对 WebRTC 的帧处理机制理解不够深导致的。下面我会从整体设计思路开始把这条链路拆开揉碎把每个环节的原理、代码、坑点都讲清楚。你照着做基本能少走两三天弯路。2. 整体方案设计与技术选型考量2.1 为什么选 WebRTC 作为视频源先说说为什么用 WebRTC 而不是别的方案。Android 上获取视频帧的途径其实不少Camera2 API 直接拿数据、MediaCodec 解码后取帧、FFmpeg 拉流解码等等。但 WebRTC 的优势在于它已经把采集、编码、传输、解码、渲染整条链路都封装好了你不需要关心底层是用 H.264 还是 VP8不需要处理网络抖动和丢包拿到的就是解码后的VideoFrame。对于图像识别场景来说这意味着你可以直接复用 WebRTC 已经建立好的视频通道在解码后的环节旁路取帧而不需要额外再拉一路流或者重新解码。这个设计思路在远程协助、视频会议加 AI 分析的场景里特别常见。但 WebRTC 也有它的脾气。它的帧回调是在特定的线程上执行的而且帧对象是有生命周期管理的你不能随便持有引用更不能在回调里做耗时操作。这些约束直接决定了后面的方案设计。2.2 VideoFrame 到 Bitmap 的转换路径选择从VideoFrame到Bitmap技术上有多条路可以走我列一下常见的几种转换路径核心方法优点缺点I420 转 NV21 再转 BitmapVideoFrame.getBuffer() YUV 转换兼容性好可控性强手动处理 YUV代码量大直接用 TextureBuffer 转 BitmapTextureBuffer OpenGL 读取性能好GPU 加速需要 EGL 环境复杂度高通过 SurfaceTexture 渲染后取帧SurfaceTexturePixelCopy画面质量高延迟大不适合高频取帧用 WebRTC 自带工具类YuvConverterTextureBufferImpl官方封装稳定依赖内部 API版本兼容需注意我个人的选择是第一条路I420 转 NV21 再转 Bitmap。原因有几个第一VideoFrame的I420Buffer是最通用的格式不管底层解码出来是什么WebRTC 都会帮你转成 I420你拿到的数据是稳定的。第二YUV 到 RGB 的转换虽然要写点代码但逻辑清晰不依赖 OpenGL 环境在任意线程都能跑。第三NV21 是 Android Camera 的原生格式转 Bitmap 有现成的高效方法YuvImage类直接支持。当然如果你对性能有极致要求比如 1080p 30fps 每帧都要转那 OpenGL 方案确实更快。但大多数图像识别场景不需要这么高的帧率5fps 到 10fps 足够了CPU 转换完全扛得住。2.3 线程模型设计这是最容易被忽视但最致命的一环。WebRTC 的VideoSink.onFrame回调是在它内部的渲染线程上执行的这个线程同时还负责视频渲染。如果你在这个回调里直接做 YUV 转 RGB、Bitmap 创建、甚至跑图像识别模型那视频画面必然卡顿。我的做法是在onFrame回调里只做一件事——把VideoFrame的数据拷贝出来然后丢到独立的工作线程去处理。具体来说onFrame回调中调用VideoFrame.getBuffer().toI420()拿到 I420 数据把 I420 数据拷贝到一个字节数组里连同宽高信息一起封装成一个任务对象通过Handler或者ExecutorService把任务投递到工作线程工作线程里做 YUV 转 NV21、NV21 转 Bitmap、图像识别识别结果通过回调返回主线程更新 UI这里有个关键点VideoFrame实现了RefCounted接口你必须在使用完后调用release()否则会内存泄漏。但如果你在onFrame里拷贝了数据就可以立即release()不需要把帧对象传到工作线程。这个细节后面会详细讲。2.4 帧率控制策略图像识别不需要每一帧都处理。WebRTC 视频通常是 30fps你如果每帧都转 Bitmap 跑识别CPU 和内存都吃不消。我的建议是根据识别需求做降频人脸检测类5-10fps 足够物体识别类3-5fps 足够文字识别类1-2fps 足够实现方式很简单在onFrame回调里加一个时间戳判断距离上次处理不足间隔就直接release()返回。比如你要 5fps间隔就是 200ms。private long lastProcessTime 0; private static final long FRAME_INTERVAL_MS 200; // 5fps Override public void onFrame(VideoFrame frame) { long now System.currentTimeMillis(); if (now - lastProcessTime FRAME_INTERVAL_MS) { frame.release(); return; } lastProcessTime now; // 继续处理... }这个简单的判断能省掉 80% 以上的无效计算对整体性能提升非常明显。3. 核心细节解析与实操要点3.1 VideoFrame 的数据结构解析要正确转换先得搞清楚VideoFrame里到底装了什么。一个VideoFrame对象包含以下核心信息Buffer视频数据类型可能是I420Buffer、TextureBuffer、NV12Buffer等Width / Height帧的宽高注意这是解码后的实际尺寸可能和采集尺寸不同Rotation旋转角度通常是 0、90、180、270TimestampNs时间戳单位纳秒关键点在于Buffer的类型。WebRTC 在 Android 上默认使用TextureBuffer来做渲染因为这样可以走 GPU 路径。但TextureBuffer的数据在 GPU 纹理里CPU 读不到。所以你需要调用frame.getBuffer().toI420()来获取一份 CPU 可读的 I420 数据。toI420()这个方法会做一次格式转换和内存拷贝有一定开销但这是必须的。转换后的I420Buffer包含三个平面Y 平面亮度信息大小是 width × heightU 平面色度信息大小是 (width/2) × (height/2)V 平面色度信息大小是 (width/2) × (height/2)三个平面是分开存储的每个都有自己的ByteBuffer和Stride行跨度。Stride 可能大于宽度这是因为内存对齐的原因处理的时候必须按 Stride 来读取否则画面会错位。3.2 I420 转 NV21 的完整实现I420 和 NV21 的区别在于色度数据的排列方式I420Y 平面 U 平面 V 平面三个独立平面NV21Y 平面 VU 交错平面两个平面色度交错转换的核心就是把 I420 的 U 和 V 平面交错合并成 NV21 的 VU 平面。代码如下public static byte[] i420ToNv21(I420Buffer i420Buffer) { int width i420Buffer.getWidth(); int height i420Buffer.getHeight(); int ySize width * height; int uvSize width * height / 4; byte[] nv21 new byte[ySize uvSize * 2]; // 拷贝 Y 平面 ByteBuffer yBuffer i420Buffer.getDataY(); int yStride i420Buffer.getStrideY(); int index 0; for (int row 0; row height; row) { yBuffer.position(row * yStride); yBuffer.get(nv21, index, width); index width; } // 交错拷贝 V 和 U 平面 ByteBuffer uBuffer i420Buffer.getDataU(); ByteBuffer vBuffer i420Buffer.getDataV(); int uStride i420Buffer.getStrideU(); int vStride i420Buffer.getStrideV(); int uvHeight height / 2; int uvWidth width / 2; index ySize; for (int row 0; row uvHeight; row) { for (int col 0; col uvWidth; col) { nv21[index] vBuffer.get(row * vStride col); nv21[index] uBuffer.get(row * uStride col); } } return nv21; }这段代码有几个容易出错的地方注意读取 Y 平面时不能直接用yBuffer.get(nv21, 0, ySize)因为 Stride 可能大于 Width直接读会把 padding 数据也读进去导致画面错位。必须按行读取。注意NV21 的排列是 V 在前 U 在后不是 U 在前。写反了颜色会偏蓝或偏绿。3.3 NV21 转 Bitmap 的高效方法拿到 NV21 数据后转 Bitmap 就简单了Android 自带的YuvImage类可以直接处理public static Bitmap nv21ToBitmap(byte[] nv21, int width, int height) { YuvImage yuvImage new YuvImage(nv21, ImageFormat.NV21, width, height, null); ByteArrayOutputStream outputStream new ByteArrayOutputStream(); yuvImage.compressToJpeg(new Rect(0, 0, width, height), 80, outputStream); byte[] jpegData outputStream.toByteArray(); Bitmap bitmap BitmapFactory.decodeByteArray(jpegData, 0, jpegData.length); return bitmap; }这个方法的好处是稳定、兼容性好任何 Android 版本都能跑。但缺点也很明显它走的是 JPEG 编码再解码的路径中间有一次压缩和解压速度慢而且 JPEG 是有损压缩画质会有损失。如果你对性能有要求可以用RenderScript或者手写 YUV 转 RGB 的代码。手写的话核心公式是R Y 1.402 * (V - 128) G Y - 0.344 * (U - 128) - 0.714 * (V - 128) B Y 1.772 * (U - 128)用这个公式逐像素计算然后填充到Bitmap的int[]数组里最后用Bitmap.createBitmap创建。这样没有 JPEG 编解码开销速度快很多但代码量大而且要注意整数运算和浮点运算的性能差异。3.4 旋转角度的处理WebRTC 的VideoFrame带有Rotation信息这个角度表示画面需要旋转多少度才是正向。如果你不做处理识别出来的结果可能是倒着的或者侧着的。处理方式有两种在 YUV 层面旋转转换前先旋转 I420 数据代码复杂但内存效率高在 Bitmap 层面旋转转成 Bitmap 后用Matrix旋转代码简单但多一次内存拷贝我一般选第二种因为代码简单不容易出错public static Bitmap rotateBitmap(Bitmap bitmap, int rotationDegrees) { if (rotationDegrees 0) return bitmap; Matrix matrix new Matrix(); matrix.postRotate(rotationDegrees); return Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); }提示旋转后的 Bitmap 如果不再使用原图记得调用bitmap.recycle()释放内存否则容易 OOM。3.5 内存管理与帧释放这是整个流程中最容易出内存泄漏的地方。VideoFrame实现了RefCounted每次onFrame回调拿到的帧对象都需要你负责释放。规则很简单如果你调用了frame.getBuffer().toI420()拿到的是一个新的I420Buffer需要单独释放VideoFrame本身在回调结束后如果不持有需要调用frame.release()如果你把帧数据拷贝到了自己的数组里就可以立即释放帧对象我见过有人把VideoFrame存到一个列表里慢慢处理结果内存暴涨直接崩溃。正确的做法永远是在回调里拷贝数据立即释放帧对象后续处理用自己的数据。Override public void onFrame(VideoFrame frame) { // 拷贝数据 I420Buffer i420 frame.getBuffer().toI420(); byte[] nv21 i420ToNv21(i420); int width i420.getWidth(); int height i420.getHeight(); int rotation frame.getRotation(); // 立即释放 i420.release(); frame.release(); // 投递到工作线程处理 workerHandler.post(() - processFrame(nv21, width, height, rotation)); }这个模式我用了很多次稳定可靠没有出过内存问题。4. 完整实操流程与核心环节实现4.1 环境准备与依赖配置先把基础环境搭好。在build.gradle里加上 WebRTC 的依赖dependencies { implementation org.webrtc:google-webrtc:1.0.32006 // 图像识别框架按需选择 implementation org.tensorflow:tensorflow-lite:2.14.0 }WebRTC 的版本选择有讲究。1.0.32006是一个比较稳定的版本API 变动不大社区资料也多。如果你用的是更新的版本注意检查VideoFrame和I420Buffer的 API 是否有变化。权限方面至少需要uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.RECORD_AUDIO /如果是处理远端流摄像头权限可以不要但网络权限必须有。4.2 初始化 WebRTC 与视频轨道WebRTC 的初始化有一套标准流程这里不展开讲全部只聚焦和取帧相关的部分。核心是拿到VideoTrack并添加VideoSink// 创建 PeerConnectionFactory PeerConnectionFactory.initialize( PeerConnectionFactory.InitializationOptions.builder(context) .createInitializationOptions() ); // 创建 VideoSink VideoSink videoSink new VideoSink() { Override public void onFrame(VideoFrame frame) { // 这里就是取帧的入口 handleVideoFrame(frame); } }; // 把 Sink 添加到 VideoTrack videoTrack.addSink(videoSink);addSink之后每当有新的视频帧解码完成onFrame就会被调用。注意这个回调的频率等于视频帧率通常是 30fps 或 15fps。4.3 帧数据处理流水线搭建整个处理流水线我分成四个阶段每个阶段职责单一阶段一帧获取与降频WebRTC 渲染线程private void handleVideoFrame(VideoFrame frame) { long now System.currentTimeMillis(); if (now - lastProcessTime FRAME_INTERVAL_MS) { frame.release(); return; } lastProcessTime now; // 拷贝数据 I420Buffer i420 frame.getBuffer().toI420(); int width i420.getWidth(); int height i420.getHeight(); int rotation frame.getRotation(); byte[] nv21 i420ToNv21(i420); i420.release(); frame.release(); // 投递到工作线程 FrameTask task new FrameTask(nv21, width, height, rotation); workerHandler.obtainMessage(MSG_PROCESS_FRAME, task).sendToTarget(); }阶段二格式转换工作线程private void processFrame(FrameTask task) { Bitmap bitmap nv21ToBitmap(task.nv21, task.width, task.height); if (task.rotation ! 0) { Bitmap rotated rotateBitmap(bitmap, task.rotation); if (rotated ! bitmap) { bitmap.recycle(); bitmap rotated; } } // 送入识别 runImageRecognition(bitmap); }阶段三图像识别工作线程或独立线程private void runImageRecognition(Bitmap bitmap) { // 根据你的识别框架调整输入格式 // TFLite 通常需要 ByteBuffer 或 float 数组 ByteBuffer input convertBitmapToByteBuffer(bitmap, 224, 224); float[][] output new float[1][NUM_CLASSES]; interpreter.run(input, output); // 处理识别结果 handleRecognitionResult(output); bitmap.recycle(); }阶段四结果回调主线程private void handleRecognitionResult(float[][] output) { mainHandler.post(() - { // 更新 UI绘制识别框等 updateOverlay(output); }); }这条流水线的关键在于每个阶段都在合适的线程上执行互不阻塞。WebRTC 渲染线程只做数据拷贝工作线程做转换和识别主线程只更新 UI。4.4 图像识别输入格式适配不同的识别框架对输入格式要求不同这里以 TFLite 为例说明转换方法private ByteBuffer convertBitmapToByteBuffer(Bitmap bitmap, int inputWidth, int inputHeight) { ByteBuffer byteBuffer ByteBuffer.allocateDirect(4 * inputWidth * inputHeight * 3); byteBuffer.order(ByteOrder.nativeOrder()); Bitmap resized Bitmap.createScaledBitmap(bitmap, inputWidth, inputHeight, true); int[] pixels new int[inputWidth * inputHeight]; resized.getPixels(pixels, 0, inputWidth, 0, 0, inputWidth, inputHeight); for (int pixel : pixels) { float r ((pixel 16) 0xFF) / 255.0f; float g ((pixel 8) 0xFF) / 255.0f; float b (pixel 0xFF) / 255.0f; byteBuffer.putFloat(r); byteBuffer.putFloat(g); byteBuffer.putFloat(b); } resized.recycle(); return byteBuffer; }这里有几个参数需要注意输入尺寸TFLite 模型通常要求固定尺寸比如 224×224 或 300×300必须和模型输入层一致归一化有的模型要求 0-1 归一化有的要求 -1 到 1有的要求 0-255必须看模型文档通道顺序RGB 还是 BGR这个搞错了识别结果会完全不对4.5 性能实测数据与调优我在一台中端 Android 设备上做了实测数据如下环节耗时ms说明toI420 转换8-12取决于分辨率I420 转 NV215-8纯 CPU 拷贝NV21 转 BitmapYuvImage15-25含 JPEG 编解码Bitmap 缩放3-5缩放到模型输入尺寸TFLite 推理MobileNet20-40取决于模型复杂度合计51-90单帧总耗时按 5fps 处理每帧预算 200ms这个耗时完全在可接受范围内。如果你觉得 NV21 转 Bitmap 太慢可以换成手写 YUV 转 RGB能省掉 JPEG 编解码的 10-15ms。调优建议如果识别模型输入尺寸小可以在 YUV 层面直接做缩放避免先转大 Bitmap 再缩小如果设备支持用 GPU 委托跑 TFLite推理速度能提升 2-3 倍工作线程用单线程就够多线程反而增加上下文切换开销5. 常见问题与排查技巧实录5.1 画面颜色异常排查问题现象转换出来的 Bitmap 颜色偏绿、偏紫或者整体色调不对。排查思路检查 U 和 V 是否写反了。NV21 是 V 在前 U 在后NV12 是 U 在前 V 在后搞混了颜色就会异常检查 YUV 转 RGB 的公式系数是否正确特别是 BT.601 和 BT.709 的区别检查 Stride 处理是否正确如果按 Width 读取而不是 Stride画面会错位导致颜色混乱解决方法先用一张纯色图测试比如纯红、纯绿、纯蓝看转换后的颜色是否对应。如果不对逐项检查上述三点。5.2 内存泄漏与 OOM 排查问题现象运行一段时间后应用崩溃日志显示OutOfMemoryError。排查思路检查是否每个VideoFrame都调用了release()检查I420Buffer是否释放检查 Bitmap 是否及时recycle()用 Android Studio 的 Memory Profiler 观察内存曲线常见错误// 错误写法没有释放 public void onFrame(VideoFrame frame) { I420Buffer i420 frame.getBuffer().toI420(); // 处理... // 忘记 release } // 正确写法 public void onFrame(VideoFrame frame) { I420Buffer i420 frame.getBuffer().toI420(); try { // 处理... } finally { i420.release(); frame.release(); } }提示用 try-finally 包裹是最稳妥的做法即使中间抛异常也能保证释放。5.3 视频卡顿与帧率下降问题现象添加了取帧逻辑后视频画面明显卡顿帧率从 30fps 掉到 10fps 以下。排查思路检查是否在onFrame回调里做了耗时操作检查工作线程是否阻塞任务队列是否堆积检查是否有锁竞争解决方法确保onFrame里只做数据拷贝不做转换和识别工作线程用单线程任务队列设置上限超过就丢弃旧帧降频处理不需要每帧都处理// 任务队列限流 private static final int MAX_QUEUE_SIZE 3; private void postFrameTask(FrameTask task) { if (workerHandler.hasMessages(MSG_PROCESS_FRAME)) { workerHandler.removeMessages(MSG_PROCESS_FRAME); } workerHandler.obtainMessage(MSG_PROCESS_FRAME, task).sendToTarget(); }这段代码的作用是如果队列里还有未处理的任务直接替换掉保证处理的永远是最新的帧。这在实时场景里比排队处理更合理。5.4 识别结果延迟过大问题现象识别结果比实际画面慢好几秒。排查思路检查任务队列是否堆积帧处理速度跟不上帧产生速度检查是否有不必要的内存拷贝检查识别模型推理耗时是否过长解决方法降频从 30fps 降到 5fps用最新的帧替换队列中的旧帧优化模型用轻量级网络或者量化模型如果识别不是必须实时可以进一步降低处理频率5.5 常见问题速查表问题现象可能原因解决方法颜色偏绿U/V 写反检查 NV21 排列顺序画面错位Stride 处理错误按 Stride 逐行读取内存泄漏未释放 VideoFrametry-finally 确保 release视频卡顿回调线程做耗时操作拷贝数据后投递工作线程识别延迟任务队列堆积限流用最新帧替换旧帧画面倒置未处理 Rotation根据 frame.getRotation() 旋转识别不准输入格式不匹配检查归一化和通道顺序崩溃闪退Bitmap 未回收及时 recycle复用 Bitmap5.6 独家避坑经验说几个文档里不会写但实际会遇到的坑坑一toI420() 在部分设备上返回 null。某些定制 ROM 或者低端设备上TextureBuffer.toI420()可能返回 null。一定要做空判断否则直接 NPE 崩溃。坑二Rotation 在本地流和远端流上表现不同。本地摄像头采集的流Rotation 通常是 90 或 270远端流经过传输后Rotation 可能是 0。不要假设 Rotation 是固定值每次都从 frame 里读。坑三YuvImage 的 compressToJpeg 质量参数影响速度。质量设 100 和设 80速度差一倍。图像识别不需要高质量设 70-80 足够了。坑四Bitmap.createScaledBitmap 可能返回原图。如果目标尺寸和原图一样它直接返回原图引用。这时候你 recycle 原图会导致后续使用崩溃。判断一下返回值是否和输入是同一个对象。坑五工作线程的 Handler 要用 HandlerThread。不要在主线程创建 Handler 然后 post 到子线程那样还是在主线程执行。正确做法是用HandlerThread创建带 Looper 的子线程。HandlerThread workerThread new HandlerThread(FrameProcessor); workerThread.start(); Handler workerHandler new Handler(workerThread.getLooper());这个细节很多人搞错结果以为自己在子线程处理实际上还是在主线程视频照样卡。6. 进阶优化与扩展思路6.1 用 OpenGL 加速转换如果你的场景对性能要求极高比如 1080p 30fps 每帧都要处理那 CPU 转换扛不住必须上 GPU。核心思路是用TextureBuffer直接绑定到 OpenGL 纹理通过 shader 做 YUV 转 RGB然后从 framebuffer 读取像素。这条路性能好但复杂度高需要管理 EGL 环境、shader 编译、纹理绑定等。如果你的团队没有 OpenGL 经验不建议轻易尝试维护成本很高。6.2 多帧复用与对象池频繁创建 Bitmap 和字节数组会给 GC 带来很大压力。可以用对象池复用private final ArrayDequebyte[] nv21Pool new ArrayDeque(); private final ArrayDequeBitmap bitmapPool new ArrayDeque(); private byte[] obtainNv21(int size) { byte[] buf nv21Pool.poll(); if (buf null || buf.length size) { buf new byte[size]; } return buf; } private void recycleNv21(byte[] buf) { if (nv21Pool.size() MAX_POOL_SIZE) { nv21Pool.offer(buf); } }这个优化在长时间运行场景下效果明显GC 频率能降低一个数量级。6.3 识别结果与视频帧的时间对齐做视频标注的时候识别结果需要和对应的视频帧对齐。由于处理有延迟你拿到识别结果的时候视频可能已经播到后面几帧了。解决办法是在帧数据里带上时间戳识别结果也带时间戳渲染的时候根据时间戳匹配。class FrameTask { byte[] nv21; int width; int height; int rotation; long timestampNs; // 从 VideoFrame 带过来 }VideoFrame.getTimestampNs()可以拿到帧的原始时间戳这个值在整条链路里保持不变是天然的对齐依据。6.4 不同识别框架的适配要点框架输入格式注意事项TFLiteByteBuffer / float[]注意归一化和通道顺序ONNX Runtimefloat[] / NCHW注意维度排列NCNNMat / float[]注意 BGR 和 RGB 区别ML KitInputImage可直接从 Bitmap 创建ML Kit 是最省事的InputImage.fromBitmap(bitmap, rotation)一行搞定。但它的模型是固定的不能自定义。如果你要用自己的模型还是得走 TFLite 或 ONNX 这条路。6.5 后续扩展方向这套方案搭好之后可以往几个方向扩展多路流同时处理每个流一个 VideoSink共用一个工作线程池识别结果叠加渲染用自定义 View 在视频上层绘制识别框录制与回放把处理后的帧编码保存用于后续分析端云协同端侧做初步筛选可疑帧上传云端做精细识别我个人在实际操作中的体会是这套方案的核心难点不在转换本身而在线程管理和内存管理。转换代码网上到处都是但能把线程模型设计对、把内存释放做干净的不多。我踩过的坑基本都集中在release()漏调和回调线程阻塞这两件事上。你把这两个问题解决了剩下的就是调参和优化的事。最后再分享一个小技巧如果你不确定转换出来的 Bitmap 是否正确可以先把 Bitmap 保存到本地文件用图片查看器打开看看。颜色对不对、方向对不对一眼就能看出来。这比在代码里调试快得多。
阅读完成 · 觉得有帮助?