Android 磨砂效果(上)

对图片或者界面应用高斯模糊效果,是设计师经常想要加上的效果,也是 Android 开发们最讨厌的工作,因为效果不理想而且做起来麻烦。

官方原汁原味的支持,是在 Android 12 才提供了的。在这之前,开发者一直是借助 RenderScript 来实现高斯模糊效果,目前比较流行的库基本都是基于 RenderScript 库来实现,其实官方已经废弃了这一项技术,但由于 Android 江河日下,少有人愿意与时俱进的更新库,所以大家基本上还是使用很旧的库来实现这个功能。

从 Android 12 开始,官方提供了 RenderEffect 这一 API,可以很方便的供我们对一个 View 做磨砂效果:

kotlin 复制代码
val blurEffect = RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)
view.setRenderEffect(blurEffect)

调用接口相当简单,而且效果也是非常可观的。

如果是 Compose, 则可以用 graphicsLayer 来实现:

kotlin 复制代码
Box(modifier = Modifier.graphicsLayer {  
    renderEffect = BlurEffect(...)
}){
    //...
}

不过我测试了下,不知道是不是还有其它设置项,其右边缘和下边缘的磨砂好像会应用 Box 外的图层,导致有点突兀。

而对于 Android 12 之下,也有几种选择,一种是官方提供了 RenderScript 的替代品,一个由 native 实现的库 renderscript-intrinsics-replacement-toolkit,然而官方并没有提供 gradle 库,需要开发自己拉代码导入到项目中。

另一种选择就是直接用 Vulkan 去写,不过也是写 native 代码了,与 toolkit 不同的是,toolkit 是使用 CPU,而 Vulkan 则是可以利用 GPU。就速度方面,大部分情况可能 toolkit 最快,官方说是 RenderScript 的两倍多。

对于这几种技术,官方都给出了实现的 sample,对于技术细节比较关注的可以详细阅读源码。

由于 Android 的碎片化, 我们还得考虑 Android 12 以下的设备, 所以针对 Android 12 及以上,我们可以用 RenderEffect 去获取最佳的性能与效果,对于 Android 12 以下,则只能使用 toolkit 来降级。对于业务方而言,当然不希望每次都写 if else 去做不同处理,所以我们得封装。

考虑使用场景,磨砂效果大体可以考虑:

  1. 对一个 Bitmap 进行磨砂,返回磨砂后的 Bitmap,供业务方使用
  2. 对整个 View 的内容进行磨砂
  3. View 的局部进行磨砂,用于凸出标题 / TopBar 之类的元素

对于 Bitmap 的磨砂:

kotlin 复制代码
suspend fun Bitmap.blur(radius: Int = DEFAULT_BLUR_RADIUS) = withContext(Dispatchers.IO){
    require(radius in 1..25) {
        "The radius should be between 1 and 25. $radius provided."
    }
    if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S){
        val imageReader = ImageReader.newInstance(
            width, height,
            PixelFormat.RGBA_8888, 1,
            HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT
        )
        val renderNode = RenderNode("blur")
        val hardwareRenderer = HardwareRenderer()
        hardwareRenderer.setSurface(imageReader.surface)
        hardwareRenderer.setContentRoot(renderNode)
        renderNode.setPosition(0, 0, width, height)
        renderNode.setRenderEffect(RenderEffect.createBlurEffect(radius.toFloat(), radius.toFloat(), Shader.TileMode.CLAMP))
        val canvas = renderNode.beginRecording(width, height)
        canvas.drawBitmap(this@blur, 0f, 0f, null)
        renderNode.endRecording()
        hardwareRenderer.createRenderRequest()
            .setWaitForPresent(true)
            .syncAndDraw()
        val image = imageReader.acquireNextImage() ?: throw RuntimeException("No Image")
        val hardwareBuffer = image.hardwareBuffer ?: throw RuntimeException("No HardwareBuffer")
        val bitmap = Bitmap.wrapHardwareBuffer(hardwareBuffer, null)
            ?: throw RuntimeException("Create Bitmap Failed")
        hardwareBuffer.close()
        image.close()
        bitmap
    } else {
        Toolkit.blur(this@blur, radius)
    }
}

高版本用 RenderEffect 实现,低版本用 toolkit。 高版本利用了 RenderNode,我们的到的是 hardwar bitmap,可以节约内存与利用 GPU 加快渲染。

如果对于整个 View 进行磨砂,Android 12 上还是直接用 RenderEffect 即可, Android 12 以下则首先将 View 转换为 Bitmap,然后对 Bitmap 进行磨砂,然后将磨砂后的 Bitmap 贴在 View 上面。

下面看看代码上如何封装,当然我的封装都是为 Compose 服务,没有纯 View 的实现:

kotlin 复制代码
// 使用方只需要用 `BlurBox` 包裹内容,设置 `radius`
@Composable
fun BlurBox(modifier: Modifier, radius: Int = DEFAULT_BLUR_RADIUS, content: @Composable (updateReporter: ()-> Unit) -> Unit) {
    AndroidView(
        factory = { context ->
            BlurView(context, radius).apply {
                setContent(content)
            }
        },
        modifier = modifier,
        update = {
            it.updateRadius(radius)
        }
    )
}

class BlurView(context: Context, radius: Int = DEFAULT_BLUR_RADIUS) : FrameLayout(context) {

    private val blurApi: BlurApi

    init {
        // 按照版本使用不同实现
        blurApi = if (Build.VERSION.SDK_INT >= 31) {
            BlurRenderEffectImpl(this, radius)
        } else {
            BlurBitmapEffectImpl(this, radius)
        }
    }

