1. 项目缘起与整体设计思路做过Android相机或者视频处理的朋友大概率都跟NV21格式的数据打过交道。相机预览回调、视频解码输出、人脸识别SDK的输入很多时候拿到的就是一帧NV21的byte数组。但UI层要显示、要处理最终还是要落到Bitmap上。这中间就涉及两个高频操作NV21转Bitmap以及两张Bitmap按透明度混合。我最早做这类转换的时候用的是Java层的YuvImage压缩成JPEG再解码的路子。这个方案能跑但问题很明显一次转换要经历YUV到JPEG编码、再JPEG解码到RGB两次大开销一帧1080P的数据在低端机上能干到几十毫秒预览直接掉帧。后来换成纯Java的YUV转RGB逐像素计算虽然省掉了编解码但一个像素一个像素地算CPU占用依然感人而且GC压力大。再后来接触到RenderScript情况就不一样了。RenderScript是Android平台上一套面向异构计算的框架能把计算密集的并行任务自动分配到CPU多核或者GPU上执行。它的核心优势在于你写的是类似C的kernel代码运行时由驱动决定跑在哪开发者不用管线程调度和SIMD优化。对于NV21转Bitmap这种典型的逐像素并行任务RenderScript几乎是量身定做的。这个工具类要解决的核心问题就三个性能NV21转Bitmap要快不能成为预览或处理的瓶颈通用两张Bitmap混合要支持任意尺寸、任意透明度且混合逻辑可配置易用对外暴露的接口要简单内部把RenderScript的初始化、Allocation管理、脚本销毁这些脏活全包掉。为什么把这两个功能放一个工具类里因为实际项目里它们经常成对出现。比如做相机滤镜先把NV21转成Bitmap再叠一层水印或者贴纸Bitmap做透明度混合一条链路走下来。放一起可以共享RenderScript上下文减少重复初始化开销。提示RenderScript从Android 12API 31开始被标记为废弃官方推荐迁移到Vulkan或OpenGL ES。但在大量存量设备和存量项目里RenderScript依然是可用的且对API 11以上的兼容性极好。本文聚焦工具类本身的实现思路迁移方案会在后面章节讨论。2. NV21格式与RenderScript核心原理拆解2.1 NV21的内存布局到底长什么样要转得对先得搞清楚NV21的排列。NV21属于YUV420SP的一种它的内存布局是前width * height个字节全是Y分量逐行排列紧接着是width * height / 2个字节的UV分量按VU VU VU...交错排列且UV平面的宽高都是Y平面的一半。这里有个容易踩的坑NV21是V在前U在后NV12是U在前V在后。很多相机回调给的是NV21但如果你按NV12去解析出来的颜色会偏。判断方法很简单看文档或者打印前几个UV字节对比一下。YUV到RGB的转换公式有好几套常见的是BT.601和BT.709。相机预览一般用BT.601视频解码可能用BT.709。工具类里我默认用BT.601因为兼容性最好同时留了参数让调用方切换。转换公式整数近似版大致是R Y 1.402 * (V - 128) G Y - 0.344 * (U - 128) - 0.714 * (V - 128) B Y 1.772 * (U - 128)实际写kernel的时候会用定点数运算代替浮点避免精度和性能问题。2.2 为什么选RenderScript而不是别的方案把几种常见方案摆一起对比一下选型逻辑就清楚了方案性能代码复杂度兼容性适用场景YuvImage压缩JPEG差低好对性能无要求的场景纯Java逐像素中中好小图、低频调用libyuvNDK优高需打包so高性能要求OpenGL ES着色器优高好已有GL上下文RenderScript优中API 11通用并行计算RenderScript的甜点区在于不用引入native库不用管理GL上下文代码量适中性能接近native。对于大多数中小型项目这是性价比最高的选择。libyuv性能确实更好但要处理ABI、打包so、JNI桥接维护成本高不少。OpenGL ES性能也好但如果你项目里本来没有GL环境为了转个格式搭一套EGL上下文属于杀鸡用牛刀。2.3 RenderScript的Allocation与kernel机制RenderScript里两个核心概念Allocation和kernel。Allocation可以理解成一块托管的内存它知道自己的元素类型和维度。你可以把Java层的byte数组、Bitmap的数据塞进Allocation也可以把Allocation的数据读回Java层。关键点在于Allocation的数据在RS运行时里是跨CPU/GPU可见的驱动会自动处理同步。kernel就是你写的并行函数它对Allocation里的每个元素或每个单元格执行一次。比如NV21转Bitmap我传入一个width * height * 3 / 2大小的byte Allocation存NV21输出一个width * height的uchar4Allocation存RGBAkernel里根据当前像素坐标去Y平面和UV平面取值算出RGB写进输出。这里有个性能关键点kernel里访问输入Allocation的索引计算要尽量简单。因为每个线程都要算一遍复杂的索引运算会拖慢整体。我一般把width、height作为参数传进kernel索引用x y * width这种线性方式。3. 工具类的完整实现与关键代码3.1 类结构设计与初始化工具类我设计成单例加静态方法的形式内部持有RenderScript实例和两个ScriptC对象一个负责NV21转Bitmap一个负责混合。为什么用单例因为RenderScript的创建和销毁开销不小频繁创建会导致明显的卡顿。整个App生命周期内共享一个实例就够了。初始化流程大致是在Application或首次调用时通过RenderScript.create(context)拿到RS实例加载编译好的.rs脚本生成对应的ScriptC_xxx对象缓存起来后续调用直接复用。public class RsImageUtils { private static RenderScript sRs; private static ScriptC_nv21ToBitmap sNv21Script; private static ScriptC_blend sBlendScript; public static void init(Context context) { if (sRs null) { sRs RenderScript.create(context.getApplicationContext()); sNv21Script new ScriptC_nv21ToBitmap(sRs); sBlendScript new ScriptC_blend(sRs); } } }注意RenderScript.create()传入ApplicationContext避免持有Activity导致内存泄漏。这个坑我见过不止一次工具类持有Activity Context页面销毁后RS实例还在直接泄漏。3.2 NV21转Bitmap的kernel实现先看.rs文件里的kernel。核心逻辑是根据输出像素坐标(x, y)算出Y、U、V三个分量的索引做YUV到RGB转换写入输出。#pragma version(1) #pragma rs java_package_name(com.example.rsimage) rs_allocation gInputYUV; int gWidth; int gHeight; uchar4 __attribute__((kernel)) nv21ToRgba(uint32_t x, uint32_t y) { int yIndex y * gWidth x; int uvIndex gWidth * gHeight (y 1) * gWidth (x ~1); int uIndex uvIndex 1; int vIndex uvIndex; int Y (int)((uchar*)rsGetElementAt(gInputYUV, yIndex))[0]; int U (int)((uchar*)rsGetElementAt(gInputYUV, uIndex))[0] - 128; int V (int)((uchar*)rsGetElementAt(gInputYUV, vIndex))[0] - 128; int R Y (int)(1.402f * V); int G Y - (int)(0.344f * U 0.714f * V); int B Y (int)(1.772f * U); R clamp(R, 0, 255); G clamp(G, 0, 255); B clamp(B, 0, 255); return (uchar4){R, G, B, 255}; }这里有几个细节值得说UV索引的x ~1因为UV是2x2下采样的同一对UV对应2x2个Y像素所以x要向下取偶。x ~1等价于x / 2 * 2但位运算更快。rsGetElementAt返回的是指针对byte Allocation来说取出来是uchar*解引用拿值。这里其实可以优化用rsGetElementAt_uchar直接取值省一次指针解引用。clamp函数RS内置了clamp不用自己写。溢出保护必须做否则颜色会乱。Java层调用public static Bitmap nv21ToBitmap(byte[] nv21, int width, int height) { Type.Builder yuvType new Type.Builder(sRs, Element.U8(sRs)) .setX(width * height * 3 / 2); Allocation yuvAlloc Allocation.createTyped(sRs, yuvType.create(), Allocation.USAGE_SCRIPT); yuvAlloc.copyFrom(nv21); Type.Builder rgbaType new Type.Builder(sRs, Element.RGBA_8888(sRs)) .setX(width).setY(height); Allocation rgbaAlloc Allocation.createTyped(sRs, rgbaType.create(), Allocation.USAGE_SCRIPT); sNv21Script.set_gInputYUV(yuvAlloc); sNv21Script.set_gWidth(width); sNv21Script.set_gHeight(height); sNv21Script.forEach_nv21ToRgba(rgbaAlloc); Bitmap bitmap Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); rgbaAlloc.copyTo(bitmap); yuvAlloc.destroy(); rgbaAlloc.destroy(); return bitmap; }3.3 两张Bitmap透明度混合的kernel实现混合的kernel更简单输入两张RGBA图和一个全局alpha输出混合结果。混合公式用标准的source-overout src * alpha dst * (1 - alpha)#pragma version(1) #pragma rs java_package_name(com.example.rsimage) rs_allocation gSrc; rs_allocation gDst; float gAlpha; uchar4 __attribute__((kernel)) blend(uchar4 dstPixel, uint32_t x, uint32_t y) { uchar4 srcPixel rsGetElementAt_uchar4(gSrc, x, y); float a gAlpha; float invA 1.0f - a; uchar4 out; out.r (uchar)(srcPixel.r * a dstPixel.r * invA); out.g (uchar)(srcPixel.g * a dstPixel.g * invA); out.b (uchar)(srcPixel.b * a dstPixel.b * invA); out.a 255; return out; }注意这里kernel的写法第一个参数是输入Allocation的当前元素返回值是输出。这是RS的一种kernel签名形式适合输入输出同尺寸的场景。gDst其实是通过第一个参数隐式传入的gSrc是显式传入的另一张图。Java层public static Bitmap blendBitmap(Bitmap src, Bitmap dst, float alpha) { if (src.getWidth() ! dst.getWidth() || src.getHeight() ! dst.getHeight()) { src Bitmap.createScaledBitmap(src, dst.getWidth(), dst.getHeight(), true); } Allocation srcAlloc Allocation.createFromBitmap(sRs, src); Allocation dstAlloc Allocation.createFromBitmap(sRs, dst); Allocation outAlloc Allocation.createTyped(sRs, srcAlloc.getType()); sBlendScript.set_gSrc(srcAlloc); sBlendScript.set_gAlpha(alpha); sBlendScript.forEach_blend(dstAlloc, outAlloc); Bitmap result Bitmap.createBitmap(dst.getWidth(), dst.getHeight(), Bitmap.Config.ARGB_8888); outAlloc.copyTo(result); srcAlloc.destroy(); dstAlloc.destroy(); outAlloc.destroy(); return result; }提示Allocation.createFromBitmap默认会创建带USAGE_SHARED的Allocation如果Bitmap是可变的数据可能直接共享内存。但为了安全我一般显式指定USAGE_SCRIPT避免意外的内存共享导致数据被改。4. 性能优化与实测数据4.1 不同方案的耗时对比我在一台中端机骁龙6系上做了实测输入是1920x1080的NV21数据转成Bitmap跑100次取平均方案平均耗时CPU占用备注YuvImage压缩JPEG42ms高有画质损失纯Java逐像素28ms很高GC频繁RenderScript6ms中首次有初始化开销libyuv4ms低需打包soRenderScript比纯Java快了将近5倍接近libyuv的水平但代码量和维护成本低得多。这个差距在低端机上更明显因为RS能利用多核而纯Java单线程跑满一个核就到头了。4.2 Allocation复用的重要性上面示例代码里每次调用都创建和销毁Allocation这在低频调用时没问题但如果是相机预览这种每秒30帧的场景频繁创建Allocation会导致明显的抖动。优化方法是缓存Allocation尺寸不变时复用。private static Allocation sCachedYuvAlloc; private static Allocation sCachedRgbaAlloc; private static int sCachedWidth, sCachedHeight; private static void ensureAllocations(int width, int height) { if (sCachedYuvAlloc null || sCachedWidth ! width || sCachedHeight ! height) { if (sCachedYuvAlloc ! null) sCachedYuvAlloc.destroy(); if (sCachedRgbaAlloc ! null) sCachedRgbaAlloc.destroy(); Type.Builder yuvType new Type.Builder(sRs, Element.U8(sRs)) .setX(width * height * 3 / 2); sCachedYuvAlloc Allocation.createTyped(sRs, yuvType.create(), Allocation.USAGE_SCRIPT); Type.Builder rgbaType new Type.Builder(sRs, Element.RGBA_8888(sRs)) .setX(width).setY(height); sCachedRgbaAlloc Allocation.createTyped(sRs, rgbaType.create(), Allocation.USAGE_SCRIPT); sCachedWidth width; sCachedHeight height; } }复用之后1080P转换的耗时能再降1-2ms而且帧率稳定性大幅提升不会出现偶发的卡顿尖峰。4.3 混合操作的alpha预乘问题做透明度混合时有个容易被忽略的点Bitmap的alpha通道是否预乘。Android的Bitmap默认是非预乘的即RGB和A是独立的。但有些来源的Bitmap比如从OpenGL读回来的可能是预乘的。如果混用混合结果会偏暗或偏亮。判断方法看Bitmap的getConfig()和来源。如果是ARGB_8888且从普通Canvas绘制来的一般是非预乘。工具类里我默认按非预乘处理如果调用方明确知道是预乘的提供一个重载方法做反预乘。5. 常见问题与排查实录5.1 颜色偏绿或偏紫这是最经典的问题八成是UV顺序搞反了。NV21是V在前U在后如果你按NV12解析绿色和紫色会对调。排查方法拿一张纯色图比如红色转一下看输出颜色对不对。如果红色变成了蓝色那就是U和V反了。还有一种可能是YUV转换矩阵选错了。BT.601和BT.709在绿色通道上差异明显如果整体偏色但红蓝基本对试试换矩阵。5.2 图像出现横向条纹或错位通常是stride问题。相机回调的NV21数据有时候Y平面的行宽stride不等于图像宽度会做对齐。比如宽度是1920但stride可能是2048。如果你按1920去算索引图像就会错位。解决办法确认回调数据的stride如果和width不一致kernel里的索引计算要用stride而不是width。这个参数需要从相机API里拿不能想当然。5.3 RenderScript初始化慢首次调用RenderScript.create()可能要几十甚至上百毫秒因为要加载驱动、编译脚本。如果放在主线程首次调用会明显卡顿。建议在Application启动时异步初始化或者用一个占位图先顶着。5.4 内存泄漏前面提过Context泄漏还有一个是Allocation没销毁。Allocation持有native内存不销毁的话GC管不到时间长了会OOM。养成习惯create和destroy配对或者用缓存池统一管理。问题现象可能原因排查方向颜色偏绿/偏紫UV顺序反了检查NV21/NV12整体偏色YUV矩阵选错切换BT.601/709横向条纹stride不等于width查相机回调stride首次调用卡顿RS初始化慢异步预初始化内存持续增长Allocation未销毁检查destroy配对混合结果偏暗alpha预乘不一致确认Bitmap是否预乘5.5 关于RenderScript废弃的应对API 31之后RenderScript被标记废弃但废弃不等于移除现有代码在可预见的未来还能跑。如果要做长期规划迁移路径有两条一是用Vulkan做compute shader性能最好但代码量大二是用OpenGL ES的片段着色器做离屏渲染相对成熟。我的建议是新项目直接用GL或Vulkan老项目如果RS跑得好好的不用急着改等有明确的下架时间表再说。6. 一些实操心得工具类写到现在踩过的坑基本都在上面了。再补几个零碎但实用的点。第一kernel里的浮点运算尽量用float而不是double。RS对float有硬件加速double在很多移动GPU上是软实现的慢一个数量级。YUV转换的系数用float精度完全够。第二rsGetElementAt和rsGetElementAt_uchar性能有差异。前者返回指针后者直接返回值。在kernel里循环访问时直接取值版本通常更快因为省了指针解引用。我实测在1080P下能差0.5ms左右。第三混合操作的alpha范围。我接口里用的是0.0到1.0的float但有些开发者习惯0到255的int。工具类里做了重载两种都支持内部统一转成float。这个细节虽小但能减少调用方的心智负担。第四Bitmap的Config选择。输出统一用ARGB_8888虽然内存占用比RGB_565大但颜色准确、支持alpha而且RS的Element.RGBA_8888和它直接对应copyTo效率最高。用565的话还得做一次格式转换得不偿失。第五多线程调用要加锁。RenderScript实例和ScriptC对象不是线程安全的如果多个线程同时调用工具类方法会出现数据竞争。我一般用synchronized包一层或者用ThreadLocal给每个线程独立的ScriptC对象。前者简单后者性能好看场景选。这套工具类在几个项目里跑了挺长时间从相机预览到视频帧处理都用它稳定性没问题。核心就是理解NV21的内存布局、用好RenderScript的并行能力、管好Allocation的生命周期。把这三点吃透剩下的都是细节打磨。