对图片或者界面应用高斯模糊效果,是设计师经常想要加上的效果,也是 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
去做不同处理,所以我们得封装。
考虑使用场景,磨砂效果大体可以考虑:
- 对一个
Bitmap
进行磨砂,返回磨砂后的Bitmap
,供业务方使用 - 对整个
View
的内容进行磨砂 - 对
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
上进行的,因为还没完成,所以也没有发布,写完下一篇文章,估计库的封装也就差不多了。