自定义View思想跨端迁移:从Flutter到Compose的UI内功 Android这条路走了这么多年如果要让我挑一门“学到之后走到哪儿都用得上”的课我一定会选自定义View。这不是客气话。当年在原生里死磕onMeasure、onDraw、onTouchEvent的时候心里想的都是“赶紧把这个控件画完收工”结果等到我真的开始碰Flutter和Compose之后才反应过来以前那些“苦工”其实是在给我打一套跨端通用的UI内功。自定义View表面上是一套Android API自定义ViewGroup去做测量和布局重写onDraw画图形处理onTouchEvent响应手势。但它背后沉淀的东西是UI控件的完整生命周期——约束如何流入、尺寸如何决策、子节点如何排列、画布如何被绘制、事件如何被命中与回调。当你把这套心智模型装进脑子里再回头看Flutter的CustomPainter、RenderObject和Compose的Canvas、Layout会发现它们并不是三个孤立的体系而是同一枚硬币在三个方向上的投影。这篇文章想做的就是把这套自定义View思想翻译到Flutter和Compose里。我会先用原生自定义View的视角拆解“测量、绘制、交互”这三层骨架然后分别在Flutter和Compose中给出可运行的落地示例再做一张三者对照的映射表最后聊一聊我在真实项目里踩过的坑。适合正在从原生转向Flutter或Compose的开发者也适合跨端项目里反复被自定义控件折磨的人。1. 先把问题看清楚自定义View思想的核心是三层骨架1.1 测量与布局你不是在画图而是在解一道约束题自定义View的第一课其实是打破一个误会自定义View的难度不在绘制代码写得好看而在你能否把测量约束这关过好。onMeasure听起来是“测量自己”但实际上它干的事是“解读父亲下发的MeasureSpec”。每回onMeasure被调用系统都会把mode和size打包给你EXACTLY表示父控件已经定了死尺寸AT_MOST表示你最大只能这么大UNSPECIFIED表示你自己看着办。如果你无视mode、硬编码一个尺寸最后布局时就会出现各种莫名其妙的错位和裁剪。我记得第一次写流式标签布局的时候就是栽在测量上。当时想着每个标签量一下宽度就行结果没处理AT_MOST下高度不确定的情况子View被挤到屏幕外面去。后来才领悟自定义View本质上是把自己当作约束传递链上的一环——父亲给你约束你结合自己的内容算出尺寸再给每个子View分发它们各自的约束。到了Flutter我发现这套逻辑几乎原封不动地保留下来了。Flutter里有一句非常经典的话“Constraints go down, sizes go up, parent sets position.”父组件把BoxConstraints传下去子组件在约束范围内决定自己的尺寸父组件再拿到具体尺寸做摆放。这里没有Android的UNSPECIFIED那种模糊地带而是明确的最小/最大宽高区间表达上更干净但思考路径完全一致你要回答的依然是我在这个约束区间里到底该长多大。Compose那边核心API是Measurable.measure(constraints)和layout(width, height)。写自定义Layout时你会先对所有子项调用measure拿到Placeable再根据测量结果决定摆放位置。这个“先测量后布局”的两段式过程本质上就是原生ViewGroup里的measure layout。所以只要你在原生里理解过“度量约束”这个概念到了任何新框架你要做的事都是把同一套思维重新翻译一遍而不是重新学一遍。1.2 绘制一个句柄到一块画布的距离自定义View的第二个核心是绘制。原生里你重写onDraw(Canvas canvas)拿到一个Canvas句柄然后就可以用它画线、画圆、画路径、画文字。程序员新手最容易在这个阶段放飞自我把Paint当毛笔一样到处甩但其实onDraw里最需要克制的是两点一是不要在绘制过程里做耗时计算二是不要在绘制过程里new对象。为什么不能在onDraw里new Paint因为绘制是高频操作任何一帧卡顿都会被用户瞬间感知。你每帧new一个Paint意味着每帧都产生垃圾对象Android的GC虽然不会立刻炸但触发的时机永远不浪漫。我的习惯是Paint先缓存成成员变量Canvas复用一个画布Path尽量复用除非路径确实需要动态重建。到了Flutter对应物是CustomPainter.paint(Canvas canvas, Size size)。你会发现签名几乎长一个样Canvas还是那个CanvasPaint在Flutter里改叫PaintingStyle之类的一套绘制参数。Compose则更进一步把绘制放进了DrawScope里你可以在Canvas composable中直接调用drawArc、drawCircle、drawLine这些顶层扩展函数。变化的是语法糖不变的还是那一块画布和一套几何原语。1.3 交互与刷新事件分发、命中测试、重绘请求自定义View里最难的部分往往是交互。原生的触摸体系包含一条完整链路事件从Activity分发到ViewGroup再由ViewGroup的dispatchTouchEvent决定传给哪个子View。这个过程中子View可能消耗掉事件也可能归还给父层一套手势下来开发者的脑袋通常要跟着转好几圈。除此之外还有一个隐含机制重绘请求。手指按下时你修改了一个状态变量然后手动调用invalidate()系统在下一帧重走onDraw把新状态画出来。自定义控件里最出彩的设计往往是那种把手势状态和绘制状态绑死的控件——手指拖到哪进度就画到哪中间全靠“事件改状态状态触发重绘”这个循环。这套循环到了声明式框架里被大幅度自动化了。Flutter里你把状态放到State里调用setState框架自动帮你重新build并更新绘制Compose里你用mutableStateOf或者remember管理状态状态一改重组自动发生绘制层跟着更新。框架把“invalidate”这一步藏了起来但并没有改变底层逻辑状态变化必须精确地映射到UI变化只是这个映射现在由框架替你调度。因此跨技术栈之后你要考虑的反而不是“怎么手动刷新”而是“我的状态边界在哪里”“重组/重建的范围会不会过大”。2. Flutter里怎么用自定义View思想从CustomPainter到RenderObject2.1 先做一次API对位原生的每个概念在Flutter里住哪儿刚接触Flutter的人容易被Widget、Element、RenderObject这三个层级绕晕。实际上从自定义View的角度看它们的分工非常清晰Widget是你写代码时面对的配置对象Element负责把Widget和树结构绑定起来RenderObject才是干“测量布局绘制”实活的角色。普通应用开发者只接触Widget就够了但一旦你要自定义一个布局或复杂的绘制组件你就得往下走一层。我把原生自定义View的常用API在Flutter里做了个对位表方便你建立方位感原生AndroidFlutter对应物用途onMeasureRenderObject.performLayout / CustomMultiChildLayoutDelegate测量与布局onDrawCustomPainter.paint / CustomPaint画布绘制onTouchEventGestureDetector / Listener / HitTestBehavior触摸事件invalidatesetState / ValueNotifier / Listenable请求重建与重绘View的attach/detachState.initState / dispose生命周期回调dp/px换算MediaQuery.devicePixelRatio / dpr像素密度这张表并不是让你死记硬背而是提醒你一件事只要你在原生里做过一次自定义View你就已经具备迁移的地图了。接下来差别只在于语法和API封装方式。2.2 绘制层实操CustomPainter并不神秘它就是onDrawCustomPainter应该算Flutter里最接近“自定义View精髓”的类。你继承它实现paint方法和shouldRepaint方法然后把实例交给CustomPaint widget它就能在你指定的尺寸区域内作画。这里有个关键点shouldRepaint一定要好好写。这个方法决定当新的painter实例传进来时框架要不要重新执行paint。很多新人直接return true结果自定义控件即使在状态没变时也会每帧重绘白白浪费性能。反过来如果你漏写了某些字段的比对又会遇到“数据变了画面不更新”的灵异情况。下面是一个环形进度条Painter的完整实现思路就是原生里画圆弧的那一套import dart:math as math; class RingProgressPainter extends CustomPainter { RingProgressPainter({ required this.progress, required this.strokeWidth, required this.backgroundColor, required this.progressColor, }); final double progress; // 0.0 ~ 1.0 final double strokeWidth; final Color backgroundColor; final Color progressColor; override void paint(Canvas canvas, Size size) { final center Offset(size.width / 2, size.height / 2); final radius (math.min(size.width, size.height) - strokeWidth) / 2; final basePaint Paint() ..isAntiAlias true ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..color backgroundColor; final progressPaint Paint() ..isAntiAlias true ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..color progressColor; // 先画底环 canvas.drawCircle(center, radius, basePaint); // 再画进度弧从12点钟方向开始 canvas.drawArc( Rect.fromCircle(center: center, radius: radius), -math.pi / 2, 2 * math.pi * progress, false, progressPaint, ); } override bool shouldRepaint(covariant RingProgressPainter oldDelegate) { return progress ! oldDelegate.progress || strokeWidth ! oldDelegate.strokeWidth || backgroundColor ! oldDelegate.backgroundColor || progressColor ! oldDelegate.progressColor; } }把类名、文件字段想好之后使用起来就更简单了SizedBox( width: 120, height: 120, child: CustomPaint( painter: RingProgressPainter( progress: 0.72, strokeWidth: 12, backgroundColor: Colors.grey.withValues(alpha: 0.2), progressColor: Theme.of(context).colorScheme.primary, ), ), )这里有一个特别容易被忽略的点CustomPaint本身不自带尺寸。如果你不把它包在SizedBox里或者父组件没有给它明确的约束它的尺寸会退化成Size.zero画了也白画。自定义View老手应该对这个很眼熟——原生自定义View在xml里也得给layout_width和layout_height不然根本没法显示。所以用CustomPaint千万不要忘记外层尺寸约束。2.3 布局层实操用RenderObject的思路去理解Flutter的测量再看布局。如果你只是做一个简单控件CustomPaint就够了可当你需要做一个“容器型组件”比如类似FlowLayout的自动换行标签列表你需要自定义布局。Flutter里常用的做法有两个一个是使用MultiChildLayoutDelegate配合CustomMultiChildLayout另一个是直接撸RenderObject层。前者对业务开发更友好因为你可以用Widget的写法来描述布局规则。我写一个简单的垂直列表布局Delegateclass SimpleListLayoutDelegate extends MultiChildLayoutDelegate { override void performLayout(Size size) { double dy 0; for (final childId in children) { final childSize layoutChild( childId, BoxConstraints( maxWidth: size.width, maxHeight: size.height - dy, ), ); positionChild(childId, Offset(0, dy)); dy childSize.height; } } override bool shouldRelayout(covariant SimpleListLayoutDelegate oldDelegate) false; }这段代码的思路和原生ViewGroup.onLayout几乎一模一样约束往下传子项一个一个测量测完再摆到纵坐标累加的Offset位置。你可能觉得这很简单但注意真正的自定义布局难点往往在“如何给每个子项分发约束”这个决策上。比如你要实现流式换行就得判断当前行的剩余宽度是否够放下一个子View不够就换行。而这个判断必须发生在测量阶段不是在摆放阶段。这个细节原生和Flutter是完全一致的。2.4 一个完整示例给环形进度控件加上触摸反馈光画不好看自定义View最让人上瘾的是交互。我们给上面的环形进度条加上一个能力点击圆环时把点击位置对应的角度直接设成新进度。这就把绘制和事件连起来了。class InteractiveRingProgress extends StatefulWidget { const InteractiveRingProgress({super.key}); override StateInteractiveRingProgress createState() _InteractiveRingProgressState(); } class _InteractiveRingProgressState extends StateInteractiveRingProgress { double _progress 0.4; void _handleTap(Offset localPosition, Size size) { final center Offset(size.width / 2, size.height / 2); final dx localPosition.dx - center.dx; final dy localPosition.dy - center.dy; final angle math.atan2(dy, dx); // -π ~ π final rawProgress (angle math.pi / 2) / (2 * math.pi); setState(() { _progress (rawProgress 1) % 1.0; }); } override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final size Size(constraints.maxWidth, constraints.maxHeight); return GestureDetector( behavior: HitTestBehavior.opaque, onTapUp: (details) _handleTap(details.localPosition, size), child: CustomPaint( painter: RingProgressPainter( progress: _progress, strokeWidth: 12, backgroundColor: Colors.grey.withValues(alpha: 0.2), progressColor: Colors.orange, ), child: const SizedBox.expand(), ), ); }, ); } }这里的LayoutBuilder是为了把父组件给的实际尺寸传给手势回调否则GestureDetector只有localPosition无法换算圆心。HitTestBehavior.opaque则是保证透明区域也能接收点击——这个就是原生自定义View里“点击区域”的概念只是换了个名字。3. Compose里怎么落地声明式框架下的自定义组件新姿势3.1 Canvas composable这就是Compose版的onDrawJetpack Compose把绘制收敛成一个名叫Canvas的Composable函数。你不需要继承任何类只需要在Canvas的lambda里面调用DrawScope的扩展方法。这个lambda相当于原生里的onDraw方法每次需要重绘时都会重新执行。一个环形进度条在Compose里可以写成这样Composable fun RingProgress( progress: Float, modifier: Modifier Modifier, strokeWidth: Dp 10.dp, backgroundColor: Color Color(0xFFE8E8E8), progressColor: Color Color(0xFF3F51B5), ) { val strokePx with(LocalDensity.current) { strokeWidth.toPx() } Canvas( modifier modifier .size(120.dp) ) { val radius (size.minDimension - strokePx) / 2f val center Offset(size.width / 2f, size.height / 2f) drawArc( color backgroundColor, startAngle 0f, sweepAngle 360f, useCenter false, topLeft Offset(center.x - radius, center.y - radius), size Size(radius * 2f, radius * 2f), style Stroke(width strokePx, cap StrokeCap.Round), ) drawArc( color progressColor, startAngle -90f, sweepAngle 360f * progress.coerceIn(0f, 1f), useCenter false, topLeft Offset(center.x - radius, center.y - radius), size Size(radius * 2f, radius * 2f), style Stroke(width strokePx, cap StrokeCap.Round), ) } }注意这里的strokeWidth是Dp而不是Float。在Compose里所有与屏幕像素相关的尺寸都必须通过LocalDensity换算成px。这是声明式框架为了适配不同屏幕密度的设计——你在代码里写dp框架在组合阶段帮你转换成具体像素习惯之后会发现它比原生手动乘density要省心得多。3.2 Layout composable自绘布局从measure开始Compose把自定义布局也做成了声明式API。你要实现类似Android ViewGroup的自定义布局只需要写一个Layout composable然后在这个组件的lambda里面对每个子项调用measure最后调用layout方法设置整体尺寸并摆放子项。Composable fun FlowColumn( modifier: Modifier Modifier, content: Composable () - Unit, ) { Layout( content content, modifier modifier, ) { measurables, constraints - val placeables measurables.map { m - m.measure(constraints) } var totalHeight 0 var maxWidth 0 val positions mutableListOfPairInt, Int() var x 0 var y 0 var rowHeight 0 placeables.forEach { p - if (x p.width constraints.maxWidth x 0) { y rowHeight x 0 rowHeight 0 } positions.add(Pair(x, y)) x p.width rowHeight maxOf(rowHeight, p.height) maxWidth maxOf(maxWidth, x) } totalHeight y rowHeight layout(maxWidth, totalHeight) { placeables.forEachIndexed { index, placeable - val (px, py) positions[index] placeable.placeRelative(px, py) } } } }这是一个简化版的自动换行布局每一行先排子项放不下就换行然后计算总高度。它的逻辑和我之前用MultiChildLayoutDelegate写的几乎一模一样区别在于Compose把“测量所有子项”和“摆放子项”拆成了两个清晰的阶段写起来反而比原生ViewGroup更容易跟踪。这里最容易出错的地方在于constraints.maxWidth可能是一个无穷大值的情况。如果父组件没有给横向约束你会得到一个无限宽度的布局代码里的判断逻辑就全部失效。所以自定义布局一定要对自己可能遇到的约束做防御性判断这个坑在原生、Flutter、Compose三个框架里我都踩过。3.3 Compose完整示例给环形进度控件加触摸交互给上面那个RingProgress加上点击事件Compose里和最普通的交互控件没什么区别直接在Modifier链上追加pointerInput就行。关键技术点在于detectTapGestures里拿到的是相对组件左上角的Offset你需要用这个Offset来推算角度进度。Composable fun InteractiveRingProgress( progress: Float, onProgressChange: (Float) - Unit, modifier: Modifier Modifier, ) { var currentProgress by remember { mutableFloatStateOf(progress) } LaunchedEffect(progress) { currentProgress progress } Canvas( modifier modifier .size(120.dp) .pointerInput(Unit) { detectTapGestures { offset - val center Offset(size.width / 2f, size.height / 2f) val dx offset.x - center.x val dy offset.y - center.y if (dx * dx dy * dy 0f) { val angle atan2(dy, dx) * 180f / PI.toFloat() val normalized ((angle 90f 360f) % 360f) / 360f onProgressChange(normalized) } } } ) { val strokePx 10.dp.toPx() val radius (size.minDimension - strokePx) / 2f val center Offset(size.width / 2f, size.height / 2f) drawArc( color Color(0xFFE8E8E8), startAngle 0f, sweepAngle 360f, useCenter false, topLeft Offset(center.x - radius, center.y - radius), size Size(radius * 2f, radius * 2f), style Stroke(width strokePx, cap StrokeCap.Round), ) drawArc( color Color(0xFF3F51B5), startAngle -90f, sweepAngle 360f * currentProgress, useCenter false, topLeft Offset(center.x - radius, center.y - radius), size Size(radius * 2f, radius * 2f), style Stroke(width strokePx, cap StrokeCap.Round), ) } }这个例子把绘制、布局、触摸、状态更新全部串在了一个Composable里看起来比Flutter那个示例紧凑不少。但紧凑不代表没有复杂度。Compose里真正要关注的是重组currentProgress是一个局部状态点击后它一变整个Canvas区域就会重组。如果你在Canvas外面还套了一个很大的父组件重组范围可能会扩大带来不必要的性能损耗。所以做自定义组件的时候我习惯把状态尽量收敛到小范围组件内部或者显式地把Canvas相关的绘制逻辑拆成一个独立组件。4. 跨技术栈映射与设计决策不要照搬API要搬思想4.1 三个框架的自定义UI要素对照表有了前文的代码铺垫现在可以展开一张更完整的对照表。这张表不是API的同义词替换而是帮你理清“同一个设计意图在三个框架里各自怎么表达”。设计意图原生AndroidFlutterJetpack Compose测量子项并获取占位信息View.measure / onMeasureRenderObject / performLayoutMeasurable.measure绘制背景/图形onDraw CanvasCustomPainter.paintDrawScope画一条圆弧canvas.drawArccanvas.drawArcdrawArc简单点击区域setOnClickListenerGestureDetectorclickable / pointerInput自定义命中区域ViewGroup.dispatchTouchEventHitTestBehaviorpointerInput重绘请求invalidate()setState / ValueNotifiermutableStateOf触发重组像素密度换算TypedValue.applyDimensionMediaQuery.devicePixelRatioLocalDensity组件对外回调interface/ListenerCallback函数lambda参数这张表最有价值的部分不是前面几行“绘制方法对应”而是最后两行“状态刷新”和“密度换算”。它们决定了一个自定义组件在跨平台时用户交互体验是否一致。4.2 状态驱动与重绘策略为什么别再手动invalidate了原生自定义View时代我养成了一个条件反射凡是手势或数据变了必然会调用invalidate()。这个条件反射在Flutter和Compose里是毒药——不是语法错误而是思维错误。声明式框架已经接管了“应用状态变化帧”你的责任从“通知界面刷新”变成了“把状态变清楚”。只有当你明确知道自己要更新哪个字段、能影响哪个绘制区域时你的更新才是精确的。但自动刷新也有代价它容易造成“过度刷新”。Flutter里你调用一个setState所有依赖了该State的Widget子树都会重建Compose里如果一个状态对象被多个组件读取任何一处改变都会触发这些组件的重组。这与原生里只invalidate自己这个View的开销模型完全不同。做自定义组件跨栈迁移时我建议你时刻问自己三个问题状态到底属于谁状态改变后哪些组件必须重建哪些是可以跳过或延迟的这三个问题想清楚性能心里就有底了。4.3 组件通信Provider、mutableStateOf与回调机制的跨栈思考自定义View往往不是孤岛。你在原生里自定义View通常通过监听器、回调接口、或者直接持有外部数据源来通信。到了Flutter常见的通信手段变成了构造函数传值、ValueChanged函数回调、ChangeNotifier Provider跨层共享。Compose则更倾向直接用回调函数参数同时利用State提升把数据上提到合适的层级再下发。我在项目里见过不少人在Flutter里用Provider把每个自定义组件的状态都挂到全局结果一个进度条变化会触发整个页面重建。这就像原生里你在Activity里直接持有View对象然后手动刷新一样粗暴。Provider这类全局状态更适合放跨页面、跨模块真正要共享的业务数据而不是放一个局部动画的进度值。局部动画就用setState或mutableStateOf保持作用域最小只有多个组件共同消费同一状态时才上提状态到公共父节点。组件通信范式本身没有谁优谁劣核心还是在设计时长点心状态越靠近消费方重绘范围越可控。5. 实战避坑自定义UI在三个框架里最容易踩的坑5.1 绘制性能不要在paint方法里做昂贵操作这个坑可以说三个框架一个都没落下。原生里是onDrawFlutter里是CustomPainter.paintCompose里是Canvas lambda它们都是高频调用点。典型的不良操作包括在绘制里读取SharedPreferences、生成Bitmap、做字符串拼接与测量、甚至打印日志。这些操作在单帧里看不出来一旦你的控件被快速拖拽或者列表滚动就是肉眼可见的掉帧。我自己的检查方式非常简单粗暴在paint方法里放一个计时器或者一次Debug统计如果一帧的绘制时间超过5ms就说明你的绘制代码有严重问题。还有一个更底层的建议尽量使用canvas.saveLayer和clipPath等操作时能不用就不用因为它们会触发离屏分配或复杂光栅化在手机低端设备上代价尤其明显。Flutter的Impeller渲染引擎上线之后很多Skia时代的绘制行为发生了变化但“别在绘制里做重活”这条原则没变。我甚至会把绘制阶段的所有纯文本布局提前缓存运行时直接复用。5.2 密度与尺寸dp/sp/px的换算不能偷懒原生自定义View里dp到px的转换是最基础的一课。到了Flutter和Compose这个换算仍然无处不在只是API不同。Flutter里CustomPainter拿到的size单位是逻辑像素也就是dp。如果你从外部传入实际像素值就必须手动除以MediaQuery.devicePixelRatio否则小屏机型上会大出一圈。Compose里则更规范所有Canvas绘制的坐标单位都是像素px但你在Modifier里设置的尺寸通常是dp这就要求你在绘制前先用LocalDensity.current.toPx()完成换算。很多跨栈项目的自定义控件尺寸不一致根源不是绘制逻辑写错了而是密度换算在不同平台没换算齐。5.3 布局约束与边界情况防御式布局必须养成肌肉记忆自定义布局里最容易出现“在测试机上一切正常换台设备就乱”的尴尬。上面的FlowColumn例子已经揭示了问题当父组件没有给横向约束时constraints.maxWidth会是无穷大此时布局会完全脱离预期。原生里类似的情况是父ViewGroup没有设置width时MeasureSpec为UNSPECIFIED。我的做法是所有自定义布局的第一步先判断constraints.hasBoundedWidth和constraints.hasBoundedHeight没有边界时主动设置一个默认值或回退方案。这种防御式编程在UI领域尤其重要因为UI一乱用户的信任感瞬间就没了。5.4 调试与性能分析三个框架各自的“显影剂”自定义UI出了问题最怕的就是光靠眼睛找原因。原生里有Layout Inspector和SystraceFlutter里有DevTools的Widget Inspector和Performance OverlayCompose里则有Layout Inspector配合重组计数工具。我个人的习惯是先看约束再看绘制最后才怀疑渲染引擎。自定义控件行为异常十有八九是约束传错了绘制内容看起来糊或者缺失再检查坐标系和透明度这两种都没问题最后测性能。千万别一上来就升级SDK或flutter upgrade那是最无效也最耗时的排查方式。另外一个非常有用的调试技巧在Flutter里重写debugFillProperties在Compose里实现debugDescription把关键状态输出到属性的调试面板里。这样排查问题的时候能直接看到这个组件此刻的progress、颜色、尺寸等状态值比低头打日志高效得多。写在最后自定义View思想是可以跟着人走的资产从原生自定义View到Flutter的CustomPainter再到Compose的Canvas说实话我并没有觉得我在“学三套技术”更像是在用同一副骨架去理解三套皮肤的差异。测量、布局、绘制、事件、状态、重绘这六件事无论换成什么框架都绕不开。真正值钱的并不是你背下了某套API的名字而是你在一次次手写控件中练出来的判断力——知道约束怎么传状态放在哪绘制在哪里收敛。跨技术栈最忌讳的是“拿着新框架还按旧框架的姿势出牌”。Flutter和Compose都是声明式框架它们最大的红利就是自动状态驱动和可组合性。如果你到了新框架还在手动管理invalidate、还在把每个子组件状态都挂到全局那跟把旧代码翻译成拼音写在注释里没什么区别。要学会把自定义View那套思维留下来把平台相关的表达方式果断换掉。最后分享一个我自己的小习惯每次要写一个新控件前先在纸上画一个“测量约束图”和一个“绘制层次图”一个箭头表示约束流向一个表示绘制顺序。这个习惯在原生、Flutter、Compose三个技术栈里帮了我无数次。自定义View思想不是某家平台的专属遗产它是做UI开发的人真正可以带一辈子的资产。