    ...
}

@TargetApi(31)
internal class BlurRenderEffectImpl(
    private val blurView: BlurView,
    private var radius: Int = DEFAULT_BLUR_RADIUS
) : BlurApi {

    private var view = ComposeView(blurView.context)

    init {
        blurView.addView(
            view, FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
        )
        // Android 12 以上直接用 `RenderEffect`,因为前面提到过 `Compose` 自己实现的小瑕疵,所以还是套壳实现。
        view.setRenderEffect(RenderEffect.createBlurEffect(radius.toFloat(), radius.toFloat(), Shader.TileMode.CLAMP))
    }

    override fun setContent(content: @Composable (reportContentUpdate: () -> Unit) -> Unit) {
        view.setContent {
            content {

            }
        }
    }

    override fun updateRadius(radius: Int) {
        if (this.radius != radius) {
            this.radius = radius
            if (radius == 0) {
                view.setRenderEffect(null)
            } else {
                view.setRenderEffect(
                    RenderEffect.createBlurEffect(radius.toFloat(), radius.toFloat(), Shader.TileMode.CLAMP)
                )
            }
        }
    }
}

internal class BlurBitmapEffectImpl(
    private val blurView: BlurView,
    private var radius: Int = DEFAULT_BLUR_RADIUS
) : BlurApi {

    private var view = ComposeView(blurView.context)
    private var blurImageView = FakeImageView(blurView.context)
    private var generateJob: Job? = null
    private var updateVersion = 0

    init {
        blurView.addView(
            view, FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
        )
        blurView.addView(
            blurImageView, FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
        )
        updateBlurImage()
    }

    override fun setContent(content: @Composable (reportContentUpdate: () -> Unit) -> Unit) {
        view.setContent {
            content {
                updateBlurImage()
            }
        }
    }

    override fun updateRadius(radius: Int) {
        if (this.radius != radius) {
            this.radius = radius
            updateBlurImage()
        }
    }

    private fun updateBlurImage() {
        generateJob?.cancel()
        updateVersion++
        if (radius == 0) {
            blurImageView.clear()
            return
        }
        val currentVersion = updateVersion
        OneShotPreDrawListener.add(view) {
            view.post {
                if (view.width <= 0 || view.height <= 0) {
                    EmoLog.w(TAG, "blur ignored because of size issue(w=${view.width}, h=${view.height})")
                    updateBlurImage()
                    return@post
                }
                view.findViewTreeLifecycleOwner()?.apply {
                    generateJob = lifecycleScope.launch {
                        if (currentVersion != updateVersion) {
                            return@launch
                        }
                        try {
                            val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
                            val canvas = Canvas(bitmap)
                            view.draw(canvas)
                            val blurImage = withContext(Dispatchers.IO) {
                                Toolkit.blur(bitmap, radius)
                            }
                            if (currentVersion == updateVersion) {
                                blurImageView.setBitmap(blurImage, 0f, 0f)
                            }
                        } catch (e: Throwable) {
                            if (e !is CancellationException) {
                                EmoLog.e(TAG, "blur image failed", e)
                            }
                        }
                    }
                }
            }
        }
        view.invalidate()
    }
}

其实麻烦的是低版本的实现,我们要等内容准备好之后通知上层去生成 Bitmap 然后进行磨砂,所以这里需要由开发主动调用 reportContentUpdate 告知有内容更新,有一个更好的方案是用 drawWithCache,官方文档说的是有状态变更了才会重新调用生成cache,理论是可行的,不过我测试没跑通,原因还没去找,可能又是我哪里傻叉写了点奇怪的代码。

另一个问题就是低版本下 BlurBox 不能有 hardware bitmap,使用上也是一个需要注意的点。

这篇文章就暂时先写到这里了,针对 View 局部磨砂的实现,我们留待下一篇文章再来展开。库的开发是在 emo 上进行的,因为还没完成,所以也没有发布,写完下一篇文章,估计库的封装也就差不多了。

相关推荐
一笑的小酒馆3 小时前
Android性能优化之截屏时黑屏卡顿问题
android
懒人村杂货铺5 小时前
Android BLE 扫描完整实战
android
TeleostNaCl8 小时前
如何安装 Google 通用的驱动以便使用 ADB 和 Fastboot 调试(Bootloader)设备
android·经验分享·adb·android studio·android-studio·android runtime
fatiaozhang95278 小时前
中国移动浪潮云电脑CD1000-系统全分区备份包-可瑞芯微工具刷机-可救砖
android·网络·电脑·电视盒子·刷机固件·机顶盒刷机
2501_915918419 小时前
iOS 开发全流程实战 基于 uni-app 的 iOS 应用开发、打包、测试与上架流程详解
android·ios·小程序·https·uni-app·iphone·webview
lichong9519 小时前
【混合开发】vue+Android、iPhone、鸿蒙、win、macOS、Linux之dist打包发布在Android工程asserts里
android·vue.js·iphone
Android出海9 小时前
Android 15重磅升级:16KB内存页机制详解与适配指南
android·人工智能·新媒体运营·产品运营·内容运营
一只修仙的猿10 小时前
毕业三年后,我离职了
android·面试
编程乐学10 小时前
安卓非原创--基于Android Studio 实现的新闻App
android·ide·android studio·移动端开发·安卓大作业·新闻app
雅雅姐11 小时前
Android14 init.rc中on boot阶段操作4
android