BackgroundBlurDrawable实战:Android自定义Drawable实现高斯模糊背景避坑指南
发布时间:2026/9/8 0:34:52 锦皓数字建站

做Android开发这几年凡是涉及界面质感优化的需求十有八九都会跟“背景高斯模糊”打交道。底部弹窗的背景、抽屉菜单的遮罩、视频播放页的封面底图都是典型场景。而我今天要写的BackgroundBlurDrawable是项目里沉淀下来的一个自定义Drawable方案用来给View设置高斯模糊背景稳定、轻量、可控。这篇文章会把它的设计思路、核心实现、参数选择以及线上遇到的各种坑整理成一份实战指南适合正在做类似效果、或者刚接触模糊渲染的同学直接参考。我不会只贴代码就算了。既然叫避坑指南我会把每一步“为什么这么做”、每一处“不这么做会出什么问题”都讲清楚。尤其是模糊算法选型、降采样策略、Bitmap回收、渲染线程这些点都是实际项目里最容易踩雷的地方。看完之后你会发现高斯模糊真正难的并不是模糊本身而是如何在有限的内存和性能下把模糊效果稳定地融进业务里。1. 项目背景与整体设计思路1.1 什么是BackgroundBlurDrawable能解决什么问题简单说BackgroundBlurDrawable就是一个自定义Drawable它的作用是把一张“已经处理好的模糊位图”绘制到View背景上。用它的好处是凡是能设置setBackground()的地方都可以用它不需要额外引入复杂的控件。举个例子弹窗Dialog的根布局背景需要毛玻璃效果。传统做法可能是搞一个自定义View在onDraw里先截屏再模糊再绘制逻辑全绑在一个控件上换个场景就不好复用。而用BackgroundBlurDrawable你只需要把模糊结果交给Drawable然后dialog.window?.setBackgroundDrawable(blurDrawable)后面页面里所有需要模糊背景的地方都可以用同一套逻辑不用重复写截屏、模糊、回收那一大坨代码。这个方案解决的另一个核心问题是“背景与前景的分离”。模糊效果本质上是实时或者准实时地对底层内容做处理如果把截屏、模糊、绘制全部塞进一个View里很容易出现职责混乱有的地方需要全屏模糊有的地方只需要模糊一块卡片区域有的位置要连续刷新背景有的位置只模糊一次。BackgroundBlurDrawable把“模糊结果”抽象成一个可绘制的对象上层只需要关心“什么时候产生新的模糊结果”至于怎么多快好省地画出来交给Drawable就好。1.2 为什么不直接用系统方案或第三方库在决定自研之前我对比过市面上几类方案也翻了不少第三方的实现最后选择自研主要基于三个原因体积、可控性、兼容性。先说系统方案。Android早期推荐用RenderScript的ScriptIntrinsicBlur它模糊质量高、速度也快。但问题是RenderScript在API 31被标记废弃Android Gradle Plugin 8.x之后连构建支持都被移除了。做新项目或者持续升级的旧项目在这个选型上踩了大坑。另一个新方案是API 31才加入的RenderEffect.createBlurEffect它可以直接对View做实时模糊效果非常流畅但最低版本要求太高。只要你还需要兼容Android 10、11就绕不开自己实现。第三方库方面BlurView这类库确实能做实时模糊滚动时背景跟着变体验很好。但它的实现通常会重写ViewGroup的绘制流程涉及drawChild、dispatchDraw这些底层方法引入之后对项目侵入性很强。需求简单的时候为了一个弹窗模糊去引入一个重型的库后面AGP升级、渲染优化都会变成负担。相比之下我自己封装BackgroundBlurDrawable核心依赖只有一个模糊算法类不碰任何ViewGroup绘制逻辑风险和体积都可控。1.3 整体架构设计整条链路的思路其实很直白可以拆成四步从当前界面截取需要模糊的区域内容把这张大图按照一定比例降采样缩小在小图上执行高斯模糊算法将模糊结果封装进BackgroundBlurDrawable设置给目标View这个设计的核心原则是“截屏、模糊、绘制三段分离”。截屏归截屏模糊归模糊绘制归绘制。每一个环节都可以单独替换截屏方式从DecorView.draw()换成PixelCopy模糊算法从StackBlur换成RenderEffect都只影响对应模块不影响整体结构。可能有人会觉得既然要做模糊为什么不直接对原图模糊这是因为高斯模糊的计算量跟像素数量成正比对1080x1920的原始大小做模糊耗时经常在几百毫秒线上根本跑不动。而先降采样再模糊计算量会大幅下降。关于降采样的原理和取值后面的章节会专门展开。2. 核心技术点解析模糊算法选型与原理2.1 三种常见模糊方案的现状与优缺点做方案选型的时候我重点对比了三种路线。为了说清楚它们的差异这里直接列一张表方案优点缺点适用场景RenderScriptScriptIntrinsicBlur模糊质量高、底层优化好API 31废弃新AGP移除构建支持老项目遗留、最低版本API 30以下RenderEffectBlurEffect官方新方案实时模糊性能强仅API 31可用只支持Android 12以上做渐进增强纯代码算法StackBlur/BoxBlur不依赖系统API任意线程可跑兼容所有版本大图计算慢必须搭配降采样通用背景模糊自研首选我最终的方案是纯代码算法优先也就是StackBlur。它在移动端非常广泛苹果当年的高斯模糊效果也有类似思路特点是在模糊半径适中的情况下质量接近真实高斯模糊但耗时要低很多。而且它不依赖任何系统的废弃API项目从API 21到API 34都能稳定跑。2.2 为什么我放弃了RenderScript很多老项目里高斯模糊的实现都是RenderScript代码也不复杂RenderScript rs RenderScript.create(context); ScriptIntrinsicBlur blur ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); blur.setRadius(25f); blur.setInput(bitmap); blur.forEach(bitmap);用起来确实方便但问题出在项目生命周期上。我处理过一个升级AGP的老工程原来的RenderScript代码在AGP 7时代还好好的升到AGP 8之后直接编译失败提示找不到android.support.v8.renderscript包。当时查了半天根因就是官方把RenderScript的构建支持移除了想保留就得用旧的构建工具链跟项目里其他新特性产生冲突。从那以后我的原则很明确新项目一律绕开RenderScript。哪怕最低版本限制在API 30以下也不建议在这个赛道上继续投入。你永远不知道官方下一次版本升级还会移除什么与其被牵着走不如用纯代码实现一劳永逸。2.3 最终采用的模糊链路降采样 StackBlur这套链路的关键在于理解“降采样为什么能加快模糊”。高斯模糊本质是对每个像素周围的邻域做加权平均模糊半径越大需要统计的范围越大。如果对一张1080x1920的图做模糊即便是性能还不错的算法也意味着要对接近200万个像素分别遍历计算耗时十分可观。但如果把图先缩小8倍变成135x240的尺寸需要处理的像素点就只剩3万个左右计算量几乎可以忽略。缩小本身也会带来“像素融合”的效果相当于做了一次粗粒度模糊后续StackBlur只需要以很小的半径补足细节视觉上就足够柔和了。所以最终的模糊链路是先降采样再StackBlur最后根据目标区域的大小选择是否放大回去。放大的操作交给了Canvas绘制阶段没必要单独创建一个超大Bitmap。这个思路既是性能优化的核心也是后面所有避坑讨论的前提。3. 实战从零实现BackgroundBlurDrawable3.1 环境准备与Android Studio配置开始写代码之前先把环境理清。我用的是较新的稳定版Android StudioGradle版本在8.x以上minSdk设置21targetSdk最新的版本。整个方案不依赖RenderScript配置所以不需要在build.gradle里加任何renderscriptTargetApi之类的设置。有同学经常问Android Studio怎么设置中文界面到Settings的Plugins商店里搜“Chinese Language Pack”就能安装。不过我的个人建议是界面可以换成中文但代码注释、报错信息尽量保留英文习惯。真出了问题把报错关键词丢到搜索引擎里英文资料命中率比中文高很多排查效率完全不一样。工程结构上我建议把模糊相关代码拆成两个类StackBlurUtil负责模糊算法BackgroundBlurDrawable负责绘制。截屏逻辑可以单独放一个ViewCaptureUtil也可以直接写在业务调用层视项目架构而定。3.2 自定义Drawable骨架直接看核心代码。BackgroundBlurDrawable的实现思路是内部保存一张模糊完成后的位图onDraw的时候把这张位图按bounds全量绘制对外提供update()方法在需要刷新时替换内部位图。class BackgroundBlurDrawable( private var blurredBitmap: Bitmap?, private var isRecycleOwner: Boolean true ) : Drawable() { private val paint Paint( Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG ) override fun draw(canvas: Canvas) { val bmp blurredBitmap ?: return canvas.drawBitmap(bmp, null, bounds, paint) } override fun setAlpha(alpha: Int) { paint.alpha alpha invalidateSelf() } override fun setColorFilter(colorFilter: ColorFilter?) { paint.colorFilter colorFilter invalidateSelf() } override fun getOpacity(): Int PixelFormat.TRANSLUCENT override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) invalidateSelf() } fun update(newBlurredBitmap: Bitmap?) { if (isRecycleOwner) { blurredBitmap?.recycle() } blurredBitmap newBlurredBitmap invalidateSelf() } fun release() { if (isRecycleOwner) { blurredBitmap?.recycle() } blurredBitmap null } }这里有几个细节需要注意。第一Paint里加了FILTER_BITMAP_FLAG。因为模糊后的位图是缩小过的绘制到View背景上通常要被放大如果不加这个Flag放大后会出现明显的锯齿和像素感模糊效果直接打折。第二update()方法里用了isRecycleOwner控制回收权。这个设计是为了满足两类使用场景有些地方自己管理位图不需要Drawable去回收有些地方模糊结果完全由Drawable持有不需要外部再维护。如果回收权处理不当很容易出现“外部回收了Drawable还在画同一张位图”的崩溃或者反过来“Drawable回收了外部还以为位图可用”。第三onBoundsChange()是不能省的空实现。View尺寸变化时系统会回调这个方法虽然我们不用在这里重新生成模糊图但也得让Drawable重绘一次否则边界区域可能会出现绘制残留。3.3 模糊工具类封装模糊算法选用StackBlur。网上流传的StackBlur版本很多这里给出一个纯Kotlin移植的可用版本逻辑上完整可以直接放到工具类里。object StackBlurUtil { fun blur(src: Bitmap, radius: Int): Bitmap { var radius radius if (radius 1) { radius 1 } val width src.width val height src.height if (width 0 || height 0) { return src } val pixels IntArray(width * height) src.getPixels(pixels, 0, width, 0, 0, width, height) val wm width - 1 val hm height - 1 val wh width * height val div radius radius 1 val r IntArray(wh) val g IntArray(wh) val b IntArray(wh) val vmin IntArray(maxOf(width, height)) var divsum (div 1) shr 1 divsum * divsum val dv IntArray(256 * divsum) for (i in dv.indices) { dv[i] i / divsum } var yi 0 var yw 0 val stack arrayOfNullsIntArray(div) var stackpointer 0 var stackstart 0 var rbs 0 val r1 radius 1 var routsum 0 var goutsum 0 var boutsum 0 var rinsum 0 var ginsum 0 var binsum 0 for (y in 0 until height) { rinsum 0 ginsum 0 binsum 0 routsum 0 goutsum 0 boutsum 0 var rsum 0 var gsum 0 var bsum 0 for (i in -radius..radius) { val p pixels[yi minOf(wm, maxOf(i, 0))] val siv intArrayOf( (p and 0xff0000) shr 16, (p and 0x00ff00) shr 8, p and 0x0000ff ) stack[i radius] siv rbs r1 - abs(i) rsum siv[0] * rbs gsum siv[1] * rbs bsum siv[2] * rbs if (i 0) { rinsum siv[0] ginsum siv[1] binsum siv[2] } else { routsum siv[0] goutsum siv[1] boutsum siv[2] } } stackpointer radius for (x in 0 until width) { r[yi] dv[rsum] g[yi] dv[gsum] b[yi] dv[bsum] rsum - routsum gsum - goutsum bsum - boutsum stackstart stackpointer - radius div val sir stack[stackstart % div]!! routsum - sir[0] goutsum - sir[1] boutsum - sir[2] if (y 0) { vmin[x] minOf(x radius 1, wm) } val p pixels[yw vmin[x]] val newSir intArrayOf( (p and 0xff0000) shr 16, (p and 0x00ff00) shr 8, p and 0x0000ff ) rinsum newSir[0] ginsum newSir[1] binsum newSir[2] rsum rinsum gsum ginsum bsum binsum stackpointer (stackpointer 1) % div val nextSir stack[stackpointer % div]!! rinsum - nextSir[0] ginsum - nextSir[1] binsum - nextSir[2] yi } yw width } for (x in 0 until width) { rinsum 0 ginsum 0 binsum 0 routsum 0 goutsum 0 boutsum 0 var rsum 0 var gsum 0 var bsum 0 var yp -radius * width for (i in -radius..radius) { val yi maxOf(0, yp) x val sir intArrayOf(r[yi], g[yi], b[yi]) stack[i radius] sir rbs r1 - abs(i) rsum r[yi] * rbs gsum g[yi] * rbs bsum b[yi] * rbs if (i 0) { rinsum sir[0] ginsum sir[1] binsum sir[2] } else { routsum sir[0] goutsum sir[1] boutsum sir[2] } if (i hm) { yp width } } var yi x stackpointer radius for (y in 0 until height) { pixels[yi] (0xff shl 24) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum] rsum - routsum gsum - goutsum bsum - boutsum stackstart stackpointer - radius div val sir stack[stackstart % div]!! routsum - sir[0] goutsum - sir[1] boutsum - sir[2] if (x 0) { vmin[y] minOf(y r1, hm) * width } val p x vmin[y] val newSir intArrayOf(r[p], g[p], b[p]) rinsum newSir[0] ginsum newSir[1] binsum newSir[2] rsum rinsum gsum ginsum bsum binsum stackpointer (stackpointer 1) % div val nextSir stack[stackpointer % div]!! rinsum - nextSir[0] ginsum - nextSir[1] binsum - nextSir[2] yi width } } val result Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) result.setPixels(pixels, 0, width, 0, 0, width, height) return result } }这段代码的核心是两次一维滑动窗口遍历先沿着水平方向对每一行做均值模糊再沿着垂直方向对每一列做均值模糊。配合栈结构复用中间结果避免每次重新计算窗口内的像素和复杂度从O(radius * n)降到了O(n)。如果你只是概念性地理解不需要记住每一行代码但有个点要注意函数内通过getPixels取像素最后setPixels写回整个过程返回新Bitmap不会修改传入的源图这一点对后续的位图复用和回收非常关键。3.4 如何截取底层内容作为模糊源截屏是背景模糊的重要一步。最直接的方式是取Activity的DecorView调用draw(Canvas)把整棵界面树画到Bitmap上。object ViewCaptureUtil { fun captureDecorView(activity: Activity): Bitmap? { val decorView activity.window.decorView val width decorView.width val height decorView.height if (width 0 || height 0) { return null } val bitmap Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) try { val canvas Canvas(bitmap) decorView.draw(canvas) } catch (e: Exception) { return null } return bitmap } }用decorView.draw(canvas)而不是getDrawingCache()是因为getDrawingCache()在API 28之后已经明确不建议使用而且它对部分硬件加速场景支持不理想。draw()方法最稳定几乎所有视图树内容都能正确绘制出来。截屏的时候要特别注意不要在视图动画进行中截。比如弹窗做平移动画、背景列表还在滚动这时候截屏往往会截到内容错位或半透明的帧模糊出来会像“鬼影”。我习惯在动画开始前截一张干净的或者等onPreDraw回调之后确保布局稳定了再截。另外如果你的模糊区域不是全屏比如只需要模糊屏幕上某个矩形区域可以用坐标换算后从大图里裁剪fun cropBitmap(source: Bitmap, targetRect: Rect): Bitmap { return Bitmap.createBitmap( source, targetRect.left, targetRect.top, targetRect.width(), targetRect.height() ) }这里有个隐含风险Bitmap.createBitmap(source, ...)在部分情况下会直接引用同一块内存并不会真的拷贝。如果想保持独立可以在创建前调用source.config判断是否需要copy()。实际项目中我会在业务层统一处理截完屏立刻裁剪裁剪结果传给模糊链路原始大图马上回收。3.5 完整接入场景底部弹窗背景模糊以一个常见的底部弹窗为例完整演示怎么把这块拼起来。第一步在子线程里完成截屏、降采样、模糊三个重活第二步回到主线程把模糊结果交给BackgroundBlurDrawable设置给弹窗。fun showBlurBottomSheet(activity: Activity) { val dialog Dialog(activity) dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) dialog.setContentView(R.layout.dialog_bottom_sheet) // 先让弹窗内容布局准备好 val rootView dialog.findViewByIdViewGroup(R.id.dialogRoot) lifecycleScope.launch { val blurred withContext(Dispatchers.IO) { // 1. 截屏 val capture ViewCaptureUtil.captureDecorView(activity) ?: returnwithContext null // 2. 降采样 val scale 8 val smallBitmap Bitmap.createScaledBitmap( capture, capture.width / scale, capture.height / scale, true ) capture.recycle() // 3. StackBlur StackBlurUtil.blur(smallBitmap, 20).also { smallBitmap.recycle() } } withContext(Dispatchers.Main) { val blurDrawable BackgroundBlurDrawable(blurred) dialog.window?.setBackgroundDrawable(blurDrawable) dialog.show() } } }这段代码看起来不复杂但有几个工程细节值得展开。lifecycleScope要绑定Activity或者Fragment的Lifecycle不能随便用一个全局作用域。否则弹窗关闭后协程还在跑操作已经不存在的DecorView轻则报空指针重则直接把Bitmaps当垃圾回收引发资源竞争。smallBitmap.recycle()在两处出现分别紧跟在截屏和StackBlur之后。为什么要这么积极因为Android的Bitmap内存很大一部分不在Java堆里而是在Native堆里如果不及时回收JVM堆看起来没涨多少Native内存却一直在涨最后触发整个进程的内存告警。裁剪、缩小时产生的临时位图一定要用try-finally或者use块包住并在用完后回收。dialog.window?.setBackgroundDrawable()的语义要理解清楚这里设置的背景并不是弹窗整个根View的背景而是Window背景它会铺在根View的后面。所以模糊图不会遮挡弹窗内容只会作为底层背景透出。3.6 刷新时机什么时候需要重新模糊一次性的弹窗背景比较简单截一次、模糊一次就完事。但有很多场景背景内容会实时变化比如抽屉菜单出现后背后的列表还在滑动。这时候就需要考虑“刷新”问题。我的做法是给需要刷新的View设置一个监听只在高频事件停止后再触发重新模糊。比如列表的场景监听RecyclerView.OnScrollListener在onScrollStateChanged拿到SCROLL_STATE_IDLE时刷新一次。不要监听onScrolled逐帧刷新那样每个滚动帧都会触发一次截屏与模糊线上会卡得惨不忍睹。如果确实需要动态背景持续变化而且变化频率很高那我建议改用BlurView这类实时模糊方案而不是自己截屏Drawable。BackgroundBlurDrawable更适合“低频刷新、静态质感”的场景强行做实时性能压力会转移到CPU与内存上得不偿失。3.7 生命周期管理与释放凡是持有Bitmap的组件生命周期管理都不能含糊。BackgroundBlurDrawable在弹窗关闭、Fragment销毁时需要显式调用release()否则模糊位图会一直被持有等GC来回收Native内存往往已经晚了。override fun onDestroy() { blurDrawable?.release() super.onDestroy() }另外注意如果同一个模糊Drawable被多个View引用回收逻辑就要慎重。比较稳妥的做法是在业务层统一管理不要在Drawable内部recycle而是由外部在合适的时机处理。我上面给出的代码用isRecycleOwner字段做了区分这个设计在多人协作的项目里非常有用A同事认为Drawable应该自己回收B同事认为外部管理更安全两个人都没错但代码必须给出一个明确约定。4. 常见问题与排查技巧实录这一章整理的全是实际线上踩过的坑。我会把每类问题的现象、原因和解决思路写清楚方便你对照排查。4.1 内存溢出与Bitmap回收这是高斯模糊最容易翻车的地方。出现OOM的场景很典型全屏1080x1920截图不降采样直接做模糊然后同时保留原图和模糊图再叠加频繁点击弹窗每点一次就新生成两张位图旧的不回收内存自然爆发。解决办法在思路上很清晰降采样、及时回收、缓存复用。实践上请注意点细节。降采样倍数至少4倍起步模糊之后立即回收缩放用的临时图如果同一个背景会高频重复展示可以把模糊结果做一层软引用缓存下次直接复用不再重新截屏和计算。这里强调一下Android的Bitmap内存上限并不等于Java堆上限。在API 26之后虽然大部分Bitmap内存已经算进Java堆统计但系统依然会对Native层做额外限制。真机上反复创建大位图经常Java堆没满JNI的引用表或者Native Heap先扛不住然后进程被杀。所以不要只盯Android Studio的Profiler看Java Heap还要关注Native Memory。4.2 RenderScript废弃后的兼容报错升级AGP后碰到RenderScript相关的编译失败基本就是踩了这个坑。现象通常是Execution failed for task :app:mergeDebugJniLibFolders. A failure occurred while executing com.android.build.gradle.tasks.MergeNativeLibsTask或者运行时找不到android.renderscript.ScriptIntrinsicBlur相关类。根治办法只有一个把代码里的RenderScript调用全面替换成纯算法实现。替换的时候不要只换算法调用还要检查build.gradle里是否残留了renderscriptTargetApi 30 renderscriptSupportModeEnabled true这些配置同样可能引发旧API警告或更严重的构建问题一律删掉。4.3 模糊后背景发灰、边缘发黑有段时间我发现部分机型上模糊背景明显发灰靠近屏幕边缘还有一条黑边。排查后发现两个原因。第一个原因是模糊算法对Alpha通道处理不当。如果算法把透明像素的RGB值也参与平均透明区域边缘的颜色会被“中和”变灰视觉上整体发灰。StackBlur的写法里我特意在写回像素时用了0xff shl 24把Alpha固定为不透明这就杜绝了透明通道参与计算导致的发灰问题。第二个原因是绘制阶段没处理好边界。模糊图是缩小的位图放大铺满后边缘区域会跟View边界产生微小的采样误差于是出现黑边。解决方法是绘制时给目标矩形留一点内缩量或者让模糊位图四周多画一圈内容。简单点说源图四周向外多扩几个像素再模糊然后绘制时向内收回来。这个小技巧能让边缘过渡自然很多。4.4 截屏捕获不到SurfaceView/TextureView内容需要模糊相机预览、视频画面时“截屏捕获空白”的问题几乎都会出现。原因是SurfaceView的内容是独立Surface根本不在DecorView的绘制树里你用decorView.draw()画不到它TextureView虽然内容在视图树里但对硬件加速的合成方式有特殊要求普通draw()可能拿到黑块。如果业务上确认要模糊的是这类动态内容建议换思路不要截屏而是对Surface实际的帧做处理。例如相机预览可以先拿到每一帧的YUV数据转成Bitmap后再走模糊链路视频播放器可以用MediaMetadataRetriever抽帧或者在TextureView上通过View.postInvalidate配合PixelCopy来取内容。这些方案都比“截屏背景Drawable”复杂但这是动态内容本身的特殊性决定的。4.5 状态栏、导航栏带来的偏移问题模糊背景跟页面内容错位是另一个容易被忽略的坑。原因在于截屏时DecorView.draw()默认画的是一个包含状态栏、导航栏的完整界面而目标View的背景区域可能只位于内容区内中间差了系统栏的高度。处理方式分两种。如果模糊背景是全屏铺满直接在dialog.window层设置背景系统栏天然在下面不会显露出错位。如果模糊背景只是页面上某个区块就必须用目标View在屏幕上的实际坐标去截取val location IntArray(2) targetView.getLocationOnScreen(location) val rect Rect(location[0], location[1], location[0] targetView.width, location[1] targetView.height)然后用Bitmap.createBitmap(source, rect...)取出对应区域再模糊。这里再次提醒Bitmap.createBitmap复用的问题裁剪后如果需要独立内存记得copy()一份。4.6 刷新频繁导致卡顿和闪烁高频刷新场景比如列表滚动中持续更新背景最大的问题是主线程卡顿和画面闪烁。闪烁通常是因为控件的背景纹路在频繁替换而替换又是在主线程完成的绘制一帧和一帧之间出现了肉眼可见的间隙。我的经验是双管齐下第一把“截屏降采样模糊”整条链路放到子线程用协程也好、HandlerThread也好绝对不允许主线程参与。第二对刷新操作做节流用一个标志位控制“当前是否已经在更新中”更新期间收到的触发请求全部丢弃完成后保留最新的一次请求Volatile private var isUpdating false fun refreshBlur() { if (isUpdating) return isUpdating true lifecycleScope.launch(Dispatchers.IO) { try { val newBlur doBlur() withContext(Dispatchers.Main) { blurDrawable?.update(newBlur) } } finally { isUpdating false } } }这个方案能保证同时只有一次模糊任务在跑既省内存又避免高频刷新把CPU打满。5. 参数调优与性能优化经验5.1 模糊半径与降采样因子怎么定很多同学调模糊参数全凭手感。但参数组合直接影响性能和效果不能乱来。模糊半径过小几乎看不出效果半径过大小尺寸位图上全是色块像蒙了一层油。要根据使用场景来定。参考这个组合使用区域模糊半径降采样倍数效果预期全屏弹窗背景20-308-12背景柔和文字不可辨识局部浮层/菜单10-184-6能隐约感知底层轮廓小卡片背景8-123-4轻微立体感不脏这里要解释一个容易误解的点降采样倍数和模糊半径是联动的。你把图缩小到原来的1/8这时候用半径20如果换成1/4缩放同样的视觉模糊程度可能需要半径40到50。所以不要拿着某套参数到处套降采样倍数一旦改变模糊半径也要跟着调整。我在项目里的经验是先把降采样倍数固定再慢慢调半径调到视觉满意为止。不要同时改两个参数否则永远调不准。5.2 缓存策略什么时候复用Bitmap模糊计算是个有成本的过程如果业务里同一个界面会反复出现比如用户多次打开同一个弹窗完全可以缓存模糊结果。推荐做法是软引用缓存加弱引用兜底object BlurCache { private val lruCache object : LruCacheString, Bitmap(12 * 1024 * 1024) { override fun sizeOf(key: String, value: Bitmap): Int { return value.byteCount } } fun put(key: String, bmp: Bitmap) { lruCache.put(key, bmp) } fun get(key: String): Bitmap? { return lruCache.get(key) } }缓存key可以用“页面名宽高radiusscale”拼接。这样用户在完全相同的场景下再次打开弹窗直接从缓存拿模糊图省掉一整轮截屏和模糊。注意缓存里的Bitmap不要随意recycle()交给LRU淘汰就可以。5.3 多线程更新与协程实践模糊计算放到子线程几乎是必须的。我习惯用协程做因为代码可读性好错误传导也自然。一个容易被忽略的点从IO线程切回主线程后要判断生命周期是否还处于活跃状态。因为协程被取消时主线程里的更新代码可能还在排队如果目标View已经销毁轻则空指针重则引发无法预期的状态异常。所以我一般会加一层判断withContext(Dispatchers.Main) { if (isActive targetView.isAttachedToWindow) { targetView.background blurDrawable } }isActive是协程的取消标志isAttachedToWindow是视图树的挂载判断两者结合基本能挡住绝大多数更新时的泄漏和空指针。5.4 性能实测参考最后给一个比较直观的参考数据。在骁龙7系中端机上截取一张1080x1920的全屏内容降采样8倍后得到135x240的小图再对这个尺寸跑StackBlur半径20单次模糊耗时大约在5到15毫秒之间。如果把截屏和降采样也算进来整个异步流程大概20到40毫秒完成。如果跳过降采样对1080x1920原图直接跑同样的StackBlur耗时经常飙升到500毫秒以上主线程根本没法碰就算放子线程也会把内存吃紧。这个对比足以说明降采样在整个方案里的分量。对于性能优化我的经验是“先压计算量再谈算法”。一张全屏大图再怎么优化算法底层的像素遍历成本都降不下来但把它缩小到原来的1/64哪怕用最简单的StackBlur性能表现也能碾压没有降采样参与的复杂算法。这个思路比纠结半径选20还是25重要得多。从我个人的体会来说BackgroundBlurDrawable这套方案的真正价值不在于代码多巧妙而在于把模糊这件事拆解成了“截屏、降采样、算法、绘制、缓存”五个独立的环节。每个环节都可以单独替换和优化。最初我从RenderScript迁移到纯代码实现时心里多少有点打鼓怕性能和效果下降但线上跑了一段时间后发现只要降采样的比例拿捏好纯算法方案在绝大多数真机上都能保持流畅而且再也不用担心SDK升级带来的废弃风险。最后再分享一个小技巧在Android Studio里跑性能分析的时候最好拿一台中低端机器而不是只用开发机。模糊类任务在旗舰机上可能毫秒级完成但在低端机上边际成本会被放大很多。提前在低端机上压一遍把降采样倍数调得保守一点线上才不会突然出现ANR或掉帧。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。