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

相关推荐
水瓶丫头站住7 小时前
安卓APP如何适配不同的手机分辨率
android·智能手机
xvch8 小时前
Kotlin 2.1.0 入门教程(五)
android·kotlin
xvch12 小时前
Kotlin 2.1.0 入门教程(七)
android·kotlin
望风的懒蜗牛12 小时前
编译Android平台使用的FFmpeg库
android
浩宇软件开发12 小时前
Android开发,待办事项提醒App的设计与实现(个人中心页)
android·android studio·android开发
ac-er888813 小时前
Yii框架中的多语言支持:如何实现国际化
android·开发语言·php
苏金标14 小时前
The maximum compatible Gradle JVM version is 17.
android
zhangphil14 小时前
Android BitmapShader简洁实现马赛克,Kotlin(一)
android·kotlin
iofomo18 小时前
Android平台从上到下,无需ROOT/解锁/刷机,应用级拦截框架的最后一环,SVC系统调用拦截。
android
我叫特踏实19 小时前
SensorManager开发参考
android·sensormanager