做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 三种常见模糊方案的现状与优缺点
做方案选型的时候,我重点对比了三种路线。为了说清楚它们的差异,这里直接列一张表:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| RenderScript(ScriptIntrinsicBlur) | 模糊质量高、底层优化好 | API 31废弃,新AGP移除构建支持 | 老项目遗留、最低版本API 30以下 |
| RenderEffect(BlurEffect) | 官方新方案,实时模糊性能强 | 仅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. 实战:从零实现BackgroundBlurDrawable
3.1 环境准备与Android Studio配置
开始写代码之前,先把环境理清。我用的是较新的稳定版Android Studio,Gradle版本在8.x以上,minSdk设置21,targetSdk最新的版本。整个方案不依赖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 = arrayOfNulls<IntArray>(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.findViewById<ViewGroup>(R.id.dialogRoot) lifecycleScope.launch { val blurred = withContext(Dispatchers.IO) { // 1. 截屏 val capture = ViewCaptureUtil.captureDecorView(activity) ?: return@withContext 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-30 | 8-12 | 背景柔和,文字不可辨识 |
| 局部浮层/菜单 | 10-18 | 4-6 | 能隐约感知底层轮廓 |
| 小卡片背景 | 8-12 | 3-4 | 轻微立体感,不脏 |
这里要解释一个容易误解的点:降采样倍数和模糊半径是联动的。你把图缩小到原来的1/8,这时候用半径20;如果换成1/4缩放,同样的视觉模糊程度可能需要半径40到50。所以不要拿着某套参数到处套,降采样倍数一旦改变,模糊半径也要跟着调整。
我在项目里的经验是先把降采样倍数固定,再慢慢调半径,调到视觉满意为止。不要同时改两个参数,否则永远调不准。
5.2 缓存策略:什么时候复用Bitmap
模糊计算是个有成本的过程,如果业务里同一个界面会反复出现,比如用户多次打开同一个弹窗,完全可以缓存模糊结果。
推荐做法是软引用缓存加弱引用兜底:
object BlurCache { private val lruCache = object : LruCache<String, 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可以用“页面名+宽高+radius+scale”拼接。这样用户在完全相同的场景下再次打开弹窗,直接从缓存拿模糊图,省掉一整轮截屏和模糊。注意缓存里的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或掉帧。