联系我们
简单又实用的WordPress网站制作教学
当前位置:网站首页 > 程序开发学习 > 正文

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

作者:访客发布时间:2023-12-28分类:程序开发学习浏览:79


导读:绘制阶段主要是将所有LayoutNode实际绘制到屏幕之上,也可以对绘制阶段进行定制。如果我们对Android原生Canvas已经非常熟悉,迁移到Compose是没有任何学习成本的...

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

一、Canvas Composable

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

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

@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成完一个简单的圆形加载进度条组件,如图所示:

Record_2023-12-28-11-03-05_41e48301cd340922d775fe9b49679844_V1.gif

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

@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。

//drawWithContent
fun Modifier.drawWithContent(
    onDraw: ContentDrawScope.() -> Unit
): Modifier = this then DrawWithContentElement(onDraw)

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

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

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来绘制组件内容本身。

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查看二者的区别,代码如下:

@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效果

Screenshot_2023-12-28-14-39-07-21_41e48301cd340922d775fe9b49679844 (1).jpg

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用法是完全相同的。源码如下:

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透明度状态。

@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效果

Record_2023-12-28-11-03-05_41e48301cd340922d775fe9b49679844_V3.gif

参考内容

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


标签:组件流程喷气式飞机Compose十五


程序开发学习排行
最近发表
网站分类
标签列表