
前几年我在Android里写自定义View一直觉得measure、layout、draw、touch event这条链就是客户端UI的终极答案。后来开始碰Flutter又在新项目里全面转向Compose才发现真正能跨技术栈复用的不是一段段代码而是自定义View那一整套“测量、布局、绘制、事件、状态更新”的思想框架。这篇内容不打算教你抄代码而是把这三个技术栈的底层逻辑拆开看看怎么把原生自定义View的经验迁移到Flutter和Compose里同时把实际项目中踩过的坑一并讲清楚。1. 自定义View思想到底指的是什么一次跨栈思维迁移的起点很多人一说跨技术栈第一反应是“把旧代码翻译成新语法”。但Flutter里没有ViewCompose里也没有View如果只盯着API做一对一翻译很容易把自己绕晕。真正能平移的是原生Android自定义View背后那套完整的问题拆解方式。1.1 从Android原生View的“四件套”说起原生自定义View的底层逻辑可以简化成四件事测量onMeasure、布局onLayout、绘制onDraw、事件响应onTouchEvent。所有复杂控件归根结底都在处理这四件事。测量阶段View会收到父容器传下来的MeasureSpec里面带着mode和size。mode有三种EXACTLY确切尺寸、AT_MOST最大不超过某个值、UNSPECIFIED不限制。自定义View最容易翻车的就是wrap_content如果不在onMeasure里单独处理AT_MOST很多自定义控件直接表现得跟match_parent一样因为默认实现没有保证wrap_content小于父容器。绘制阶段onDraw拿到一个Canvas往上面画线、画圆、画文字然后交给系统去栅格化。事件阶段onTouchEvent接收MotionEvent你需要在DOWN、MOVE、UP之间维护手势状态并且决定返回true还是false——这决定了后续事件还送不送给你。最后状态一变要么调用invalidate触发重绘要么调用requestLayout触发重新测量。这一套循环看起来很朴素但它是所有UI框架的通用骨架。1.2 Flutter和Compose为什么也要面对同一道题Flutter和Compose没有暴露“View”这个概念不等于它们不处理同样的问题。Flutter的RenderObject替代了View的位置它同样要经过performLayout计算尺寸、paint绘制内容、hitTest处理点击命中Compose则把测量和绘制下沉到了Modifier链里通过Layout、Canvas、pointerInput这些节点等价地完成同一套流程。举个例子一个带拖拽点的环形进度条在原生里你要做的是onMeasure处理好尺寸onDraw画两个圆弧onTouchEvent里用atan2算角度和进度最后invalidate触发重绘。到Flutter里这项工作变成CustomPainter的paint方法画圆弧、GestureDetector处理拖拽、ValueNotifier或setState驱动shouldRepaint。到Compose里又变成Canvas的drawArc、Modifier.pointerInput和remember状态。三个技术栈的API完全不同但思考链条高度一致尺寸怎么来、图形怎么画、触摸点怎么映射回业务数据、状态变化怎么触发刷新。所以跨技术栈应用自定义View思想核心是先把这一套“四阶段思维”建立起来再去看每个框架分别用什么组件承接。这套思维一旦建立你再看Flutter和Compose的自绘API会觉得很亲切。2. Flutter里落地自定义View思想的五个关键点Flutter的自绘能力很容易上手但很多人用一段时间就发现“怎么画都行性能一复杂就露馅”。这通常不是Flutter本身的问题而是没有把原生自定义View里那套“重绘边界、对象复用、命中测试”的纪律带过来。2.1 CustomPainter自绘把onDraw换成paintFlutter里最接近自定义View自绘的就是CustomPaint组件配合CustomPainter。CustomPainter的paint(Canvas canvas, Size size)方法跟原生的onDraw(Canvas canvas)几乎是同一种体验。你可以在paint里用canvas.drawArc、drawCircle、drawLine也可以自己new Paint()设置颜色、描边宽度、StrokeCap。这里有一个非常关键的纪律不要在build方法里随手new一个Painter传进去。这样做每次父Widget重建都会产生一个新Painter实例而CustomPainter的shouldRepaint判断的是新旧实例的字段差异。如果你不做缓存shouldRepaint几乎永远返回true导致整个自绘区域频繁重绘。我自己的做法是在State里缓存Painter实例只在参数真正变化时更新Painter里的字段并且让shouldRepaint做字段级比较class RingPainter extends CustomPainter { RingPainter({ required this.progress, required this.trackColor, required this.progressColor, }); final double progress; final Color trackColor; final Color progressColor; override void paint(Canvas canvas, Size size) { final strokeWidth 20.0; final rect Rect.fromLTWH( strokeWidth / 2, strokeWidth / 2, size.width - strokeWidth, size.height - strokeWidth, ); final trackPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..color trackColor; final progressPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..color progressColor; canvas.drawArc(rect, 0, 360, false, trackPaint); canvas.drawArc(rect, -90, progress * 360, false, progressPaint); } override bool shouldRepaint(RingPainter oldDelegate) { return oldDelegate.progress ! progress || oldDelegate.trackColor ! trackColor || oldDelegate.progressColor ! progressColor; } }这段代码放在原生View的onDraw里也成立先算好矩形边界再画背景弧和进度弧最后把进度值映射成sweepAngle。差别只在Flutter用shouldRepaint替代了原生的invalidate判断。2.2 状态重绘的开关shouldRepaint和RepaintBoundary原生的invalidate会让View重新走onDrawFlutter里对应的是markNeedsPaint。但Flutter的状态驱动通常更隐蔽setState会触发buildbuild会重建Widget树Widget树变化后RenderObject才可能重新布局或绘制。Painter的shouldRepaint在这里扮演了“重绘开关”的角色所以一定要写得足够精细。除此之外RepaintBoundary是个被低估的组件。它相当于给自绘区域画了一道隔离墙区域内部重绘不会向父级扩散。在列表里如果每一项都有复杂的自绘内容包一层RepaintBoundary能明显减少无谓绘制。我见过一些团队把RepaintBoundary当成“加了可能有用”的玄学组件其实它的作用跟原生View的clipChildren或者Layer类型类似把绘制内容提升到独立图层重绘时只更新该图层。要注意的是RepaintBoundary不是越多越好。每个边界都会创建一个独立图层占用GPU内存在低端机上反而可能拖慢帧率。正确用法是给“高频更新的自绘范围”加边界比如动画进度条、实时图表区域而不是给整个页面无脑包一层。2.3 RenderObject级别的测量与布局定制CustomPaint能解决“画什么”但解决不了“怎么摆”。如果你的自定义控件需要自己决定子组件的尺寸和位置比如实现一个流式布局、一个自适应高度容器那就必须下沉到RenderObject层。Flutter里的常规路径是写一个SingleChildRenderObjectWidget子类在里面返回一个RenderProxyBox或自定义的RenderBox。关键要重写performLayout根据父级传下来的Constraints计算自身尺寸然后用child.layout去布局子节点。这个过程跟原生View的onMeasure、onLayout高度相似先在约束里协商尺寸再决定摆放位置。不过我的建议是对于绝大多数业务场景不要轻易写RenderObject。它虽然灵活但API复杂度高而且一旦写错布局会以一种很难排查的方式错乱。在Flutter里我会先尝试用CustomMultiChildLayout、Wrap、Flow这些现成布局组件只有在它们覆盖不住的场景才下沉。自定义View思想里的“测量、布局”流程虽然通用但不同框架提供的默认布局能力差异很大优先吃透框架自带能力比自己造轮子更稳。2.4 Flutter手势与绘制的联动自绘控件很少有纯静态的几乎都要响应触摸。Flutter里最常用的方案是用GestureDetector包住CustomPaint然后在onPanUpdate、onTapDown这类回调里做业务计算。这里有个原生开发者容易忽略的差异Flutter的Canvas本身没有命中测试。它不会帮你判断“用户点的是不是弧线上的拖拽点”你必须在手势回调里自己拿坐标算。所以我在实现可拖动环形进度条时会在onPanUpdate里把触点坐标换算成相对于控件中心的偏移再用atan2算出角度最终映射为0到1的进度值GestureDetector( onPanUpdate: (details) { final center Offset(size.width / 2, size.height / 2); final dx details.localPosition.dx - center.dx; final dy details.localPosition.dy - center.dy; var angle math.atan2(dy, dx) * 180 / math.pi; angle (angle 360) % 360; controller.value ((angle - 90 360) % 360) / 360; }, child: CustomPaint( size: size, painter: RingPainter( progress: controller.value, trackColor: trackColor, progressColor: progressColor, ), ), )这套坐标换算逻辑跟原生View里onTouchEvent拿event.x、event.y然后算角度几乎一模一样。区别是原生View的return true决定是否消费事件而Flutter里主要通过GestureDetector的竞技场机制决定谁来处理手势。如果不希望手势被父级抢走可以在回调里用GestureDetector套一个RawGestureDetector处理或者配合Listener做更底层的原始事件监听。3. Compose里迁移自定义View思想的四条路线Compose的写法最像“声明式”但它并没有丢掉自绘能力。Canvas、Layout、pointerInput全都在只是换了一层更符合组合思想的皮。很多人从View体系切到Compose觉得陌生本质上是不习惯“绘制和布局不是类方法而是Modifier链上的节点”。3.1 DrawScope画布drawBehind与drawWithContent的区别Compose里Canvas是一个composable它接收一个DrawScope lambda所有绘制都在这个lambda里执行。但Canvas直接铺在页面上时只是画布本身要和布局组合一般会配合Modifier.drawBehind或Modifier.drawWithContent。drawBehind的意思是“画在内容后面”也就是在子内容测量和绘制之前先画背景。drawWithContent则把控制权交给你lambda里通过content()主动调用子内容绘制你可以在content()之前画背景也可以在content()之后画前景。我用一个类比说明drawBehind等于直接把背景贴在墙上再挂画drawWithContent等于你先挂画再用画笔在画框周围画装饰顺序完全由你定。实操中经常有人搞混这两者结果背景画到了内容上面。记住一个判别点只要你的自绘内容不应该盖住子组件就用drawBehind只要你需要“先画底、再画内容、最后再画一层高亮”这种三层结构的顺序就必须用drawWithContent。3.2 自定义Layout测量子Modifier并控制摆放Compose里自定义View的“测量布局思想”对应的是Layout composable和Modifier.layout。Layout接收一个measurePolicy你在里面拿到measurables先调用measurable.measure(constraints)得到Placeable再调用layout(width, height)声明自身尺寸然后逐个place(placeable)摆放子项。这一段流程其实就是View的onMeasure加onLayout。比如实现一个简单的流式标签容器让子项按最大宽度自动换行measurePolicy里就需要遍历measurables、累积行宽、处理换行。写惯了原生的人到这里会觉得意外地顺手还是在约束里量尺寸还是手动决定每个子项坐标只是把onMeasure和onLayout合并成了measurePolicy一个回调。跟Flutter一样Compose也建议优先用自带布局组件。Row、Column、Flow、BoxWithConstraints能覆盖绝大多数场景。自定义Layout只留给两种场景一是现有组件无法表达复杂测量规则二是需要性能更精细地控制重组范围。Compose的重组粒度是“读取状态的表达式”自定义Layout如果滥用反而会让重组范围变得难以预测。3.3 pointerInput手势管道拖拽、点击、手势消费Compose的手势API和Flutter差别明显风格更接近协程。Modifier.pointerInput(Unit)进入一个挂起点你可以在这个块里调用awaitPointerEventScope持续接收事件或者直接用detectDragGestures、detectTapGestures这些高层辅助函数。拿环形进度条的拖拽场景举例在pointerInput里使用detectDragGestures然后通过change.position拿到触点。注意这里position是相对该Modifier的局部坐标需要用控件的中心点做一次偏移换算。换算逻辑和Flutter、原生View完全一致dx、dy代入atan2算出角度再归一化成进度。跟原生View最大的区别是消费事件的方式。原生靠onTouchEvent返回true/falseCompose则要在事件处理完后调用change.consume()告诉系统这个事件已经被消费避免被父级手势识别器抢走。这个习惯得刻意养成因为它藏在GestureDetector框架的背后一旦忘记就会出现“拖拽子控件的同时父级列表也跟着滚动”的经典bug。3.4 把原生View嵌回Compose的兼容方案跨技术栈有时候不是“迁移”而是“共存”。如果项目里已经有一整套写好的原生自定义View不想立刻重写Compose可以通过AndroidView composable把原生View直接嵌进组合树AndroidView( factory { context - MyCustomView(context) }, modifier Modifier.fillMaxWidth().height(200.dp), )factory里返回一个View实例这个View会像普通Compose节点一样参与布局。但要注意AndroidView的性能代价比普通Compose自绘高很多因为它走的是平台视图混合渲染链路涉及到视图绑定、输入事件跨层传递、触摸命中和栅格化协调。频繁更新或者列表内大量使用PlatformView容易出现滚动卡顿和触摸事件错乱。我的原则是存量View能复用就复用新写的自绘代码优先用Compose原生Canvas实现不要因为“舍不得旧代码”把所有老View全部塞进Compose。4. 三个技术栈的对照表与选型建议做了这么多思想迁移最终还是要落到一张清晰的对照表上。把原生View、Flutter、Compose三者的生命周期对应起来之后写代码会非常省脑子。4.1 从onMeasure到performLayout再到Layout生命周期对照阶段Android原生ViewFlutterJetpack Compose测量onMeasure MeasureSpecRenderBox.performLayout ConstraintsLayout composable measurePolicy布局onLayoutperformLayout中child.layout child.offsetplaceable.place绘制onDraw(Canvas)CustomPainter.paint(Canvas, Size)Canvas DrawScope / drawBehind / drawWithContent触摸onTouchEvent MotionEventGestureDetector / ListenerpointerInput awaitPointerEventScope重绘invalidate / postInvalidateOnAnimationshouldRepaint / markNeedsPaint状态变化驱动重组自动重绘重新测量requestLayoutmarkNeedsLayout尺寸相关状态变化触发重新layout对照表里最值得玩味的是重绘一行。原生和Flutter都是显式触发重绘Compose则是在组合阶段对状态自动依赖跟踪。这意味着Compose自绘代码里只要你读取的progress是mutableStateOf进度一变Canvas所在的组合表达式就会自动重组重绘。习惯“手动invalidate”的人一开始会觉得不踏实总想找一个类似invalidate的API其实在Compose里你只需要让值变成状态。4.2 什么时候该自绘、什么时候该用PlatformView跨栈开发最纠结的问题永远是一个复杂组件是该用当前技术栈自绘重新实现还是直接嵌入原生View。我总结了一套自己的判断标准。需要自绘的场景通常是组件形态高度定制、逻辑相对轻量比如进度环、图表中的自定义标记、签名板、滑块、仪表盘。这些组件用Canvas画代码量小而且能跟当前框架的动画、手势、状态体系无缝集成。就算不同端要分别实现核心算法抽出来共享UI层各自写工作量也可控。需要嵌入PlatformView的场景通常是原生能力很强、重写成本高比如地图、视频播放器、WebView、扫码相机。这些组件底层依赖SDK和Surface像视频播放和相机预览在Flutter的纹理体系里做自绘适配非常复杂Compose虽然可以直接复用Android系统View但也同样面临混合渲染的开销。遇到这类组件最优解不是重写而是把原生View包成平台视图嵌入框架。另外还有一条很重要的实操经验跨技术栈复用不一定只有“自绘”和“嵌入式”两个极端。你可以把控件拆成两层——一层是和UI无关的核心算法层比如角度计算、进度判断、坐标归一、值域映射一层是各技术栈自己的UI层。算法层用纯Kotlin或Dart写不依赖任何框架API三个端共用同一套逻辑UI层只做Canvas和手势适配。这个方案在真实项目里比“一套代码跑三个端”现实得多维护成本也低。5. 实战拆解一个可拖动环形进度条的跨栈实现理论聊多了容易飘我用一个具体的案例把前面所有思想串起来一个带拖拽能力的环形进度条。这个控件覆盖了尺寸计算、Canvas绘制、触摸映射、进度状态更新、动画联动非常适合用来验证自定义View思想如何跨栈复用。5.1 核心算法层和UI框架无关的角度计算环形进度条的核心交互是用户按住弧线拖动手指在360度内的位置被换算成0到1之间的进度。这个换算逻辑和UI框架没有任何关系只跟坐标有关。数学上用Math.atan2、Dart的math.atan2都能算。我会写一个这样的纯函数double progressFromPoint(Offset point, Offset center) { final dx point.dx - center.dx; final dy point.dy - center.dy; var angle math.atan2(dy, dx) * 180 / math.pi; angle (angle 360) % 360; return ((angle - 90 360) % 360) / 360; }这段代码为什么是“跨栈”的因为它既没有Flutter的Widget依赖也没有Compose的Modifier依赖也没有Android的View依赖。同样的逻辑在Kotlin里几乎原样搬过去在原生View的onTouchEvent里也照样用。唯一的差别是把Offset换成一个简单的x、y参数而已。这里有个细节值得说清楚atan2返回的角度范围是负180度到正180度需要先规整到0到360度。为什么要减90度因为atan2的0度起点在三点钟方向而环形进度条的起始点通常应该放在十二点钟方向。减90度并模360才能保证从顶部开始顺时针增长。5.2 原生View版本的关键片段原生View的核心就两个方法onDraw画环onTouchEvent算进度。onDraw要提前把画笔定义成成员变量避免每次绘制创建新对象不然频繁invalidate会让GC抖动。onTouchEvent里拿到事件后先算圆心到触摸点的向量再转成进度值override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE - { val cx width / 2f val cy height / 2f val dx event.x - cx val dy event.y - cy val angle Math.toDegrees( Math.atan2(dy.toDouble(), dx.toDouble()) ).toFloat() val normalized (angle 360f) % 360f progress ((normalized - 90f 360f) % 360f) / 360f postInvalidateOnAnimation() return true } } return super.onTouchEvent(event) }注意我在ACTION_DOWN和ACTION_MOVE都做了计算并且只在事件被自己消费时返回true。这样父容器不会把事件误判成普通点击。进度更新之后调用postInvalidateOnAnimation而不是invalidate是为了让系统在下一帧垂直同步信号到来时再重绘动画频繁触发时更省电也减少丢帧概率。5.3 Flutter版本的关键片段Flutter版本的核心是CustomPainter这个在上面的RingPainter代码里已经写过了。状态层我用ValueNotifier持有进度或者直接在StatefulWidget里用setState。把手势回调绑定到GestureDetector手势回调里复用那套纯函数换算进度class RingSlider extends StatefulWidget { override StateRingSlider createState() _RingSliderState(); } class _RingSliderState extends StateRingSlider { double _progress 0.3; void _updateProgress(Offset position, Size size) { final center Offset(size.width / 2, size.height / 2); setState(() { _progress progressFromPoint(position, center); }); } override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final size Size(constraints.maxWidth, constraints.maxHeight); return GestureDetector( onPanUpdate: (details) _updateProgress(details.localPosition, size), child: CustomPaint( size: size, painter: RingPainter( progress: _progress, trackColor: Colors.grey.shade300, progressColor: Colors.blue, ), ), ); }, ); } }LayoutBuilder在这里是必要的因为GestureDetector回调里要拿到控件当前尺寸去算圆心而CustomPaint的size参数不一定等于实际渲染尺寸最好通过LayoutBuilder拿约束再显式构造Size。5.4 Compose版本的关键片段Compose版本最贴近“声明式”写法。用remember持有进度状态Canvas负责绘制pointerInput负责手势状态一变Canvas自动重绘Composable fun RingSlider( modifier: Modifier Modifier, initialProgress: Float 0.3f, trackColor: Color Color(0xFFE0E0E0), progressColor: Color Color(0xFF2196F3), ) { var progress by remember { mutableFloatStateOf(initialProgress) } Box( modifier modifier .size(200.dp) .pointerInput(Unit) { detectDragGestures { change, _ - val center Offset(size.width / 2f, size.height / 2f) val angle Math.toDegrees( Math.atan2( (change.position.y - center.y).toDouble(), (change.position.x - center.x).toDouble() ) ).toFloat() progress ((angle - 90 360) % 360) / 360f change.consume() } } ) { Canvas(modifier Modifier.fillMaxSize()) { val strokeWidth 20.dp.toPx() val arcSize Size(size.width - strokeWidth, size.height - strokeWidth) val topLeft Offset(strokeWidth / 2f, strokeWidth / 2f) drawArc( color trackColor, startAngle 0f, sweepAngle 360f, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidth) ) drawArc( color progressColor, startAngle -90f, sweepAngle progress * 360f, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidth, cap StrokeCap.Round) ) } } }Compose里的pointerInput块可以访问size属性它是IntSize类型代表当前Modifier的尺寸。因为我们在Box的Modifier链上挂了pointerInput所以size.width、size.height就是整个控件的宽高直接除以2就是圆心。这个细节如果忘了很多人会在手势回调里莫名算出偏差很大的进度值。6. 跨栈自绘踩坑实录重绘、命中测试与对象复用最后分享一些实战里反复踩到的坑。这些东西写文档的人不会告诉你但调试的时候真的很影响头发数量。6.1 Flutter侧Painter缓存、shouldRepaint、文本测量Flutter自绘第一个高频坑就是Painter对象不缓存。只要你把Painter创建在build方法里每次重建都new一个shouldRepaint必然返回true因为oldDelegate和newDelegate根本不是一个对象。正确做法是在State里维护Painter实例字段变更时手动更新。第二个坑是TextPainter在paint方法里反复创建。绘制文字时很多人直接new TextPainter然后layout、paint代码跑起来没问题但帧率一高就会发现CPU占用上去了。正确做法是像原生View复用Paint对象一样复用TextPainter至少不要在每次paint时都重新测量。第三个坑和命中测试有关CustomPaint没有默认的hitTest能力如果你在CustomPaint上直接放一个Listener去监听点击很大概率会发现事件区域和视觉区域对不上。要么在CustomPaint外面包GestureDetector要么记得用child属性让事件穿透到指定区域。事件和绘制在Flutter里是两条独立的链路这一点和原生View“一个View同时负责绘制和事件”有明显区别需要重新适应。6.2 Compose侧不要在绘制lambda里new对象、pointerInput的key要稳定Compose因为状态自动跟踪绘制调用频率不低如果在DrawScope里反复创建Brush、Stroke、Paint对象一样会造成分配压力。虽然Compose封装的很好但该复用还是得复用。像Stroke这种对象可以提到remember里或者干脆用静态常量。pointerInput有个容易被忽略的key参数。Modifier.pointerInput(key)里的key决定了这个手势协程是否需要重启。如果不小心传入了一个会变化的值比如某个页面状态那么每次状态变化手势协程都会被取消并重启拖拽过程中极易出现“拖到一半突然断触”。稳定场景直接用Unit就行。还有一个Compose独有的坑drawArc的useCenter参数。画描边进度环时useCenter必须为false否则画出来的是一个扇形填充而不是圆环。第一次从原生切过来的人很容易盯着true这个默认值找半天原因实际上把样式改成Stroke之后useCenter的作用已经不直观了我建议每次都显式写上false防止以后改成填充样式时顺手出错。6.3 尝试用“无UI依赖的计算层”收拢跨栈逻辑最后说一个我最推荐的实操习惯在写跨栈自绘控件之前先把这个控件的数学逻辑梳理成纯函数层。进度值换算、角度归一化、命中区域判断、颜色插值这些逻辑都不要写进任何一个框架的UI文件里。比如角度换算用Dart写成progressFromPoint在Kotlin里写一份等价函数然后三个端的UI层都只调用各自语言版本的这个纯函数。测试的时候也只需要测这一层因为绘制代码的正确性很难用单元测试覆盖但算法层的正确性很容易。这个思路听起来土但在真实项目里比任何抽象框架都管用。如果你要把这套思想带到自己项目里我建议先做一件事挑一个简单的自绘组件比如进度环或者滑块在Flutter和Compose里各写一遍但强制自己把核心计算抽到纯函数里。写完之后你会发现真正让你跨栈顺畅的不是背API而是对不同框架如何处理“测量、布局、绘制、事件、状态”这五件事有了自己的答案。