🔮 从「拍立得相机」到「魔法毛玻璃」:深入剖析 BlurView 高斯模糊实现原理

27 阅读12分钟

本文基于 BlurView 3.2.0 源码,用通俗故事 + 源码 + 时序图,带你彻底搞懂 Android 端实时高斯模糊是怎么炼成的。

📖 一、从一个生活场景说起

想象一下:你正在用手机看一个很美的风景照片,突然底部弹出一个半透明的导航栏,它能透出后面照片的内容,但是像隔了一层毛玻璃,朦胧又高级。

这种效果,就是大名鼎鼎的 毛玻璃效果(Frosted Glass / Gaussian Blur)

iOS 玩得炉火纯青,Android 开发者看着眼馋。于是有了 BlurView 这个开源库(GitHub 星标 3k+),它让你可以在任何 Android View 上实现动态实时模糊。

今天,我们就化身"源码侦探",钻进 BlurView 的肚子里,看看这层"魔法毛玻璃"到底是怎么贴上去的。


🎭 二、先认识剧中的几位"主角"

在深入源码前,先用一个 "照相馆" 的比喻,认识一下 BlurView 的几位核心角色:

角色源码对应类在故事中的身份
🧑‍🎨 摄影师BlurView对外的"门面",承接你的所有调用请求
🎯 被拍摄对象BlurTarget你想要模糊的背景内容(API 31+ 才有)
📷 老式相机PreDrawBlurController老版本(API < 31)的摄影师助理,用软件拍照
📸 数码相机RenderNodeBlurController新版本(API 31+)的摄影师助理,用硬件拍照
🖌️ PS 滤镜BlurAlgorithm / RenderScriptBlur把照片变模糊的"后期滤镜"
📐 缩放师SizeScaler把大照片缩小,省内存省时间
✨ 化妆师Noise给模糊后的照片加点"噪点颗粒",更自然
🏷️ 标签纸BlurViewCanvas贴在画布上的标记,防止"自己拍自己"死循环

记住这个比喻,后面看代码时你会发现,每一行都在演绎这个故事。


🗺️ 三、整体架构:一图看懂

BlurView 根据手机系统版本,走两条完全不同的路

                    ┌─────────────────────────────────┐
                    │          BlurView (门面)          │
                    │   setupWith(target, ...)         │
                    └───────────────┬─────────────────┘
                                    │ 选择哪条路?
                    ┌───────────────┴───────────────┐
                    │                               │
            API >= 31 (S)                   API < 31
                    │                               │
        ┌───────────▼──────────┐        ┌──────────▼──────────┐
        │ RenderNodeBlurCtrl   │        │ PreDrawBlurCtrl     │
        │ (硬件渲染路线)        │        │ (软件渲染路线)       │
        │  BlurTarget 记录快照  │        │ OnPreDrawListener  │
        │  RenderEffect 模糊    │        │ rootView.draw()    │
        │  在 RenderThread 执行 │        │ RenderScriptBlur   │
        └──────────────────────┘        └─────────────────────┘

这个分流逻辑就在 BlurView.setupWith() 里,只有寥寥几行:

public BlurViewFacade setupWith(@NonNull BlurTarget target, BlurAlgorithm algorithm,
                                float scaleFactor, boolean applyNoise) {
    blurController.destroy();
    if (BlurTarget.canUseHardwareRendering) {
        // API 31+:忽略传入的算法,强制用 RenderEffect(硬件模糊)
        blurController = new RenderNodeBlurController(this, target, overlayColor, scaleFactor, applyNoise);
    } else {
        // API < 31:走老路,软件绘制 + RenderScript
        blurController = new PreDrawBlurController(this, target, overlayColor, algorithm, scaleFactor, applyNoise);
    }
    return blurController;
}

canUseHardwareRendering 的判断也很直白:

// BlurTarget.java
static final boolean canUseHardwareRendering = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S;

