1. 先想清楚:自定义View思想到底在讲什么
1.1 传统自定义View的“三大件”不是API,是职责拆解
很多从原生开发转过来的朋友,对自定义View这套东西又爱又恨。爱的是它上限高,屏幕里任何像素都能自己说了算;恨的是它门道多,onMeasure、onLayout、onDraw、onTouchEvent,任何一个环节没搞明白,渲染出来就是黑屏或者错位。我在带某跨平台项目的UI基础模块时,发现一个很有意思的现象:凡是原生功底扎实的同事,到了Flutter或者Compose里做自绘组件,思路往往比一直写声明式UI的人更清晰。原因不在于谁代码写得快,而在于他们对“一个组件到底要管哪些事”有一套固定的思考框架。
这套框架其实就是传统自定义View的职责拆解。我用大白话重新说一遍:
- 测量(onMeasure):你的控件到底想要多大。父容器给一个约束范围,你自己决定宽高。
- 布局(onLayout):子控件摆放位置。如果是组合型View,你要决定每个子View的坐标。
- 绘制(onDraw):把内容画出来。画笔、路径、文本、位图,全在这一层。
- 事件(onTouchEvent):用户手指按下后,由谁来响应、谁来拦截、谁来消费。
- 状态刷新(invalidate/requestLayout):数据变化以后,如何通知系统只要重绘、不要整体重新布局。
这五件事不是Android独有的。任何一套UI框架,只要组件需要“持久地显示在屏幕上并跟用户交互”,就必须回答这些问题。声明式框架也不例外,它只是把答案的写法换了一副皮囊。
想明白这一点,跨技术栈这件事就有了抓手。你不是在“学一套全新的UI范式”,而是在“用新语法重新表达旧问题”。这个视角一旦建立,Flutter和Compose就不再是两门割裂的技术,而是一个问题的两种解法。
1.2 声明式框架没有消灭问题,只是换了表述
有人会说:Compose和Flutter不是都号称“UI = f(state)”吗?我直接写一个组合树,状态变了自动重组,根本不关心测量、布局、绘制,这还有什么自定义View思想可谈?
这话对了一半。声明式确实把“状态到UI”的映射自动化了,但自动化不代表魔法。以Compose为例,你写了一个Modifier.background(color = ...),底层最终还是要经历测量、布局、绘制、渲染这几个阶段;Flutter同理,哪怕你只在build()里返回一个Container,它依然要走Element、RenderObject、Paint这些链路。声明式框架优化的只是“你不需要手动调invalidate()”这件事,但组件该多大、内容该画在哪、事件该怎么响应,这些决策依然要你来做。差别只在于:原来你得在onMeasure里写一堆MeasureSpec解析逻辑,现在是在Modifier.layout或Flutter的BoxConstraints里表达同样的意图。
所以我坚持一个观点:自定义View思想在跨技术栈里的价值,不是让你照搬某个类的写法,而是让你脑子里有一套稳定的“组件职责模型”。有了这套模型,你在Flutter里看到CustomPainter不会觉得陌生,在Compose里看到Modifier.drawWithContent也不会觉得抽象——它们都是在用各自的方式,承接传统View时代那几个固定的职责。
2. Flutter端的迁移实践:从onDraw到CustomPainter
2.1 最常用的入口:CustomPaint与CustomPainter
Flutter里做自绘组件,最常见的入口就是CustomPaint。它的工作方式跟自定义View非常像:CustomPaint本身是一个Widget,你给它一个painter,系统在绘制阶段回调CustomPainter.paint(Canvas canvas, Size size)。
我团队里做数据可视化组件时,大量用到这个模式。比如监控曲线图、仪表盘、环形进度,第一版基本都是CustomPaint实现。为什么?因为它给了一个Canvas对象,用过原生Canvas的人几乎零成本上手。
这里有一个容易被忽略的细节:CustomPainter的绘制范围不是无限大的,它会传给你一个Size。很多人写第一个自绘组件时,习惯直接用任意坐标画东西,结果内容被裁掉或者显示位置不对。这个Size是CompositedTransformTarget之类的布局结果传下来的,本质上就是传统View里onDraw的宽高。你要在这块画布里做内容排布,超出部分默认被裁剪。
class PercentPainter extends CustomPainter { final double progress; // 0.0 ~ 1.0 final Color color; PercentPainter({required this.progress, required this.color}); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = (size.width < size.height ? size.width : size.height) / 2 - 4; final rect = Rect.fromCircle(center: center, radius: radius); // 背景轨道 canvas.drawArc(rect, 0, 2 * 3.14159, false, Paint() ..style = PaintingStyle.stroke ..strokeWidth = 6 ..color = Colors.grey.shade300); // 进度弧 canvas.drawArc(rect, -3.14159 / 2, 2 * 3.14159 * progress, false, Paint() ..style = PaintingStyle.stroke ..strokeWidth = 6 ..strokeCap = StrokeCap.round ..color = color); } @override bool shouldRepaint(covariant PercentPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.color != color; } }对CustomPainter来说,核心逻辑跟onDraw一样:拿到Canvas和尺寸,画出你想要的内容。但有两个区别值得留意。
第一是“数据怎么传进来”。传统View里靠成员变量或者setter,在Flutter里则是painter作为不可变对象,每次数据变化都新建painter传入。听起来麻烦,但这正好贴合声明式的数据流思路:组件接收参数,参数变了,系统判断是否需要重绘。shouldRepaint就是那个判断函数,它好比传统View里你手写的脏标记——只不过这里框架帮你形成了约束,你只需要诚实比较新旧参数。
第二是“点击事件怎么加”。Flutter的自绘组件里,你别在painter里处理手势,painter只管画。事件交给CustomPaint外面的手势Widget。这也是很多新手容易陷入的误区:在自定义View时代,你可以直接在onTouchEvent里判断坐标是否落在某个圆形区域,然后触发回调;但Flutter里你应该用GestureDetector包住CustomPaint,在onTapUp回调里再通过localPosition判断是否命中圆形。思路仍然没变,只是通信边界变了。
2.2 想接管测量布局:用RenderObject做更底层控制
CustomPaint适合“画一块画布”的场景,但如果你要做的是一个容器型组件,需要对子组件进行自定义测量和排列,CustomPaint就不够用了。这时要往下钻一层,用RenderObject。
这是Flutter里最接近传统View体系的层。RenderObject有performLayout和paint两个核心方法,跟onMeasure/onLayout/onDraw几乎一一对应。我重新实现过一个流式标签布局(类似Android的FlexboxLayout),在Flutter里就是用RenderBox的performLayout完成的:
class RenderWrapBox extends RenderBox with ContainerRenderObjectMixin<RenderBox, WrapParentData> { @override void performLayout() { // 遍历所有子节点,测量每个子节点后按行排列 var child = firstChild; double x = 0, y = 0, rowHeight = 0; while (child != null) { child.layout(constraints.loosen()); if (x + child.size.width > constraints.maxWidth && x > 0) { x = 0; y += rowHeight; rowHeight = 0; } // 记录offset child.parentData!.offset = Offset(x, y); x += child.size.width + spacing; rowHeight = max(rowHeight, child.size.height); child = childAfter(child); } size = Size(constraints.maxWidth, y + rowHeight); } @override void paint(PaintingContext context, Offset offset) { // 逐个绘制子组件 defaultPaint(context, offset); } }一般情况下我不建议大多数项目直接手写RenderObject。它要求你理解Flutter的约束流转模型(父给约束,子返回尺寸,父决定位置),而且少了Widget层的缓存,调试成本高。只有在官方布局widget不满足需求、且通过组合也不能拼出来的时候才值得动手。但如果你来自原生阵营,写一次RenderObject会很有帮助——它能帮你把“测量”和“绘制”这两个概念彻底钉死在脑子里,之后再回到组合式写法,你会更清楚每个组件在背后做了什么事。
2.3 实操案例:一个圆环进度的自绘写法
为了后面跟Compose对比,我用Flutter完整实现一个圆环进度组件。这个组件不需要子组件,所以CustomPaint就够。数据驱动方式用ValueListenable或者简单setState都行,这里为了简洁,直接用StatefulWidget:
class CircleProgress extends StatefulWidget { final double value; // 0.0 ~ 1.0 final Color trackColor; final Color progressColor; const CircleProgress({super.key, required this.value, this.trackColor = Colors.grey, this.progressColor = Colors.blue}); @override State<CircleProgress> createState() => _CircleProgressState(); } class _CircleProgressState extends State<CircleProgress> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation<double> _animation; @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 800)); _animation = Tween<double>(begin: 0, end: widget.value).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), ); _controller.forward(); } @override void didUpdateWidget(covariant CircleProgress oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.value != widget.value) { _animation = Tween<double>(begin: _animation.value, end: widget.value).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), ); _controller.forward(from: 0); } } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animation, builder: (context, child) { return CustomPaint( size: const Size(80, 80), painter: PercentPainter(progress: _animation.value, color: widget.progressColor), ); }, ); } }这里我故意做了两件事:一是动画过渡,二是刷新的最小化。AnimatedBuilder只重建CustomPaint部分,不重建外层布局。这也对应了传统自定义View里“局部重绘”的思路——invalidate局部区域,而不是requestLayout整体刷新。
3. Compose端的迁移实践:从View体系到Modifier链
3.1 Compose自绘的最小路径:Canvas与drawScope
聊完Flutter看Compose。Jetpack Compose的哲学更激进:没有View层级,一切都是Modifier链和组合函数。很多原生转Compose的朋友第一反应是“那自绘怎么办?”,其实Compose保留了完整的Canvas能力,只是入口变了。
最直接的写法是在组合函数里这样用:
@Composable fun CircleProgress( progress: Float, color: Color, trackColor: Color, modifier: Modifier = Modifier ) { Canvas(modifier = modifier) { val strokeWidth = 6.dp.toPx() val radius = size.minDimension / 2f - strokeWidth / 2f val rect = Rect( center = center, radius = radius ) drawArc( color = trackColor, startAngle = 0f, sweepAngle = 360f, useCenter = false, topLeft = rect.topLeft, size = Size(rect.width, rect.height), style = Stroke(strokeWidth) ) drawArc( color = color, startAngle = -90f, sweepAngle = 360f * progress, useCenter = false, topLeft = rect.topLeft, size = Size(rect.width, rect.height), style = Stroke(strokeWidth, cap = StrokeCap.Round) ) } }这段代码里,Canvas是一个Layout模组的组合函数,它传入的DrawScope就是自绘上下文。DrawScope里有size、center这些属性,drawArc这些方法跟原生Canvas的API基本同构。只要理解“这里的size等于布局最终确定的大小”,跟onDraw里的宽高完全一个意思。
3.2 测量与布局在Compose里的声明式表达
Compose里测量与布局的入口是Modifier.layout和SubcomposeLayout。对大多数自绘组件来说,Modifier.layout就够用:
Modifier.layout { measurable, constraints -> val placeable = measurable.measure(constraints) val wider = placeable.width + 20.dp.roundToPx() val taller = placeable.height + 20.dp.roundToPx() layout(wider, taller) { placeable.place(10.dp.roundToPx(), 10.dp.roundToPx()) } }这个逻辑跟Flutter RenderObject的performLayout是同构的:接收constraints,测量子组件,决定自身尺寸,再把子组件放到指定坐标。这里的layout尺寸参数就是“父布局认账的尺寸”,跟传统View里onMeasure最终setMeasuredDimension的结果一个性质。
SubcomposeLayout则用来做更复杂的场景,比如一个瀑布流容器,它依赖子组件的测量结果来决定整体尺寸。使用起来比较绕,因为它允许你在layout阶段去“组合并测量”一些额外的内容,但如果你理解传统View里通过measureChildWithMargins给子View测量收集尺寸的做法,就会觉得这个API顺理成章。
3.3 同款圆环进度的Compose实现
为了对比清晰,我在Compose里完整写一个环形进度,加上动画:
@Composable fun AnimatedCircleProgress( targetValue: Float, modifier: Modifier = Modifier, progressColor: Color = Color(0xFF3F8CFF), trackColor: Color = Color(0xFFE0E0E0) ) { val transition = updateTransition(targetValue, label = "progress") val animatedProgress by transition.animateFloat( transitionSpec = { tween(durationMillis = 800, easing = FastOutSlowInEasing) }, label = "progressAnim" ) { it } Canvas( modifier = modifier .size(80.dp) .then( Modifier.drawWithContent { // 在这里可以访问 drawScope 的绘制方法 val strokeWidth = 6.dp.toPx() val radius = size.minDimension / 2f - strokeWidth / 2f val arcRect = Rect( center = center, radius = radius ) drawArc( color = trackColor, startAngle = 0f, sweepAngle = 360f, useCenter = false, topLeft = arcRect.topLeft, size = Size(arcRect.width, arcRect.height), style = Stroke(strokeWidth) ) drawArc( color = progressColor, startAngle = -90f, sweepAngle = 360f * animatedProgress, useCenter = false, topLeft = arcRect.topLeft, size = Size(arcRect.width, arcRect.height), style = Stroke(strokeWidth, cap = StrokeCap.Round) ) } ) ) }注意这里我用了updateTransition替代了传统动画写法。为什么不用Animatable?因为updateTransition的好处是当目标值变化时,它会自动从当前值插值到新值,无需手动管理控制器生命周期,而且它在重组作用域内天然感知状态的取消与恢复。这跟Flutter那边的AnimationController + forward(from: 0)是干同一件事的两种表达。
4. 两张代码背后相同的设计骨架
4.1 状态驱动绘制:一个变量,两个世界
把Flutter和Compose两个版本摆在一起,你很快会发现:除了语法不同,结构几乎是一个模子刻出来的。传统自定义View的思路是“数据变化时手动调用invalidate”,而这两个框架都帮我把这步做掉了——我把progress当成一个状态,状态变化导致painter或Canvas重绘。用表格总结一下:
| 职责 | 传统自定义View | Flutter | Compose |
|---|---|---|---|
| 测量 | onMeasure(MeasureSpec) | performLayout(BoxConstraints) | Modifier.layout(constraints) |
| 绘制 | onDraw(Canvas) | CustomPainter.paint(Canvas) | DrawScope.drawXxx(Canvas) |
| 局部更新 | invalidate() | shouldRepaint/AnimatedBuilder | 状态变化触发重组 |
| 事件 | onTouchEvent/事件拦截 | GestureDetector | Modifier.pointerInput |
这种对应关系让我意识到:不管用什么框架,底层都绕不开“测量、绘制、事件、状态”这四个底盘。声明式的价值不是替你思考组件如何呈现,而是让你不用手写“何时刷新”的胶水逻辑。因此一个能在Android上写好自定义View的人,只要愿意把语言差异放下,迁到Flutter和Compose里,做自绘组件的速度不会比写普通UI慢多少。
4.2 事件与手势的声明式改造
传统自定义View里最麻烦的就是事件分发:dispatchTouchEvent要不要拦截?onInterceptTouchEvent要不要抢?子View冲突时谁说了算?写的时候全是心智负担。
在Flutter里,手势系统被抽象成一整套GestureDetector组件,长按、双击、缩放、拖拽都有对应的回调。跨技术栈之后我最大的感触是:事件不再需要层层传递,而是直接声明“我想识别哪种手势”。但这不代表你完全不用考虑冲突,例如竖直ListView里嵌一个横向滑动的自绘滑块,你还是得通过GestureDetector的behavior参数和手势竞技场(GestureArena)机制来协调。
Compose这边的Modifier.pointerInput则更接近底层。它有awaitPointerEventScope,能拿到完整的PointerEvent流,你可以自己编写命中检测逻辑。我做过一个自定义的旋钮控件,旋转角度的计算就是在这个作用域里完成的:先判断手指按下时是否落在可拖拽半径范围内,再根据滑动角度变化更新状态。这套做法跟传统View里onTouchEvent里的坐标判断没有本质区别,区别在于Compose只在你声明了pointerInput的组件范围内收事件,天然避免了View体系里事件穿越View层级带来的繁琐问题。
4.3 组件复用:继承树、组合树与Modifier的对比
传统自定义View的复用方式是继承:做一个BaseProgressView,再派生出若干个变体。这种方式在代码量少时看起来很爽,一旦业务复杂,继承树会越来越深,父类里的逻辑越来越重,最后谁都动不了。
Flutter和Compose都取消了继承式复用,转向组合式复用。Flutter里你通过组合Widget来复用逻辑,Compose里则封装可组合函数。这跟自定义View思想里的“职责拆解”并不冲突——你把绘制逻辑从组件里抽取成painter或者DrawScope扩展函数后,组件本身变成了一个“壳”,这个壳可以随意跟其他Modifier拼接。
我自己的经验是:传统自定义View时代最值钱的抽象是“把绘制逻辑跟状态更新逻辑分离”,这个思想在声明式框架里依然成立。具体到Flutter,就是把CustomPainter抽出来单独维护;在Compose里,就是把drawWithContent里的绘制代码抽成独立的扩展函数甚至单独的绘制类。很多自绘组件后期维护困难,不是因为框架不对,而是因为所有绘制代码和状态代码全堆在一个类里,跟当年在View里堆逻辑的毛病一模一样。
5. 实测中的常见坑与我的工作习惯
5.1 别在组合阶段做耗时绘制
这是我接手过的项目里出现最多的问题。在Compose里,有人习惯直接在Canvas的DrawScope里反复调用measureText和Paint测量文本,还会在组合阶段做一些复杂的字符串计算。在Flutter里更常见的是在build()方法里直接new一个复杂的Painter,并且Painter的paint里做大量Path运算。
要明白一点:组合/构建阶段的目标是“用最少的代价描述UI意图”,而不是“把最终显示内容全部算完”。真正耗时的测量与绘制,应该被推迟到绘制阶段,或者提前缓存。举一个实际例子:我写一个数据分布直方图组件,柱子的位置计算放在remember块里,只有当数据源变化时才重算;绘制阶段只做canvas.drawRect。这样数据源不变时,滚动页面时绘制开销大幅降低。
5.2 过度拆分与过度自绘之间如何取舍
有一类同事习惯把一切UI都做成自绘,觉得用系统组件是“不够酷”。另一类则相反,什么都用框架自带的控件拼,拼不出来的就暴力嵌套十几层。两种做法都走了极端。
我的判断标准很简单:能用系统组件拼出来的,优先拼;只有系统组件无法表达、或者表达成本过高时,才引入自绘。比如一个普通的卡片,带圆角、阴影和水波纹效果,用Card+Modifier就够了,你要硬用自定义绘制,代码量翻倍不说,手感还不一定比系统好。而像图表、签名板、仪表盘这类像素级可控的需求,才真正属于自定义View思想的主场。这个取舍原则在Flutter、Compose还是原生体系里都适用。
注意:跨技术栈迁移时,最容易踩的坑不是写不出代码,而是把“能用系统组件凑合”硬生生改成自绘,结果后续维护成本直线上升。自绘是为了解决问题,不是为了炫技。
5.3 跨技术栈的验收清单
最后分享一个我每次写完自绘组件后的自检清单,两个框架通用:
- 布局约束:在父容器给紧约束/松约束/无限宽高时,组件尺寸是否正确?
- 状态刷新:progress变化后,组件是否只发生了必要的重绘,而不是整棵子树重建?
- 事件点击:组件命中区域是否跟视觉区域一致?有没有出现点击空白也触发回调的情况?
- 边界条件:progress为0、为1、为负数时,绘制是否正常?文字过长时是否被裁剪?
- 多设备适配:不同分辨率下,控件会不会因为依赖具体像素而变形?
这套清单本身也印证了自定义View思想的普适性:不管底层是Java的View体系,还是Flutter的Widget树,抑或Compose的Modifier链,你要考虑的问题永远是那几件——布局、绘制、事件、状态、边界。把这些问题想清楚了,跨技术栈只是换一个写法的练习而已。
写代码这些年,我最深的体会是:框架会过时,但思想不会。无论是Flutter、Compose还是未来可能冒出的新方案,只要它能渲染像素、能接收手势,自定义View那套思考方式就有用武之地。与其焦虑“又出了新框架要不要学”,不如先把“组件到底是怎么画出来、怎么跟用户交互”这件事吃透。有了这个底层认知,学任何UI框架都只是查文档的功夫。