发布时间:2026/10/12 6:33:03来源:迅启考通分类:考试资讯文章详情以下为资讯详情页模板:正文区域由后台内容渲染,图片与正文将自动替换为对应文章内容。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到CustomPainter2.1 最常用的入口CustomPaint与CustomPainterFlutter里做自绘组件最常见的入口就是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 ContainerRenderObjectMixinRenderBox, 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都行这里为了简洁直接用StatefulWidgetclass 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 StateCircleProgress createState() _CircleProgressState(); } class _CircleProgressState extends StateCircleProgress with SingleTickerProviderStateMixin { late AnimationController _controller; late Animationdouble _animation; override void initState() { super.initState(); _controller AnimationController(vsync: this, duration: const Duration(milliseconds: 800)); _animation Tweendouble(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 Tweendouble(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重绘。用表格总结一下职责传统自定义ViewFlutterCompose测量onMeasure(MeasureSpec)performLayout(BoxConstraints)Modifier.layout(constraints)绘制onDraw(Canvas)CustomPainter.paint(Canvas)DrawScope.drawXxx(Canvas)局部更新invalidate()shouldRepaint/AnimatedBuilder状态变化触发重组事件onTouchEvent/事件拦截GestureDetectorModifier.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都做成自绘觉得用系统组件是“不够酷”。另一类则相反什么都用框架自带的控件拼拼不出来的就暴力嵌套十几层。两种做法都走了极端。我的判断标准很简单能用系统组件拼出来的优先拼只有系统组件无法表达、或者表达成本过高时才引入自绘。比如一个普通的卡片带圆角、阴影和水波纹效果用CardModifier就够了你要硬用自定义绘制代码量翻倍不说手感还不一定比系统好。而像图表、签名板、仪表盘这类像素级可控的需求才真正属于自定义View思想的主场。这个取舍原则在Flutter、Compose还是原生体系里都适用。注意跨技术栈迁移时最容易踩的坑不是写不出代码而是把“能用系统组件凑合”硬生生改成自绘结果后续维护成本直线上升。自绘是为了解决问题不是为了炫技。5.3 跨技术栈的验收清单最后分享一个我每次写完自绘组件后的自检清单两个框架通用布局约束在父容器给紧约束/松约束/无限宽高时组件尺寸是否正确状态刷新progress变化后组件是否只发生了必要的重绘而不是整棵子树重建事件点击组件命中区域是否跟视觉区域一致有没有出现点击空白也触发回调的情况边界条件progress为0、为1、为负数时绘制是否正常文字过长时是否被裁剪多设备适配不同分辨率下控件会不会因为依赖具体像素而变形这套清单本身也印证了自定义View思想的普适性不管底层是Java的View体系还是Flutter的Widget树抑或Compose的Modifier链你要考虑的问题永远是那几件——布局、绘制、事件、状态、边界。把这些问题想清楚了跨技术栈只是换一个写法的练习而已。写代码这些年我最深的体会是框架会过时但思想不会。无论是Flutter、Compose还是未来可能冒出的新方案只要它能渲染像素、能接收手势自定义View那套思考方式就有用武之地。与其焦虑“又出了新框架要不要学”不如先把“组件到底是怎么画出来、怎么跟用户交互”这件事吃透。有了这个底层认知学任何UI框架都只是查文档的功夫。特别提醒:考试时间、报名批次等安排如有调整,以河南省应急管理厅及官方考点最新通知为准。