Jetpack Compose(十五)Compose组件渲染流程-绘制

绘制阶段主要是将所有LayoutNode实际绘制到屏幕之上,也可以对绘制阶段进行定制。如果我们对Android原生Canvas已经非常熟悉,迁移到Compose是没有任何学习成本的。即使从未接触过也没有关系,在Compose中,官方为我们提供了大量简单且实用的基础绘制API,能够满足绝大多数场景下的定制需求,通过本节的学习,我们将具备扎实的组件绘制定制能力。

一、Canvas Composable

Canvas Composable是官方提供的一个专门用来自定义绘制的单元组件。之所以说是单元组件,是因为这个组件不可以包含任何子组件,可以看作是传统View系统中的一个单元View。

CanvasComposable包含两个参数,一个是Modifier,另一个是DrawScope作用域代码块。

kotlin 复制代码
@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))   //所有的绘制逻辑最终都传入drawBehind()修饰符方法里

在DrawScope作用域中,Compose提供了基础绘制API,如表所示:

API 描述
drawLine 绘制线
drawRect 绘制矩形
drawlmage 绘制图片
drawRoundRect 绘制圆角矩形
drawCircle 绘制圆
drawOval 绘制椭圆
drawArc 绘制弧线
drawPath 绘制路径
drawPoints 绘制点

接下来我们通过绘制API成完一个简单的圆形加载进度条组件,如图所示:

加载进度组件绘制起来并不复杂,可以通过圆环与圆弧的叠加进行实现。完整代码如下:

kotlin 复制代码
@Composable
fun Greeting() {
    //初始进度
    var progress by remember { mutableStateOf(0F) }
    //展示进度条
    LoadingProgressBar(progress)
    //使用Timer模拟
    LaunchedEffect(Unit) {
        timer(period = 100) {
            if (progress < 100F) {
                progress += 1F
            }
        }
    }
}

/**
 * 加载Loading圆形进度条
 * @param progress 进度
 */
@Composable
fun LoadingProgressBar(progress: Float) {
    val loadingText = "Loading"
    //将绘制的角度平滑过渡
    val sweepAngle = animateFloatAsState(
        targetValue = progress / 100F * 360F,
        animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, label = ""
    ).value
    Box(
        modifier = Modifier
            .size(300.dp),
        contentAlignment = Alignment.Center
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text(
                text = loadingText,
                color = Color.Black,
                fontSize = 28.sp,
                fontWeight = FontWeight.Bold
            )
            Text(
                text = "${progress.toInt()}%",
                color = Color.Black,
                fontSize = 28.sp,
                fontWeight = FontWeight.Bold
            )
        }
        Canvas(
            modifier = Modifier
                .fillMaxSize()
                .padding(30.dp)
        ) {
            //绘制圆,drawContext.size当前绘制环境的尺寸
            drawCircle(
                color = Color(0xFF1E7171),
                center = Offset(drawContext.size.width / 2f, drawContext.size.height / 2f),
                style = Stroke(width = 20.dp.toPx())
            )
            //绘制圆弧
            drawArc(
                color = Color(0xFF3BDCCE),
                startAngle = -90f,
                sweepAngle = sweepAngle,
                useCenter = false,
                style = Stroke(width = 20.dp.toPx(), cap = StrokeCap.Round)
            )
        }
    }
}

代码并没有很复杂的地方,带着注释大概都能看懂,其中animateFloatAsState是让值平滑的过渡,绘制的UI也会看起来更加丝滑,如上面UI图所示。

查阅Canvas组件的实现,可以发现其本质上就是一个Spacer,所有的绘制逻辑最终都传入drawBehind()修饰符方法里。这个API字面意思很明确,绘制在后面即绘制在底部图层。由于该修饰符方法修饰在Spacer上,这表明我们其实是在Spacer的底部图层上完成的定制绘制。由于Spacer背景是透明的,所以绘制的内容就完全展示出来了。

接下来看看其他一些与绘制相关的修饰符方法。由于这些修饰符方法返回的都是DrawModifier的子类,所以将这些修饰符统称为DrawModifier类修饰符方法。

二、DrawModifier

DrawModifier类修饰符方法共有三个,每个都有其各自的使命。drawWithContent允许开发者可以在绘制时自定义绘制层级,Canvas Composable中使用的drawBehind是用来定制绘制组件背景的,而drawWithCache则允许开发者在绘制时可以携带缓存。接下来学习这三个API该如何正确使用。

1、drawWithContent

