Android 磨砂效果(下)

上一篇文章,我介绍了下 Android 端磨砂效果实现的接口以及方案的变更,并提到其应用场景主要有以下几种:

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

我已经给出了 1 和 2 的大致的封装方案:

  1. 通过调用 Bitmap.blur(radius) 实现对 Bitmap 的磨砂
  2. 通过 BlurBox 包裹内容,就可以实现对整个 View 的磨砂

今天我们来讨论第 3 种场景的实现与封装

这种对于图片的磨砂效果可能是最多的,我提供的封装为 PartBlurBox,上述效果的实现代码为:

kotlin 复制代码
Box(modifier = Modifier.width(300.dp).height(300.dp)){
    val infoHeight = 56.dp
    val infoHeightPx = with(LocalDensity.current){
        infoHeight.toPx()
    }
    PartBlurBox(
        modifier = Modifier.fillMaxSize(),
        partProvider = { w, h ->
            Rect(0, (h - infoHeightPx).toInt(), w, h)
        },
        radius = 100
    ) { reporter ->
        Image(
            painter = painterResource(id = R.mipmap.avatar),
            contentDescription = "",
            contentScale = ContentScale.Crop
        )
        LaunchedEffect(Unit){
            reporter.onContentUpdate()
        }
    }
    Box(modifier = Modifier
        .fillMaxWidth()
        .height(infoHeight)
        .align(Alignment.BottomCenter)
        .background(Color.Black.copy(alpha = 0.3f)),
        contentAlignment = Alignment.Center
    ){
        Text(
            text = "关注公众号-古哥E下,可私聊,群聊",
            lineHeight = 56.sp,
            color = Color.White,
            fontSize = 16.sp
        )
    }
}

业务使用方主要关心两个参数:

  1. partProvider: 需要磨砂的区域信息
  2. radius:磨砂的半径,越大,磨砂效果越好,但是越耗费性能,在低于 Android S 的手机上,radius 最大取值为 25,当然组件内会处理好这个事情。

在其实现上,我们依旧是在 Android S 及以上用 RenderEffect 的实现,而在低版本使用 Toolkit 的实现。

RenderEffect 一般是作用于整个 View,但其实它也可以作用于 RenderNode,这为我们对某个区域进行磨砂提供了可能:

kotlin 复制代码
// 创建两个 RenderNode
val contentNode = RenderNode("content")
val blurNode = RenderNode("blur")
fun draw(canvas: Canvas){
    // 将原本的内容 draw 到contentNode 上
    contentNode.setPosition(0, 0, width, height)
    val rnCanvas = contentNode.beginRecording()
    super.draw(rnCanvas)
    contentNode.endRecording()
    // 将 contentNode draw 回 View 的 canvas
    canvas.drawRenderNode(contentNode)
    if(this.radius > 0){
        // 对 blurNode 应用 RenderEffect
        blurNode.setRenderEffect(RenderEffect.createBlurEffect(this.radius.toFloat(), this.radius.toFloat(),
            Shader.TileMode.CLAMP))
        // 获取磨砂区域
        val part = partProvider(width, height)
        blurNode.setPosition(0, 0, part.width(), part.height())
        blurNode.translationY = part.top.toFloat()
        blurNode.translationX = part.left.toFloat()

        // 将内容再 draw 到 blurNode 上
        val blurCanvas = blurNode.beginRecording()
        blurCanvas.translate(-part.left.toFloat(), -part.top.toFloat())
        blurCanvas.drawRenderNode(contentNode)
        blurNode.endRecording()
        // 将 blurNode draw 回 View 的 canvas
        canvas.drawRenderNode(blurNode)
    }
}

这代码基本上就是抄 Medium 上的文章 RenderNode for Bigger, Better Blurs

对于低版本,其处理逻辑和整个 View 的磨砂差异不大,就是生成一个区域的 Bitmap, 由于代码重叠度较高,所以抽取一个通用的辅助类:

