简介:面向Android开发者的玻璃蒙层实现资源,重点解决界面半透明、模糊与毛玻璃视觉效果问题,覆盖高斯模糊、BitmapShader、Renderscript、第三方模糊库等关键技术点,适合关注UI层次感与交互反馈的进阶开发者。压缩包共55个文件,体积1.23MB,内容包含java源码与class字节码、xml布局文件、png图片资源、jar依赖库,以及可运行的apk和工程配置信息,便于从源码到运行全方位研读;其中xml配合alpha属性与PorterDuff混合模式控制透明度,java等文件承载模糊算法与动态动画逻辑。资源结合PicDim图片处理工具,完整演示了颜色透明度叠加、动态蒙层显隐动画和异步模糊性能优化等落地思路,并说明了大图与复杂效果下的性能处理方法,可直接迁移到实际项目。已有907人学习下载,对希望提升Android界面质感、深入理解模糊算法工程化实现的开发者有很高的参考价值。 讲真,Android上做玻璃蒙层这件事,我踩过的坑比大多数人写过的代码都多。记得几年前第一次在项目里接到毛玻璃弹窗需求,我天真地以为给View设个半透明背景再调下模糊半径就完事了,结果一跑起来直接傻眼——背景是糊了,但糊的是自己View里面的内容,后面的界面该长什么样还长什么样。后来翻了一圈资料才发现,Android的模糊和iOS完全不是一个路子,你得自己想办法把后面的画面抓出来再做处理。
这篇博文就把我做Android玻璃蒙层的完整思路、代码实现和踩坑记录都整理出来。不管你是要做弹窗背景、底部导航栏、还是那种沉浸式的状态栏毛玻璃效果,这篇都能给你一个可以直接落地的方案。
1. 玻璃蒙层的整体设计与方案选型
1.1 核心需求拆解:我们要的到底是什么
先想清楚“玻璃蒙层”这个问题到底在解决什么。用户看到的玻璃效果,本质上是前景半透明层对背景画面进行实时模糊,产生一种“透过去但看不清”的视觉反馈。它和普通半透明遮罩最大的区别在于:半透明遮罩只能改变背景的亮度或色相,而玻璃蒙层会抹掉背景的细节,让文字、图片等信息在蒙层下方无法被辨认,同时保留大色块和轮廓光线,看起来高级、干净。
在Android里实现这个效果,按技术路径分无非三种:原生RenderEffect实时模糊、第三方库BlurView/模糊位图、以及静态模糊图兜底。各有各的适用场景,我得先看看项目实际要什么。
如果说你的目标页面是那种全屏弹窗,背景是一张静态图片或者内容基本不动的界面,直接用我后面讲的BlurView方案最省事,它是通过抓取整个Window的Bitmap再模糊后塞到View里,实时性和兼容性都比较均衡。如果App的最低版本是Android 12(API 31)以上,那直接用系统自带的RenderEffect挂在View上做实时模糊,性能和效果都是最好的,代码量最少,还没什么兼容性负担。如果只是想要一个“看起来像毛玻璃”的背景,对实时性要求不高,比如某个页面的头部区域、底部操作栏,那用静态模糊图加渐变遮罩的组合就够了,性能开销最低。
我这次做的是一个带“玻璃蒙层”效果的全局弹窗组件,要求是弹窗出现时背景内容被实时模糊,同时弹窗本身是一个半透明的玻璃质感面板。技术选型上我直接选了BlurView做核心方案,再用RenderEffect作为高版本设备的优化通道,双轨并行,后面我会细讲这么搭的原因。
1.2 方案对比:三种主流实现路径的取舍
选择之前,我先把三种方案的核心机制和利弊列出来对比一下,方便后面按需选型。
| 方案 | 核心机制 | 最低系统要求 | 实时性 | 性能开销 | 局限 |
|---|---|---|---|---|---|
| RenderEffect | 基于RenderNode的GPU实时模糊 | Android 12+ | 完全实时 | 低 | 低版本不可用 |
| BlurView(第三方库) | 截取Window位图+ScriptIntrinsicBlur模糊 | Android 4.1+ | 接近实时 | 中高,需控制刷新频率 | 复杂页面可能出现截屏延迟 |
| 静态模糊图 | 预模糊的图片或渐变遮罩 | 所有版本 | 无 | 几乎为零 | 无法匹配动态背景 |
看到这个表你可能会觉得,既然RenderEffect性能好、代码简单,直接只用它不就行了。问题就在于国内存量设备里Android 12以下的机型还占着不小比例,产品说要兼容到Android 8,那RenderEffect就只能当增强优化用,不能当主力方案。BlurView这种“截屏-模糊-呈现”的思路虽然有点古老,但它硬是能跑在Android 4.1以上的所有设备上,兼容性就是它最大的价值。
静态模糊图方案我没法把它作为核心,因为它的表现力差了一个量级——背景一变,静态图就露馅了。但在个别场景我会拿它做保底策略,比如极低端设备上BlurView性能扛不住的时候,自动降级成静态模糊图,体验不至于完全缺失。
1.3 为什么最终选择“BlurView + RenderEffect”双轨结构
标准库和自研,我用的是自研的轻量实现,架构上其实借鉴了一些开源库的思路。核心原因有三个。
第一,接口风格统一。自研组件对外暴露的是“模糊半径、蒙层颜色、蒙层透明度”三个参数,内部根据系统版本来决定用RenderEffect还是BlurView,这样上层业务完全不需要关心实现细节,切换和降级都在内部完成。
第二,降级链路清晰。在主流的Android 10、11设备上跑BlurView,在Android 12以上跑RenderEffect,如果BlurView抓屏失败或者性能掉帧太厉害,自动从模糊半径降到半透明遮罩。这条降级路径在自研组件里可以做得非常顺滑,避免线上出现白屏或卡死。
第三,可控性更强。很多开源库的模糊效果在处理RecyclerView滚动、视频播放这类高频刷新场景时会出现明显的滞后感和锯齿感,自研以后可以对刷新频率、缓存策略做精细控制,这个后面在性能优化章节详细说。
2. 核心细节解析:模糊原理与关键参数
2.1 Bitmap模糊处理的原理:别再把模糊当滤镜
先补一个基础点:Android里做模糊,本质上是对一张位图的像素点做卷积计算。每个像素的新值由它周围一定半径内的像素决定,半径(radius)越大,采样范围越大,模糊效果越强。听起来不复杂,但计算量非常大——一张1080×1920的图,如果半径设为25,那每个像素要计算大约1961个周边像素的加权和,性能再好的手机也扛不住。
所以BlurView这类方案在实际操作中的做法是:先把要模糊的View区域截成Bitmap,然后把这个Bitmap缩小到原来的1/8甚至1/16,再进行模糊计算。因为模糊算法对小图的计算量是平方级下降的,缩小的图算完以后再放大回原尺寸,视觉效果上几乎看不出区别。这也是为什么网上很多模糊实现代码里都有scaleFactor这个参数,它就是干这个用的。
2.2 关键参数:模糊半径、缩放因子、蒙层颜色
自研玻璃蒙层组件,我对外暴露了三个核心参数,下面给出我实践下来比较稳的配置区间。
| 参数 | 作用 | 推荐值 | 备注 |
|---|---|---|---|
| blurRadius | 模糊强度 | 10~25dp | 小于10几乎看不出效果,大于25会糊成一片 |
| scaleFactor | 位图缩放比 | 0.1~0.2 | 越小性能越好,但过小会显得模糊不均匀 |
| overlayColor | 蒙层颜色与透明度 | #66000000(约40%黑) | 深色背景建议透明度再低一点,避免画面太黑 |
这几个参数不是随便配的。模糊半径决定了“玻璃感”的强弱,太小看着像普通半透明遮罩,太大背景完全变成色块;缩放因子是性能和效果之间的天平,我在多台测试机上对比过,0.125这个值比较平衡,也就是先把图缩到1/8再模糊再放大;蒙层颜色是决定“高级感”的关键,很多新手直接把蒙层做成纯白半透明,配浅色背景还好,遇到深色背景就显脏,建议用黑色或深灰色配低透明度,风格更统一。
2.3 对背景内容动态变化的处理思路
玻璃蒙层最容易翻车的地方,其实是背景在动。比如背景里有一个轮播Banner在自动切图,或者用户在蒙层后面的列表还在滚动,如果模糊是静态的,画面就会穿帮。
BlurView解决这个问题的思路是监听View树的绘制事件,在每一帧绘制前判断背景区域是否发生变化,如果变了就重新截屏、模糊、更新纹理。实现方式可以粗犷也可以精细。粗犷做法是每隔几十毫秒强制刷新一次;精细做法是给需要模糊的View设置ViewTreeObserver.OnDrawListener,只在真正发生绘制的时候才触发重算。
我这边还做了一个小优化:同一个Window里可能有多个玻璃蒙层组件,比如顶部一个、底部一个,它们是共享同一份截屏Bitmap的,刷新的时候只截一次屏,模糊计算也可以复用中间结果,避免重复消耗性能。
2.4 完整实现代码:一个可直接运行的玻璃蒙层组件
直接上代码,这是我封装的一个自研玻璃蒙层View的核心实现,做了简化,但主流程完整,你可以直接跑起来看效果。
class GlassOverlayView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { private var blurRadius = 20f private var scaleFactor = 0.125f private var overlayColor = Color.parseColor("#66000000") private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private var blurredBitmap: Bitmap? = null private var renderEffect: RenderEffect? = null // 是否使用 RenderEffect private val useRenderEffect: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (useRenderEffect) { // 高版本:直接用 RenderEffect 做实时模糊 renderEffect = RenderEffect.createBlurEffect( blurRadius, blurRadius, Shader.TileMode.CLAMP ) paint.renderEffect = renderEffect canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) } else { // 低版本:绘制抓取并模糊的 Bitmap,再叠加半透明遮罩 blurredBitmap?.let { bitmap -> canvas.drawBitmap(bitmap, null, Rect(0, 0, width, height), paint) } canvas.drawColor(overlayColor) } } /** * 刷新背景模糊,需要从外部获取根视图引用 * @param rootView 当前 Window 的根视图 */ fun refreshBlur(rootView: View) { if (useRenderEffect) { invalidate() return } val location = IntArray(2) getLocationOnScreen(location) val screenBitmap = grabScreenBitmap(rootView) if (screenBitmap == null) { // 抓屏失败,降级为纯色遮罩 blurredBitmap = null invalidate() return } // 按缩放因子缩小,再进行模糊 val scaledBitmap = Bitmap.createScaledBitmap( screenBitmap, (width * scaleFactor).toInt().coerceAtLeast(1), (height * scaleFactor).toInt().coerceAtLeast(1), true ) // 回收大图,避免内存飙升 if (screenBitmap != scaledBitmap) { screenBitmap.recycle() } blurredBitmap = blurBitmap(scaledBitmap, blurRadius * scaleFactor) scaledBitmap.recycle() invalidate() } private fun grabScreenBitmap(rootView: View): Bitmap? { val bitmap = Bitmap.createBitmap( rootView.width, rootView.height, Bitmap.Config.ARGB_8888 ) val canvas = Canvas(bitmap) rootView.draw(canvas) return bitmap } private fun blurBitmap(bitmap: Bitmap, radius: Float): Bitmap? { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { return bitmap } val rs = RenderScript.create(context) val input = Allocation.createFromBitmap(rs, bitmap) val output = Allocation.createTyped(rs, input.type) val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)) script.setRadius(radius) script.setInput(input) script.forEach(output) output.copyTo(bitmap) input.destroy() output.destroy() script.destroy() rs.destroy() return bitmap } }这段代码有一个很关键的设计,就是RenderEffect分支和BlurView分支共用同一个View入口,上层只需要调用refreshBlur()就能触发模糊刷新。在Android 12以上,refreshBlur()实际只做了一件事——invalidate(),剩下的交给系统对View做实时模糊。而在低版本上,会走“截屏-缩放-模糊-重绘”的老路子。
2.5 在协调布局(CoordinatorLayout)+ Banner场景下的适配
热词里有人提到“协调布局+banner”的组合,这个场景我正好专门调过。Banner轮播图所在的页面通常会有一个ImageView或者ViewPager2,玻璃蒙层往往要覆盖一部分Banner区域形成沉浸式视觉。这里要注意的一个坑是:截屏不能用View的draw()方法,因为部分硬件加速下的View并不会把内容画进你手动创建的Canvas里,尤其是视频、SurfaceView这些基于独立窗口的组件,抓出来是黑块。
解决办法有两个。一个是改用PixelCopy,从Window层面做截屏,这样能拿到GPU合成后的真实画面,视频和Banner都能正确显示;另一个是把轮播图的当前帧单独画到Bitmap里,作为模糊源。前者通用性强但代码多一点,后者只适用于已知内容源的场景。我这边实际项目里用的PixelCopy方案,具体实现下一节一起来说。
3. 实操过程:完整接入与关键环节实现
3.1 第一步:确定模糊源的获取方式
要把后面的内容变模糊,首先得拿到后面的画面。在Android里,实际操作可以分两种方式:
第一种是旧的View.draw方式,直接把根View画进Bitmap,优点是兼容API 19,缺点是遇到硬件加速层(比如SurfaceView、部分WebView内容)会抓到黑块或者空白。第二种是用PixelCopy,它从系统层面抓取指定窗口的像素,能拿到屏幕真正显示出来的画面,和手动draw的结果差别很大。
下面是PixelCopy的关键代码,从一个Window获取像素:
fun captureWindowPixelCopy( window: Window, onResult: (Bitmap) -> Unit ) { val bitmap = Bitmap.createBitmap( window.decorView.width, window.decorView.height, Bitmap.Config.ARGB_8888 ) val location = IntArray(2) window.decorView.getLocationOnScreen(location) PixelCopy.request(window, bitmap, { copyResult -> if (copyResult == PixelCopy.SUCCESS) { onResult(bitmap) } else { // 失败则走备选逻辑 } }, Handler(Looper.getMainLooper())) }这段代码可以放在Activity或者Dialog里调用,拿到Bitmap以后再裁剪出玻璃蒙层所在的区域,交给模糊管线处理。实测下来PixelCopy的效率和稳定性都高出View.draw一截,但注意它要求Android 8.0(API 26)以上,所以低版本还得保留View.draw作为兼容路径。
3.2 第二步:模糊运算的性能优化策略
拿到屏幕Bitmap之后,不要直接对它做模糊运算,必须先缩小。我用的缩放因子是0.125(也就是1/8),把1080×1920的大图直接缩到135×240,这个尺寸下的模糊运算量比原图小了64倍,时间从几百毫秒降到几毫秒。
模糊半径也要做联动调整。如果原图模糊半径是25,缩到1/8之后传进ScriptIntrinsicBlur的半径就必须是25×0.125≈3。这个不调整的话,模糊效果会过强,背景会糊成色块。
还有个容易翻车的细节:RenderScript在Android 12以上已经废弃,而且在个别厂商ROM上有兼容性问题。如果你要长期维护,建议在API 31以上直接走RenderEffect,API 31以下再走RenderScript。如果是API 33以上的设备,甚至可以直接用RenderEffect.createBlurEffect挂到根View上,连PixelCopy都不用做。
RenderScript和RenderEffect之间怎么切换,参考下面这个版本判断逻辑:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // 使用 RenderEffect } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { // 使用 RenderScript } else { // 直接降级为半透明遮罩 }3.3 第三步:把GlassOverlayView嵌入到页面
接入的时候,GlassOverlayView作为覆盖层放在页面布局的顶层,下面是布局示例:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <!-- 真正的页面内容 --> <androidx.viewpager2.widget.ViewPager2 android:id="@+id/banner" android:layout_width="match_parent" android:layout_height="200dp" /> <com.example.widget.GlassOverlayView android:id="@+id/glassOverlay" android:layout_width="match_parent" android:layout_height="200dp" android:layout_gravity="bottom" /> </FrameLayout>在代码里,需要监听页面绘制完成后再触发模糊刷新,否则可能出现蒙层先显示一片黑的情况:
glassOverlay.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { glassOverlay.viewTreeObserver.removeOnGlobalLayoutListener(this) refreshOverlay() } }) fun refreshOverlay() { val activity = context as? Activity ?: return activity.window?.let { window -> window.decorView.post { glassOverlay.refreshBlur(window.decorView) } } }这里用post是为了确保布局已经完成,防止拿到的是一个0×0的区域。
3.4 第四步:与Banner自动轮播的联动刷新
Banner场景的特殊性在于背景内容在动,玻璃蒙层如果不动就显得背景是静止的。所以Banner每次切换图片以后,都需要重新触发模糊刷新。
比较常见的做法是在Banner的监听回调里调用refreshOverlay()。但要注意,如果Banner每3秒切一次图,每次都走一次“截屏-缩小-模糊-重绘”全链路,内存和CPU压力都不小。我实测下来,4秒切一次图的中低端手机还能流畅跑,2秒以内就会肉眼可见掉帧。所以我在组件里加了一个模糊结果缓存,如果背景内容和上一帧比变化不大就跳过重算,画面仍然沿用旧的模糊结果,视觉上基本察觉不到差别。
override fun refreshBlur(rootView: View) { if (useRenderEffect) { invalidate() return } // 如果距离上次刷新时间小于 100ms,直接跳过,避免频繁截图 val now = System.currentTimeMillis() if (now - lastRefreshTime < 100) { return } lastRefreshTime = now // 其余逻辑保持不变 }这个100毫秒的节流,在Banner快速切换时能挡住大量重复计算,亲测有效。
4. 常见问题与排查技巧实录
4.1 模糊蒙层显示黑色或空白,怎么办
这是新手最常遇到的问题,大部分是因为用View.draw手动创建Canvas时,部分内容没有被画出来。检查方向有两个:一是确认是不是用了SurfaceView、TextureView、VideoView这类独立渲染的组件,如果是,改用PixelCopy抓图;二是确认GlassOverlayView是否真的在DecorView的顶层,如果被遮挡了,抓出来的区域自然不是你想要的样子。
另一个可能就是截屏拿到的Bitmap本身是空的。可以加一个调试开关,把模糊前的Bitmap存到本地,一眼就能看出是截屏出问题还是模糊出问题:
if (BuildConfig.DEBUG) { File("/sdcard/Download/glass_screen.png").apply { FileOutputStream(this).use { out -> screenBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } } }4.2 低端机型卡顿和内存抖动问题
中低端机型上,玻璃蒙层最容易引发的问题就是内存抖动和掉帧。原因很简单——截一张1080×1920的ARGB_8888位图大约占8MB内存,缩放后的Bitmap和模糊后的Bitmap又是两份新对象,再加上各种临时变量,一次模糊操作轻松吃掉20MB以上内存。如果频繁触发,GC就会频繁执行,掉帧顺理成章。
我的解决思路是“复用Bitmap”。一个是在Activity启动时就创建好缩放后的目标Bitmap,截完屏直接画进去,不再重复创建;另一个是给模糊管线维护一个对象池,同一个尺寸的Bitmap重复使用。实测下来,内存抖动减少了70%左右。
还有一个容易被忽略的问题:BlurView刷新频率要控制在每秒不超过10次,操作太快时做降级。也就是用户快速滑动或快速操作时,把模糊半径临时降低,或者暂停模糊刷新,等操作停稳了再恢复,体验比一直卡顿好得多。
4.3 状态栏和刘海屏适配问题
做沉浸式玻璃蒙层的时候,状态栏区域的适配是个老大难。如果直接把GlassOverlayView铺满全屏,状态栏区域也会被模糊掉,但Android的状态栏图标是系统绘制的,遮罩盖上去图标还是会透过来,颜色重叠容易变得很难看。
我通常的做法是给蒙层的上下区域预留状态栏高度,把模糊效果只应用在内容区域,状态栏区域用纯色半透明遮罩代替。这样视觉上依然是整体玻璃感,但不会和系统图标打架:
val statusBarHeight = getStatusBarHeight() glassOverlay.setPadding(0, statusBarHeight, 0, 0)获取状态栏高度的写法已经烂大街了,这里不再赘述。注意刘海屏设备在不同厂商ROM上的高度值可能不同,建议用WindowInsets方式读取,比直接读status_bar_height资源更可靠。
4.4 常见问题速查表
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 蒙层黑屏 | View.draw没抓到内容 | 改用PixelCopy抓取 |
| 蒙层模糊效果过强 | 缩放后半径未调整 | 半径乘以缩放因子 |
| 卡顿严重 | 大图直接模糊、频繁刷新 | 缩小后再模糊,加节流 |
| 背景内容不更新 | Banner切换后没触发刷新 | 监听页面切换回调,调refreshBlur |
| 状态栏区域模糊后发黑 | 遮罩覆盖了系统图标 | 给蒙层加状态栏避让 |
5. 升级方向:实时模糊在更多场景中的应用
玻璃蒙层做完以后,我发现这套架构可以顺手迁移到另外几个场景里。
一个是底部弹窗面板的背景模糊。传统做法是弹窗底下加一层黑色半透明遮罩,换成玻璃蒙层以后质感提升非常明显。实现方式就是弹窗show出来的时候获取DecorView的Bitmap,模糊后作为弹窗背景,注意在弹窗dismiss的时候要及时回收Bitmap。
另一个是Tab切换或者页面切换时的过渡模糊。在ViewPager切换过程中,给两个页面的背景加一层玻璃质感,切换动画会显得非常柔和。这个场景可以利用RenderEffect的实时性,直接挂到目标View上,配合属性动画调整模糊半径,代码量很少效果却很出彩。
如果你要长期维护这类组件,我建议把模糊这一层单独拆成一个工具类,因为未来系统版本可能会继续更新模糊API,API 34上用RenderEffect,API 31上用RenderScript兼容,工具类把这些差异封装起来,上层业务就不需要跟着系统更新反复改代码了。
我在实际做的时候遇到的一个比较让人崩溃的坑是,PixelCopy在某些定制ROM的Dialog窗口上会失败,返回PixelCopy.ERROR_TIMEOUT。这个问题没有通用的源码级解法,最后我是在失败后自动重试两次,重试还不行就降级成纯色遮罩,至少用户看到的不是一块黑屏白屏。分享出来提醒一下,遇到这种问题别死磕,降级策略才是保命手段。
本文还有配套的精品资源,点击获取