1. 这块组件到底解决什么问题
1.1 为什么还要手写自定义 View
先说个实际场景。某业务线需要做一批数据展示页面,原型里是五个圆环,每个圆环带不同的百分比、不同的颜色、不同的标签位置,还要响应点击跳转。第一反应是找开源库,翻了几个排名靠前的,要么样式对不上设计稿,要么交互方式不太一样,要么包体积加得肉疼。最后决定自己写一个。
其实很多团队遇到类似需求都会陷入选择困难。用组合布局拼,圆环背景和文字叠放要调半天,阴影和渐变也费劲;用第三方库,依赖重不说,自定义属性不够灵活,改样式要改源码,出问题还不好排查。自定义 View 的核心价值就在这里:把高频变化的部分沉淀成可配置项,把稳定的绘制逻辑封装起来,一次投入、到处复用。
这个组件最终定为一个带状态的多功能进度环,支持渐变、动画、点击反馈、数据标签,附带完整代码和文档。标题里写了“从 0 到 1”,本质上就是一套完整闭环——从 View 的骨架搭建,到测量、绘制、触摸,再到数据驱动、性能优化,最后落地到组件库。适合正在做类似可视化组件、想系统梳理自定义 View 知识体系、或者被现有库坑过想自研的同学参考。
1.2 组件设计的核心思路
写自定义 View 最容易犯的错,是一上来就画图,画完再想哪里抽出来。正确顺序是先定目标,再定边界,最后写代码。
这个进度环的目标很明确:业务方传入数据和配置,组件负责渲染和交互。所以数据模型和样式配置必须解耦。进度值、颜色、标签文字是数据;线宽、动画时长、字体大小属于样式。数据和样式分开,好处是后续埋点、数据刷新、多主题适配都能平滑过渡。
边界也很关键。组件做到什么程度算完?我的原则是:绘制和交互属于 View 的职责,网络请求、页面跳转、状态持久化不属于。业务方在回调里自己处理后续逻辑,组件只负责把事件抛出来。
确定了目标和边界,再想绘制方案。圆环无非是圆弧加背景圆弧,用Canvas.drawArc就能实现,难点在反锯齿、渐变、动画插值、点击热区这几件事上。方案选型上我直接走原生 Canvas,不用 SVG 也不用第三方图表库,原因很简单:这个组件的复杂度原生 API 完全覆盖得住,能少一个依赖就少一个依赖,包体积、稳定性、可控性都更好。
1.3 目标场景与适用范围
梳理一下这类组件实际能覆盖的场景:
- 数据大盘里的完成率、达标率展示
- 运营活动页的倒计时、任务进度
- 学习类产品的课程完成度、打卡进度
- 个人中心里的等级、信用分、健康指标
- 需要多组数据同时展示的报表页面
这些场景的共同点,是视觉规格多、数据变化频繁、交互方式多样。用自定义 View 做,一套代码能够通过参数切换满足不同运营位和版本的差异化需求。那为什么不用 Jetpack Compose?如果项目已经全面 Compose 化,确实可以优先考虑 Compose 方案。但很多存量项目依然是 View 体系,或者只是对单个页面做组件独立化,自定义 View 依然是最稳的选择,性能和兼容性都有保障。另外,这个组件的绘制逻辑很基础,把它讲透,能帮你把在 Canvas 层面积累的经验,更自然地平移到 Compose 的自定义绘制场景里去。所以本文所有代码基于 View 体系,但设计思路对其他 UI 体系同样适用。
2. 关键代码结构与绘制流程
2.1 自定义属性定义与获取
自定义属性是整个组件配置化的基础。设计上我分为三组:
<declare-styleable name="ProgressRingView"> <!-- 数据相关 --> <attr name="ring_progress" format="float" /> <attr name="ring_max" format="float" /> <attr name="ring_suffix" format="string" /> <!-- 样式相关 --> <attr name="ring_color_start" format="color" /> <attr name="ring_color_end" format="color" /> <attr name="ring_bg_color" format="color" /> <attr name="ring_width" format="dimension" /> <attr name="ring_start_angle" format="integer" /> <!-- 文案相关 --> <attr name="ring_text_size" format="dimension" /> <attr name="ring_text_color" format="color" /> <attr name="ring_show_center_text" format="boolean" /> </declare-styleable>分组的好处是阅读清晰,业务方在使用时也能直观感知哪些是必填、哪些是可选。获取属性时常用 TypedArray,注意一定要在 finally 里回收:
private fun init(attrs: AttributeSet?) { val ta = context.obtainStyledAttributes(attrs, R.styleable.ProgressRingView) progress = ta.getFloat(R.styleable.ProgressRingView_ring_progress, 0f) maxValue = ta.getFloat(R.styleable.ProgressRingView_ring_max, 100f) colorStart = ta.getColor(R.styleable.ProgressRingView_ring_color_start, Color.CYAN) colorEnd = ta.getColor(R.styleable.ProgressRingView_ring_color_end, Color.BLUE) bgColor = ta.getColor(R.styleable.ProgressRingView_ring_bg_color, 0x33000000) ringWidth = ta.getDimension(R.styleable.ProgressRingView_ring_width, dp(12f)) startAngle = ta.getInteger(R.styleable.ProgressRingView_ring_start_angle, -90) textSize = ta.getDimension(R.styleable.ProgressRingView_ring_text_size, sp(20f)) textColor = ta.getColor(R.styleable.ProgressRingView_ring_text_color, Color.BLACK) showCenterText = ta.getBoolean(R.styleable.ProgressRingView_ring_show_center_text, true) suffix = ta.getString(R.styleable.ProgressRingView_ring_suffix) ?: "" ta.recycle() }注意:
getDimension返回的是像素值,sp和dp的换算要注意时机。构造函数里还没有独立的 density 计算,建议在 init 里用TypedValue.applyDimension或者拿到resources.displayMetrics做统一换算,避免不同屏幕密度下出现文本大小和环宽不协调的问题。
2.2 测量逻辑:ConstraintLayout 之外的第二道防线
自定义 View 的测量是整篇文章最容易被忽略、也最容易被检查的部分。一个组件如果只是放在固定宽高的容器里,onMeasure 随便写写也看不出来问题。但一旦放进wrap_content场景,或者因为父子布局层级导致测量约束不确定,绘制错乱就会立刻暴露。
进度环本质上是正方形或者固定宽高比,所以常用做法是让测量结果在AT_MOST情况下取一个合理的默认值:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val defaultSize = dp(120f).toInt() val wrapWidth = if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST) { minOf(defaultSize, MeasureSpec.getSize(widthMeasureSpec)) } else { MeasureSpec.getSize(widthMeasureSpec) } val wrapHeight = if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.AT_MOST) { minOf(defaultSize, MeasureSpec.getSize(heightMeasureSpec)) } else { MeasureSpec.getSize(heightMeasureSpec) } val sideLength = minOf(wrapWidth, wrapHeight).coerceAtLeast(minWidth) setMeasuredDimension(sideLength, sideLength) }这里有个细节:如果父容器给了固定尺寸,就要按照父容器的约束来;如果是 wrap_content,用默认尺寸 120dp 兜底,再用 min 约束防止超出父容器范围。这个逻辑对圆形组件特别友好,保证绘制的时候 width 和 height 相等,省掉很多min(w, h)之类的临时判断。
2.3 绘制逻辑:从 Canvas 到 GPU 指令的过程
绘制之前,先明确几个关键坐标概念。View 的坐标系以左上角为原点,圆环的中心是width/2, height/2,半径需要考虑环宽的一半。很多人第一次画圆环,直接用整个 View 的半径,画出来边缘被截断。正确做法是半径减去一半环宽,预留出抗锯齿和描边的空间:
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val centerX = width / 2f val centerY = height / 2f val radius = minOf(width, height) / 2f - ringWidth / 2f // 背景环 bgPaint.apply { color = bgColor style = Paint.Style.STROKE strokeWidth = ringWidth isAntiAlias = true } canvas.drawArc(centerX - radius, centerY - radius, centerX + radius, centerY + radius, startAngle, 360f, false, bgPaint) // 进度环 val sweepAngle = calculateSweepAngle() progressPaint.apply { if (colorStart != colorEnd) { shader = LinearGradient( 0f, 0f, width.toFloat(), height.toFloat(), colorStart, colorEnd, Shader.TileMode.CLAMP ) } strokeWidth = ringWidth style = Paint.Style.STROKE strokeCap = Paint.Cap.ROUND isAntiAlias = true } canvas.drawArc(centerX - radius, centerY - radius, centerX + radius, centerY + radius, startAngle, sweepAngle, false, progressPaint) drawCenterText(canvas) }几个关键的绘制知识点:
- drawArc 的方向:
startAngle我默认取 -90 度,代表从 12 点钟方向开始。正角度是顺时针,负角度是逆时针。大多数环形组件的起始位置都是 12 点,这里不要搞混。 - sweepAngle 的计算:
progress / maxValue * 360。注意progress需要先做范围限定,防止大于 max 时画出完整的假 100%。 - strokeCap 的选择:
ROUND会让线段两端带圆角,视觉效果更现代,但是会导致线段长度比实际角度多出一点点,角度的边界处理需要自己做容差调整。如果不想要这种视觉偏差,用BUTT,但部分设计稿会明显感觉到棱角。 - 渐变的方向:我用了对角渐变
LinearGradient(0, 0, width, height),这样看起来更立体。如果业务方需要圆形放射渐变,要用SweepGradient。
drawCenterText这段我单独拿出来说,因为文本测量是很多人的痛点:
private fun drawCenterText(canvas: Canvas) { if (!showCenterText) { return } // 主文案 val rawText = "${progressText()}%" val fontMetrics = textPaint.fontMetrics val textHeight = fontMetrics.descent - fontMetrics.ascent val baseline = centerY + (textHeight / 2f - fontMetrics.descent) canvas.drawText(rawText, centerX, baseline, textPaint) }文本垂直居中的核心,是用fontMetrics.ascent + descent算出行高,再用baseline = centerY - (ascent + descent) / 2定位。网上很多代码直接用getTextBounds算,试下来不同字号和不同系统字体下误差很大,原因在于getTextBounds不包含额外的行间距和内部边距,而fontMetrics是最接近绘制引擎实际行为的测量方式。另外,百分比数字建议用同一个宽度,避免数字跳动时整个文本左右飘移,做法是给textPaint设置setTabWidth或者固定宽度,业务方也可以直接用等宽数字字体。
2.4 事件处理与手势反馈
进度环的触摸交互,不能只做一个setOnClickListener。自定义 View 里更合理的做法,是把触摸命中区域计算出来,再回调给宿主,同时也把点击反馈做精致。
override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { isPressed = true startPulseAnimator() performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) } MotionEvent.ACTION_UP -> { if (isInRing(event.x, event.y)) { // 命中环内区域 listener?.onRingClick(progressText()) } isPressed = false stopPulseAnimator() performClick() } MotionEvent.ACTION_CANCEL -> { isPressed = false stopPulseAnimator() } } return true } private fun isInRing(x: Float, y: Float): Boolean { val dx = x - centerX val dy = y - centerY val distance = sqrt(dx * dx + dy * dy) return distance >= radius - ringWidth / 2f && distance <= radius + ringWidth / 2f }注意两个细节:performClick()必须调用,否则无障碍服务和辅助功能扫描会漏掉这个组件的语义信息;按下动画用属性动画做一个轻微的放大或光晕反馈,这是自定义 View 提升质感最便宜的方式。
3. 让组件真正“业务化”:数据、动画与对外接口
3.1 数据驱动刷新:避免手动触发重绘的坑
初期封装最容易踩的坑,是暴露 setter 之后每个 setter 里都调invalidate()。直观,但不够干净。数据一多,互相交叉刷新就会导致重复绘制。
我采用的方式是数据集中管理和统一刷新:
data class RingData( val progress: Float, val max: Float = 100f, val label: String = "" ) fun setData(data: RingData) { this.currentData = data animateTo(target = data.progress) invalidate() }核心逻辑在animateTo里。动画过程不能直接改数据源并重绘,不然每帧的动画值会和最终值互相污染。通常做法是:动画前备份目标值,动画过程中用插值器算出当前展示值,动画结束后一次性把数据源设为最终目标值。这样既能看到动画过渡,又保证数据状态和 UI 状态最终一致。
3.2 动画实现:ValueAnimator 的正确用法
进度动画如果手动用Thread + sleep实现,很可能在页面销毁后还在跑,而且主线程绘制和消息队列的配合也不够优雅,容易掉帧。建议统一走ValueAnimator或者ObjectAnimator:
private var animator: ValueAnimator? = null private fun animateTo(target: Float) { animator?.cancel() val current = currentProgress animator = ValueAnimator.ofFloat(current, target.coerceIn(0f, maxValue)).apply { duration = 800L interpolator = DecelerateInterpolator(2f) addUpdateListener { valueAnimator -> currentProgress = valueAnimator.animatedValue as Float invalidate() } addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { currentProgress = target.coerceIn(0f, maxValue) invalidate() } }) start() } }有几个注意事项:
- cancel 时机:动画未结束就启动新一轮动画,必须
cancel(),不然两帧互相覆盖数值。 - 页面生命周期:对外暴露
release()方法,在onDetachedFromWindow里调用,把 animatorcancel()并置空,避免内存泄漏。这是很多初阶自定义 View 最容易忽略的点。 - 动画时长:产品要求的动效时长不要超过 1 秒。超过 1 秒用户会产生等待焦虑,测试数据也表明这类展示型组件的合理动画时长是 600~900ms。
- 减量刷新:当进度差值小于 1% 时直接 set 最终值并 invalidate,避免动画开销浪费在肉眼几乎识别不到的细微变化上。
3.3 对外接口:暴露哪些能力、隐藏哪些细节
组件对外提供的 API 设计得越克制,后续扩展空间就越大。我最终只暴露了这几个能力:
setData(data: RingData):设置数据setStyle(config: RingStyleConfig):切换样式setOnRingClickListener(listener):点击回调release():释放资源
RingStyleConfig单独作为一个配置数据类,好处是业务方可以把一套配置固化成常量,在多个页面上复用。比如某模块统一用蓝紫渐变风格,就可以把 config 对象定义在全局,传递到任何需要的地方。
回调接口设计为只回传组件内部解析后的结果,不把 View 实例本身传出去。降低耦合,也避免外部直接操作内部状态导致不可预期的问题。另一个建议是回调尽量回调到主线程。如果组件内部需要异步计算,也可以自己在内部切换到主线程再回调,避免业务方重复处理线程问题。
3.4 性能与绘制优化:用系统工具找瓶颈
写自定义 View 最容易出现的性能问题有三个:
第一个问题:过度绘制。圆环背景本身是透明区域,如果业务方给 View 的背景还设置了不透明颜色,那 Canvas 会把整块区域都渲染一遍。建议组件内部不要默认设置背景,如果需要背景,让宿主布局去设置,或者用setLayerType控制层类型。可以用系统自带的 GPU 过度绘制调试工具检查,从蓝色到红色逐级排查,红色区域说明该区域的像素被重复绘制了 4 次以上。
第二个问题:在 onDraw 中做对象分配。onDraw 每帧都会被调,如果你在里面 new 一个 Paint 对象、new 一个 RectF,就会在每一帧产生对象分配,触发 GC 频率上升,动画自然掉帧。所有 Paint、RectF、Path 这些绘制对象,应该在 init 阶段创建并复用。
第三个问题:忽略了硬件加速的限制。从 Android 3.0 开始硬件加速默认开启,很多 canvas 方法在硬件加速下行为有差异,比如canvas.clipPath只支持某些情况下的 antialias,drawText的一些绘制模式也不一定支持。兼容性最好的是自己控制好 clip 的边界,避免依赖系统的扩展行为。
真实场景下,进度环这类组件用 Systrace 抓一帧就能直观看到是否有掉帧。组件本身逻辑简单的话,除非是动画期间同时在做网络请求或大数据解析,否则 60 帧基本没什么问题。但在列表页中批量暴露多个进度环时,要特别注意:如果列表项的每个 View 都在做动画,GPU 的绘制压力会成倍增加。建议在页面处于不可见或者滚动状态时暂停动画、只渲染静态帧,用 RecyclerView 的setRecyclerListener或者监听页面生命周期来做节流。
4. 完整代码接入示例与定制技巧
4.1 在 XML 和代码中分别怎么使用
接入方式一:XML 方式,适合配置固定的样式场景:
<com.example.component.ProgressRingView android:id="@+id/ringView" android:layout_width="120dp" android:layout_height="120dp" app:ring_progress="0" app:ring_max="100" app:ring_color_start="#4FC3F7" app:ring_color_end="#0288D1" app:ring_bg_color="#22000000" app:ring_width="10dp" app:ring_start_angle="-90" app:ring_text_size="14sp" app:ring_suffix="%" app:show_center_text="true" />接入方式二:代码方式,适合动态数据加载、样式需要根据业务状态变化的场景:
val ringView = ProgressRingView(context) ringView.setStyle( RingStyleConfig( startColor = Color.parseColor("#FF6D00"), endColor = Color.parseColor("#FF3D00"), bgColor = Color.parseColor("#33FF6D00"), ringWidth = dp(8f), textSize = sp(12f) ) ) ringView.setData(RingData(progress = 86f, max = 100f, label = "本月任务")) ringView.setOnRingClickListener { text -> // 处理业务跳转 }XML 适合把组件放到布局里,与周围的约束关系一起完成;代码方式更适合组件被动态添加到布局里、或者数据完全来自服务端的场景。两种都保留,是一个组件成熟的表现。
4.2 示例:做两个不同业务形态的组件变体
接入了骨架之后,可以非常快地变体。第一个变体是“倒计时进度环”,改点在于:
progress不再是静态值,而是根据剩余时间算出一个动态值- 文案变成“还剩 1 天 06:32:18”,需要每秒调一次
setData - 动画时长不用变更,动画线性匀速更符合倒计时的语义
第二个变体是“多圆环组合”,这个要额外做一层容器封装,内部通过纯代码动态创建 N 个ProgressRingView,再用FlowLayout或LinearLayout排列。应用场景是市场活动页里多个目标分别展示,界面整体一致性非常高。
这些变体的经验说明:组件核心绘制逻辑几乎可以不动,变的是数据来源、文案格式和容器布局。这就是高复用组件的基本要求。
4.3 对设计稿的还原技巧与参数换算
UI 给的设计稿一般是 750x1334 或者 1440x2560 这样的一套基准。咱们开发常用的密度换算公式是px = dp * (density / 160),但设计稿里的标注需要用换算工具。比如设计稿里环宽是 12px(以 750 设计稿为基准),那么在不同设备上渲染时:
- 设计稿基准 width:750,对应逻辑 width:
750 / densityDpi * 160,常用的换算因子是scale = screenWidth / 750。 - 实际环宽 = 12 * scale。
如果组件内统一用 dp 为单位,就需要把设计稿的 px 标注先换算成 dp,这个过程在团队里经常会有偏差,所以我建议在自定义 View 内部统一存 px,对外暴露的方法是 dp 转好的数值,代码内部只做一种单位运算,外加TypedValue.applyDimension做兜底。
在实际团队项目中,设计走查时最容易反馈的一个点是:进度环的起点位置和结束位置的圆角是否平滑。ROUND cap 的端点在角度值边界处会轻微外扩,如果用 360 的角度去画,起点和终点会各多出半个圆帽,看起来环不是闭合的。要处理这种情况,需要在创建 Paint 时把strokeCap改为BUTT,或者将 sweepAngle 做小幅修正。
5. 你一定会遇到的坑和排查思路
5.1 测量相关:布局宽高对不上
如果你发现组件显示尺寸和自己设定的宽高不一致,优先检查以下三点:
- 父布局的 measure 模式是不是
EXACTLY,是的话子 View 的onMeasure必须遵守父容器给的 size,自己不能随意改写。 - 如果用了
wrap_content,自定义 View 必须自己实现onMeasure里AT_MOST分支的处理,不做任何处理的话很多 ROM 上会默认为 0,导致组件消失。 - 检查的时候别只看 Debug 布局,看一下真正测量之后的
width/height和measuredWidth/measuredHeight的差异。
一个典型错误日志表现是:View 高度算出来是 0,onDraw 直接被跳过,屏幕上什么都看不到。这时候先加日志看setMeasuredDimension的值,而不是怀疑绘制代码写错了。记住这个排查顺序,能省很多时间。
5.2 绘制的坑:圆环被切边、颜色断层、文字不居中
圆环被切边,基本上是半径没减环宽一半造成的。我前面已经说明过正确写法。颜色断层多半是渐变方向和角度不匹配,比如用SweepGradient却传了对角坐标,实际扫描起点和你期望的起点相差了 45 度。建议在实现渐变时,先画一个简单的辅助射线确认角度位置,再上真实数据。
文字不居中很容易被忽略,典型场景是“百分比数字在单双数之间跳动”。因为drawText的基准点是左边的 baseline,而不同数字的宽度不同,比如 “1” 和 “8” 宽度差很大。解决方式很简单,用固定格式让数字占位相等:可以把数字转换为两位小数,再用 String.format 补齐;或者每次绘制的时候用测量好的文本宽度减去textPaint.measureText的一半,让文本在视觉上居中。两种方式都可行,前者更稳定,文本内容一换也能保持。
5.3 交互与动画状态不同步
动画没结束,页面就退出了,怎么处理都不对。核心解法是:组件在onDetachedFromWindow统一 cancel 所有动画和 Outer 回调。建议把动画恢复逻辑也做一下:如果页面重新可见,需要把动画停在当前数值而不是重新跑一遍,否则用户视角会看到一次跳变。
另外一个状态不同步的来源是进度值从外部传入时没有限制区间。有次线上反馈某个运营位进度条偶尔显示为负数,排查之后发现是服务端下发的字段可能是 -1 表示“未开始”。组件内部做一次coerceIn(0, maxValue)就能把这种脏数据挡在渲染之前。记住:自定义 View 是纯 UI 层,不能假设数据层的输入永远符合预期。
5.4 列表卡顿:多个进度环同时存在时的优化策略
列表页如果有多个进度环,最佳实践是错开动画时间。比如第一个进度环立即开启动画,第二个延迟 50ms,第三个延迟 100ms,避免同一帧里多个 View 同时请求绘制。从视觉上也可以接受这种先后加载的效果。此外,如果列表项复用时进度环 progress 未重置,会出现新数据叠加旧动画的瞬间错乱,所以 ViewHolder 复用时需要立刻用目标值初始化并 cancel 未完成的动画。
我自己在调这种列表场景时踩过比较深的坑,是RecyclerView的预加载机制。它会提前创建列表项并走进度环的onDraw,而这个时候组件可能还没拿到真实数据。处理方法是:组件内部在没有数据时直接 return,不画任何内容,数据到位后再 invalidate。这样既能避免空数据渲染,也不会被预加载机制干扰。
6. 组件化落地:封装、交付与长期维护
6.1 把组件打包进团队组件库的流程
组件验证完成后,真正有价值的一步是沉淀为团队组件库里的一员。实际操作上我一般走这么几步:
- 模块化工程中单独建一个
ui-ringprogress模块,把代码、资源、样例页放一起。 - 代码层面单独抽出一个接口模块,让业务方只依赖接口,不依赖实现类,便于后续替换实现。
- 打包成 AAR 上传到公司内部仓库,用版本号管理。注意上传的 AAR 要带源码注释,不然团队里的人拿到 lib 却不知道每个 attr 是干嘛的。
- 给组件写一份简短的 README:包含效果图、属性表、常用 API、已知问题和性能说明。
一个组件进组件库不代表结束,恰恰是维护责任的开始。团队越大,组件接口的改动成本越高。所以在 v1.0 发布前用一周时间收集团队的使用反馈,把 API 里不顺手的部分调整好,比发布之后再频繁升版要省事得多。
6.2 这套方案在团队里如何推广
组件写好,还得让团队愿意用。很多自研组件握在手里没人知道,最后变成“个人玩具”。我的经验是一顿饭时间都比写一堆文档有效:拿两个真实业务页面,当场重构给同事看,跑通流程再约定 demo 落地。嘴上说一百遍组件好用,不如现场展示两分钟。把样例代码放进工程里的samples目录,后续新人来了照着改就能用。
推广的时候也要注意排雷:如果团队里有同事习惯用开源控件,要提前说明清楚和第三方控件的取舍关系。可以明确新版组件相比开源库的核心差异:统一维护、样式可配、体积可控、出问题能找到人改。这些点比简单说“自研就是好”更有说服力。
6.3 后续可以怎么扩展
进度环只是个起点,同样的设计骨架可以直接扩展出下面这些组件:
- 用于仪表盘的扇形仪表组件,绘制从圆弧变成扇形加刻度
- 用于社交产品的等级/徽章组件,加更多装饰性绘制
- 用于数据报表的水波纹百分比组件,加贝塞尔闭合路径
- 用于表单完整性检测的步骤指示组件,加连接线和节点状态
核心思想是一致的:测量、绘制、触摸、动画、数据驱动、对外接口,六件套搞定了,其他复杂组件无非是在这两个维度上做更多样化的叠加。
7. 一些踩坑之后的心里话
自定义 View 的难点从来不是 API 怎么调,而是你有没有建立一套自己的思考框架。我在这类组件上翻过最大的车,是在一次活动页用第三方库画了六个圆环,结果运营临时要改渐变方向,改源码改了整整两天。后来下决心用原生 Canvas 写出一套自己的组件,类似的改动十分钟交付。基于原生实现,自定义 View 的掌控力是任何第三方封装都给不了的。
另一个深刻体会是,组件边界要收得住。一个进度环组件如果承载了跳转逻辑、数据缓存、埋点上报,后期谁都不敢动它。把组件限制在“画好一张图、把事件抛出去”这个范围内,反而能在更多项目里活得久、用得稳。
最后,如果你正在纠结某个场景要不要自己写自定义 View,我的判断标准很简单:只有一两个页面的样式需求,复用预期不高,就用组合布局或者现有的库先顶上;如果这个视觉元素会在十个页面出现、并且样式差异五花八门,那就值得投入时间沉淀组件。投入的时间会在多个页面里成倍赚回来。这组代码开源到团队内部仓库后,半年内被复用了七八次,每次接入时间基本控制在半小时以内,这就是高复用组件应该有的样子。