简介:这是一份面向安卓开发者的玻璃蒙层实现工程,聚焦于半透明蒙层、高斯模糊及毛玻璃效果,适用于需要增强界面层次感与交互反馈的场景,技能上覆盖从初级到中级的界面特效实现。压缩包共55个文件,仅1.23MB,包含Java源码、编译产物、XML布局与主题资源、图片素材和第三方依赖库,并附可直接安装的APK,结构简洁,可快速导入查看。工程围绕“PicDim”示例展开,演示了Alpha通道颜色叠加、BitmapShader、Renderscript及第三方模糊库的多套方案,并处理了动态蒙层显示隐藏动画与异步模糊、硬件加速等性能优化,能帮助开发者理解不同实现路线的取舍与适用场景。已有909人学习下载,对于想在自己应用中复现毛玻璃对话框、模糊背景或过渡蒙层的开发者而言,是一份实用且轻量的参考样本。1. 玻璃蒙层:一个“半透明遮罩”背后藏着一整条渲染链路平时用第三方库做个毛玻璃遮罩只花五分钟,可真到要把「android玻璃蒙层」接进弹窗、引导页和侧边抽屉这三类场景时,问题就冒出来了:低版本设备上 RenderScript 被废弃之后带不动、截出来的背景是一块黑屏、列表滚动时模糊区域跟不上手势。这个项目把整条链路拆成“背景采集 → 降采样 → 模糊 → 蒙层合成”四个环节,用 Kotlin 封装成一个可以直接丢进工程的自定义 View,兼容 API 21+,核心逻辑不依赖任何第三方库。适合正在做弹出层、新手引导、局部高亮遮罩,以及想搞懂模糊到底怎么算出来的 Android 开发者。2. 技术选型与架构设计:三层结构与渲染管线的取舍2.1 三条实现路线对比,为什么没有选 RenderScriptAndroid 上做“把背景模糊掉”这件事,成熟思路其实只有三条,我先把结论放在前面:这条项目走的是“离屏截屏 + 三次 BoxBlur + Canvas 合成”路线。方案兼容范围性能表现实现成本后续维护坑RenderScript(ScriptIntrinsicBlur)API 19~30,31 起被官方废弃原生调度,整体较快低打包要带 support 库,部分新设备驱动行为不一致,已被官方移出依赖链WindowManager 系统级背景模糊仅 API 31+由系统合成器完成,耗电低极低Android 12 以下完全不可用,部分厂商 ROM 会忽略模糊参数离屏截屏 + 高斯模糊算法API 21+ 全兼容取决于采样率与算法复杂度中需要自己管内存、线程和 Bitmap 复用之前我一度想用 RenderScript,因为代码最短,两行就能把 Bitmap 模糊掉。但后来在一个 targetSdk 31 的项目里发现,RenderScript 相关类在部分设备上直接抛异常,官方文档也明确标记废弃,继续用等于给线上埋雷。而 WindowManager 那条路虽然清爽,但只服务 Android 12+,一个做通用组件的项目不可能接受这个限制。所以最后走的是第三条路:截当前 Window 的 DecorView 到一张小尺寸 Bitmap,做模糊,再盖一层半透明颜色。这样做的最大好处是把“模糊成什么样”完全控制在自己手里,后面要支持动态跟手、局部挖空、圆角遮罩,都只需要在 Canvas 层做文章。2.2 三层架构:采集、模糊、渲染互相解耦我拆项目时习惯先把“会变化的部分”和“相对稳定的部分”分开。玻璃蒙层里最容易变的是三个点:背景从哪来、模糊算法用哪种、蒙层怎么画。所以这个源码包内部被拆成了三个层次:采集层(BackgroundCapturer):负责从 DecorView 拿到背景 Bitmap,内部做降采样,避免 1080P 屏幕全尺寸截屏导致 OOM。模糊层(BoxBlur):负责把 Bitmap 做近似高斯模糊,只暴露一个 blur(src, radius) 方法;将来想换 StackBlur 或接入 RenderEffect,只动这一层。渲染层(BlurMaskView):最终用户看到的 View,持有模糊后的 Bitmap,在 onDraw 里合成背景和半透明遮罩色,同时负责触摸事件穿透。三者之间的调用链是这样的伪代码:fun attachTo(decorView: View) { // 1. 监听布局变化,等 DecorView 拿到有效的宽高 decorView.viewTreeObserver.addOnGlobalLayoutListener { // 2. 采集层截取背景,按 scaleFactor 缩成小图 val captured = backgroundCapturer.capture(decorView, scaleFactor) // 3. 模糊层做三遍 BoxBlur val blurred = BoxBlur.blur(captured, blurRadius) // 4. 渲染层持有结果并触发重绘 blurBitmap = blurred invalidate() } }这个链路里每一步的输入输出都是 Bitmap 或 View,彼此不持有内部引用。比如某一天你想让背景来源换成“当前页面下方某个列表的截图”,只需要替换采集层的实现,模糊层和渲染层完全不用碰。这也是我建议你在二次开发时遵守的边界:别把截屏逻辑写进 onDraw。2.3 目录结构与核心类职责源码包里的代码不算多,去掉注释和空行大概六百行左右,结构上按职责分了五个文件:glassmask/src/main/java/com/example/glassmask/ ├── mask/ │ ├── BlurMaskView.kt // 自定义 View,对外入口 │ ├── BackgroundCapturer.kt // 背景采集与降采样 │ ├── BoxBlur.kt // 三遍 BoxBlur 近似高斯模糊 │ ├── BlurRender.kt // 蒙层合成与圆形挖空绘制 │ └── MaskConfig.kt // 统一参数配置,可被 XML 属性覆盖BlurMaskView 是所有逻辑的对外门面,持有另外三个类;MaskConfig 用 data class 收拢 blurRadius、scaleFactor、maskColor、passCount 这几个可调参数。这种写法让单元测试变得很好做:想验证模糊算法,直接 new 一个 BoxBlur 丢一张测试图进去,不需要起模拟器。3. 核心代码拆解:从 DecorView 截屏到模糊上屏的完整链路3.1 BlurMaskView 骨架与 attachTo 调用链BlurMaskView 是项目里唯一需要被业务代码直接触碰的类。构造函数不直接拿 Activity,而是暴露一个 attachTo 方法,让调用方决定“模糊哪块内容”。这个设计是有意为之:如果你在构造函数里通过 context 强转 Activity 拿 DecorView,那这个组件就没法用在 Dialog 的 Window 上,也没法只对某个 Fragment 区域做模糊。class BlurMaskView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val config = MaskConfig() private val capturer = BackgroundCapturer() private var blurBitmap: Bitmap? = null private val drawPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) fun attachTo(decorView: View) { decorView.viewTreeObserver.addOnGlobalLayoutListener { val w = decorView.width val h = decorView.height if (w == 0 || h == 0) return@addOnGlobalLayoutListener scope.launch { val captured = withContext(Dispatchers.Default) { capturer.capture(decorView, config.scaleFactor, config) } val blurred = withContext(Dispatchers.Default) { BoxBlur.blur(captured, config.blurRadius, config.passCount) } blurBitmap = blurred invalidate() } } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) blurBitmap?.let { bmp -> canvas.drawBitmap( bmp, null, Rect(0, 0, width, height), drawPaint ) } canvas.drawColor(config.maskColor) BlurRender.drawHighlight(canvas, config.highlightRect, config.highlightRadius) } }逻辑说明:整个模糊链路由 attachTo 触发,核心跳过了主线程。capture 和 blur 都丢到 Dispatchers.Default,只有在最后把 Bitmap 赋给成员变量并 invalidate 时才回到主线程。这里用 CoroutineScope 而不是直接在 onGlobalLayout 里同步算,是为了避免打开弹窗瞬间卡住主线程。参数说明:config.scaleFactor 控制采样倍率,数值越大背景图越小、模糊越快,但颗粒感越重;config.blurRadius 是模糊半径,单位是“小图上的像素”,不是原始屏幕像素,这个换算关系在 3.3 节会展开讲;config.maskColor 用 ARGB 格式,比如 0x66000000 表示半透明黑。highlightRect 和 highlightRadius 是给引导页高亮用的,默认 null 时不绘制。3.2 背景采集:降采样和 Canvas.draw 的正确用法采集层是整个项目最容易翻车的地方。新手通常会调用 view.getDrawingCache(),这个 API 有两个问题:一是它会强制触发一次完整的 measure 和 layout,性能开销不可控;二是它不支持自定义采样率,拿回来的永远是全尺寸图,后续模糊压力很大。正确做法是手动创建一张小尺寸 Bitmap,用 Canvas 把 DecorView 画进去,画之前对 Canvas 做 scale。这样一次 draw 调用,既完成了截屏又完成了降采样。object BackgroundCapturer { fun capture(decorView: View, scaleFactor: Int, config: MaskConfig): Bitmap { val targetW = (decorView.width / scaleFactor).coerceAtLeast(1) val targetH = (decorView.height / scaleFactor).coerceAtLeast(1) val bitmap = Bitmap.createBitmap(targetW, targetH, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) canvas.scale(1f / scaleFactor, 1f / scaleFactor) canvas.translate(0f, -config.statusBarHeight.toFloat()) decorView.draw(canvas) return bitmap } }逻辑说明:先算目标宽高,再创建 Bitmap,然后用 Canvas.scale 把 DecorView 的内容“压”进小图。注意这里 translate 了 statusBarHeight,是因为 DecorView 包含状态栏区域,而业务蒙层通常从状态栏下方开始,如果不平移,模糊出来的画面会整体错位。参数说明:scaleFactor 一般取 6 到 10。取 8 意味着 1080x2400 的屏幕会被压成 135x300 的小图,像素量只有原来的 1/64,后续做三遍 BoxBlur 的耗时基本可以控制在个位数毫秒。数值再小比如 4,模糊质量会更高,但内存和耗时都会线性上涨;再大比如 16,背景会糊成色块,蒙层一盖基本看不出原本内容。3.3 三次 BoxBlur 实现的高斯近似模糊模糊算法的选择上,我没有直接用 StackBlur,而是写了更精简的三遍 BoxBlur。BoxBlur 的原理是滑动窗口平均值:每个像素取周围 2r+1 个像素的平均值。单次 BoxBlur 的效果偏“生硬”,但连续做三次之后,曲线会逼近高斯模糊,视觉上足够干净。object BoxBlur { fun blur(src: Bitmap, radius: Int, passCount: Int = 3): Bitmap { var current = src repeat(passCount) { current = apply(current, radius) } return current } private fun apply(src: Bitmap, r: Int): Bitmap { val w = src.width val h = src.height val srcPixels = IntArray(w * h) val tmpPixels = IntArray(w * h) src.getPixels(srcPixels, 0, w, 0, 0, w, h) val div = 2 * r + 1 // 水平方向滑动窗口 for (y in 0 until h) { var sumA = 0 var sumR = 0 var sumG = 0 var sumB = 0 for (x in -r..r) { val px = srcPixels[y * w + x.coerceIn(0, w - 1)] sumA += (px ushr 24) and 0xFF sumR += (px ushr 16) and 0xFF sumG += (px ushr 8) and 0xFF sumB += px and 0xFF } for (x in 0 until w) { val idx = y * w + x tmpPixels[idx] = ((sumA / div) shl 24) or ((sumR / div) shl 16) or ((sumG / div) shl 8) or (sumB / div) val xOut = (x - r).coerceIn(0, w - 1) val xIn = (x + r + 1).coerceIn(0, w - 1) val outPx = srcPixels[y * w + xOut] val inPx = srcPixels[y * w + xIn] sumA += ((inPx ushr 24) and 0xFF) - ((outPx ushr 24) and 0xFF) sumR += ((inPx ushr 16) and 0xFF) - ((outPx ushr 16) and 0xFF) sumG += ((inPx ushr 8) and 0xFF) - ((outPx ushr 8) and 0xFF) sumB += (inPx and 0xFF) - (outPx and 0xFF) } } // 垂直方向滑动窗口,结果写回 srcPixels for (x in 0 until w) { var sumA = 0 var sumR = 0 var sumG = 0 var sumB = 0 for (y in -r..r) { val srcIdx = y.coerceIn(0, h - 1) * w + x val px = tmpPixels[srcIdx] sumA += (px ushr 24) and 0xFF sumR += (px ushr 16) and 0xFF sumG += (px ushr 8) and 0xFF sumB += px and 0xFF } for (y in 0 until h) { val idx = y * w + x srcPixels[idx] = ((sumA / div) shl 24) or ((sumR / div) shl 16) or ((sumG / div) shl 8) or (sumB / div) val yOut = (y - r).coerceIn(0, h - 1) val yIn = (y + r + 1).coerceIn(0, h - 1) val outPx = tmpPixels[yOut * w + x] val inPx = tmpPixels[yIn * w + x] sumA += ((inPx ushr 24) and 0xFF) - ((outPx ushr 24) and 0xFF) sumR += ((inPx ushr 16) and 0xFF) - ((outPx ushr 16) and 0xFF) sumG += ((inPx ushr 8) and 0xFF) - ((outPx ushr 8) and 0xFF) sumB += (inPx and 0xFF) - (outPx and 0xFF) } } return Bitmap.createBitmap(srcPixels, w, h, Bitmap.Config.ARGB_8888) } }逻辑说明:滑动窗口的关键技巧是不必每个像素重新遍历 2r+1 个邻居,只需要在窗口向右移动时,把新进入窗口的像素加到 sum,把滑出窗口的像素从 sum 里减掉。这段代码先水平扫一遍把结果存在 tmpPixels,再垂直扫一遍写回 srcPixels,最后用 createBitmap 生成新图。ARGB 四个通道分别累加再分别求平均,最后用位运算拼回一个像素。参数说明:r 是模糊半径,在组件里它作用于降采样后的小图,所以视觉上的等效模糊范围约等于 r * scaleFactor。比如 scaleFactor 为 8,r 配 18,等效原始分辨率下 144 像素的模糊范围,这也是为什么很多时候小图半径设 15~25 就已经能出很明显的毛玻璃感。passCount 默认 3,大于 3 边际收益递减,小于 3 会有明显“糖块感”。3.4 蒙层合成、点击穿透与异步更新蒙层合成在 onDraw 里完成:先画模糊 Bitmap,再盖一层 maskColor。点击穿透则是重写 onTouchEvent,判断触摸点是否落在“允许穿透”的区域。这块逻辑单拎出来说,是因为 Android 的触摸事件是在 dispatchTouchEvent 阶段就往下走的,onTouchEvent 返回 false 只代表当前 View 不消费,并不能直接把事件“送回”给下方兄弟视图,所以组件选择用 Region 判定来决定是否拦截。class BlurMaskView @JvmOverloads constructor(...) : View(...) { private val touchRegion = Region() private val highlightRect = RectF() fun setTouchThroughBounds(bounds: List<RectF>) { touchRegion.setEmpty() for (rect in bounds) { val scaled = Rect( rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt() ) touchRegion.op(scaled, Region.Op.UNION) } invalidate() } override fun onTouchEvent(event: MotionEvent): Boolean { val x = event.x.toInt() val y = event.y.toInt() // 点在允许穿透的区域里,放行给下面 return !touchRegion.contains(x, y) } }逻辑说明:setTouchThroughBounds 接收一组 RectF,把它们合并进 Region。onTouchEvent 里做一次 contains 判断,如果点在穿透区域内就返回 false,否则返回 true 消费事件。背面那层模糊背景本身不需要响应点击,返回 true 会阻止点击蔓延到蒙层之下的列表,避免出现“想关弹窗却点到列表项”的误触。参数说明:bounds 里的坐标系是蒙层 View 的本地坐标,调用方如果拿的是屏幕坐标,记得先做 View 坐标换算。Region.Op.UNION 是并集操作,多个矩形可以直接叠加成一块不规则点击区。4. 性能与避坑记录:黑屏、掉帧、错位、OOM 的排查实战4.1 现象一:打开蒙层瞬间弹窗掉帧明显现象:在低端测试机上,点击按钮弹出蒙层时掉到每秒 10 帧,弹窗动画明显卡顿。原因:第一版实现把 capture 和 blur 全放在主线程里。1080P 屏幕全尺寸截图本来就耗时,再做三遍模糊,每一遍都在遍历几十万个像素,主线程被阻塞了 200 多毫秒。解决:把模糊链路拆进 Dispatchers.Default,同时在采集阶段把 scaleFactor 提到 8。我在接入项目里验证过一组数据:同样做三遍半径 18 的模糊,全尺寸耗时约 180ms,降采样后降到 6ms。注意不要在 capture 和 blur 之间切回主线程,否则 Bitmap 对象跨线程传递没有可见性问题,但调度开销会白费。4.2 现象二:截出来的背景是黑屏现象:蒙层弹出来,背景区域全黑,只能看到前景文案和按钮。原因:这个现象有两个来源。一是目标屏幕上如果有 SurfaceView(比如视频播放器),SurfaceView 的内容是绘制在独立 Surface 上的,DecorView.draw() 根本拿不到它的像素,只能拿到黑色占位。二是开发者习惯在 capture 之后手动 recycle 掉 source Bitmap,结果 Canvas 还没画完就报错,表现为花屏而不是黑屏,但初学者容易混在一起排查。解决:第一个来源要么要求业务方把播放器改成 TextureView,要么在蒙层对应区域单独截取 SurfaceTexture 的纹理;通用组件内部只能做检测并打日志提示。第二个来源很简单,模糊函数返回的是新建 Bitmap,原图交给组件内部统一管理,onDetachedFromWindow 里再统一回收。4.3 现象三:状态栏那块区域模糊错位现象:蒙层整体下移,状态栏区域露出的背景明显没有经过模糊,和下面内容有一条分界线。原因:DecorView 的坐标系是从屏幕最顶端开始的,而业务上蒙层通常配在 setContentView 的布局里,起始点已经在状态栏下方。截屏时不做偏移,模糊出来的位图最上面一段是状态栏内容,绘制时却被对齐到了内容区顶部。解决:capture 时把 statusBarHeight 作为 Canvas 偏移量传入,也就是 3.2 节代码里的 canvas.translate(0f, -config.statusBarHeight.toFloat())。statusBarHeight 用 ViewCompat.getRootWindowInsets(decorView) 读取,不要自己写反射获取,不同厂商返回值的单位偶尔有坑。4.4 现象四:反复开关蒙层后内存陡增现象:连续开关弹窗十几次,Android Profiler 里看到一个明显的内存爬坡,每个都是几 MB 的 Byte 数组。原因:每次 capture 都创建一个新 Bitmap,blur 过程里又产生两个 IntArray(srcPixels 和 tmpPixels),加起来单次峰值内存大约是原图的 12 倍字节量。用户开关十次,GC 还没来得及回收,堆就被打满。解决:组件内部维护一个极简 Bitmap 复用池,宽高不变时直接复用上一次的 Bitmap 画 Canvas;同时把 srcPixels 和 tmpPixels 改成对象池。注意 createBitmap 不支持 inBitmap 复用,所以池子是自己用 ArrayDeque 包的,取出来前检查宽高是否一致。这个池子生命周期跟随 BlurMaskView,onDetachedFromWindow 时清空。5. 接入与运行:把蒙层组件装进业务工程并调通5.1 导入组件与 Gradle 依赖补齐接入方式很直接:把上述五个 Kotlin 文件拷进你的工程,保持包名一致即可。这个组件没有用任何 AndroidX 专属 API,唯一的外部依赖是 kotlinx-coroutines-android,因为异步链路里用到了 withContext(Dispatchers.Default)。如果你的工程本身已经在用协程,直接复用,不需要额外加。implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"如果你的工程是纯 Java 写的,也只有一个额外成本:把 attachTo 里的协程改写成 HandlerThread + Handler 即可,模糊函数本身不依赖协程。其实我建议你把 BoxBlur 和 BackgroundCapturer 当成普通 Java 类保留,将来任何业务都能直接调用。5.2 XML 布局与 Activity 里的接入代码以最常见的“弹窗蒙层”为例:蒙层包在 FrameLayout 里,压在所有内容之上,前景是一个居中的卡片。注意 BlurMaskView 的宽高必须是 match_parent,否则截出来的图没有足够区域容纳背景偏移。<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/bottom_list" android:layout_width="match_parent" android:layout_height="match_parent" /> <com.example.glassmask.mask.BlurMaskView android:id="@+id/mask_view" android:layout_width="match_parent" android:layout_height="match_parent" app:blur_radius="18" app:scale_factor="8" app:mask_color="#66000000" /> <LinearLayout android:id="@+id/dialog_card" android:layout_width="300dp" android:layout_height="wrap_content" android:layout_gravity="center"> <!-- 弹窗内容 --> </LinearLayout> </FrameLayout>Activity 里的逻辑分三步:找到 View、attach 到 DecorView、按下返回键时让蒙层消失。attach 的时机很关键,必须在 DecorView 测量完成之后,否则宽高为 0,capture 里 coerceAtLeast(1) 会截出 1x1 的图,蒙层会是一块纯色。class MainActivity : AppCompatActivity() { private lateinit var maskView: BlurMaskView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) maskView = findViewById(R.id.mask_view) // onGlobalLayout 内部会兜底宽高为 0 的情况 maskView.attachTo(window.decorView) maskView.setTouchThroughBounds(listOf( RectF(0f, 0f, 500f, 200f) )) } }逻辑说明:attachTo 不是在 onCreate 里立刻截屏,而是注册了 ViewTreeObserver.OnGlobalLayoutListener,等 DecorView 完成第一次布局后再触发。所以你在 onCreate 里调用也安全。setTouchThroughBounds 把屏幕顶部 500x200 区域设为可穿透,事件会漏到下面的列表。参数说明:stage 判断实际项目中通常会由“是否已显示过引导”之类状态控制,这块属于业务逻辑,组件不掺和。5.3 核心参数调优表与运行时报错排查调整毛玻璃效果时,我建议按“先采样率、再模糊半径、最后透明度”的顺序来,不要三个一起改,否则某个参数出问题你根本不知道是谁的锅。参数推荐范围调大效果调小效果scaleFactor6~10更省内存,速度更快,颗粒感变强画面更细腻,耗时和内存上升blurRadius12~25背景更糊,看不清具体文字偏透明玻璃,能隐约辨认文字maskColor 透明度0x40~0x8C前景内容更突出,背景压制更深背景透出更多,层次感更强passCount2~4更接近高斯平滑,耗时线性增加回退到“窗口平均”的粗糙感运行时报错最常见的三个,我在接入时都踩过:一是日志出现 “Canvas: trying to use a recycled bitmap”,原因是 Bitmap 在子线程被 recycle 了;二是 “Only the original thread that created a view hierarchy can touch its views”,原因是模糊结果在主线程之外调用了 invalidate;三是 “RenderScript not supported” 这行,那是历史版本用过时 API 留下的,当前组件里已经没有 RenderScript,换用 BoxBlur 后不会再有这条日志。6. 二次开发:把静态蒙层升级为跟随手势的实时动态遮罩6.1 动态模糊半径,让手感像系统通知栏一样跟手目前组件只在布局变化时算一次模糊。要做成“手指下拉、模糊跟着变强”的效果,需要公开一个 setBlurRadius 方法,内部用 ValueAnimator 把半径从 0 平滑过渡到目标值。fun animateBlurRadiusTo(targetRadius: Int, durationMs: Long = 300) { val start = config.blurRadius ValueAnimator.ofInt(start, targetRadius).apply { duration = durationMs addUpdateListener { anim -> setBlurRadius(anim.animatedValue as Int) } start() } }每帧回调 setBlurRadius 都会触发一次背景重截和模糊,所以这里必须做节流。我一般会让 capture 和 blur 复用 3.2 节那个小图,并且只在 radius 变化超过 2 个像素时才真正重算,否则直接沿用上一帧的 Bitmap。6.2 滚动监听更新与圆形挖空高亮静态截图有一个短板:底部列表滚动时,蒙层里的背景不会跟着动,看起来像贴了一张壁纸。改造方法是给目标 RecyclerView 注册 OnScrollListener,在 onScrolled 回调里调用 BlurMaskView.updateNow(),让组件重新走一遍 capture 链路。注意滚动监听里事件频率很高,建议配合跳跃缩放:列表每滚动超过 30 个像素才触发一次。圆形挖空则是 BlurRender.drawHighlight 里要做的事:先把模糊 Bitmap 画到 Canvas,再用 PorterDuff.Mode.DST_OUT 画一个圆,把圆内的透明度清零,露出清晰的原始内容。fun drawHighlight(canvas: Canvas, rect: RectF, radius: Float) { canvas.saveLayer(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat(), null) canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) paint.xfermode = PorterDuff.Mode.DST_OUT canvas.drawOval(rect, paint) canvas.restore() }6.3 验证方法改完后别只看着顺眼,上一台中端机在开发者选项里打开“GPU 渲染模式分析”,观察蒙层出现那一帧的条形图高度。三遍 BoxBlur 加全屏合成,小图场景下整帧耗时应该压在 8ms 以内,超过这个数先检查是不是又有人把 scaleFactor 调回 4。我还保留一个习惯:每轮改动都强制把关掉硬件加速的路径也跑一遍。因为很多自定义 View 只在软件绘制时才会暴露 Canvas 层级的错误,硬件加速下 GPU 自动帮你兜住了。那次我没关硬件加速,在几台低端机线上翻车之后,这个流程就成了固定动作。希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一份面向安卓开发者的玻璃蒙层实现工程,聚焦于半透明蒙层、高斯模糊及毛玻璃效果,适用于需要增强界面层次感与交互反馈的场景,技能上覆盖从初级到中级的界面特效实现。压缩包共55个文件,仅1.23MB,包含Java源码、编译产物、XML布局与主题资源、图片素材和第三方依赖库,并附可直接安装的APK,结构简洁,可快速导入查看。工程围绕“PicDim”示例展开,演示了Alpha通道颜色叠加、BitmapShader、Renderscript及第三方模糊库的多套方案,并处理了动态蒙层显示隐藏动画与异步模糊、硬件加速等性能优化,能帮助开发者理解不同实现路线的取舍与适用场景。已有909人学习下载,对于想在自己应用中复现毛玻璃对话框、模糊背景或过渡蒙层的开发者而言,是一份实用且轻量的参考样本。1. 玻璃蒙层:一个“半透明遮罩”背后藏着一整条渲染链路平时用第三方库做个毛玻璃遮罩只花五分钟,可真到要把「android玻璃蒙层」接进弹窗、引导页和侧边抽屉这三类场景时,问题就冒出来了:低版本设备上 RenderScript 被废弃之后带不动、截出来的背景是一块黑屏、列表滚动时模糊区域跟不上手势。这个项目把整条链路拆成“背景采集 → 降采样 → 模糊 → 蒙层合成”四个环节,用 Kotlin 封装成一个可以直接丢进工程的自定义 View,兼容 API 21+,核心逻辑不依赖任何第三方库。适合正在做弹出层、新手引导、局部高亮遮罩,以及想搞懂模糊到底怎么算出来的 Android 开发者。2. 技术选型与架构设计:三层结构与渲染管线的取舍2.1 三条实现路线对比,为什么没有选 RenderScriptAndroid 上做“把背景模糊掉”这件事,成熟思路其实只有三条,我先把结论放在前面:这条项目走的是“离屏截屏 + 三次 BoxBlur + Canvas 合成”路线。方案兼容范围性能表现实现成本后续维护坑RenderScript(ScriptIntrinsicBlur)API 19~30,31 起被官方废弃原生调度,整体较快低打包要带 support 库,部分新设备驱动行为不一致,已被官方移出依赖链WindowManager 系统级背景模糊仅 API 31+由系统合成器完成,耗电低极低Android 12 以下完全不可用,部分厂商 ROM 会忽略模糊参数离屏截屏 + 高斯模糊算法API 21+ 全兼容取决于采样率与算法复杂度中需要自己管内存、线程和 Bitmap 复用之前我一度想用 RenderScript,因为代码最短,两行就能把 Bitmap 模糊掉。但后来在一个 targetSdk 31 的项目里发现,RenderScript 相关类在部分设备上直接抛异常,官方文档也明确标记废弃,继续用等于给线上埋雷。而 WindowManager 那条路虽然清爽,但只服务 Android 12+,一个做通用组件的项目不可能接受这个限制。所以最后走的是第三条路:截当前 Window 的 DecorView 到一张小尺寸 Bitmap,做模糊,再盖一层半透明颜色。这样做的最大好处是把“模糊成什么样”完全控制在自己手里,后面要支持动态跟手、局部挖空、圆角遮罩,都只需要在 Canvas 层做文章。2.2 三层架构:采集、模糊、渲染互相解耦我拆项目时习惯先把“会变化的部分”和“相对稳定的部分”分开。玻璃蒙层里最容易变的是三个点:背景从哪来、模糊算法用哪种、蒙层怎么画。所以这个源码包内部被拆成了三个层次:采集层(BackgroundCapturer):负责从 DecorView 拿到背景 Bitmap,内部做降采样,避免 1080P 屏幕全尺寸截屏导致 OOM。模糊层(BoxBlur):负责把 Bitmap 做近似高斯模糊,只暴露一个 blur(src, radius) 方法;将来想换 StackBlur 或接入 RenderEffect,只动这一层。渲染层(BlurMaskView):最终用户看到的 View,持有模糊后的 Bitmap,在 onDraw 里合成背景和半透明遮罩色,同时负责触摸事件穿透。三者之间的调用链是这样的伪代码:fun attachTo(decorView: View) { // 1. 监听布局变化,等 DecorView 拿到有效的宽高 decorView.viewTreeObserver.addOnGlobalLayoutListener { // 2. 采集层截取背景,按 scaleFactor 缩成小图 val captured = backgroundCapturer.capture(decorView, scaleFactor) // 3. 模糊层做三遍 BoxBlur val blurred = BoxBlur.blur(captured, blurRadius) // 4. 渲染层持有结果并触发重绘 blurBitmap = blurred invalidate() } }这个链路里每一步的输入输出都是 Bitmap 或 View,彼此不持有内部引用。比如某一天你想让背景来源换成“当前页面下方某个列表的截图”,只需要替换采集层的实现,模糊层和渲染层完全不用碰。这也是我建议你在二次开发时遵守的边界:别把截屏逻辑写进 onDraw。2.3 目录结构与核心类职责源码包里的代码不算多,去掉注释和空行大概六百行左右,结构上按职责分了五个文件:glassmask/src/main/java/com/example/glassmask/ ├── mask/ │ ├── BlurMaskView.kt // 自定义 View,对外入口 │ ├── BackgroundCapturer.kt // 背景采集与降采样 │ ├── BoxBlur.kt // 三遍 BoxBlur 近似高斯模糊 │ ├── BlurRender.kt // 蒙层合成与圆形挖空绘制 │ └── MaskConfig.kt // 统一参数配置,可被 XML 属性覆盖BlurMaskView 是所有逻辑的对外门面,持有另外三个类;MaskConfig 用 data class 收拢 blurRadius、scaleFactor、maskColor、passCount 这几个可调参数。这种写法让单元测试变得很好做:想验证模糊算法,直接 new 一个 BoxBlur 丢一张测试图进去,不需要起模拟器。3. 核心代码拆解:从 DecorView 截屏到模糊上屏的完整链路3.1 BlurMaskView 骨架与 attachTo 调用链BlurMaskView 是项目里唯一需要被业务代码直接触碰的类。构造函数不直接拿 Activity,而是暴露一个 attachTo 方法,让调用方决定“模糊哪块内容”。这个设计是有意为之:如果你在构造函数里通过 context 强转 Activity 拿 DecorView,那这个组件就没法用在 Dialog 的 Window 上,也没法只对某个 Fragment 区域做模糊。class BlurMaskView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val config = MaskConfig() private val capturer = BackgroundCapturer() private var blurBitmap: Bitmap? = null private val drawPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) fun attachTo(decorView: View) { decorView.viewTreeObserver.addOnGlobalLayoutListener { val w = decorView.width val h = decorView.height if (w == 0 || h == 0) return@addOnGlobalLayoutListener scope.launch { val captured = withContext(Dispatchers.Default) { capturer.capture(decorView, config.scaleFactor, config) } val blurred = withContext(Dispatchers.Default) { BoxBlur.blur(captured, config.blurRadius, config.passCount) } blurBitmap = blurred invalidate() } } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) blurBitmap?.let { bmp -> canvas.drawBitmap( bmp, null, Rect(0, 0, width, height), drawPaint ) } canvas.drawColor(config.maskColor) BlurRender.drawHighlight(canvas, config.highlightRect, config.highlightRadius) } }逻辑说明:整个模糊链路由 attachTo 触发,核心跳过了主线程。capture 和 blur 都丢到 Dispatchers.Default,只有在最后把 Bitmap 赋给成员变量并 invalidate 时才回到主线程。这里用 CoroutineScope 而不是直接在 onGlobalLayout 里同步算,是为了避免打开弹窗瞬间卡住主线程。参数说明:config.scaleFactor 控制采样倍率,数值越大背景图越小、模糊越快,但颗粒感越重;config.blurRadius 是模糊半径,单位是“小图上的像素”,不是原始屏幕像素,这个换算关系在 3.3 节会展开讲;config.maskColor 用 ARGB 格式,比如 0x66000000 表示半透明黑。highlightRect 和 highlightRadius 是给引导页高亮用的,默认 null 时不绘制。3.2 背景采集:降采样和 Canvas.draw 的正确用法采集层是整个项目最容易翻车的地方。新手通常会调用 view.getDrawingCache(),这个 API 有两个问题:一是它会强制触发一次完整的 measure 和 layout,性能开销不可控;二是它不支持自定义采样率,拿回来的永远是全尺寸图,后续模糊压力很大。正确做法是手动创建一张小尺寸 Bitmap,用 Canvas 把 DecorView 画进去,画之前对 Canvas 做 scale。这样一次 draw 调用,既完成了截屏又完成了降采样。object BackgroundCapturer { fun capture(decorView: View, scaleFactor: Int, config: MaskConfig): Bitmap { val targetW = (decorView.width / scaleFactor).coerceAtLeast(1) val targetH = (decorView.height / scaleFactor).coerceAtLeast(1) val bitmap = Bitmap.createBitmap(targetW, targetH, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) canvas.scale(1f / scaleFactor, 1f / scaleFactor) canvas.translate(0f, -config.statusBarHeight.toFloat()) decorView.draw(canvas) return bitmap } }逻辑说明:先算目标宽高,再创建 Bitmap,然后用 Canvas.scale 把 DecorView 的内容“压”进小图。注意这里 translate 了 statusBarHeight,是因为 DecorView 包含状态栏区域,而业务蒙层通常从状态栏下方开始,如果不平移,模糊出来的画面会整体错位。参数说明:scaleFactor 一般取 6 到 10。取 8 意味着 1080x2400 的屏幕会被压成 135x300 的小图,像素量只有原来的 1/64,后续做三遍 BoxBlur 的耗时基本可以控制在个位数毫秒。数值再小比如 4,模糊质量会更高,但内存和耗时都会线性上涨;再大比如 16,背景会糊成色块,蒙层一盖基本看不出原本内容。3.3 三次 BoxBlur 实现的高斯近似模糊模糊算法的选择上,我没有直接用 StackBlur,而是写了更精简的三遍 BoxBlur。BoxBlur 的原理是滑动窗口平均值:每个像素取周围 2r+1 个像素的平均值。单次 BoxBlur 的效果偏“生硬”,但连续做三次之后,曲线会逼近高斯模糊,视觉上足够干净。object BoxBlur { fun blur(src: Bitmap, radius: Int, passCount: Int = 3): Bitmap { var current = src repeat(passCount) { current = apply(current, radius) } return current } private fun apply(src: Bitmap, r: Int): Bitmap { val w = src.width val h = src.height val srcPixels = IntArray(w * h) val tmpPixels = IntArray(w * h) src.getPixels(srcPixels, 0, w, 0, 0, w, h) val div = 2 * r + 1 // 水平方向滑动窗口 for (y in 0 until h) { var sumA = 0 var sumR = 0 var sumG = 0 var sumB = 0 for (x in -r..r) { val px = srcPixels[y * w + x.coerceIn(0, w - 1)] sumA += (px ushr 24) and 0xFF sumR += (px ushr 16) and 0xFF sumG += (px ushr 8) and 0xFF sumB += px and 0xFF } for (x in 0 until w) { val idx = y * w + x tmpPixels[idx] = ((sumA / div) shl 24) or ((sumR / div) shl 16) or ((sumG / div) shl 8) or (sumB / div) val xOut = (x - r).coerceIn(0, w - 1) val xIn = (x + r + 1).coerceIn(0, w - 1) val outPx = srcPixels[y * w + xOut] val inPx = srcPixels[y * w + xIn] sumA += ((inPx ushr 24) and 0xFF) - ((outPx ushr 24) and 0xFF) sumR += ((inPx ushr 16) and 0xFF) - ((outPx ushr 16) and 0xFF) sumG += ((inPx ushr 8) and 0xFF) - ((outPx ushr 8) and 0xFF) sumB += (inPx and 0xFF) - (outPx and 0xFF) } } // 垂直方向滑动窗口,结果写回 srcPixels for (x in 0 until w) { var sumA = 0 var sumR = 0 var sumG = 0 var sumB = 0 for (y in -r..r) { val srcIdx = y.coerceIn(0, h - 1) * w + x val px = tmpPixels[srcIdx] sumA += (px ushr 24) and 0xFF sumR += (px ushr 16) and 0xFF sumG += (px ushr 8) and 0xFF sumB += px and 0xFF } for (y in 0 until h) { val idx = y * w + x srcPixels[idx] = ((sumA / div) shl 24) or ((sumR / div) shl 16) or ((sumG / div) shl 8) or (sumB / div) val yOut = (y - r).coerceIn(0, h - 1) val yIn = (y + r + 1).coerceIn(0, h - 1) val outPx = tmpPixels[yOut * w + x] val inPx = tmpPixels[yIn * w + x] sumA += ((inPx ushr 24) and 0xFF) - ((outPx ushr 24) and 0xFF) sumR += ((inPx ushr 16) and 0xFF) - ((outPx ushr 16) and 0xFF) sumG += ((inPx ushr 8) and 0xFF) - ((outPx ushr 8) and 0xFF) sumB += (inPx and 0xFF) - (outPx and 0xFF) } } return Bitmap.createBitmap(srcPixels, w, h, Bitmap.Config.ARGB_8888) } }逻辑说明:滑动窗口的关键技巧是不必每个像素重新遍历 2r+1 个邻居,只需要在窗口向右移动时,把新进入窗口的像素加到 sum,把滑出窗口的像素从 sum 里减掉。这段代码先水平扫一遍把结果存在 tmpPixels,再垂直扫一遍写回 srcPixels,最后用 createBitmap 生成新图。ARGB 四个通道分别累加再分别求平均,最后用位运算拼回一个像素。参数说明:r 是模糊半径,在组件里它作用于降采样后的小图,所以视觉上的等效模糊范围约等于 r * scaleFactor。比如 scaleFactor 为 8,r 配 18,等效原始分辨率下 144 像素的模糊范围,这也是为什么很多时候小图半径设 15~25 就已经能出很明显的毛玻璃感。passCount 默认 3,大于 3 边际收益递减,小于 3 会有明显“糖块感”。3.4 蒙层合成、点击穿透与异步更新蒙层合成在 onDraw 里完成:先画模糊 Bitmap,再盖一层 maskColor。点击穿透则是重写 onTouchEvent,判断触摸点是否落在“允许穿透”的区域。这块逻辑单拎出来说,是因为 Android 的触摸事件是在 dispatchTouchEvent 阶段就往下走的,onTouchEvent 返回 false 只代表当前 View 不消费,并不能直接把事件“送回”给下方兄弟视图,所以组件选择用 Region 判定来决定是否拦截。class BlurMaskView @JvmOverloads constructor(...) : View(...) { private val touchRegion = Region() private val highlightRect = RectF() fun setTouchThroughBounds(bounds: List<RectF>) { touchRegion.setEmpty() for (rect in bounds) { val scaled = Rect( rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt() ) touchRegion.op(scaled, Region.Op.UNION) } invalidate() } override fun onTouchEvent(event: MotionEvent): Boolean { val x = event.x.toInt() val y = event.y.toInt() // 点在允许穿透的区域里,放行给下面 return !touchRegion.contains(x, y) } }逻辑说明:setTouchThroughBounds 接收一组 RectF,把它们合并进 Region。onTouchEvent 里做一次 contains 判断,如果点在穿透区域内就返回 false,否则返回 true 消费事件。背面那层模糊背景本身不需要响应点击,返回 true 会阻止点击蔓延到蒙层之下的列表,避免出现“想关弹窗却点到列表项”的误触。参数说明:bounds 里的坐标系是蒙层 View 的本地坐标,调用方如果拿的是屏幕坐标,记得先做 View 坐标换算。Region.Op.UNION 是并集操作,多个矩形可以直接叠加成一块不规则点击区。4. 性能与避坑记录:黑屏、掉帧、错位、OOM 的排查实战4.1 现象一:打开蒙层瞬间弹窗掉帧明显现象:在低端测试机上,点击按钮弹出蒙层时掉到每秒 10 帧,弹窗动画明显卡顿。原因:第一版实现把 capture 和 blur 全放在主线程里。1080P 屏幕全尺寸截图本来就耗时,再做三遍模糊,每一遍都在遍历几十万个像素,主线程被阻塞了 200 多毫秒。解决:把模糊链路拆进 Dispatchers.Default,同时在采集阶段把 scaleFactor 提到 8。我在接入项目里验证过一组数据:同样做三遍半径 18 的模糊,全尺寸耗时约 180ms,降采样后降到 6ms。注意不要在 capture 和 blur 之间切回主线程,否则 Bitmap 对象跨线程传递没有可见性问题,但调度开销会白费。4.2 现象二:截出来的背景是黑屏现象:蒙层弹出来,背景区域全黑,只能看到前景文案和按钮。原因:这个现象有两个来源。一是目标屏幕上如果有 SurfaceView(比如视频播放器),SurfaceView 的内容是绘制在独立 Surface 上的,DecorView.draw() 根本拿不到它的像素,只能拿到黑色占位。二是开发者习惯在 capture 之后手动 recycle 掉 source Bitmap,结果 Canvas 还没画完就报错,表现为花屏而不是黑屏,但初学者容易混在一起排查。解决:第一个来源要么要求业务方把播放器改成 TextureView,要么在蒙层对应区域单独截取 SurfaceTexture 的纹理;通用组件内部只能做检测并打日志提示。第二个来源很简单,模糊函数返回的是新建 Bitmap,原图交给组件内部统一管理,onDetachedFromWindow 里再统一回收。4.3 现象三:状态栏那块区域模糊错位现象:蒙层整体下移,状态栏区域露出的背景明显没有经过模糊,和下面内容有一条分界线。原因:DecorView 的坐标系是从屏幕最顶端开始的,而业务上蒙层通常配在 setContentView 的布局里,起始点已经在状态栏下方。截屏时不做偏移,模糊出来的位图最上面一段是状态栏内容,绘制时却被对齐到了内容区顶部。解决:capture 时把 statusBarHeight 作为 Canvas 偏移量传入,也就是 3.2 节代码里的 canvas.translate(0f, -config.statusBarHeight.toFloat())。statusBarHeight 用 ViewCompat.getRootWindowInsets(decorView) 读取,不要自己写反射获取,不同厂商返回值的单位偶尔有坑。4.4 现象四:反复开关蒙层后内存陡增现象:连续开关弹窗十几次,Android Profiler 里看到一个明显的内存爬坡,每个都是几 MB 的 Byte 数组。原因:每次 capture 都创建一个新 Bitmap,blur 过程里又产生两个 IntArray(srcPixels 和 tmpPixels),加起来单次峰值内存大约是原图的 12 倍字节量。用户开关十次,GC 还没来得及回收,堆就被打满。解决:组件内部维护一个极简 Bitmap 复用池,宽高不变时直接复用上一次的 Bitmap 画 Canvas;同时把 srcPixels 和 tmpPixels 改成对象池。注意 createBitmap 不支持 inBitmap 复用,所以池子是自己用 ArrayDeque 包的,取出来前检查宽高是否一致。这个池子生命周期跟随 BlurMaskView,onDetachedFromWindow 时清空。5. 接入与运行:把蒙层组件装进业务工程并调通5.1 导入组件与 Gradle 依赖补齐接入方式很直接:把上述五个 Kotlin 文件拷进你的工程,保持包名一致即可。这个组件没有用任何 AndroidX 专属 API,唯一的外部依赖是 kotlinx-coroutines-android,因为异步链路里用到了 withContext(Dispatchers.Default)。如果你的工程本身已经在用协程,直接复用,不需要额外加。implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"如果你的工程是纯 Java 写的,也只有一个额外成本:把 attachTo 里的协程改写成 HandlerThread + Handler 即可,模糊函数本身不依赖协程。其实我建议你把 BoxBlur 和 BackgroundCapturer 当成普通 Java 类保留,将来任何业务都能直接调用。5.2 XML 布局与 Activity 里的接入代码以最常见的“弹窗蒙层”为例:蒙层包在 FrameLayout 里,压在所有内容之上,前景是一个居中的卡片。注意 BlurMaskView 的宽高必须是 match_parent,否则截出来的图没有足够区域容纳背景偏移。<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/bottom_list" android:layout_width="match_parent" android:layout_height="match_parent" /> <com.example.glassmask.mask.BlurMaskView android:id="@+id/mask_view" android:layout_width="match_parent" android:layout_height="match_parent" app:blur_radius="18" app:scale_factor="8" app:mask_color="#66000000" /> <LinearLayout android:id="@+id/dialog_card" android:layout_width="300dp" android:layout_height="wrap_content" android:layout_gravity="center"> <!-- 弹窗内容 --> </LinearLayout> </FrameLayout>Activity 里的逻辑分三步:找到 View、attach 到 DecorView、按下返回键时让蒙层消失。attach 的时机很关键,必须在 DecorView 测量完成之后,否则宽高为 0,capture 里 coerceAtLeast(1) 会截出 1x1 的图,蒙层会是一块纯色。class MainActivity : AppCompatActivity() { private lateinit var maskView: BlurMaskView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) maskView = findViewById(R.id.mask_view) // onGlobalLayout 内部会兜底宽高为 0 的情况 maskView.attachTo(window.decorView) maskView.setTouchThroughBounds(listOf( RectF(0f, 0f, 500f, 200f) )) } }逻辑说明:attachTo 不是在 onCreate 里立刻截屏,而是注册了 ViewTreeObserver.OnGlobalLayoutListener,等 DecorView 完成第一次布局后再触发。所以你在 onCreate 里调用也安全。setTouchThroughBounds 把屏幕顶部 500x200 区域设为可穿透,事件会漏到下面的列表。参数说明:stage 判断实际项目中通常会由“是否已显示过引导”之类状态控制,这块属于业务逻辑,组件不掺和。5.3 核心参数调优表与运行时报错排查调整毛玻璃效果时,我建议按“先采样率、再模糊半径、最后透明度”的顺序来,不要三个一起改,否则某个参数出问题你根本不知道是谁的锅。参数推荐范围调大效果调小效果scaleFactor6~10更省内存,速度更快,颗粒感变强画面更细腻,耗时和内存上升blurRadius12~25背景更糊,看不清具体文字偏透明玻璃,能隐约辨认文字maskColor 透明度0x40~0x8C前景内容更突出,背景压制更深背景透出更多,层次感更强passCount2~4更接近高斯平滑,耗时线性增加回退到“窗口平均”的粗糙感运行时报错最常见的三个,我在接入时都踩过:一是日志出现 “Canvas: trying to use a recycled bitmap”,原因是 Bitmap 在子线程被 recycle 了;二是 “Only the original thread that created a view hierarchy can touch its views”,原因是模糊结果在主线程之外调用了 invalidate;三是 “RenderScript not supported” 这行,那是历史版本用过时 API 留下的,当前组件里已经没有 RenderScript,换用 BoxBlur 后不会再有这条日志。6. 二次开发:把静态蒙层升级为跟随手势的实时动态遮罩6.1 动态模糊半径,让手感像系统通知栏一样跟手目前组件只在布局变化时算一次模糊。要做成“手指下拉、模糊跟着变强”的效果,需要公开一个 setBlurRadius 方法,内部用 ValueAnimator 把半径从 0 平滑过渡到目标值。fun animateBlurRadiusTo(targetRadius: Int, durationMs: Long = 300) { val start = config.blurRadius ValueAnimator.ofInt(start, targetRadius).apply { duration = durationMs addUpdateListener { anim -> setBlurRadius(anim.animatedValue as Int) } start() } }每帧回调 setBlurRadius 都会触发一次背景重截和模糊,所以这里必须做节流。我一般会让 capture 和 blur 复用 3.2 节那个小图,并且只在 radius 变化超过 2 个像素时才真正重算,否则直接沿用上一帧的 Bitmap。6.2 滚动监听更新与圆形挖空高亮静态截图有一个短板:底部列表滚动时,蒙层里的背景不会跟着动,看起来像贴了一张壁纸。改造方法是给目标 RecyclerView 注册 OnScrollListener,在 onScrolled 回调里调用 BlurMaskView.updateNow(),让组件重新走一遍 capture 链路。注意滚动监听里事件频率很高,建议配合跳跃缩放:列表每滚动超过 30 个像素才触发一次。圆形挖空则是 BlurRender.drawHighlight 里要做的事:先把模糊 Bitmap 画到 Canvas,再用 PorterDuff.Mode.DST_OUT 画一个圆,把圆内的透明度清零,露出清晰的原始内容。fun drawHighlight(canvas: Canvas, rect: RectF, radius: Float) { canvas.saveLayer(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat(), null) canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) paint.xfermode = PorterDuff.Mode.DST_OUT canvas.drawOval(rect, paint) canvas.restore() }6.3 验证方法改完后别只看着顺眼,上一台中端机在开发者选项里打开“GPU 渲染模式分析”,观察蒙层出现那一帧的条形图高度。三遍 BoxBlur 加全屏合成,小图场景下整帧耗时应该压在 8ms 以内,超过这个数先检查是不是又有人把 scaleFactor 调回 4。我还保留一个习惯:每轮改动都强制把关掉硬件加速的路径也跑一遍。因为很多自定义 View 只在软件绘制时才会暴露 Canvas 层级的错误,硬件加速下 GPU 自动帮你兜住了。那次我没关硬件加速,在几台低端机线上翻车之后,这个流程就成了固定动作。希望帮到你。本文还有配套的精品资源,点击获取