💡 为什么是 API 31(Android 12)?  因为 Android 12 才正式开放了 RenderEffect 和完善的 RenderNode API,这两样是硬件模糊的"杀手锏"。

接下来,我们分两条路深入讲解。先讲新路线(推荐重点关注),再讲老路线(理解原理)。


🚀 四、新路线(API 31+):硬件渲染的"数码相机"

4.1 故事篇:数码相机是怎么工作的

在新路线里,流程是这样的:

  1. BlurTarget 是一个相框,你把要模糊的内容放进这个相框。
  2. 每次界面要重绘时,BlurTarget 就会用一块叫 RenderNode 的"数码底片"把自己的内容拍下来,然后正常画到屏幕上。
  3. 真正的 BlurView(毛玻璃)登场时,它会拿来这块"数码底片" ,套上一个 RenderEffect 模糊滤镜,然后贴在自己身上。
  4. 由于 RenderNode 和 RenderEffect 都是硬件加速的,整个模糊过程跑在系统的 Render Thread(渲染线程) ,几乎不占用主线程,性能爆表!

最妙的是:BlurView 从来不主动调用 invalidate() 刷新自己,因为系统每次绘制 View 树时会自动更新 RenderNode 的内容,BlurView 只需要"读取"最新的快照即可。这就是官方说的  "near zero overhead of snapshotting"(快照几乎零开销)

4.2 源码篇:四步走

第一步:BlurTarget 拍快照(数码底片)

// BlurTarget.java
@Override
protected void dispatchDraw(@NonNull Canvas canvas) {
    if (canUseHardwareRendering && canvas.isHardwareAccelerated()) {
        renderNode.setPosition(0, 0, getWidth(), getHeight());
        RecordingCanvas recordingCanvas = renderNode.beginRecording(); // 开始录制
        super.dispatchDraw(recordingCanvas);                            // 把子 View 全画进去
        renderNode.endRecording();                                      // 结束录制
        canvas.drawRenderNode(renderNode);                              // 正常显示到屏幕
    } else {
        super.dispatchDraw(canvas);
    }
}

🎯 关键点RenderNode 就像一个"显示列表(Display List)",它记录绘制指令而不是直接画像素。所以 BlurTarget 画一遍自己,既显示到了屏幕,又把"绘制指令"存了一份在 renderNode 里,供 BlurView 复用。一次绘制,两处使用,这就是零开销的秘密。

第二步:BlurView.draw() 被调用

// BlurView.java
@Override
public void draw(@NonNull Canvas canvas) {
    boolean shouldDraw = blurController.draw(canvas);  // 委托给控制器
    if (shouldDraw) {
        super.draw(canvas);  // 再画自己的子 View(比如 TabLayout)
    }
}

第三步:RenderNodeBlurController 把快照贴上来 + 套滤镜

// RenderNodeBlurController.java
@Override
public boolean draw(Canvas canvas) {
    if (!enabled) return true;
    saveOnScreenLocation();          // 记录 BlurView 在屏幕上的位置


    if (canvas.isHardwareAccelerated()) {
        if (!target.renderNode.hasDisplayList()) {
            blurView.invalidate();   // 快照还没准备好,下一帧再试
        }
        hardwarePath(canvas);        // ✅ 主力路线
    } else {
        softwarePath(canvas);        // 软件兜底(截图、转场动画时)
    }
    return true;
}


private void hardwarePath(Canvas canvas) {
    blurNode.setPosition(0, 0, target.getWidth(), target.getHeight());
    updateRenderNodeProperties();    // 调整位移(让快照对齐 BlurView 位置)
    drawSnapshot();                  // 把 target 的快照画到自己的 blurNode 上


    canvas.save();
    canvas.clipRect(0, 0, blurView.getWidth(), blurView.getHeight()); // 不画到边界外
    canvas.drawRenderNode(blurNode); // 🎯 把带模糊滤镜的 blurNode 画到屏幕
    if (applyNoise) Noise.apply(...); // 加噪点
    if (overlayColor != TRANSPARENT) canvas.drawColor(overlayColor); // 叠加颜色
    canvas.restore();
}