先来看看drawWithContent,这个API允许开发者在绘制时自定义绘制层级,那么什么是绘制层级呢?其实就是越先绘制的内容Z轴越小,后面绘制的内容可能会遮盖前面绘制的内容,这样就产生了绘制的层级关系。通过API声明,可以看到drawWithContent需要一个ContentDrawScope作用域Lambda,而这个ContentDrawScope实际上就是在DrawScope作用域基础上拓展了一个drawContent。

kotlin 复制代码
//drawWithContent
fun Modifier.drawWithContent(
    onDraw: ContentDrawScope.() -> Unit
): Modifier = this then DrawWithContentElement(onDraw)

//ContentDrawScope
@JvmDefaultWithCompatibility
interface ContentDrawScope : DrawScope {
    fun drawContent()
}

Modifier是修饰某个具体组件的,drawContent的作用就是绘制组件本身的内容。例如Text,组件本身会绘制一串文本。当我们想为这个文本绘制背景色时,就需要先绘制背景色再绘制文本,在传统View中会像这样

kotlin 复制代码
class CustomTextView(context: Context) : AppCompatTextViw(context) {
    override fun onDraw(canvas: Canvas?) {
        //在TextView下层绘制
        super.onDraw(canvas)
        //在TextView上层绘制
    }
}

而这与drawContent的设计是相通的。

2、drawBehind

了解了drawContent,drawBehind就很好理解了就是用来自定义绘制组件背景的。

在drawBehind的实现源码中,定制的绘制逻辑onDraw会被传入DrawBackgroundModifier的主构造器中。在重写的draw方法中,首先调用了我们传入的定制绘制逻辑,之后调用drawContent来绘制组件内容本身。

kotlin 复制代码
fun Modifier.drawBehind(
    onDraw: DrawScope.() -> Unit
) = this then DrawBehindElement(onDraw)    //DrawBehindElement

@OptIn(ExperimentalComposeUiApi::class)
private data class DrawBehindElement(   //DrawBehindElement
    val onDraw: DrawScope.() -> Unit
) : ModifierNodeElement<DrawBackgroundModifier>() {
    override fun create() = DrawBackgroundModifier(onDraw)  //DrawBackgroundModifier

   ...
}

@OptIn(ExperimentalComposeUiApi::class)
private class DrawBackgroundModifier(    //DrawBackgroundModifier
    var onDraw: DrawScope.() -> Unit
) : Modifier.Node(), DrawModifierNode {

    override fun ContentDrawScope.draw() {
        onDraw()    //先绘制背景
        drawContent()   //再绘制内容
    }
}

我们看一下二个Api如何使用以及更直观的通过UI查看二者的区别,代码如下:

kotlin 复制代码
@Composable
fun Greeting() {
   Column {
       Spacer(modifier = Modifier
           .fillMaxWidth()
           .height(30.dp))

       DrawBefore()

       Spacer(modifier = Modifier
           .fillMaxWidth()
           .height(30.dp))

       DrawBehind()
   }
}


@Composable
fun DrawBefore() {
    Box {
        Card(
            shape = RoundedCornerShape(8.dp),
            modifier = Modifier
                .size(100.dp)
                .drawWithContent {
                    drawContent()  //绘制内容
                    drawCircle(    //在内容上绘制红点
                        color = Color.Red,
                        18.dp.toPx() / 2,
                        center = Offset(drawContext.size.width, 0f)
                    )
                }) {
            Image(painter = painterResource(id = R.mipmap.rabit2), contentDescription = null)
        }
    }
}


@Composable
fun DrawBehind() {
    Box {
        Card(
            shape = RoundedCornerShape(8.dp),
            modifier = Modifier
                .size(100.dp)
                .drawBehind {    //绘制背景
                    drawCircle(    //绘制小圆点
                        color = Color.Red,
                        18.dp.toPx() / 2,
                        center = Offset(drawContext.size.width, 0f)
                    )
                }) {
            Image(painter = painterResource(id = R.mipmap.rabit2), contentDescription = null)
        }
    }
}

UI效果

3、drawWithCache

有时在DrawScope中绘制时,会用到一些与绘制有关的对象(如ImageBitmap、Paint、Path等),当组件发生重绘时,由于DrawScope会反复执行,这其中声明的对象也会随之重新创建,实际上这类对象是没必要重新创建的。如果这类对象占用内存空间较大,频繁多次重绘意味着这类对象会频繁地加载重建,从而导致内存抖动等问题。

