
做 Flutter 界面做久了你会发现真正花时间的往往不是那些大页面而是一个看起来特别简单的小图形。前阵子做一套卡片式的视觉组件需求原话大意就是画两个椭圆小的黑色椭圆压在大的紫色椭圆上面紫色椭圆下半部分要从紫色渐变到透明。听起来像是十分钟能搞定的活结果我在渐变那一段上来回改了大半天中间还遇到了椭圆被画成圆、渐变末端出现一条灰色硬边、列表滚动时掉帧这些破事。这类两个椭圆 一个渐变的组合本质上是在考验你对 Flutter 绘制层Canvas、Shader、图层裁剪的理解深度Container能糊出来但糊出来的东西在尺寸变化、动画、深色模式下会一个接一个地暴露问题。下面我把自己从选型、写代码到踩坑、再到封装成可复用组件的完整过程摊开讲一遍不管你是刚学 Flutter 不久、只会堆 Widget 的新手还是已经写过CustomPainter但被渐变坑过的人应该都能从里面抠出点能直接抄的东西。1. 拿到这个需求先别写代码三个视觉要素的优先级排序需求描述里其实塞了三件事大的紫色椭圆、小的黑色椭圆、底部的紫色到透明渐变。新手的第一反应通常是两个 Container 叠一叠但这三步里每一步对实现方式的约束强度完全不一样先把它们的权重排清楚才不至于写到一半推倒重来。1.1 大椭圆、小椭圆、渐变谁决定了实现方案先把椭圆这个词拆开。Flutter 里画椭圆有好几种入口BoxShape.circle只能出正圆ClipOval出的是内切于子组件矩形的椭圆BorderRadius.all(Radius.elliptical(w, h))配合矩形也能凑出椭圆最自由的还是Canvas.drawOval。这几种方式的差别不在能不能画椭圆而在于形状和尺寸的耦合方式。Radius.elliptical的问题是硬编码宽 320、高 200 的容器半径就得写死成 160 和 100一旦外层用了Expanded或者屏幕旋转容器尺寸变了、半径没变椭圆立刻变形甚至被裁成圆角矩形。而drawOval拿的是一个Rect你把Rect交给LayoutBuilder或者CustomPaint的size来算尺寸天然跟着走这是它能扛住响应式布局的根本原因。再看小的黑色椭圆。它要覆盖在大椭圆上涉及的是层级与定位问题同一个坐标系里算中心点便宜用StackAlign就得考虑两套约束怎么对齐尤其是你想让这个小椭圆稍微偏一点、而不是死板地居中时Stack方案的调试成本会陡增。真正把方案钉死的是第三个要素——渐变。渐变不是画个颜色那么简单它要求你能控制渐变的坐标系范围从紫到透明的过渡必须严格落在大椭圆的下半区域不能溢出椭圆的形状边界。BoxDecoration的渐变是先把整个矩形涂满、再按borderRadius裁剪只要形状规则倒也没问题但如果你想在渐变之上再叠一层描边、或者让渐变只覆盖椭圆的下 60%BoxDecoration就开始力不从心了。1.2 渐变到透明才是真正决定技术路线的那个点我给自己的判断标准很粗暴凡是涉及渐变 不规则形状 需要精确控制渐变范围的组合优先考虑CustomPaint。原因不复杂渐变的本质是一个Shader而Shader需要一个边界矩形来映射坐标——LinearGradient.createShader(Rect rect)就是这么个签名。在 Widget 体系里这个Rect由 Flutter 帮你算你只能通过begin/end的Alignment间接影响它在CustomPaint里Rect是你自己传的你想让它等于大椭圆的外接矩形它就等于外接矩形想让它只覆盖下半部分就传下半部分。还有一个更隐蔽的因素透明。很多人以为渐变到透明就是把终点颜色写成Colors.transparent视觉效果应该和渐隐一样。实际跑起来会发现中间那一段发灰发暗像蒙了一层脏东西。这个问题在Container方案里同样存在但因为CustomPaint里你能清楚地看到shader是怎么生成的、Rect是从哪来的排查起来会快很多。后面第 3 章我会专门讲这个灰边的成因和一行代码的解法。所以这篇的路线是以CustomPaint为主线把Container和ClipPath两条替代路线也讲清楚让你在需求简单的时候可以走捷径、在需求变复杂的时候知道往哪升级。2. 三条技术路线摆上桌为什么我最后选了 CustomPaint同一个视觉效果Flutter 至少给三条路。我把三条都实现了一遍、丢到真机上对比下面逐条说手感最后给一张对照表方便你按项目情况直接选。2.1 路线一Container BoxDecoration最快但形状受限这是最省事的写法二十行代码以内能出效果Container( width: 320, height: 200, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(160, 100)), gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ const Color(0xFF7C4DFF), const Color(0xFF7C4DFF).withValues(alpha: 0), ], ), ), )黑色小椭圆再套一层同样思路的Container用Stack叠上去。半小时能交付hot reload立刻能看到效果非常适合做原型验证。但它有三个绕不过去的限制。第一半径写死尺寸一变形状就崩想让它自适应就得外包一层LayoutBuilder用constraints.maxWidth / 2重新算半径代码从二十行涨到四十行已经不比CustomPaint短了。第二BoxDecoration的渐变只能覆盖整个组件的矩形区域你想让渐变只作用在下半部分得切成两个容器拼接缝处一旦有抗锯齿就会露出细线。第三也是最要命的一点这个方案里渐变和形状是耦合的borderRadius裁剪发生在绘制之后如果之后要给椭圆加描边、加投影、加外发光全部得绕开BoxDecoration自己画等于把省下的时间又还回去。2.2 路线二ClipPath ShaderMask灵活但代码量翻倍ClipPath配合自定义CustomClipperPath可以直接把子组件裁成椭圆然后里层的Container只管放心地铺渐变。这比路线一灵活的地方在于Path里能塞任意形状——椭圆只是其中一种后期想改成椭圆 底部切平或者椭圆缺一角改几行path就完事。ShaderMask则是另一条思路子组件是纯色的ShaderMask用一张渐变作为遮罩来调制透明度。我在一个需要椭圆 内阴影的变体里用过ClipPath体验是形状控制确实爽但图层数量会悄悄翻倍。每次ClipPath都会引入一个独立的裁剪图层ShaderMask再加一个一个列表项里叠上五六层滚动起来就有掉帧风险。而且ClipPath有个容易漏掉的点——它默认不开抗锯齿clipBehavior只影响裁剪方式边缘平滑靠的是系统裁剪的默认行为在某些中低端机上椭圆边缘能看到锯齿。顺便纠正一个很常见的误解ClipOval裁出来的不是圆是椭圆。它的语义是用子组件矩形的内切椭圆去裁剪所以宽 320 高 200 的容器套上ClipOval得到的就是一个 320×200 的椭圆。这个知识点很多人不知道白白写了半天CustomClipper。但ClipOval的问题是它不能控制椭圆的位置和大小只能严格内切所以小椭圆那种偏一点、小一圈的需求它还是搞不定。2.3 路线三CustomPaint 手绘可控性天花板CustomPaintCustomPainter是所有方案的兜底。它给你一块画布、一个尺寸剩下全靠Paint和Path。好处是坐标系完全自主椭圆的外接矩形怎么算、渐变shader绑到哪个Rect、小椭圆往哪个方向偏移多少全是你说了算。而且两个椭圆画在同一个paint方法里天然共享坐标系不存在对齐问题Stack里那种两个子组件约束不一致导致错位的烦恼直接消失。代价是要自己处理重绘逻辑。CustomPainter的shouldRepaint返回false而你又改了参数画面就是不动——这个坑后面第 5 章会详细说。另外布局约束不会自动传导CustomPaint如果没有child、size又是默认的Size.zero画出来的东西就是一片空白得靠size参数或者外层SizedBox把尺寸喂进去。2.4 三条路线的实测对照表维度Container BoxDecorationClipPath / ShaderMaskCustomPaint上手速度最快半小时中等一小时中等需要熟悉 Canvas API尺寸自适应差半径要手算好Path 可基于 size 算好完全基于 size形状自由度只有圆角矩形/圆任意 Path任意 Path 任意绘制渐变范围控制弱只能盖整个组件中靠遮罩间接控制强shader 绑任意 Rect图层开销最低较高每次裁剪一层低一次绘制搞定后期扩展描边/发光/动画差中强适用场景静态原型、尺寸固定形状复杂但绘制简单需要精确控制或有动画我的实际选择是原型阶段用路线一快速验证配色和比例确认视觉稿后切到路线三。中间那条路线留给形状不规则但绘制内容简单的场景比如需要一个椭圆形的图片裁剪框。3. 紫色渐变到透明的真相Colors.transparent 是个陷阱这一段是整篇里最值钱的部分。凡是做过渐变到透明效果的人八成都被那条脏灰边坑过但知道成因的人不多。3.1 RGB 插值与 alpha 插值那条灰边从哪来先看一个事实Colors.transparent的定义是Color(0x00000000)也就是完全透明的黑色。你在写渐变时如果这么写colors: [Color(0xFF7C4DFF), Colors.transparent]那么渐变在插值时四个通道会同时从紫色 不透明线性过渡到黑色 全透明。ARGB 分别是起点A255, R124, G77, B255终点A0, R0, G0, B0在渐变中段t0.5如果不考虑预乘插值结果大约是 A128, R62, G38, B128——一个半透明的暗紫色。问题就出在这里肉眼看到的颜色是 RGB 和 alpha 叠加之后的结果。当 alpha 降到 0.5 时这层颜色和底层背景混合背景通常是白色或浅色卡片会把这层暗紫稀释成灰紫色视觉上就是一条发脏的过渡带。终点附近 alpha 很低但 RGB 已经接近黑色混出来的效果更接近灰色烟雾。这跟设计稿里那种紫色轻轻化开、消失于无形完全是两回事。设计稿里的透明在概念上是紫色本身变得看不见而不是颜色变成黑色然后看不见。Skia 在渲染渐变时会做预乘 alpha 处理把 RGB 乘以 alpha这一步在数学上其实能规避部分问题但不同渲染后端、不同设备上的实际表现并不完全一致你在模拟器上看不出来的灰边换到某几款真机上可能就明显了。稳妥的做法是从源头把 RGB 管住不要让它往黑色跑。3.2 同色透明色一行代码消灭灰边解法就是把终点色改成和起点同色、但 alpha 为 0的颜色final purple const Color(0xFF7C4DFF); final purpleFade purple.withValues(alpha: 0);这时候四个通道是起点A255, R124, G77, B255终点A0, R124, G77, B255插值过程中 RGB 恒定不变只有 alpha 从 255 走到 0视觉上就是纯粹的紫色渐隐干净利落不会有任何灰调污染。这里有个版本兼容的细节要注意。withOpacity()在 Flutter 3.27 之后被标记为废弃推荐用withValues(alpha: 0)。如果你维护的项目还在更早的版本上withOpacity(0)依然可用。两种写法等价都是保留 RGB、只改 alpha。另外还有个更底层的写法直接操作 raw value// 旧版本取原始 ARGB 值把 alpha 位清零 Color(purple.value 0x00FFFFFF) // 新版本value 已废弃改用 toARGB32() Color(purple.toARGB32() 0x00FFFFFF)注意上面这种位运算写法虽然能用但可读性差而且Color.value/toARGB32()涉及色彩空间转换宽色域屏幕下Color可能是 P3 空间做位运算容易出错。日常开发直接用withValues(alpha: 0)就够了。3.3 渐变方向、stops 与矩形范围的关系LinearGradient的begin/end是相对坐标Alignment它们映射到的是createShader传入的那个Rect而不是屏幕。这一点在CustomPaint里尤其重要——如果你传进去的Rect只有椭圆的下半部分那么Alignment.topCenter指的是下半部分矩形的顶部而不是整个椭圆的顶部。在我这个需求里视觉稿是整个椭圆上半部是纯紫从大约 50% 位置开始往下渐隐。实现上有两种写法效果一样但思路不同第一种是整段渐变、用stops控制纯色区LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [purple, purpleFade], stops: const [0.45, 1.0], )stops的含义是这个颜色出现在渐变轴的哪个百分比位置。写[0.45, 1.0]意味着 0 到 0.45 的区间全是纯紫因为第一个 stop 之前都用第一个颜色填充0.45 开始往紫色透明过渡到 1.0 完全透明。第二种是缩小shader的Rect只在下半部分生成渐变final fadeRect Rect.fromLTWH( bigRect.left, bigRect.top bigRect.height * 0.45, bigRect.width, bigRect.height * 0.55, ); final shader LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [purple, purpleFade], ).createShader(fadeRect);第二种更啰嗦但它的好处是渐变的物理范围和视觉范围严格对齐——当你想在这个Rect上加一些基于位置的计算比如让渐隐速度和椭圆高度成比例时第二种更直观。而且第二种配合canvas.saveLayer或者drawPath分段绘制能做到上半部分纯色实心、下半部分渐变渐变不会污染上半部分。我最后用的是第一种因为stops够用且代码更短。判断标准很简单如果渐变只需要一个方向、一段过渡用stops如果需要多段异形过渡才去折腾Rect。3.4 不同写法的效果对照我在三台不同价位的真机上把几种写法各跑了一遍肉眼看的结果大致如下终点色写法过渡带观感与深色背景叠加备注Colors.transparent中段发灰、整体偏脏更明显出现灰雾不推荐purple.withOpacity(0)干净纯紫渐隐自然融入背景推荐Color(0x007C4DFF)同上同上硬编码维护性差位运算 0x00FFFFFF同上同上可读性差有色彩空间风险结论很直接渐变到透明这句话在代码里的正确翻译是渐变到同色的零透明度而不是渐变到透明色。这个习惯一旦养成之后所有蒙层、遮罩、淡出效果都会少一大半返工。4. 从画布到屏幕两个椭圆的坐标该怎么算方案定了接下来是把视觉稿翻译成坐标。这一步看着机械实际上有两个地方特别容易出错CustomPaint的尺寸来源以及小椭圆的偏移基准。4.1 CustomPaint 的尺寸从哪来约束传导链路CustomPaint的尺寸确定规则官方文档写得很含蓄实际行为是如果给了size且没有child就用size但会被父级约束夹住如果有child尺寸取child的尺寸size被忽略什么都没有就是Size.zeropaint里size.width是 0画出来一片空白。我见过最常见的翻车方式是在Column里直接扔一个CustomPaint(size: Size(320, 200))然后发现宽度被拉伸或者被压扁。原因在于Column的交叉轴约束是尽可能撑满CustomPaint的size只是期望尺寸最终以约束为准。真要让尺寸完全由自己说了算外面套一层SizedBox或者用ConstrainedBox把上下限卡死。更灵活的写法是用LayoutBuilder拿父级约束再把算出来的尺寸喂给CustomPaintLayoutBuilder( builder: (context, constraints) { final w constraints.maxWidth; final h w * 0.625; // 视觉稿的宽高比 320:200 return CustomPaint( size: Size(w, h), painter: TwoOvalPainter(), ); }, )0.625这个比例我是从设计稿上量出来的不是拍的。做这种图形组件的经验是所有尺寸都用相对比例 常量基准来表达绝不写死像素值。因为最终它要放到各种宽度的卡片里写死 320 就意味着在大屏上是个小不点、在小屏上会溢出。4.2 大椭圆drawOval createShader大椭圆的外接矩形就是整块画布所以final bigRect Rect.fromLTWH(0, 0, size.width, size.height);drawOval接收一个Rect画的是内切于该矩形的椭圆。用Paint()..shader ...上色时shader的坐标映射以传入createShader的Rect为基准所以这里要把同一个bigRect传进去渐变才会完整覆盖椭圆final purplePaint Paint() ..isAntiAlias true ..shader LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [purple, purple.withValues(alpha: 0)], stops: const [0.42, 1.0], ).createShader(bigRect); canvas.drawOval(bigRect, purplePaint);isAntiAlias默认就是true我显式写出来是为了提醒自己这个属性在某些追求像素级锐利的场景下会被关掉但椭圆这种曲线图形一旦关掉抗锯齿边缘就是锯齿状的马赛克绝对不能关。stops我用了[0.42, 1.0]这是照着视觉稿调的42% 以上是纯紫实体往下开始渐隐。这个数值没有标准答案纯粹靠眼睛比对改一次hot reload一次两三分钟能调到位。我的习惯是把这类魔法数字提到构造参数里命名成fadeStartRatio而不是埋在绘制逻辑中间。后面做动画的时候你就知道这有多重要。4.3 小椭圆中心点偏移与缩放系数的取舍小椭圆的定位我一开始想的是和画布中心对齐后来对着视觉稿看发现它其实略微偏上偏左是个有不明显偏移的构图。如果按居中写死后面产品改了视觉稿就得重新算。我的处理是中心点 画布中心 偏移量宽高 画布宽高 × 缩放系数。两个参数都暴露成构造入参默认值是当前视觉稿的最优解final baseCenter Offset(size.width / 2, size.height / 2); final smallCenter baseCenter smallCenterOffset; final smallRect Rect.fromCenter( center: smallCenter, width: size.width * smallWidthScale, height: size.height * smallHeightScale, ); canvas.drawOval(smallRect, Paint()..color const Color(0xFF000000));这里我把宽度和高度拆成两个系数而不是一个统一的scale。原因是椭圆一旦用单系数缩放宽高比会跟着大椭圆的变化而变化——大椭圆在不同容器下比例本来就会变手机窄、平板宽小椭圆跟着变不见得是好事。分开控制的话视觉上更容易锁住小椭圆的形状。默认值我给的是0.55和0.62意思是宽的 55%、高的 62%视觉上比等比缩小更饱满一点。Rect.fromCenter这个构造是真好用比Rect.fromLTWH少写两次除法。偏移量我用的是Offset类型而不是两个double因为Offset天生支持运算将来要加动画的时候lerp一下就能动。4.4 完整可运行代码与结构说明把上面几块拼起来就是一个可以直接跑的组件import package:flutter/material.dart; class TwoOvalStack extends StatelessWidget { const TwoOvalStack({ super.key, this.purple const Color(0xFF7C4DFF), this.blackOvalWidthScale 0.55, this.blackOvalHeightScale 0.62, this.blackOvalOffset const Offset(-8, -10), this.fadeStartRatio 0.42, this.aspectRatio 0.625, }); final Color purple; final double blackOvalWidthScale; final double blackOvalHeightScale; final Offset blackOvalOffset; final double fadeStartRatio; final double aspectRatio; override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final width constraints.maxWidth; final height width * aspectRatio; return CustomPaint( size: Size(width, height), isComplex: false, painter: _TwoOvalPainter( purple: purple, widthScale: blackOvalWidthScale, heightScale: blackOvalHeightScale, offset: blackOvalOffset, fadeStartRatio: fadeStartRatio, ), ); }, ); } } class _TwoOvalPainter extends CustomPainter { const _TwoOvalPainter({ required this.purple, required this.widthScale, required this.heightScale, required this.offset, required this.fadeStartRatio, }); final Color purple; final double widthScale; final double heightScale; final Offset offset; final double fadeStartRatio; override void paint(Canvas canvas, Size size) { if (size.isEmpty) return; // 1. 大椭圆实心紫色 向下渐隐到同色透明 final bigRect Offset.zero size; final bigPaint Paint() ..isAntiAlias true ..shader LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [purple, purple.withValues(alpha: 0)], stops: [fadeStartRatio, 1.0], ).createShader(bigRect); canvas.drawOval(bigRect, bigPaint); // 2. 小椭圆黑色压在中心偏左上 final smallRect Rect.fromCenter( center: Offset(size.width / 2, size.height / 2) offset, width: size.width * widthScale, height: size.height * heightScale, ); canvas.drawOval(smallRect, Paint()..color const Color(0xFF000000)); } override bool shouldRepaint(covariant _TwoOvalPainter old) { return old.purple ! purple || old.widthScale ! widthScale || old.heightScale ! heightScale || old.offset ! offset || old.fadeStartRatio ! fadeStartRatio; } }用法很简单丢进任意有宽度约束的容器里SizedBox( width: 320, child: const TwoOvalStack(), )有一个细节值得单独说bigRect我用的是Offset.zero size这是Rect.fromLTWH(0, 0, w, h)的简写运算符在Offset和Size之间重载出了这个语义。写起来短但第一次见会愣一下团队协作时如果新人多老老实实写fromLTWH可能更友好。另外isComplex: false这个参数是给 Flutter 的光栅缓存系统看的。标true表示这块绘制很复杂、缓存起来能省算力但缓存本身有维护成本绘制内容一变化就要重建。像这种两次drawOval的轻量绘制标true反而可能因为缓存失效判断反而更慢。这个值我实测下来在列表里差异不明显但设为false更保守。5. 跑起来之后的五个坑我一条条记录的排查过程代码跑通只是一半真正耗时间的是看起来差不多但总觉得哪里不对的阶段。下面五个问题是我在这个过程中逐个撞上的把排查链路完整写出来方便你遇到类似现象时对号入座。5.1 椭圆被画成了正圆第一版跑起来大椭圆是个标准的圆。我盯着代码看了半天drawOval的Rect明明是320×200怎么会出圆排查顺序是这样的先怀疑CustomPaint的size没生效——用debugPrint在paint里打了size发现是320×200没问题。再怀疑父级约束把宽度拉成了高度——把CustomPaint单独拎出来放到一个SizedBox(width: 320, height: 320)里果然还是圆说明约束确实被改了。最后定位到我外面套的AspectRatioaspectRatio: 1.0把 320×200 的意图硬生生掰成了正方形。这个坑的教训是CustomPaint的尺寸不透明父级的每一种约束都会悄悄改写它。排查手段就一招——在paint方法第一行打日志先确认拿到的size是不是你以为的那个值。90% 的形状不对问题都是尺寸不对导致的跟绘制代码无关。顺带一个相关的坑ClipOval也会被误认为只能画圆。如果你用ClipOval包了一个320×200的容器得到的就是椭圆不是圆。很多人写CustomClipper走了弯路其实是没试过这个。5.2 渐变末尾出现一条硬边现象是椭圆底部边缘有一条 1 像素宽的、颜色略深的弧线像描了边一样。这个问题的源头是抗锯齿和裁剪的配合drawOval在边缘做抗锯齿时会把边缘像素的 alpha 平滑到接近 0但渐变的终点本身 alpha 也是 0两层接近 0叠加某些像素会算出一个微小的负值或溢出值渲染出来就是一条可见的线。我先试了关抗锯齿结果边缘变成明显的锯齿比硬边更丑排除。然后试了给Paint加blendMode: BlendMode.srcOver默认就是这个没变化排除。最后有效的做法是给渐变终点稍微留一点余量不要让它精确落在椭圆边界上stops: [fadeStartRatio, 0.98],让渐变在 98% 处就走完全透明剩下 2% 全是 alpha0 的纯透明填充。这样椭圆边缘的抗锯齿计算不再和渐变的临界 0打架硬边消失。这个技巧通用性很强任何渐变正好贴到形状边缘的场景都建议给渐变留 1%~3% 的余量。特别是在做圆形头像的渐隐环、卡片底部的淡出遮罩时这一步能省掉大量为什么边缘有个亮点的困惑。5.3 小椭圆没盖住边缘露出一圈紫小椭圆明明是纯黑、不透明的理论上应该完全遮住底下的紫色但边缘能看到一圈紫色描边。原因不是没盖住而是底下的大椭圆在边缘做了抗锯齿——边缘那圈像素是半透明的紫色而小椭圆的边缘同样半透明两层半透明叠加紫色就渗出来了。解决思路有两个。第一种是让小椭圆实际绘制范围比视觉范围大一圈用Path的addOval加大半径再drawPath第二种更简单画完小椭圆后再补一圈同色描边final blackPaint Paint()..color const Color(0xFF000000); canvas.drawOval(smallRect, blackPaint); canvas.drawOval( smallRect, Paint() ..color const Color(0xFF000000) ..style PaintingStyle.stroke ..strokeWidth 1.2 ..isAntiAlias true, );第二种的代价是多一次drawOval收益是边缘绝对干净。strokeWidth取 1.2 而不是 1.0是因为在 2x/3x 屏上 1.0 的逻辑像素可能不足一个物理像素补出来还是有缝。顺便说个经验这类边缘渗色问题八成都是抗锯齿叠加造成的而不是层级顺序问题。排查时先别急着怀疑drawOval的调用顺序改成两个纯色矩形叠一下试试如果纯色没问题那一定是 alpha 相关的。5.4 重绘风暴为什么滑一下列表就掉帧这个组件放进一个可以横向滚动的卡片列表后DevTools的Performance面板上出现了明显的紫色长条。排查过程先看是不是shouldRepaint返回了true——确实是因为我当时图省事写了return true;理由是反正参数不多。但return true不是唯一原因。真正的问题在于我把_TwoOvalPainter的构造写在了build方法里每次重建都创建一个新实例即使shouldRepaint里面判断参数相等比较的是两个不同的对象。虽然我重写了... 其实我没重写。CustomPainter默认用引用相等新实例!旧实例shouldRepaint恒为true。两个修法叠加才彻底解决。第一shouldRepaint老老实实逐字段比较就像 4.4 里那段代码。第二把painter实例提到State里缓存或者用const构造——但const要求所有字段都是编译期常量颜色和比例是动态的就没办法了所以我还是走了字段比较这条路。另外还发现一个小问题LayoutBuilder在滚动时如果宽度约束频繁变化会触发高频重建。这里宽度其实是固定的卡片宽度由外层决定所以把LayoutBuilder换成直接读MediaQuery或者外层传固定宽度也能省掉一层重建。滚动列表里的绘制组件能少一层LayoutBuilder就少一层。5.5 深色模式下效果消失切到深色模式后小椭圆纯黑和深色背景融为一体看起来像是只剩下一个渐隐的紫色弧。这不是代码 bug是配色问题但很值得单独拎出来说因为它暴露了一个设计上的盲点黑色的对比度依赖背景。我的处理是给小椭圆的颜色也做成可配置并且在深色模式下用一个看起来是黑、实际是深灰的颜色final isDark Theme.of(context).brightness Brightness.dark; final ovalColor isDark ? const Color(0xFF1A1A1A) : const Color(0xFF000000);0xFF1A1A1A在浅色背景上看和纯黑几乎没差别但在深色背景上能拉开一层微妙的层次既不会消失也不会亮得刺眼。这种 8%~10% 亮度的深灰是深色模式里替代纯黑的通用解法值得记下来。顺带一提紫色本身在深色模式下也需要调整。0xFF7C4DFF在深色背景上会显得过于饱和、有点刺眼我在深色模式下换成了0xFF9E7BFF把亮度提上去一档。这个不需要写死在代码里走主题色的primary分支就行。6. 把这个效果封装成能复用的组件单独一个效果做完了如果它只在某个页面出现一次那到这儿就可以收工了。但我的实际场景里这个椭圆叠椭圆的构图在多个卡片、多个页面都出现了所以必须考虑封装。6.1 参数化设计哪些该暴露哪些该锁死我在 4.4 的代码里暴露了六个参数purple、blackOvalWidthScale、blackOvalHeightScale、blackOvalOffset、fadeStartRatio、aspectRatio。这个数量的选择是有依据的——每个参数都要对应一个真的会变的维度。purple会变因为不同模块用不同主色。比例和偏移会变因为视觉稿在多个场景下的构图有微调。aspectRatio会变因为同样的组件在横版和竖版卡片里比例不同。反过来我有几个东西是故意没暴露的。抗锯齿开关不暴露——没有理由关掉它。stops的终点值那个 0.98不暴露——它是纯粹的技术性补偿不是设计参数暴露出去只会让人困惑。小椭圆的补边描边宽度也不暴露同理。判断标准其实就一句话能让设计师提需求的参数才暴露只为修 bug 而存在的参数藏在内部。这条经验我在很多组件封装里用过效果都不错——暴露太多参数用的人反而不知道该填什么。6.2 加上呼吸动画的写法与控制这个效果最后被要求能缓慢地呼吸一下也就是大椭圆的渐隐范围来回微微变化。有了前面把fadeStartRatio参数化的基础加动画非常简单class _BreathingOval extends StatefulWidget { const _BreathingOval(); override State_BreathingOval createState() _BreathingOvalState(); } class _BreathingOvalState extends State_BreathingOval with SingleTickerProviderStateMixin { late final AnimationController _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 2600), )..repeat(reverse: true); late final Animationdouble _ratio Tweendouble( begin: 0.36, end: 0.52, ).animate(CurvedAnimation( parent: _controller, curve: Curves.easeInOut, )); override void dispose() { _controller.dispose(); super.dispose(); } override Widget build(BuildContext context) { return AnimatedBuilder( animation: _ratio, builder: (context, _) { return TwoOvalStack(fadeStartRatio: _ratio.value); }, ); } }几个实践上的注意点。第一Curves.easeInOut是必须的线性来回摆动看起来非常机械加了缓动之后才有呼吸的感觉。第二duration我给到 2600ms比直觉要慢——呼吸类动画一旦快于 2 秒就会显得焦躁这是我在多个项目里反复验证过的经验值。第三AnimatedBuilder只包裹TwoOvalStack不要包住整个build返回的树否则整页都要重建。还有一个隐藏的成本问题repeat(reverse: true)会以 60fps或 120fps持续触发重绘一个页面里有五六个这样的组件同时跑GPU 压力就不小了。我的处理是只在组件可见的时候跑动画用VisibilityDetector之类的可见性检测或者简单点在页面dispose时停掉。如果动画区域很小其实也可以退一步用TickerMode统一控制。6.3 截图导出与边界情况处理最后说一个后续需求带来的扩展这个组件要支持导出成图片做分享。CustomPaint在这块比Container方案省心——它本来就是一整块绘制结果用RepaintBoundary包一层拿RenderRepaintBoundary的toImage就能导出final boundary _key.currentContext!.findRenderObject() as RenderRepaintBoundary; final image await boundary.toImage(pixelRatio: 3.0); final bytes await image.toByteData(format: ImageByteFormat.png);pixelRatio: 3.0是为了在导出图片上保证清晰度但注意这会让内存占用按 9 倍增长尺寸大的话容易 OOM实际用 2.0 更稳妥。这里遇到一个边界情况toImage在某些时机调用会抛异常原因是RepaintBoundary还没完成首次绘制。我的处理是加一个try/catch并且延后一帧await Future.delayed(const Duration(milliseconds: 16));另外还有个容易忽略的点CustomPaint如果尺寸算出来是Size.zero比如被父级压成 0 宽paint里所有绘制都会退化成一个点。我在 4.4 的代码里加了if (size.isEmpty) return;就是防这个避免在极端约束下出现一些莫名其妙的异常。我自己在这个小效果上花了差不多一天回头看有半天是耗在渐变到透明那条灰边上——一开始根本没意识到Colors.transparent是黑色的只是觉得怎么这么脏反复调stops、换begin/end全都没用。后来把终点色换成purple.withValues(alpha: 0)一行搞定。所以现在凡是要做渐隐我的第一反应就是终点的 RGB 和起点保持一致这个习惯帮我省了很多次返工。另一个值得记的是shouldRepaint别偷懒return true在单页静态展示里看不出问题一进列表就原形毕露。