private void drawSnapshot() {
    RecordingCanvas recordingCanvas = blurNode.beginRecording();
    if (frameClearDrawable != null) frameClearDrawable.draw(recordingCanvas);
    recordingCanvas.drawRenderNode(target.renderNode); // 把 target 的快照画进来
    applyBlur();  // 给 blurNode 套上模糊滤镜
    blurNode.endRecording();
}


private void applyBlur() {
    float realBlurRadius = blurRadius * scaleFactor;
    RenderEffect blur = RenderEffect.createBlurEffect(
        realBlurRadius, realBlurRadius, Shader.TileMode.CLAMP);
    blurNode.setRenderEffect(blur);  // ✨ 关键:设置硬件模糊滤镜
}

第四步:跟着 BlurView 滚动/移动

当 BlurView 在可滚动容器里移动时,需要不断更新它的位置,让快照"对齐"到正确的位置:

// RenderNodeBlurController.java
private final ViewTreeObserver.OnPreDrawListener drawListener = () -> {
    saveOnScreenLocation();
    updateRenderNodeProperties();  // 重新设置 blurNode 的位移/旋转/缩放
    return true;
};


private void updateRenderNodeProperties() {
    float layoutTranslationX = -getLeft();  // BlurView 相对 target 的偏移
    float layoutTranslationY = -getTop();
    blurNode.setTranslationX(layoutTranslationX);
    blurNode.setTranslationY(layoutTranslationY);
    // ... API 31 有 bug,需要重新 applyBlur() 触发重绘
}

💡 这里 getLeft() = blurViewLocation[0] - targetLocation[0],算的是 BlurView 相对于 target 的偏移。模糊后,要把快照"反向位移"过来,对齐到 BlurView 当前位置,看起来就像透过毛玻璃看到了正下方的真实内容。

4.3 时序图:硬件渲染路线全流程

毛玻璃效果.png mermaid RenderThread(渲染线程)blurNode(毛玻璃底片)RenderNodeBlurControllerBlurViewtarget.renderNode(数码底片)BlurTargetAndroid系统(View树遍历)RenderThread(渲染线程)blurNode(毛玻璃底片)RenderNodeBlurControllerBlurViewtarget.renderNode(数码底片)BlurTargetAndroid系统(View树遍历)每一帧绘制周期开始真正的模糊计算在GPU/RenderThread上完成主线程几乎零负担!dispatchDraw(canvas)beginRecording()记录子View绘制指令endRecording()drawRenderNode(node1) 正常显示draw(canvas)draw(canvas)saveOnScreenLocation()记录屏幕坐标beginRecording()drawRenderNode(node1)复用target快照setRenderEffect(模糊滤镜)endRecording()drawRenderNode(node2)叠加噪点+颜色提交所有 RenderNode渲染完成,毛玻璃呈现


📷 五、老路线(API < 31):软件渲染的"老式相机"

5.1 故事篇:老式相机的笨办法

在没有 RenderEffect 的旧手机上,BlurView 只能用"笨办法":

  1. 每次界面要重绘前OnPreDrawListener 触发),用一个软件 Canvas 把整个背景内容画到一张 Bitmap 上(这就是"老式胶卷")。
  2. 把这张 Bitmap 交给 RenderScript 这个 GPU 滤镜去模糊。
  3. BlurView 显示时,把这张模糊后的 Bitmap 贴出来。

这个"拍照"过程(rootView.draw())是软件渲染,比较慢,而且每帧都要重拍。所以官方建议老机型用较大的 scaleFactor(默认 4),把 Bitmap 缩小再模糊,牺牲精度换性能。

5.2 源码篇:核心三步

第一步:监听绘制,每帧拍照

// PreDrawBlurController.java
private final ViewTreeObserver.OnPreDrawListener drawListener = new OnPreDrawListener() {
    @Override
    public boolean onPreDraw() {
        // 每次系统准备绘制前,先更新模糊Bitmap
        updateBlur();
        return true;
    }
};

