
这两年 Jetpack Compose 在 Android 端的普及速度确实快越来越多的新项目直接全面拥抱 Compose老项目也在逐步用 Compose 写新页面。日常开发里如果不用第三方图表库想实现一个带渐变效果、能显示进度的圆形进度条传统 View 体系下可能要自定义 View 重写 onDraw代码量不小。而在 Compose 里借助 Canvas 和 Brush几行代码就能实现一个效果相当不错、还能随时定制动画的圆形渐变进度条。这篇文章我就以自己的实际开发经验为基础完整拆解一个“Compose 圆形渐变进度条”的从 0 到 1 的实现过程包含最核心的 Canvas 绘制原理、两种渐变方案的选择对比、动画过渡的实现以及我在实际项目中踩过的坑和总结的性能优化思路。无论你是刚接触 Compose 的新手还是正在做自定义绘制的进阶选手这篇文章应该都能给你一些参考。1. 圆形渐变进度条的方案选型与设计思路1.1 为什么选择自绘而非第三方库很多人在做进度条展示时第一反应就是去 GitHub 上找一个现成的库比如一些知名的 Compose 轮子库里面确实提供了不少进度组件。但在实际业务场景中第三方库往往存在几个绕不开的问题一是定制成本高。设计师给出的视觉稿常常带有具体要求比如渐变色角度、进度起始位置、端点形状、底色透明度、动画时长这些细节在主流的通用库里不一定能完全配置。即使能配置阅读源码、覆写参数的成本有时候比自己画一个还高。二是依赖膨胀。为一个进度条引入一个库连带的传递依赖可能有好几十个类。如果只是单一页面的小需求实在不划算。更别说有些库长期不维护和最新 Compose 版本的兼容性是个隐患。三是渐变效果的控制粒度不够。进度条最核心的视觉点是“渐变”。扫描渐变sweep gradient的方向、中心点、颜色分布线性渐变linear gradient的映射范围这些在通用库里很难逐一按需调节。自己用 Canvas 绘制拿到的就是绘制底层 API想怎么控制就怎么控制视觉效果完全拿捏在自己手里。1.2 整体组件架构设计动手写代码之前我先在纸上理了一下这个组件应该具备哪些能力可配置的进度范围默认 0f 到 1f也可以做成支持自定义最大值可配置的轨道颜色和进度渐变颜色渐变支持多色可配置进度条宽度、起始角度通常从 12 点钟方向开始即 -90 度、扫过角度整圆 360 度或半圆 180 度可配置端点样式默认是圆头Round也可以切成平头Butt动画过渡进度变化时有平滑的动画效果支持在中间展示文字或自定义内容最好使用 slot 方式开放出来。把这些能力封装成一个 Composable 函数输入是参数输出是绘制结果。内部用 Canvas 绘制轨道圆环和进度圆环用 animateFloatAsState 做数值动画用 Brush 做渐变。这样一个组件既能快速复用到不同页面也能在单个页面里通过不同参数组合出多种视觉效果。1.3 对比传统自定义 View 的核心差异在 View 体系下实现类似效果需要继承 View重写 onDraw还要自己处理 Padding、WrapContent 测量逻辑如果适配不同屏幕密度还要注意 dp、px 转换。Compose 的优势在于这些都被框架层处理掉了。Compose 里的 Canvas 本身就是基于 DrawScope 的轻量封装绘制 API 和 View 的 Canvas 基本一一对应但写起来更简洁。我不用关心 onMeasure不用处理 AttributeSet不用写 styleable只需定义 Composable 的函数参数即可。对于团队协作来说代码审查时看到的是一个纯粹的函数输入输出非常清晰。另外Compose 的重组机制让“状态驱动绘制”成了一件非常自然的事。当进度值从 0.5 变到 0.8 时只要状态发生变化Canvas 会自动重绘完全不需要像 View 那样手动 invalidate。这一点在动画场景里尤其省心。2. 核心绘制原理与关键技术拆解2.1 Canvas 绘制圆弧的核心 APICompose 中绘制圆形进度条最核心的 API 是 DrawScope 里的 drawArc。它的完整签名如下fun DrawScope.drawArc( brush: Brush, startAngle: Float, sweepAngle: Float, useCenter: Boolean, topLeft: Offset, size: Size, style: DrawStyle Fill, colorFilter: ColorFilter? null, alpha: Float 1.0f )参数看起来多但真正需要关注的没几个。startAngle 是绘制起始角度单位是度0 度对应 3 点钟方向角度递增方向是顺时针。我之前第一次用的时候想从 12 点钟方向开始画直接传了 startAngle 0f结果进度条是从右侧起点开始走的这就是没搞懂角度基准。正确的做法是传 -90f。sweepAngle 表示扫过的角度整圆就是 360f半圆就是 180f。useCenter 是是否从圆弧两端连接圆心画饼图的时候才用 true进度环必须传 false。还有一点要注意如果进度值是一个相对比例比如 progress 0.75那绘制角度就是 0.75 * 360 270 度中间有个乘法换算。起始角固定终点角随时间变化视觉上就是一个逐渐填充的进度环。2.2 渐变的实现原理与两种主流方案进度条的渐变本质上是给绘制圆弧的 Brush 传入一个带颜色分布的渐变对象。Compose 里常见的圆形进度渐变方案主要有三种第一种是sweepGradient 扫描渐变。它的特点是颜色围绕圆心沿着角度方向展开像雷达扫描一样。颜色从起始角度开始随着角度方向均匀过渡到结束颜色。这种渐变特别适合环形进度因为进度本身就是按角度走的渐变方向天然契合。另一种是linearGradient 线性渐变。它的特点是颜色沿一条直线方向过渡从起点颜色到终点颜色。用在圆形进度条上时效果就是某个固定方向上的颜色渐变视觉上更像是一块彩色的环。还有一种是用Brush.radialGradient 径向渐变从圆心向外辐射。这种效果在进度环上比较少见一般用作底部的光影装饰所以这里不重点展开。实际项目里扫描渐变是首选方案因为它是贴合的。但有一个细节容易被忽略sweepGradient 的默认起始颜色位置是 0 度也就是 3 点钟方向。如果我们的进度条从 -90 度12 点钟方向开始画那一上来颜色就会对不上。解决方式有两种一是给 sweepGradient 传入 end Offset(centerX, centerY)然后利用 rotate 旋转 brush 的角度让它和圆弧起始角对齐二是直接用 linearGradient 配 start/end 坐标控制颜色过渡方向。我自己的经验是如果你的设计稿只要求“颜色从 A 渐变到 B”对具体角度位置没有严格限定那么直接用 sweepGradient 就好。如果设计稿明确说“渐变要从左上角到右下角”那就得用 linearGradient 指定坐标。2.3 轨道层与进度层的绘制顺序绘制圆形进度条时通常需要两层圆环底层是轨道track也就是淡色的大圆环用来表示总进度范围上层是进度层progress也就是带渐变的彩色圆环表示当前实际进度。绘制顺序上先画轨道再画进度层这样进度层能覆盖在轨道上视觉层级最清晰。轨道层和进度层可以共用同一个圆心、同一个半径和同一个线宽只是颜色和 Brush 不一样。另外有一个很重要的参数就是绘制的线宽。进度环的宽度本质上通过 style Stroke(width strokeWidth) 来控制。这里的 strokeWidth 是基于像素的在实际使用中需要用 dp 转 px。Compose 里可以用 LocalDensity.current 拿到 density 进行转换或者直接在 Modifier 的尺寸参数里用 dp然后绘制时再转换。这里有个小坑需要注意Stroke 的宽度是以圆弧路径为中心线向两侧平均扩散的。如果你的画布尺寸是 100dp进度条宽度是 10dp那圆弧的最大半径只能是 45dp因为 45 5 50。如果不留出这个空间进度条边缘会被 Canvas 裁剪掉。3. 完整实现步骤与代码解析3.1 环境准备与依赖配置这个组件基于 Compose项目需要在 build.gradle 里配置 Compose 相关依赖。我使用的是当前比较稳定的版本组合这里给出参考// 项目级 build.gradle buildscript { ext { compose_bom 2024.06.00 } dependencies { classpath org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0 } } // app 模块 build.gradle dependencies { implementation platform(androidx.compose:compose-bom:$compose_bom) implementation androidx.compose.ui:ui implementation androidx.compose.material3:material3 implementation androidx.compose.ui:ui-tooling-preview }如果用的是 Android Studio 新模板Compose 相关依赖已经默认内置不加也能跑。但如果你是从老项目接 Compose建议用 BOM 统管版本避免各个 compose 库版本不一致引发编译问题。Kotlin 和 Compose 编译器插件必须匹配这一点在升级 Kotlin 时要格外注意。3.2 基础版绘制静态无渐变进度环先不急着上渐变我们把最基础的静态进度环画出来。为了方便调用方传值我把组件设计成一个 Composable 函数Composable fun CircularProgress( progress: Float, // 当前进度取值范围 0f..1f modifier: Modifier Modifier, strokeWidth: Dp 8.dp, trackColor: Color Color(0xFFE0E0E0), progressColor: Color Color(0xFF3F51B5), startAngle: Float -90f, progressSweepAngle: Float 360f ) { val strokeWidthPx with(LocalDensity.current) { strokeWidth.toPx() } val size with(LocalDensity.current) { // 默认尺寸 120.dp实际项目中建议提供默认值或由调用方传入 } Canvas(modifier modifier.size(120.dp)) { val diameter this.size.minDimension val strokeInset strokeWidthPx / 2f val arcSize Size(diameter - strokeWidthPx, diameter - strokeWidthPx) val topLeft Offset(strokeInset, strokeInset) // 轨道 drawArc( color trackColor, startAngle startAngle, sweepAngle progressSweepAngle, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidthPx) ) // 进度 val sweep progress * progressSweepAngle drawArc( color progressColor, startAngle startAngle, sweepAngle sweep, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidthPx, cap StrokeCap.Round) ) } }这段代码里尺寸、线宽、起始角度都是可以传参控制的。绘制轨道时用整圆角度绘制进度时用 progress 乘以整圆角度。你可能会注意到我把进度层加了 cap StrokeCap.Round端点变成圆头视觉上更好看。轨道层一般保持平头就行或者也加圆头看需求。3.3 进阶版引入扫描渐变接下来是重头戏。给进度环加上扫描渐变核心就是给进度层的 drawArc 换一个 Brush 参数。Composable fun GradientCircularProgress( progress: Float, modifier: Modifier Modifier, strokeWidth: Dp 10.dp, trackColor: Color Color(0xFFF0F0F0), gradientColors: ListColor listOf(Color(0xFF4FC3F7), Color(0xFF3F51B5)), startAngle: Float -90f, progressSweepAngle: Float 360f ) { val strokeWidthPx with(LocalDensity.current) { strokeWidth.toPx() } Canvas(modifier modifier.size(140.dp)) { val diameter this.size.minDimension val strokeInset strokeWidthPx / 2f val arcSize Size(diameter - strokeWidthPx, diameter - strokeWidthPx) val topLeft Offset(strokeInset, strokeInset) val center this.center drawArc( color trackColor, startAngle startAngle, sweepAngle progressSweepAngle, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidthPx) ) val sweep if (progressSweepAngle 360f) progressSweepAngle * progress else progress * progressSweepAngle val brush Brush.sweepGradient( colors gradientColors, center center ) drawArc( brush brush, startAngle startAngle, sweepAngle sweep, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidthPx, cap StrokeCap.Round) ) } }这里有一个重要问题默认的 sweepGradient 是从 0 度3 点钟方向开始的而进度弧线的 startAngle 是 -90 度12 点钟方向。如果直接画会发现渐变起始颜色和圆弧起始位置错位。解决方式有两种一是给 Brush 加 rotate旋转到和圆弧起始角一致val brush Brush.sweepGradient( colors gradientColors, center center ).rotated(startAngle, center)二是调整 startAngle 为 0f让圆弧从 3 点钟方向开始画。这个方法在视觉上比较特殊适合做仪表盘从右侧起手的场景。实际项目的设计稿一般都希望从 12 点钟方向开始所以旋转 brush 是更通用的方案。旋转操作会整体改变渐变颜色分布方向但不会改变绘制区域不会裁剪内容。3.4 动画过渡让进度变化更丝滑静态的进度环只是基础实际产品里进度值往往是动态变化的。比如下载进度从 20% 到 80%如果直接跳变视觉效果非常生硬。这时候需要引入动画。Compose 里最简单的动画方式是 animateFloatAsStateval animatedProgress by animateFloatAsState( targetValue progress, animationSpec tween(durationMillis 800, easing FastOutSlowInEasing), label circularProgress )把画圆弧的 sweep 计算改为基于 animatedProgress这样 progress 每次变化时动画系统会自动从旧值插值到新值进度环就会平滑地转起来。如果你想实现“加载中”的循环旋转效果可以用 rememberInfiniteTransition 做无限动画val infiniteTransition rememberInfiniteTransition(label loading) val angle by infiniteTransition.animateFloat( initialValue 0f, targetValue 360f, animationSpec infiniteRepeatable( animation tween(1200, easing LinearEasing), repeatMode RepeatMode.Restart ), label angle )然后将 startAngle 设置为 angle这样整个圆弧就会原地转圈。配合一个截断的弧线段就能实现类似 Material 风格的加载转圈。3.5 增加中心内容槽位进度环中间通常要放一些内容比如百分比文字、图标或者自定义视图。考虑到复用性应该把这个能力开放出来而不是写死。做法很简单用 Box 包住 Canvas在 Canvas 上叠加一个内容槽位Composable fun GradientCircularProgress( progress: Float, modifier: Modifier Modifier, strokeWidth: Dp 10.dp, trackColor: Color Color(0xFFF0F0F0), gradientColors: ListColor listOf(Color(0xFF4FC3F7), Color(0xFF3F51B5)), startAngle: Float -90f, progressSweepAngle: Float 360f, content: Composable (() - Unit)? null ) { Box(contentAlignment Alignment.Center, modifier modifier) { Canvas(...) { ... } content?.invoke() } }这样外部调用可以灵活传入文本、图标或者别的 Composable整个组件的通用性大大增强。我在实际项目中把这个组件用在了打卡页面的月度完成率展示上中间显示百分比数字下方显示“已完成”小字效果很自然。4. 实战完整组件封装与参数解析4.1 组件完整代码把前面几节的能力合并起来我整理出了一个相对完整的可复用组件Composable fun GradientCircularProgress( progress: Float, modifier: Modifier Modifier, strokeWidth: Dp 10.dp, trackColor: Color Color(0xFFEEEEEE), gradientColors: ListColor listOf( Color(0xFF4FC3F7), Color(0xFF3F51B5) ), startAngle: Float -90f, totalSweepAngle: Float 360f, animationDuration: Int 800, strokeCap: StrokeCap StrokeCap.Round, content: Composable (() - Unit)? null ) { val animatedProgress by animateFloatAsState( targetValue progress.coerceIn(0f, 1f), animationSpec tween(durationMillis animationDuration, easing FastOutSlowInEasing), label progress ) Box(contentAlignment Alignment.Center, modifier modifier) { Canvas(modifier Modifier.fillMaxSize()) { val strokeWidthPx strokeWidth.toPx() val diameter this.size.minDimension val arcSize Size(diameter - strokeWidthPx, diameter - strokeWidthPx) val topLeft Offset(strokeWidthPx / 2f, strokeWidthPx / 2f) val center this.center // 轨道 drawArc( color trackColor, startAngle startAngle, sweepAngle totalSweepAngle, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidthPx, cap strokeCap) ) // 进度渐变层 val sweep animatedProgress * totalSweepAngle val brush Brush.sweepGradient( colors gradientColors, center center ).rotated(startAngle, center) drawArc( brush brush, startAngle startAngle, sweepAngle sweep, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidthPx, cap strokeCap) ) } content?.invoke() } }4.2 关键参数说明与选择依据实际使用过程中我总结了一套参数选择的心得strokeWidth和组件尺寸有关。如果组件直径是 120dp进度条宽度 10dp 比较协调视觉占比约 8%如果是 200dp宽度可加到 14dp 到 16dp更饱满。太细会显得单薄太粗则会挤压中间内容空间。gradientColors渐变颜色顺序直接影响视觉方向。List 第一个颜色是渐变起点第二个是终点。想实现炫酷效果可以放 3 到 4 个颜色比如青、蓝、紫的过渡适合科技感页面。startAngle绝大多数情况用 -90f也就是从顶部起笔。如果你的设计是仪表盘样式从 135f 到 405f 这种区间就改 startAngle 和 totalSweepAngle 配合。totalSweepAngle整圆进度用 360f半圆仪表盘风格用 180f。需要注意如果小于 360f轨道层也会只画出部分圆弧。4.3 调用示例下面是一个实际调用示例展示一个 75% 进度的渐变圆环中间显示百分比GradientCircularProgress( progress 0.75f, modifier Modifier.size(160.dp), strokeWidth 12.dp, gradientColors listOf( Color(0xFFFF6A88), Color(0xFFFF9A8B) ), trackColor Color(0xFFFFE0D9), strokeCap StrokeCap.Round ) { Column(horizontalAlignment Alignment.CenterHorizontally) { Text( text 75%, style MaterialTheme.typography.headlineMedium, fontWeight FontWeight.Bold, color Color(0xFFFF6A88) ) Text( text 已完成, style MaterialTheme.typography.bodySmall, color Color(0xFF999999) ) } }这段代码展示的是暖色渐变风格。如果你做的是金融类页面可以换冷色系做健身打卡可以换绿色系。组件本身不限制颜色视觉风格完全由调用方决定。5. 踩坑记录与性能优化5.1 渐变方向与圆弧起点的错位问题这是所有使用 sweepGradient 的人第一次都会碰到的问题。由于 sweepGradient 颜色从 0 度开始而圆弧从 -90 度开始颜色分布和进度起点对不上。我第一次实现时直接在 Brush 后面调用 rotated 方法旋转发现旋转的 center 参数如果传错整个渐变会偏移颜色会从圆弧中间开始。后面我仔细观察发现Brush.rotated 的 center 默认是 Offset(0, 0)必须显式传圆弧的 center。所以我最终的代码里都是val brush Brush.sweepGradient( colors gradientColors, center center ).rotated(startAngle, center)这样就可以确保渐变第一个颜色在圆弧的起始点最后一个颜色在终点。5.2 半圆进度条的渐变角度适配如果要画半圆进度条比如某个数值达标仪表盘起始角可能是 180f总角度是 180f这时候 sweepGradient 的旋转角度也得是 180f否则渐变依然从 0 度开始和视觉起点的颜色对不上。有一个快速验证方法把渐变颜色设成黑白两个极端色如果白色在黑位的左边说明旋转角度不对。5.3 进度值越界导致绘制异常有些调用方可能传入 progress 1.5f 或者 progress -0.2f这种越界值如果不处理会导致绘制出的圆弧超出轨道或者反向绘制出现视觉错乱。我在组件里做了 coerceIn(0f, 1f) 约束把进度值限制在合法范围内。这个防御性处理在多人协作时非常有用避免后台返回异常数据时 UI 崩掉。5.4 动画重复执行的问题如果父组件重组频繁animateFloatAsState 可能会被反复触发导致动画一直抖动。解决方式是确认 progress 值变化时才会触发动画因为 animateFloatAsState 是基于 targetValue 的变化做比较的。如果父重组传入的是同一个 progress动画不会重启。但如果你是在列表项里使用这个组件建议开启 Compose 的 lint 检查确认 key 正确、状态上提避免列表滚动时动画反复执行。5.5 性能优化策略Canvas 绘制本身的性能开销主要取决于重绘频率。进度动画过程中每帧都会调用 drawArc在低端机上如果页面里同时存在多个进度环可能会有掉帧风险。优化手段有几个方向一是控制动画时长。常见的 tween 时长 800 毫秒到 1000 毫秒比较合适太短看着突兀太长容易让人觉得卡顿。二是减少过度绘制。如果页面里多个进度环的轨道层不变可以考虑抽离成单独层但这在 Compose 里比较难直接操作。更实际的做法是保证轨道层不参与动画只有进度层参与这样至少在做 Canvas 绘制时减少了一部分 work。三是合理使用 Modifier.size。给组件一个确定的尺寸避免在测量阶段反复计算。Box Canvas 的 fillMaxSize 已经能保证一次性测量完成不用额外设置 fixedSize。四是最重要的一条关掉不必要的重组。把 progress 状态放在 ViewModel 或 State 里在 UI 层读取时用 collectAsStateWithLifecycle避免底层数据每帧发射导致 UI 做无谓重组。5.6 常见问题速查表我在实际开发和测试过程中整理了下面几个高频问题供你参考问题现象原因解决方案渐变颜色从 3 点钟方向开始而不是进度起点sweepGradient 默认 0 度是 3 点钟方向给 Brush 调用 rotated(startAngle, center)进度条边缘被裁剪Stroke 宽度没有预留空间topLeft 设为 strokeWidth / 2尺寸减去完整 strokeWidth进度变化时无动画未使用 animateFloatAsState用动画状态包装 progress 值半圆弧线两端突然变细没有设置 strokeCap 或设成了 Butt使用 StrokeCap.Round 实现圆头端点渐变颜色过渡突兀色值差异过大或颜色列表过少增加中间颜色或减小色差Canvas 尺寸为 0 导致看不到内容modifier 未设置尺寸父布局也没有约束在调用处设置 Modifier.size() 或由父布局撑开动画结束时进度环闪烁Rotated brush 每次重组重算用 remember 缓存 brush 变量6. 从 demo 到可交付组件细节打磨与扩展思路6.1 细节定制让组件更耐看一个进度环如果只是画个圈视觉效果往往比较平淡。想让组件真正达到可交付的品相还需要在几个细节上下功夫。首先是渐变颜色的分布位置。默认 sweepGradient 会在整个 360 度上均匀分布颜色。但如果你希望某个颜色更早出现可以配合 colorStops 参数手动指定颜色偏移val brush Brush.sweepGradient( colorStops arrayOf( 0.0f to Color(0xFF4FC3F7), 0.5f to Color(0xFF7C4DFF), 1.0f to Color(0xFF3F51B5) ), center center ).rotated(startAngle, center)colorStops 的每个值是 0f 到 1f 的小数表示颜色在渐变的哪个位置出现。用这个 API可以做出“前段浅色、后段深色”这种更有层次感的效果。其次是进度弧的端点发光效果。进度走到终点时往往希望那个点更突出。可以额外绘制一个小圆点val endAngle Math.toRadians((startAngle sweep).toDouble()) val endX center.x radius * cos(endAngle) val endY center.y radius * sin(endAngle) drawCircle( color gradientColors.last(), radius strokeWidthPx / 2f 2.dp.toPx(), center Offset(endX.toFloat(), endY.toFloat()) )这一小段代码可以给进度环增加一个原点标记视觉上更精致适合用在目标值展示场景。6.2 组件的可测试性自绘组件往往不好做 UI 测试因为 Canvas 内容没法直接用语义断言。但我们可以测试传入参数后的状态是否正确。我自己常用的方法是把核心计算抽成纯函数比如把 progress 换算成 sweep 角度的逻辑单独写一个函数并做单元测试internal fun progressToSweepAngle( progress: Float, totalSweepAngle: Float ): Float { val clamped progress.coerceIn(0f, 1f) return clamped * totalSweepAngle }这样既保证进度换算逻辑的准确性也让组件代码更清晰。UI 层只负责把函数结果传给 drawArc不会夹带复杂业务逻辑。6.3 扩展方向组件封装完成后扩展方向其实非常多多段进度把 drawArc 的 sweepAngle 拆成多段每段各用不同颜色就能实现多级进度条比如考试得分分档展示。倒计时效果配合无限动画旋转再在中部填充一个弧形缺口可以做一个类似 iOS 的倒计时圆环。拖动交互给 Canvas 加 pointerInput监听手势坐标通过计算角度来更新进度就能变成可拖动的进度环适合做音量调节或亮度调节。语音播报配合进度环动画完成时触发回调适合打卡成功、任务完成这类场景。不过扩展也要有边界意识。做多段进度时如果颜色数量多、段数细直接改 drawArc 的绘制参数复杂度会快速上升。我会建议另起一个专门的多段组件而不是往现有组件上无限制堆参数。这算是我踩过几次坑之后的经验总结组件的参数越多调用方理解和维护的成本就越高。7. 我对 Compose 自绘组件的几点体会做这个圆形渐变进度条的整个过程我最大的体会是Compose 把自定义绘制的门槛降得非常低但想画得好看、画得够灵活对 Canvas API 的理解深度依然很重要。以前用 View 自定义绘制setup 代码一堆状态管理全靠手动 invalidate。Compose 把这层负担去掉了状态即 UI数据变了 UI 自己会更新配合动画 API 做动效特别顺手。尤其是做这种视觉复杂一点的组件写自定义 Composable 反而比找库、改造库、兼容库更高效。最后分享一个小技巧调试渐变进度条时可以先把渐变颜色临时改成反差极大的两个颜色比如黑色和白色、红色和绿色。这样一眼就能看出起始颜色落在哪个位置渐变方向是否正确。确认没问题后再换回设计稿的颜色。这个小办法帮我排掉过很多次肉眼难辨的渐变错位问题省了不少时间。