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 上进行的,因为还没完成,所以也没有发布,写完下一篇文章,估计库的封装也就差不多了。

相关推荐
诸神黄昏EX6 分钟前
Android 分区相关介绍
android
大白要努力!1 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
Estar.Lee1 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip
Winston Wood1 小时前
Perfetto学习大全
android·性能优化·perfetto
Dnelic-4 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记
Eastsea.Chen7 小时前
MTK Android12 user版本MtkLogger
android·framework
长亭外的少年14 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
建群新人小猿16 小时前
会员等级经验问题
android·开发语言·前端·javascript·php
1024小神17 小时前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri
兰琛18 小时前
20241121 android中树结构列表(使用recyclerView实现)
android·gitee