第二步:把背景画到 Bitmap(拍照)

// PreDrawBlurController.java
void updateBlur() {
    if (!blurEnabled || !initialized) return;


    // 1. 清空"胶卷"
    if (frameClearDrawable == null) {
        internalBitmap.eraseColor(Color.TRANSPARENT);
    } else {
        frameClearDrawable.draw(internalCanvas);
    }


    // 2. 调整矩阵:让快照对齐 BlurView 的位置
    internalCanvas.save();
    setupInternalCanvasMatrix();
    try {
        rootView.draw(internalCanvas);  // 🎯 把整个背景画到 Bitmap
    } catch (Exception e) {
        Log.e("BlurView", "Error during snapshot capturing", e);
    }
    internalCanvas.restore();


    // 3. 模糊处理
    blurAndSave();
}

这里有个非常巧妙的设计——BlurViewCanvas

// BlurViewCanvas.java —— 只是一个"标记"类!
public class BlurViewCanvas extends Canvas {
    public BlurViewCanvas(@NonNull Bitmap bitmap) { super(bitmap); }
}

它没有任何新方法,纯粹是个标签。它的作用在 draw() 方法里:

// PreDrawBlurController.java
@Override
public boolean draw(Canvas canvas) {
    if (canvas instanceof BlurViewCanvas) {
        return false;  // 🚫 正在拍背景,不要把"自己"拍进去!
    }
    // ... 正常画模糊内容
}

💡 为什么需要这个?  想象一下:BlurView 也是 rootView 的子 View。当 rootView.draw(internalCanvas) 执行时,会递归绘制所有子 View,包括 BlurView 自己!如果不跳过,就会无限递归(BlurView 画背景→背景里有 BlurView→BlurView 又画背景……)。BlurViewCanvas 这个"标签"就是用来识别"这是内部拍照画布,跳过自己"。

第三步:矩阵对齐 + RenderScript 模糊

// PreDrawBlurController.java —— 矩阵对齐
private void setupInternalCanvasMatrix() {
    rootView.getLocationOnScreen(rootLocation);
    blurView.getLocationOnScreen(blurViewLocation);
    int left = blurViewLocation[0] - rootLocation[0];
    int top = blurViewLocation[1] - rootLocation[1];
    // 因为 Bitmap 被缩小了 scaleFactor 倍,所以位置也要相应换算
    float scaleFactorH = (float) blurView.getHeight() / internalBitmap.getHeight();
    float scaleFactorW = (float) blurView.getWidth() / internalBitmap.getWidth();
    internalCanvas.translate(-left / scaleFactorW, -top / scaleFactorH);
    internalCanvas.scale(1 / scaleFactorW, 1 / scaleFactorH);
}
// RenderScriptBlur.java —— GPU 模糊
@Override
public Bitmap blur(@NonNull Bitmap bitmap, float blurRadius) {
    Allocation inAllocation = Allocation.createFromBitmap(renderScript, bitmap); // 输入
    outAllocation = Allocation.createTyped(renderScript, inAllocation.getType()); // 输出
    blurScript.setRadius(min(blurRadius, 25f));  // RenderScript 最大半径 25
    blurScript.setInput(inAllocation);
    blurScript.forEach(outAllocation);            // 🎯 GPU 并行计算模糊
    outAllocation.copyTo(bitmap);                 // 结果写回 Bitmap
    return bitmap;
}

5.3 时序图:软件渲染路线全流程

软件渲染和RenderScript.png

mermaid