也许有人会提出疑问,将这类对象存放到外部Composable作用域中,并利用remember缓存不可以吗?当然这个做法从语法上来说是可行的,但这样做违反了迪米特法则,这类对象可能会被同Composable内其他组件依赖使用。如果将这类对象存放到全局静态域会更危险,不仅会污染全局命名空间,并且当该Composable组件离开视图树时,还会导致内存泄漏问题。由于这类对象只跟这次绘制有关,所以还是放在一块比较合适。

补充提示:

迪米特法则,又称最少知识原则,是面向对象五大设计原则之一。它规定每个类应对其他类尽可能少了解。如果两个类不必直接相互通信,便采用第三方类进行转发,尽可能减小类与类之间的耦合度。

为解决这个问题,Compose为我们提供了drawWithCache方法,就是支持缓存的绘制方法。通过drawWithCache声明可以看到,需要一个传入CacheDrawScope作用域的Lambda,值得注意的是返回值是DrawResult类型。可以在CacheDrawScope接口声明中发现仅有onDrawBehind与onDrawWithContent这两个API提供了DrawResult类型返回值,实际上这两个API和前面所提及的drawBehind与drawWithContent用法是完全相同的。源码如下:

kotlin 复制代码
fun Modifier.drawWithCache(
    onBuildDrawCache: CacheDrawScope.() -> DrawResult   //CacheDrawScope
) = composed(
    inspectorInfo = debugInspectorInfo {
        name = "drawWithCache"
        properties["onBuildDrawCache"] = onBuildDrawCache
    }
) {
    val cacheDrawScope = remember { CacheDrawScope() }
    this.then(DrawContentCacheModifier(cacheDrawScope, onBuildDrawCache))
}

//CacheDrawScope
class CacheDrawScope internal constructor() : Density {
    ...
    fun onDrawBehind(block: DrawScope.() -> Unit): DrawResult = onDrawWithContent {
        block()
        drawContent()
    }

    fun onDrawWithContent(block: ContentDrawScope.() -> Unit): DrawResult {
        return DrawResult(block).also { drawResult = it }
    }

    ...

这里使用drawCache来绘制多张图片,并不断改变这些图片的透明度。假设每张图片像素尺寸都比较大,一次性把这些图片全部装载到内存不仅耗时,并且也会占用大量内存空间。每当透明度发生变化时,我们不希望重新加载这些图片。在这个场景下,只需使用drawWithCache方法,将图片加载过程放到缓存区中完成就可以了。

由于我们暂时还没有学习动画相关知识,这里大家可以简单理解为利用transition创建了个无限循环变化的alpha透明度状态。接下来就可以在drawWithCache缓存区域中加载ImageBitmap实例了,并在DrawScope中使用这些ImageBitmap实例与前面声明的无限循环变化的alpha透明度状态。

kotlin 复制代码
@Composable
fun DrawCache() {
    //获取上下文
    val context = LocalContext.current
    //动态改变alpha
    val transition = rememberInfiniteTransition(label = "")
    val alpha by transition.animateFloat(
        initialValue = 0F,
        targetValue = 1F,
        animationSpec = infiniteRepeatable(   //无限重复
            animation = tween(2000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ), label = ""
    )

    Box(modifier = Modifier
        .size(300.dp)
        .drawWithCache {   //drawWithCache
            val imageBitmap =
                ImageBitmap.imageResource(context.resources, R.mipmap.rabit2)   //图片资源
            onDrawBehind {   //绘制背景
                drawImage(
                    image = imageBitmap,
                    dstSize = IntSize(
                        width = 300.dp.roundToPx(),
                        height = 300.dp.roundToPx()
                    ),
                    dstOffset = IntOffset.Zero,
                    alpha = alpha
                )
            }

        }) {}
}

UI效果

参考内容

本文为学习博客,内容来自书籍《Jetpack Compose 从入门到实战》,代码为具体实践。

相关推荐
coder_pig1 小时前
🤡 公司Android老项目升级踩坑小记
android·flutter·gradle
死就死在补习班2 小时前
Android系统源码分析Input - InputReader读取事件
android
死就死在补习班2 小时前
Android系统源码分析Input - InputChannel通信
android
死就死在补习班2 小时前
Android系统源码分析Input - 设备添加流程
android
死就死在补习班2 小时前
Android系统源码分析Input - 启动流程
android
tom4i3 小时前
Launcher3 to Launchpad 01 布局修改
android
雨白3 小时前
OkHttpClient 核心配置详解
android·okhttp
淡淡的香烟3 小时前
Android auncher3实现简单的负一屏功能
android
RabbitYao4 小时前
Android 项目 通过 AndroidStringsTool 更新多语言词条
android·python
RabbitYao4 小时前
使用 Gemini 及 Python 更新 Android 多语言 Excel 文件
android·python