kotlin 复制代码
class ViewToBlurBitmapCreator(
    private val contentView: View,
    private val onBlurBitmapCreated: (bitmap: Bitmap?, x: Float, y: Float) -> Unit
) : LogTag{
    private var generateJob: Job? = null
    private var updateVersion = 0

    fun run(radius: Int, partProvider: ((w: Int, h: Int) -> Rect)?){
        generateJob?.cancel()
        updateVersion++
        if(radius == 0){
            onBlurBitmapCreated(null, 0f, 0f)
        }
        val safeRadius = radius.coerceAtMost(25)
        val currentVersion = updateVersion
        OneShotPreDrawListener.add(contentView) {
            contentView.post {
                contentView.findViewTreeLifecycleOwner()?.apply {
                    generateJob = lifecycleScope.launch {
                        if(currentVersion != updateVersion){
                            return@launch
                        }
                        if(contentView.width <= 0 || contentView.height <= 0){
                            EmoLog.w(TAG, "blur ignored because of size issue(w=${contentView.width}, h=${contentView.height})")
                            // retry
                            run(safeRadius, partProvider)
                            return@launch
                        }
                        try {
                            var x = 0f
                            var y = 0f
                            val bitmap = if(partProvider == null){
                                Bitmap.createBitmap(contentView.width, contentView.height, Bitmap.Config.ARGB_8888).also {
                                    val canvas = Canvas(it)
                                    contentView.draw(canvas)
                                }
                            } else {
                                val part = partProvider(contentView.width, contentView.height)
                                val w = part.width()
                                val h = part.height()
                                if (w <= 0 || h <= 0 ||
                                    part.left < 0 || part.top < 0 || part.right > contentView.width || part.bottom > contentView.height
                                ) {
                                    throw IllegalStateException("part is illegal")
                                }
                                x = part.left.toFloat()
                                y = part.top.toFloat()
                                Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888).also {
                                    val canvas = Canvas(it)
                                    canvas.translate(-x, -y)
                                    contentView.draw(canvas)
                                }
                            }
                            val blurImage = withContext(Dispatchers.IO) {
                                Toolkit.blur(bitmap, safeRadius)
                            }
                            if(currentVersion == updateVersion){
                                onBlurBitmapCreated(blurImage, x, y)
                            }
                        } catch (e: Throwable) {
                            if(e !is CancellationException){
                                EmoLog.e(TAG, "blur image failed", e)
                            }
                        }
                    }
                }
            }
        }
        contentView.invalidate()
    }
}

然后在 View 中直接使用它完成所需的逻辑:

kotlin 复制代码
internal class PartBlurBitmapEffectView(
    context: Context,
    radius: Int = DEFAULT_BLUR_RADIUS,
    partProvider: (width: Int, height: Int)-> Rect
) : PartBlurView(context, radius, partProvider) {

    private val blurImageView = FakeImageView(context)
    private val blurBitmapCreator = ViewToBlurBitmapCreator(contentView){ bitmap, x, y ->
        if(bitmap != null){
            blurImageView.setBitmap(bitmap, x, y)
        } else {
            blurImageView.clear()
        }
    }

    init {
        addView(blurImageView, LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT
        ))
        onContentUpdate()
    }

    override fun onContentUpdate() {
        blurBitmapCreator.run(radius, partProvider)
    }
}

这样整个实现就搞定了,就可以愉快的用在业务上了,但我们依旧需要留意一下:

  1. Android S 以下由于最大 radius 只能是 25,所以磨砂效果只能到那个程度,比不得 RenderEffect 的实现
  2. Android S 以下由于是 CPU 实现,所以无法使用 Hardware Bitmap,基本上图片加载框架都会默认启用 Hardware Bitmap,所以需要判断版本关掉。

目前还没有打包上传 Maven Central,还在纠结要不要删除 Toolkit 里与 blur 无关的代码,让 so 尽可能小,也在纠结要不要去体验下 Vulkan 的实现,或许在低版本效果比 Toolkit 实现更好~

相关推荐
大耳猫4 小时前
主动测量View的宽高
android·ui
帅次6 小时前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
枯骨成佛7 小时前
Android中Crash Debug技巧
android
kim565912 小时前
android studio 更改gradle版本方法(备忘)
android·ide·gradle·android studio
咸芝麻鱼12 小时前
Android Studio | 最新版本配置要求高,JDK运行环境不适配,导致无法启动App
android·ide·android studio
无所谓จุ๊บ12 小时前
Android Studio使用c++编写
android·c++
csucoderlee13 小时前
Android Studio的新界面New UI,怎么切换回老界面
android·ui·android studio
kim565913 小时前
各版本android studio下载地址
android·ide·android studio
饮啦冰美式13 小时前
Android Studio 将项目打包成apk文件
android·ide·android studio
夜色。13 小时前
Unity6 + Android Studio 开发环境搭建【备忘】
android·unity·android studio