internalBitmap(模糊后的胶卷)BlurViewRenderScript(GPU滤镜)internalCanvas(软件画布/胶卷)PreDrawBlurControllerrootView(ViewTreeObserver)Android系统internalBitmap(模糊后的胶卷)BlurViewRenderScript(GPU滤镜)internalCanvas(软件画布/胶卷)PreDrawBlurControllerrootView(ViewTreeObserver)Android系统每一帧绘制前软件渲染整个背景遇到 BlurViewCanvas 自动跳过自己触发 OnPreDrawListeneronPreDraw()updateBlur()eraseColor(清空胶卷)设置对齐矩阵rootView.draw(internalCanvas)拍照完成blur(bitmap, radius)GPU并行计算高斯模糊返回模糊BitmaponPreDraw return truedraw(canvas)draw(canvas)将Bitmap放大画到屏幕+噪点 + 颜色叠加毛玻璃呈现


🔬 六、两个"幕后功臣":SizeScaler 和 Noise

6.1 SizeScaler:缩放师的智慧

老路线里,Bitmap 要缩小 4 倍再模糊。但这里有个

// SizeScaler.java
// Bitmap 的宽度必须能被 64 整除(三星设备的 stride 要求)
private static final int ROUNDING_VALUE = 64;


private int roundSize(int value) {
    if (value % ROUNDING_VALUE == 0) return value;
    return value - (value % ROUNDING_VALUE) + ROUNDING_VALUE; // 向上取整到 64 的倍数
}

💡 什么是 stride(步长)?  RenderScript 处理 Bitmap 时,每行像素在内存里要按固定长度(64)对齐。如果 Bitmap 宽度不是 64 的倍数,系统会偷偷再分配一个对齐的 Bitmap,白白浪费内存和时间。SizeScaler 提前帮你对齐好,避免这个隐藏开销。这就是高手写库的细节!

6.2 Noise:化妆师的"颗粒感"

纯高斯模糊的画面会过于平滑、有塑料感Noise 类在模糊后叠加一层"蓝噪声"纹理,模拟真实磨砂玻璃的颗粒质感:

// Noise.java
private static void getNoiseBitmap(Context context) {
    Bitmap bitmap = BitmapFactory.decodeResource(..., R.drawable.blue_noise);
    Paint paint = new Paint();
    paint.setAlpha(38);  // 15% 透明度,很淡
    canvas.drawBitmap(bitmap, 0, 0, paint);
}


// 使用 PorterDuff.Mode.SRC_ATOP,只在已有内容上叠加,不覆盖透明区域
noisePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP));
noisePaint.setShader(new BitmapShader(alphaBitmap, TileMode.REPEAT, TileMode.REPEAT));

⚡ 七、BlurView 为什么这么快?三大性能秘籍

读完源码,我总结出 BlurView 高性能的三个关键设计:

秘籍 1:从不主动 invalidate(避免无限绘制循环)

很多老库(BlurKit、RealtimeBlurView)的做法是自己 invalidate 自己,触发下一帧重绘,形成循环。这会导致 CPU 持续满载

BlurView 的做法是被动响应

  • 老路线:监听系统的 OnPreDrawListener,系统自己要重绘时顺便更新 Bitmap。
  • 新路线:完全依赖 RenderNode 的自动更新,0 次 invalidate

源码里 PreDrawBlurController.onPreDraw() 的注释说得很清楚:

// Not invalidating a View here, just updating the Bitmap.
// This relies on the HW accelerated bitmap drawing behavior in Android

秘籍 2:硬件加速 + RenderNode(新路线零开销)

新路线用 RenderNode 记录绘制指令而不是像素,BlurTarget 画一遍自己就同时完成了"显示"和"录制快照"两件事。模糊计算交给 RenderEffect,跑在 Render Thread,主线程无感。

秘籍 3:Allocation 复用 + Bitmap 对齐(老路线优化)

// RenderScriptBlur.java —— 复用 Allocation,避免重复创建
if (!canReuseAllocation(bitmap)) {
    if (outAllocation != null) outAllocation.destroy();
    outAllocation = Allocation.createTyped(renderScript, inAllocation.getType());
}

配合 SizeScaler 提前对齐尺寸,老路线也做到了性能榨干。


🛠️ 八、实战:5 行代码接入 BlurView

8.1 XML 布局

<!-- 1. 用 BlurTarget 包裹要模糊的内容 -->
<eightbitlab.com.blurview.BlurTarget
    android:id="@+id/target"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    
    <!-- 你的主内容:图片、列表、滚动视图等 -->
    <ImageView android:src="@drawable/bg" ... />
    
</eightbitlab.com.blurview.BlurTarget>


<!-- 2. 放置 BlurView,它会透出下方模糊内容 -->
<eightbitlab.com.blurview.BlurView
    android:id="@+id/blurView"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:blurOverlayColor="#66FFFFFF">
    
    <!-- BlurView 的子 View 不会被模糊,比如导航栏 -->
    <com.google.android.material.tabs.TabLayout ... />
    
</eightbitlab.com.blurview.BlurView>

8.2 Java 代码

BlurTarget target = findViewById(R.id.target);
BlurView blurView = findViewById(R.id.blurView);


Drawable windowBackground = getWindow().getDecorView().getBackground();


blurView.setupWith(target)                        // 绑定目标
        .setFrameClearDrawable(windowBackground)  // 可选:设置背景(透明背景时建议加)
        .setBlurRadius(25f);                      // 模糊半径(实际半径 = radius × scaleFactor)

就这么简单!API 31+ 自动走硬件路线,旧机器自动走软件路线,你完全不用操心。


📊 九、两条路线对比总结

维度RenderNodeBlurController(API 31+)PreDrawBlurController(API < 31)
快照方式RenderNode 录制指令(硬件)rootView.draw() 软件绘制
模糊方式RenderEffect(硬件,GPU/RenderThread)RenderScript(GPU 加速)
更新触发RenderNode 自动更新OnPreDrawListener 每帧检查
invalidate❌ 几乎不调用❌ 不调用(更新 Bitmap 即可)
性能开销极低(近乎零开销)较高(每帧软件绘制)
支持 TextureView✅ 支持❌ 不支持
主线程影响极小有一定影响
scaleFactor 作用仅放大模糊半径缩小 Bitmap 省性能

🎓 十、源码设计亮点回顾(写给想进阶的你)

读完整个 BlurView,我被它几个设计细节深深折服:

  1. 门面模式(Facade)BlurView + BlurViewFacade 接口对外提供统一 API,内部用不同 Controller 实现,对用户透明。
  2. 空对象模式(Null Object)NoOpController 在未初始化或编辑模式下提供安全默认实现,杜绝 NPE。
  3. 标记类技巧BlurViewCanvas 这个"空类"巧妙解决了递归绘制问题,是设计模式的活教材。
  4. 策略模式BlurAlgorithm 接口让模糊算法可替换(RenderScript 只是一个实现)。
  5. 版本自适应:同一个 API,底层根据 Build.VERSION 自动切换最优实现,向后兼容。

🏁 结语

毛玻璃效果看似神秘,拆开 BlurView 的源码后,你会发现它的核心思想朴素而精妙:

"先拍一张背景的快照 → 加模糊滤镜 → 贴到目标 View 上"

新旧两条路线的差异,本质上就是  "怎么拍快照"  和  "怎么加滤镜"  的技术演进——从 CPU 软件绘制到 GPU 硬件渲染,从 RenderScript 到 RenderEffect。这背后折射出的,正是 Android 图形系统这些年飞速进步的缩影。

希望这篇文章能帮你彻底搞懂 BlurView。下次产品经理再说"加个毛玻璃效果",你不仅能 5 分钟接入,还能在 Code Review 时给同事讲一讲它背后的故事。😎


📚 参考资料

  • BlurView GitHub 仓库
  • BlurView 3.0 迁移指南
  • Android RenderEffect 官方文档
  • Android RenderNode 官方文档

如果觉得本文对你有帮助,欢迎 点赞 + 收藏 + 关注,后续会带来更多 Android 源码深度解析!你有用过哪些毛玻璃方案?欢迎评论区交流~ 💬