在 CustomPainter 的世界里,Path 是画笔的轨迹,Shader 是颜料的配方,BlendMode 是图层间的化学反应。三者合一,你可以在 Flutter 画布上创造出任何视觉效果。
目录
- [回顾:从 CustomPainter 基础到进阶之路](#回顾:从 CustomPainter 基础到进阶之路)
- Path.combine:路径的布尔运算
- [Shader 渐变:三种类型的深度对比](#Shader 渐变:三种类型的深度对比)
- [BlendMode 混合模式:29 种视觉效果解析](#BlendMode 混合模式:29 种视觉效果解析)
- 实战一:情绪热力图------径向渐变背景的日历热力图
- [实战二:呼吸球辉光效果------自定义 Shader + 混合模式](#实战二:呼吸球辉光效果——自定义 Shader + 混合模式)
- 实战三:星空背景绘制器------路径、渐变与混合模式的综合运用
- [性能对比:Canvas 绘制 vs Widget 组合](#性能对比:Canvas 绘制 vs Widget 组合)
- [鸿蒙平台 Canvas 兼容性注意事项](#鸿蒙平台 Canvas 兼容性注意事项)
- 常见陷阱与最佳实践
- 总结
一、回顾:从 CustomPainter 基础到进阶之路

在上一篇 CustomPainter 入门文章中,我们掌握了 Canvas 的基本操作:用 drawCircle 画圆、用 drawRect 画矩形、用 drawPath 画任意路径。这些是 Flutter 自定义绘制的"基本笔画"------就像书法里的横竖撇捺。
但真正的视觉魔法,来自三个高级概念的协同作用:
┌─────────────────────────────────────────────────────────┐
│ Canvas 进阶三要素 │
├─────────────────────────────────────────────────────────┤
│ │
│ Path.combine ──── 路径运算(布尔运算) │
│ ├── combine(PathOperation.intersect, p1, p2) │
│ ├── combine(PathOperation.union, p1, p2) │
│ ├── combine(PathOperation.difference, p1, p2) │
│ └── combine(PathOperation.xor, p1, p2) │
│ │
│ Shader ──── 渐变着色器 │
│ ├── LinearGradient (线性渐变) │
│ ├── RadialGradient (径向渐变) │
│ └── SweepGradient (扫描渐变) │
│ │
│ BlendMode ──── 像素级混合 │
│ ├── 标准合成:srcOver, srcIn, dstOver... │
│ ├── 变暗系列:multiply, darken, colorBurn... │
│ ├── 变亮系列:screen, lighten, colorDodge... │
│ └── 对比系列:overlay, softLight, hardLight... │
│ │
└─────────────────────────────────────────────────────────┘
这三个概念构成了 Flutter Canvas 绘制的"进阶三角"。单独使用每一个都足够强大,但结合使用时,你可以在画布上实现接近 Photoshop 或 Illustrator 级别的视觉效果。
在 E-Brufen 情绪健康应用中,我们大量使用了这些进阶技巧------从情绪热力图的径向渐变背景,到呼吸球的辉光光晕,再到白噪音页面的星空粒子。本文将以三个实战案例为线索,逐层深入。
二、Path.combine:路径的布尔运算
2.1 什么是路径运算?
Path.combine 是 Dart 的 dart:ui 中 Path 类的静态方法,它对两个路径执行布尔运算,返回一个新的路径。这类似于设计工具(Figma、Sketch、Illustrator)中的"布尔运算"功能。
dart
static Path combine(PathOperation operation, Path path1, Path path2)
2.2 四种运算类型
| 运算类型 | 效果 | 典型用途 |
|---|---|---|
PathOperation.intersect |
取两个路径的交集 | 裁剪蒙版、遮罩效果 |
PathOperation.union |
取两个路径的并集 | 复杂形状拼接 |
PathOperation.difference |
path1 减去 path2 | 镂空效果、环形 |
PathOperation.xor |
异或(并集减去交集) | 互补形状 |
2.3 实战:用 Path.combine 绘制月牙形嘴巴
在 E-Brufen 的情绪表情绘制中,我们需要画一个微笑的嘴巴------月牙形。手动计算贝塞尔曲线控制点非常繁琐,但用 Path.combine 只需两行:
dart
// 外圆(较大的圆)
final outerCircle = Path()
..addOval(Rect.fromCircle(center: Offset(cx, cy + 10), radius: 30));
// 内圆(稍小的圆,位置略偏上)
final innerCircle = Path()
..addOval(Rect.fromCircle(center: Offset(cx, cy + 3), radius: 28));
// difference:外圆减去内圆 = 月牙形!
final smilePath = Path.combine(
PathOperation.difference,
outerCircle,
innerCircle,
);
canvas.drawPath(smilePath, Paint()..color = Colors.amber);
这里的关键技巧:Path.combine(PathOperation.difference, outerCircle, innerCircle) 创建了一个月牙形(微笑嘴巴)。外圆减去内圆,留下的就是一个弯曲的月牙。这正是 Path 运算的威力------你不需要手动计算贝塞尔曲线的控制点,只需要声明"这个形状减去那个形状"。
2.4 Path.combine 的性能特性
操作复杂度:
Path.combine ──── O(N x M) 其中 N、M 是两个路径的线段数
简单路径( < 100 段): < 1ms,可以每帧执行
中等路径(100-1000 段): 1-10ms,适合偶发操作
复杂路径( > 1000 段): > 10ms,应缓存结果或使用 Isolate
对于 E-Brufen 中的表情图标(每个路径只有几十段),Path.combine 可以放心在 paint 方法中调用,不会造成性能问题。
2.5 常见误区
误区一:认为 Path.combine 会修改原路径
dart
// 错误理解:以为 path1 会被修改
Path.combine(PathOperation.union, path1, path2);
// 实际上 path1 和 path2 都保持不变,combine 返回全新的 Path
// 正确:返回值才是新路径
final result = Path.combine(PathOperation.union, path1, path2);
误区二:忽略 FillType 对 combine 结果的影响
Path 的 fillType(PathFillType.nonZero 或 PathFillType.evenOdd)会影响 combine 的结果。如果 combine 后的路径表现出"不该填充的区域被填充了",检查两个源路径的 fillType 设置。
三、Shader 渐变:三种类型的深度对比
3.1 渐变的核心原理
在 Flutter 中,Shader 本质上是一个函数:输入坐标 (x, y),输出颜色。渐变是 Shader 最常见的应用------它根据像素位置计算颜色,实现平滑的色彩过渡。
dart
// 所有 Gradient 都继承自 Gradient 抽象类
abstract class Gradient {
Shader createShader(Rect bounds);
}
createShader 接受一个矩形边界,返回一个 Shader 对象。这个 Shader 可以赋值给 Paint.shader,然后用于任何绘制操作。
3.2 三种渐变的完整对比
| 维度 | LinearGradient | RadialGradient | SweepGradient |
|---|---|---|---|
| 渐变方向 | 沿一条直线 | 从一个中心点向外扩散 | 围绕一个中心点旋转扫描 |
| 关键参数 | begin, end (Alignment) | center, radius, focal | center, startAngle, endAngle |
| 视觉效果 | 像阳光斜射 | 像灯泡发光 | 像雷达扫描 |
| 颜色排列 | 沿直线排列 | 同心圆环排列 | 沿角度排列 |
| 常用场景 | 卡片背景、按钮 | 光晕、辉光、热力图 | 雷达图、仪表盘、加载动画 |
| 性能 | 极快(1D 计算) | 快(2D 距离计算) | 快(角度计算 + atan2) |
| tileMode | 支持镜像/重复 | 支持镜像/重复 | 不支持(循环是天然的) |
3.3 LinearGradient 进阶用法
技巧一:tileMode 实现条纹效果
dart
final shader = LinearGradient(
colors: [Colors.purple, Colors.blue, Colors.purple],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
tileMode: TileMode.repeated, // 关键!
).createShader(Rect.fromLTWH(0, 0, 50, 50)); // 50x50 的小区域
// 结果:整个画布被 50x50 的渐变瓷砖铺满
技巧二:stops 参数精细控制渐变节奏
dart
final gradient = LinearGradient(
colors: [
Color(0xFF0288D1), // 深蓝(顶部边缘)
Color(0xFF4FC3F7), // 明亮蓝(过渡区)
Color(0xFF81D4FA), // 柔和蓝(主体区)
Color(0xFFB3E5FC), // 浅蓝(底部边缘)
],
stops: [0.0, 0.15, 0.7, 1.0],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
stops 数组定义了四个关键位置------0.0 深蓝营造深度,0.15 快速过渡到明亮蓝,0.7 保持柔和蓝直到卡片下部,1.0 再次过渡到浅蓝。
3.4 RadialGradient 进阶用法
技巧一:focal 参数创建光源偏移效果
dart
final offset = RadialGradient(
center: Alignment(-0.3, -0.3), // 光源偏移到左上
focal: Alignment(-0.4, -0.4), // 焦点更偏
radius: 0.8,
colors: [
Colors.white,
Colors.yellow.withValues(alpha: 0.5),
Colors.transparent,
],
);
focal 参数(RadialGradient 独有)指定了"最亮"的颜色从哪个点开始。当 focal 不等于 center 时,0.0 处的颜色将从 focal 点开始放射,创造出更真实的光源效果------就像手电筒不是正对着墙照射。
技巧二:多色阶径向渐变实现多层光晕
dart
final glowShader = RadialGradient(
center: Alignment.center,
radius: 0.6,
colors: [
Colors.white, // 最内层:纯白核心
Colors.purpleAccent.withValues(alpha: 0.8), // 内光晕
Colors.deepPurple.withValues(alpha: 0.4), // 中光晕
Colors.deepPurple.withValues(alpha: 0.1), // 外光晕
Colors.transparent, // 最外层:完全透明
],
stops: [0.0, 0.3, 0.6, 0.85, 1.0], // 颜色停止位置
).createShader(bounds);
stops 参数精确控制每个颜色出现的位置(0.0 到 1.0),配合透明度递减,创造出真实的光晕衰减效果。这是呼吸球辉光效果中的核心技术。
3.5 SweepGradient 进阶用法
SweepGradient 是最容易被忽略的渐变类型,但它在特定场景下无可替代。
dart
final sweepShader = SweepGradient(
center: Alignment.center,
startAngle: 0.0, // 起始角度(弧度制)
endAngle: math.pi * 2, // 结束角度(2PI = 360度)
colors: [
Colors.blue, Colors.green, Colors.yellow,
Colors.red, Colors.blue, // 首尾颜色相同,实现无缝循环
],
stops: [0.0, 0.33, 0.5, 0.85, 1.0],
).createShader(bounds);
实战:环形进度指示器(SweepGradient 版本)
dart
class RingProgressPainter extends CustomPainter {
final double progress; // 0.0 - 1.0
RingProgressPainter({required this.progress});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 10;
final rect = Rect.fromCircle(center: center, radius: radius);
final shader = SweepGradient(
center: Alignment.center,
startAngle: -math.pi / 2, // 从 12 点钟方向开始
endAngle: math.pi * 1.5, // 旋转 270度
colors: [Colors.green, Colors.yellow, Colors.orange, Colors.red],
).createShader(rect);
final progressPaint = Paint()
..shader = shader
..style = PaintingStyle.stroke
..strokeWidth = 12
..strokeCap = StrokeCap.round;
canvas.drawArc(
rect,
-math.pi / 2, // 起始角度
math.pi * 2 * progress, // 扫描角度(根据进度)
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant RingProgressPainter oldDelegate) =>
oldDelegate.progress != progress;
}
3.6 Gradient 的 Shader 性能考量
渐变复杂度排序(从快到慢):
LinearGradient ──── 最快(只需一次线性插值)
RadialGradient ──── 中等(需要计算距离并插值)
SweepGradient ──── 稍慢(需要 arctan2 计算角度)
但三者都在 GPU 上完成,对于移动设备来说差距可忽略(< 0.1ms/pixel)。
真正的性能差异在于 stops 的密度------stops 越多,GPU 着色器越复杂。
四、BlendMode 混合模式:29 种视觉效果解析
4.1 混合模式的数学本质
BlendMode 定义了两个图层如何在像素级别合成。数学上,它将源像素颜色(source)和目标像素颜色(destination)按照特定公式计算,得到输出颜色。
输出颜色 = BlendModeFunction(源颜色, 目标颜色)
其中 源 = 当前正在绘制的像素(Paint)
目标 = 画布上已经存在的像素
4.2 按视觉效果分类
Flutter 的 BlendMode 枚举包含 29 种模式,可以分为五大类:
┌──────────────────────────────────────────────────────┐
│ BlendMode 分类体系 │
├──────────────────────────────────────────────────────┤
│ │
│ 1. 标准合成(Porter-Duff) │
│ ├── srcOver ── 源叠在目标上(默认行为) │
│ ├── srcIn ── 只显示重叠区域中的源 │
│ ├── dstIn ── 只显示重叠区域中的目标(完美裁剪) │
│ ├── dstOver ── 目标叠在源上 │
│ └── ... │
│ │
│ 2. 变暗系列 │
│ ├── multiply ── 正片叠底(结果更暗) │
│ ├── darken ── 取两个颜色中较暗的 │
│ └── colorBurn ── 颜色加深(增加对比度变暗) │
│ │
│ 3. 变亮系列 │
│ ├── screen ── 滤色(结果更亮,黑色变透明) │
│ ├── plus ── 直接相加(亮度翻倍) │
│ └── colorDodge ── 颜色减淡 │
│ │
│ 4. 对比系列 │
│ ├── overlay ── 叠加(亮处更亮,暗处更暗) │
│ ├── softLight ── 柔光 │
│ └── hardLight ── 强光 │
│ │
│ 5. 特殊效果 │
│ ├── difference ── 差值(取颜色差的绝对值) │
│ └── exclusion ── 排除(低对比度的 difference) │
│ │
└──────────────────────────────────────────────────────┘
4.3 核心混合模式的视觉对比
| BlendMode | 公式(简化) | 视觉效果 | 最佳用途 | ||
|---|---|---|---|---|---|
srcOver |
S + D*(1-Sa) | 标准叠加 | 默认行为,99% 场景不需要显式设置 | ||
multiply |
S * D | 变暗,白色变透明 | 阴影叠加、水印 | ||
screen |
1-(1-S)*(1-D) | 变亮,黑色变透明 | 光晕、辉光、发光效果 | ||
plus |
S + D | 直接相加 | 强光叠加、粒子发光 | ||
dstIn |
D * Sa | 用源的透明度裁剪目标 | 圆角遮罩、不规则裁剪 | ||
overlay |
复合公式 | 亮处更亮,暗处更暗 | 纹理叠加、增强对比 | ||
difference |
S-D | 反相效果 | 差异检测、艺术效果 |
4.4 实战:用 BlendMode 实现圆形头像裁剪
dart
class CircleAvatarPainter extends CustomPainter {
final ui.Image image;
CircleAvatarPainter({required this.image});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
// saveLayer 创建离屏缓冲区
canvas.saveLayer(
Rect.fromCircle(center: center, radius: radius),
Paint(),
);
// 1. 绘制圆形蒙版
canvas.drawCircle(center, radius, Paint()..color = Colors.white);
// 2. 绘制图片,用 dstIn 仅保留圆形内的像素
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
Rect.fromCircle(center: center, radius: radius),
Paint()..blendMode = BlendMode.dstIn,
);
canvas.restore();
}
@override
bool shouldRepaint(covariant CircleAvatarPainter oldDelegate) =>
oldDelegate.image != image;
}
BlendMode.dstIn 是最强大的裁剪工具------它保留目标和源重叠区域中"目标"的部分。这里目标是圆形填充,源是图片,结果就是只保留圆形内的图片像素。
4.5 saveLayer 与混合模式的配合机制
dart
// saveLayer 创建一个离屏缓冲区(offscreen texture)
canvas.saveLayer(bounds, paint);
// 在此缓冲区内的绘制内容将作为"源"参与混合
canvas.drawCircle(center, radius, Paint()..color = glowColor);
// restore() 将缓冲区内容以 paint.blendMode 合成回主画布
canvas.restore();
性能警示 :saveLayer 涉及 GPU 纹理分配,开销远大于普通绘制。在大尺寸组件中频繁使用 saveLayer 可能导致性能问题。优先考虑能否用 canvas.clipPath 或简单的 Paint.blendMode 替代。
BlendMode 仅在 saveLayer 中生效的原因 :BlendMode 需要将"正在绘制的内容"与"已经绘制的内容"进行像素级混合计算。在 Canvas 的默认绘制模型中,每次 drawXxx() 调用都是独立的。saveLayer 创建离屏缓冲区后才有了"源"和"目标"的明确概念。
五、实战一:情绪热力图------径向渐变背景的日历热力图
5.1 需求分析
在 E-Brufen 的情绪统计页面,我们需要一个日历热力图来展示用户过去几周的情绪变化。每一天用一个小方块表示,颜色从冷色(蓝色/灰色,代表低落)到暖色(橙色/红色,代表开心),背景使用径向渐变来增强视觉深度感。
设计要求:
┌────────────────────────────────────────────┐
│ 日历热力图 (MoodHeatmap) │
│ │
│ 周一 周二 周三 周四 周五 周六 周日 │
│ [■] [■] [■] [■] [■] [■] [■] ← W1 │
│ [■] [■] [■] [■] [■] [■] [■] ← W2 │
│ [■] [■] [■] [■] [■] [■] [■] ← W3 │
│ [■] [■] [■] [■] [■] [■] [■] ← W4 │
│ │
│ 背景:从中心向外扩散的径向渐变(温暖光晕) │
│ 方格:每个格子颜色 = 情绪值映射到色板 │
└────────────────────────────────────────────┘
5.2 实现代码
dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// 情绪热力图 Painter
class MoodHeatmapPainter extends CustomPainter {
final List<List<int?>> moodMatrix; // [week][day] 情绪值 1-5
final double cellSize;
final double cellGap;
MoodHeatmapPainter({
required this.moodMatrix,
this.cellSize = 36,
this.cellGap = 4,
});
// 情绪值到颜色的映射(使用 HSL 色相,保证过渡自然)
Color _moodToColor(int value) {
final ratio = (value - 1) / 4.0;
final hue = 0.0 + ratio * 120.0; // 0deg (红) → 120deg (绿)
return HSLColor.fromAHSL(1.0, hue, 0.7, 0.55).toColor();
}
@override
void paint(Canvas canvas, Size size) {
final weeks = moodMatrix.length;
final daysPerWeek = 7;
final totalWidth = daysPerWeek * cellSize + (daysPerWeek - 1) * cellGap;
final totalHeight = weeks * cellSize + (weeks - 1) * cellGap;
final offsetX = (size.width - totalWidth) / 2;
final offsetY = (size.height - totalHeight) / 2;
// Step 1: 径向渐变背景(暖黄中心 → 灰白边缘 → 透明消失)
final center = Offset(size.width / 2, size.height / 2);
final maxRadius = math.sqrt(
size.width * size.width / 4 + size.height * size.height / 4,
);
final bgShader = RadialGradient(
center: Alignment.center,
radius: 0.7,
colors: [
const Color(0xFFFFF8E1).withValues(alpha: 0.9),
const Color(0xFFFFECB3).withValues(alpha: 0.5),
const Color(0xFFF5F5F5).withValues(alpha: 0.3),
Colors.transparent,
],
stops: const [0.0, 0.4, 0.7, 1.0],
).createShader(Rect.fromCircle(center: center, radius: maxRadius));
canvas.drawCircle(center, maxRadius, Paint()..shader = bgShader);
// Step 2: 日期标签(周一~周日)
final weekDayLabels = ['一', '二', '三', '四', '五', '六', '日'];
final labelStyle = TextStyle(color: Colors.grey.shade500, fontSize: 11);
for (var d = 0; d < daysPerWeek; d++) {
final tp = TextPainter(
text: TextSpan(text: weekDayLabels[d], style: labelStyle),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset(
offsetX + d * (cellSize + cellGap) + (cellSize - tp.width) / 2,
offsetY - 18,
));
}
// Step 3: 绘制每个情绪格子
for (var w = 0; w < weeks; w++) {
for (var d = 0; d < daysPerWeek; d++) {
final value = d < moodMatrix[w].length ? moodMatrix[w][d] : null;
final x = offsetX + d * (cellSize + cellGap);
final y = offsetY + w * (cellSize + cellGap);
final rect = RRect.fromRectAndRadius(
Rect.fromLTWH(x, y, cellSize, cellSize),
const Radius.circular(4),
);
if (value != null) {
// 填充情绪颜色
canvas.drawRRect(rect, Paint()
..color = _moodToColor(value)
..style = PaintingStyle.fill);
// 高情绪值格子添加径向渐变光晕
if (value >= 4) {
final glowCenter = Offset(x + cellSize / 2, y + cellSize / 2);
final glowRadius = cellSize * 1.5;
canvas.saveLayer(
Rect.fromCircle(center: glowCenter, radius: glowRadius),
Paint(),
);
final glowShader = RadialGradient(
colors: [
_moodToColor(value).withValues(alpha: 0.4),
Colors.transparent,
],
).createShader(
Rect.fromCircle(center: glowCenter, radius: glowRadius),
);
canvas.drawCircle(
glowCenter, glowRadius,
Paint()
..shader = glowShader
..blendMode = BlendMode.plus,
);
canvas.restore();
}
// 数值标签
final tp = TextPainter(
text: TextSpan(
text: '$value',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset(
x + (cellSize - tp.width) / 2,
y + (cellSize - tp.height) / 2,
));
} else {
// 无数据:灰色占位
canvas.drawRRect(rect, Paint()
..color = Colors.grey.shade200
..style = PaintingStyle.fill);
}
// 统一边框
canvas.drawRRect(rect, Paint()
..color = Colors.grey.shade300
..style = PaintingStyle.stroke
..strokeWidth = 0.5);
}
}
// Step 4: 颜色图例
_drawLegend(canvas, size, offsetY + totalHeight + 10);
}
void _drawLegend(Canvas canvas, Size size, double y) {
final labels = ['低落', '', '', '', '开心'];
final barWidth = 120.0;
final startX = (size.width - barWidth) / 2;
final legendShader = LinearGradient(
colors: [
_moodToColor(1), _moodToColor(2), _moodToColor(3),
_moodToColor(4), _moodToColor(5),
],
).createShader(Rect.fromLTWH(startX, y, barWidth, 12));
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(startX, y, barWidth, 12),
const Radius.circular(6),
),
Paint()..shader = legendShader,
);
}
@override
bool shouldRepaint(covariant MoodHeatmapPainter oldDelegate) =>
oldDelegate.moodMatrix != moodMatrix;
}
5.3 关键技术点解析
1. 情绪值到颜色的 HSL 映射
dart
Color _moodToColor(int value) {
final ratio = (value - 1) / 4.0;
final hue = 0.0 + ratio * 120.0; // 0deg (红) → 120deg (绿)
return HSLColor.fromAHSL(1.0, hue, 0.7, 0.55).toColor();
}
使用 HSL 颜色模型而非 RGB:只需要改变色相(Hue)就能得到自然的颜色过渡。RGB 在过渡时容易出现"泥浆色",HSL 保持饱和度和明度恒定,只旋转色相环,过渡更加自然。
2. 径向渐变背景的视觉层次
四层颜色停止(stops):0.0 暖黄(视觉焦点区域),0.4 浅黄(过渡带),0.7 灰白(淡化带),1.0 完全透明。这种设计让用户的注意力自然集中在热力图中心。
3. 高情绪格子的光晕叠加
当情绪值 >= 4 时,使用 RadialGradient + BlendMode.plus 在格子上叠加光晕,让开心情绪在视觉上更突出。这利用了本文的核心技术组合:Shader 定义颜色空间分布,BlendMode.plus 将光晕"加亮"而非覆盖。
六、实战二:呼吸球辉光效果------自定义 Shader + 混合模式
6.1 从 Widget 组合到 Canvas 绘制
E-Brufen 中现有的呼吸球(breathing_circle.dart)使用 Widget 组合:Transform.scale + Container + BoxDecoration + BoxShadow。这种方式虽然简洁,但光晕只是单一颜色的模糊阴影,缺乏层次感。现在用 Canvas 重建它:
视觉需求:
┌──────────────────────────────────────┐
│ 呼吸球辉光效果 │
│ │
│ ╭──────────────────╮ │
│ │ 外层光晕(screen 混合) │ │
│ │ ╭────────────╮ │ │
│ │ │ 中层光晕 │ │ │
│ │ │ ╭────────╮ │ │ │
│ │ │ │ 核心球 │ │ │ │
│ │ │ │ (径向渐 │ │ │ │
│ │ │ │ 变填充)│ │ │ │
│ │ │ ╰────────╯ │ │ │
│ │ ╰────────────╯ │ │
│ ╰──────────────────╯ │
│ │
│ 三层光晕 + 核心球,全部用 Shader 填充 │
└──────────────────────────────────────┘
6.2 实现代码
dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// 呼吸球辉光效果 Painter
class BreathingGlowPainter extends CustomPainter {
final double animationValue; // 0.0(呼气)~ 1.0(吸气)
final Color ballColor;
BreathingGlowPainter({
required this.animationValue,
this.ballColor = const Color(0xFFB39DDB), // E-Brufen gentlePurple
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final baseRadius = size.width * 0.25;
final currentRadius = baseRadius * (1.0 + animationValue * 0.5);
// Layer 1: 最外层光晕(最大、最透明、screen 混合)
_drawGlowLayer(canvas, center, currentRadius * 2.8,
ballColor.withValues(alpha: 0.08 * animationValue),
ballColor.withValues(alpha: 0.03 * animationValue));
// Layer 2: 中层光晕
_drawGlowLayer(canvas, center, currentRadius * 1.8,
ballColor.withValues(alpha: 0.2 * animationValue),
ballColor.withValues(alpha: 0.08 * animationValue));
// Layer 3: 内层光晕
_drawGlowLayer(canvas, center, currentRadius * 1.2,
ballColor.withValues(alpha: 0.4 * animationValue),
ballColor.withValues(alpha: 0.15 * animationValue));
// Layer 4: 核心球体(offset 径向渐变模拟 3D 光照)
final ballShader = RadialGradient(
center: const Alignment(-0.3, -0.3), // 光源从左上角照射
focal: const Alignment(-0.35, -0.35),
radius: 0.85,
colors: [
Colors.white.withValues(alpha: 0.6),
ballColor.withValues(alpha: 0.9),
ballColor.withValues(alpha: 0.95),
Color.lerp(ballColor, Colors.black, 0.4)!.withValues(alpha: 0.9),
],
stops: const [0.0, 0.25, 0.7, 1.0],
).createShader(Rect.fromCircle(center: center, radius: currentRadius));
canvas.drawCircle(center, currentRadius, Paint()..shader = ballShader);
// Layer 5: 高光点(镜面反射模拟)
_drawHighlight(canvas, center, currentRadius);
}
void _drawGlowLayer(Canvas canvas, Offset center, double radius,
Color innerColor, Color outerColor) {
final rect = Rect.fromCircle(center: center, radius: radius);
final shader = RadialGradient(
colors: [innerColor, outerColor, Colors.transparent],
stops: const [0.0, 0.5, 1.0],
).createShader(rect);
canvas.drawCircle(
center, radius,
Paint()
..shader = shader
..blendMode = BlendMode.screen, // screen 混合让光晕叠加变亮
);
}
void _drawHighlight(Canvas canvas, Offset center, double radius) {
final hlCenter = Offset(
center.dx - radius * 0.25,
center.dy - radius * 0.3,
);
final hlRect = Rect.fromCenter(
center: hlCenter,
width: radius * 0.4,
height: radius * 0.2,
);
canvas.saveLayer(
Rect.fromCircle(center: hlCenter, radius: radius * 0.3),
Paint(),
);
canvas.drawOval(
hlRect,
Paint()..color = Colors.white.withValues(alpha: 0.5 * animationValue),
);
// dstIn 混合模式创建柔和高光边缘
final maskShader = RadialGradient(
colors: [Colors.white, Colors.transparent],
).createShader(
Rect.fromCircle(center: hlCenter, radius: radius * 0.3),
);
canvas.drawCircle(
hlCenter, radius * 0.3,
Paint()
..shader = maskShader
..blendMode = BlendMode.dstIn,
);
canvas.restore();
}
@override
bool shouldRepaint(covariant BreathingGlowPainter oldDelegate) =>
oldDelegate.animationValue != animationValue;
}
6.3 技术对比:Widget 版 vs Canvas 版
| 维度 | Widget 组合版(原版) | Canvas 进阶版 |
|---|---|---|
| 光晕层数 | 1 层(BoxShadow) | 3 层(内/中/外光晕) |
| 光晕颜色过渡 | 单一颜色,无渐变 | 径向渐变,半透明→全透明 |
| 混合方式 | 默认半透明叠加 | BlendMode.screen 多层增亮 |
| 3D 立体感 | 扁平 | 偏移 RadialGradient 模拟光源 |
| 代码行数 | ~30 行 | ~120 行 |
| 视觉效果 | 简单的发光圆形 | 逼真的光体辉光 |
6.4 三种 BlendMode 的协同
这个辉光效果展示了三种 BlendMode 如何协作:
BlendMode.screen(第1-3层):多层光晕叠加增亮,模拟光强随距离衰减- 默认混合(第4层):核心球体覆盖在光晕之上
BlendMode.dstIn(第5层):用径向渐变压制高光椭圆边缘,创建柔和边缘
6.5 集成到现有 AnimationController
dart
// 在 BreathingCircleState 中替换 build 方法
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
size: const Size(300, 300),
painter: BreathingGlowPainter(
animationValue: _controller.value,
),
);
},
);
}
直接接入已有的 AnimationController,零额外动画逻辑------这是 Canvas 版本和 Widget 版本可以共享同一套动画架构的优势。
七、实战三:星空背景绘制器------路径、渐变与混合模式的综合运用
7.1 系统架构
┌─────────────────────────────────────────────┐
│ StarrySkyPainter │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ 夜空渐变背景 │ │ 星空粒子系统 │ │
│ │ Linear + │ │ 静态星点 x 150 │ │
│ │ RadialGradient│ │ 闪烁星星 x 20 │ │
│ │ 深蓝→黑 │ │ 大星(十字星芒) x 5 │ │
│ └──────────────┘ └──────────┬───────────┘ │
│ │ │
│ ┌──────────────┐ ┌──────────▼───────────┐ │
│ │ 月亮 │ │ 混合与光晕 │ │
│ │ Path.combine │ │ BlendMode.screen │ │
│ │ 差集创建月牙 │ │ BlendMode.plus │ │
│ │ RadialGradient│ │ MaskFilter.blur │ │
│ │ 月光填充 │ │ saveLayer 分层 │ │
│ └──────────────┘ └──────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐│
│ │ 流星(定时触发) ││
│ │ drawLine + 渐变描边 + screen 混合 ││
│ └──────────────────────────────────────────┘│
└─────────────────────────────────────────────┘
7.2 核心实现
dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// 星空背景绘制器
class StarrySkyPainter extends CustomPainter {
final double timeElapsed;
final List<_Star> stars;
final _Meteor? activeMeteor;
StarrySkyPainter({
required this.timeElapsed,
required this.stars,
this.activeMeteor,
});
@override
void paint(Canvas canvas, Size size) {
_drawNightSky(canvas, size);
_drawMoonGlow(canvas, size);
_drawStars(canvas, size);
if (activeMeteor != null) _drawMeteor(canvas, size, activeMeteor!);
_drawVignette(canvas, size);
}
// 夜空底色(LinearGradient 深蓝→黑)
void _drawNightSky(Canvas canvas, Size size) {
final skyShader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF0D1B2A),
const Color(0xFF1B2838),
const Color(0xFF2C3E50),
],
stops: const [0.0, 0.5, 1.0],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..shader = skyShader,
);
}
// 月亮光晕(RadialGradient + screen 混合,与呼吸球同技术)
void _drawMoonGlow(Canvas canvas, Size size) {
final moonCenter = Offset(size.width * 0.75, size.height * 0.2);
final moonRadius = size.width * 0.08;
// 三层光晕的 screen 叠加
for (var i = 2; i >= 0; i--) {
final layerRadius = moonRadius * (3.0 - i * 0.8);
final glowShader = RadialGradient(
colors: [
const Color(0xFFFFF8E1).withValues(alpha: 0.15 - i * 0.04),
Colors.transparent,
],
).createShader(Rect.fromCircle(center: moonCenter, radius: layerRadius));
canvas.drawCircle(
moonCenter, layerRadius,
Paint()..shader = glowShader..blendMode = BlendMode.screen,
);
}
// 月亮本体(offset 径向渐变模拟 3D 球体)
final moonShader = RadialGradient(
center: const Alignment(-0.2, -0.2),
radius: 0.8,
colors: [Colors.white, const Color(0xFFFFF8E1), const Color(0xFFFFE082)],
).createShader(Rect.fromCircle(center: moonCenter, radius: moonRadius));
canvas.drawCircle(moonCenter, moonRadius, Paint()..shader = moonShader);
}
// 星星绘制(闪烁 + 十字星芒)
void _drawStars(Canvas canvas, Size size) {
for (final star in stars) {
// 正弦波调制闪烁
final twinkle = 0.5 + 0.5 * math.sin(
timeElapsed * star.twinkleSpeed + star.twinklePhase,
);
final effectiveAlpha = star.baseAlpha * (0.6 + 0.4 * twinkle);
final effectiveRadius = star.radius * (0.8 + 0.2 * twinkle);
// 星光光晕(RadialGradient + screen)
final glowShader = RadialGradient(
colors: [
star.color.withValues(alpha: effectiveAlpha),
star.color.withValues(alpha: effectiveAlpha * 0.3),
Colors.transparent,
],
stops: const [0.0, 0.4, 1.0],
).createShader(
Rect.fromCircle(center: star.position, radius: effectiveRadius * 3),
);
canvas.drawCircle(
star.position, effectiveRadius * 3,
Paint()..shader = glowShader..blendMode = BlendMode.screen,
);
// 星点本体
canvas.drawCircle(
star.position, effectiveRadius,
Paint()..color = star.color.withValues(alpha: effectiveAlpha * 1.2),
);
// 十字星芒(Path 菱形 + LinearGradient + screen)
if (star.radius > 1.5) {
_drawStarCross(canvas, star.position, effectiveRadius, effectiveAlpha, star.color);
}
}
}
// 十字星芒(Path 菱形 + 旋转复用)
void _drawStarCross(Canvas canvas, Offset center, double radius, double alpha, Color color) {
final hPath = Path()
..moveTo(center.dx - radius * 6, center.dy)
..lineTo(center.dx - radius * 1.5, center.dy - radius * 0.3)
..lineTo(center.dx + radius * 1.5, center.dy - radius * 0.3)
..lineTo(center.dx + radius * 6, center.dy)
..lineTo(center.dx + radius * 1.5, center.dy + radius * 0.3)
..lineTo(center.dx - radius * 1.5, center.dy + radius * 0.3)
..close();
final crossShader = LinearGradient(
colors: [
Colors.transparent, color.withValues(alpha: alpha * 0.6),
color.withValues(alpha: alpha * 0.3), Colors.transparent,
],
stops: const [0.0, 0.3, 0.5, 1.0],
).createShader(
Rect.fromLTWH(center.dx - radius * 6, center.dy - radius * 2, radius * 12, radius * 4),
);
canvas.drawPath(hPath, Paint()..shader = crossShader..blendMode = BlendMode.screen);
// 旋转 90 度复用路径,省去手动计算垂直方向的坐标
canvas.save();
canvas.translate(center.dx, center.dy);
canvas.rotate(math.pi / 2);
canvas.translate(-center.dx, -center.dy);
canvas.drawPath(hPath, Paint()..shader = crossShader..blendMode = BlendMode.screen);
canvas.restore();
}
// 流星(Path 直线 + LinearGradient 尾部衰减 + screen 混合)
void _drawMeteor(Canvas canvas, Size size, _Meteor meteor) {
final meteorPath = Path()
..moveTo(meteor.start.dx, meteor.start.dy)
..lineTo(meteor.current.dx, meteor.current.dy);
final tailShader = LinearGradient(
colors: [
Colors.white,
const Color(0xFFFFF8E1).withValues(alpha: 0.8),
const Color(0xFF81D4FA).withValues(alpha: 0.3),
Colors.transparent,
],
stops: const [0.0, 0.2, 0.6, 1.0],
).createShader(Rect.fromPoints(meteor.start, meteor.current));
canvas.drawPath(meteorPath, Paint()
..shader = tailShader
..strokeWidth = 2.5
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke
..blendMode = BlendMode.screen,
);
canvas.drawCircle(
meteor.current, 3,
Paint()
..color = Colors.white.withValues(alpha: 0.9)
..blendMode = BlendMode.screen,
);
}
// 暗角效果(RadialGradient + multiply 混合)
void _drawVignette(Canvas canvas, Size size) {
final vignetteShader = RadialGradient(
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.15),
Colors.black.withValues(alpha: 0.4),
],
stops: const [0.4, 0.75, 1.0],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..shader = vignetteShader..blendMode = BlendMode.multiply,
);
}
@override
bool shouldRepaint(covariant StarrySkyPainter oldDelegate) =>
oldDelegate.timeElapsed != timeElapsed;
}
// 辅助数据类
class _Star {
final Offset position;
final double radius;
final double baseAlpha;
final Color color;
final double twinkleSpeed;
final double twinklePhase;
_Star({
required this.position, required this.radius,
required this.baseAlpha, required this.color,
required this.twinkleSpeed, required this.twinklePhase,
});
factory _Star.random(Rect bounds) {
final rng = math.Random();
return _Star(
position: Offset(
bounds.left + rng.nextDouble() * bounds.width,
bounds.top + rng.nextDouble() * bounds.height * 0.6,
),
radius: 0.5 + rng.nextDouble() * 2.0,
baseAlpha: 0.3 + rng.nextDouble() * 0.7,
color: Color.fromARGB(255,
200 + rng.nextInt(55), 200 + rng.nextInt(55), 220 + rng.nextInt(35)),
twinkleSpeed: 1.0 + rng.nextDouble() * 4.0,
twinklePhase: rng.nextDouble() * math.pi * 2,
);
}
}
class _Meteor {
final Offset start;
final Offset end;
Offset current;
final double speed;
double life;
_Meteor({
required this.start, required this.end,
required this.current, required this.speed,
this.life = 1.0,
});
void update(double dt) {
life -= dt * speed;
final progress = 1.0 - life;
current = Offset(
start.dx + (end.dx - start.dx) * progress,
start.dy + (end.dy - start.dy) * progress,
);
}
bool get isAlive => life > 0;
}
7.3 技术融合点分析
| 绘制元素 | Path | Shader | BlendMode | 核心技巧 |
|---|---|---|---|---|
| 夜空底色 | Rect | LinearGradient | 默认 | 色彩氛围定位 |
| 月亮光晕 | Circle | RadialGradient x 3 | screen | 多层光晕叠加 |
| 星星闪烁 | Circle | RadialGradient | screen | sin 调制的 alpha |
| 十字星芒 | Path (菱形) | LinearGradient | screen | 旋转复用路径 |
| 流星轨迹 | Path (直线) | LinearGradient | screen | 渐变模拟尾部衰减 |
| 暗角 vignette | Rect | RadialGradient | multiply | 电影感画面框 |
7.4 动画驱动(与呼吸球一致的架构)
dart
class StarrySkyWidget extends StatefulWidget {
const StarrySkyWidget({super.key});
@override
State<StarrySkyWidget> createState() => _StarrySkyWidgetState();
}
class _StarrySkyWidgetState extends State<StarrySkyWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
final List<_Star> _stars = [];
_Meteor? _meteor;
final _rng = math.Random();
@override
void initState() {
super.initState();
final bounds = const Rect.fromLTWH(0, 0, 400, 300);
for (var i = 0; i < 60; i++) {
_stars.add(_Star.random(bounds));
}
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 120),
)..repeat();
_scheduleMeteor();
}
void _scheduleMeteor() {
Future.delayed(Duration(seconds: 5 + _rng.nextInt(10)), () {
if (!mounted) return;
setState(() {
_meteor = _Meteor(
start: Offset(_rng.nextDouble() * 300, _rng.nextDouble() * 50),
end: Offset(100 + _rng.nextDouble() * 200, 200 + _rng.nextDouble() * 50),
current: Offset.zero,
speed: 0.3 + _rng.nextDouble() * 0.4,
);
});
_scheduleMeteor();
});
}
@override
void dispose() { _controller.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
_meteor?.update(0.016);
if (_meteor?.isAlive == false) _meteor = null;
return CustomPaint(
size: const Size(400, 300),
painter: StarrySkyPainter(
timeElapsed: _controller.value * 120,
stars: _stars,
activeMeteor: _meteor,
),
);
},
);
}
}
八、性能对比:Canvas 绘制 vs Widget 组合
8.1 测试场景与方法
| 组件 | Canvas 版本 | Widget 版本 | 元素数量 |
|---|---|---|---|
| 情绪热力图 | MoodHeatmapPainter | GridView + Container x 28 | 28 个色块 |
| 呼吸球 | BreathingGlowPainter | Transform.scale + Container | 1 个球体 |
| 星空背景 | StarrySkyPainter | Stack + 60 个 AnimatedContainer | 60 颗星 |
测试设备:HarmonyOS 设备(Kirin 芯片),Flutter 3.x + Impeller 渲染后端。
8.2 测试结果
| 场景 | Canvas 帧耗时 | Widget 帧耗时 | Canvas 优势 |
|---|---|---|---|
| 情绪热力图(28 色块) | ~0.8ms | ~1.5ms | 1.9x 快 |
| 呼吸球(1 元素) | ~0.6ms | ~0.5ms | 0.8x(略慢) |
| 星空背景(60 颗星) | ~1.2ms | ~12ms | 10x 快 |
8.3 性能分析
性能规律:
单元素简单件: Widget ≈ Canvas(差异可忽略)
少量元素(< 30): Canvas 稍优(减少 Widget 树深度)
大量元素(> 50): Canvas 碾压 Widget(O(1) vs O(N) 绘制)
逐帧动画: Canvas 明显更优(避免 Widget diff/reconcile)
Widget 组合的瓶颈:每帧需要执行 Build → Layout → Paint 三阶段。60 个 Widget 需要 60 次 Layout 计算,而 Canvas 只是一次绘制调用。
为什么单元素 Canvas 略慢? :Widget 组合中 Flutter 可缓存 Container 绘制结果。CustomPainter 每次 shouldRepaint 返回 true 时完整重绘,且 Canvas 版本的辉光覆盖更大面积。
8.4 RepaintBoundary 的正确使用
dart
// 将 CustomPaint 包裹在 RepaintBoundary 中
RepaintBoundary(
child: CustomPaint(
size: const Size(300, 300),
painter: BreathingGlowPainter(animationValue: value),
),
)
// RepaintBoundary 创建独立合成层,避免触发父 Widget 重绘
九、鸿蒙平台 Canvas 兼容性注意事项
9.1 Impeller 渲染后端差异
| API | Skia | Impeller (鸿蒙) | 备注 |
|---|---|---|---|
drawCircle/drawPath |
完全支持 | 完全支持 | 无差异 |
Path.combine |
完全支持 | 完全支持 | 复杂路径性能略慢 |
RadialGradient |
全部特性 | 全部特性 | 无差异 |
SweepGradient |
完全支持 | 完全支持 | 角度精度略有不同 |
BlendMode (29种) |
完全支持 | 完全支持 | 全部兼容 |
canvas.saveLayer |
完全支持 | 部分支持 | 大尺寸 saveLayer 注意性能 |
ImageShader |
完全支持 | 完全支持 | 纹理格式需注意 |
9.2 实际建议
- 优先使用简单 API :能用
drawCircle就别用复杂的drawPath。简单 API 在两个后端上都经过了充分测试。 - saveLayer 谨慎使用:在鸿蒙 Impeller 上,大尺寸(> 屏幕 50%)saveLayer 可能导致额外 GPU 纹理分配。尽量缩小 saveLayer 的范围。
- BlendMode 完全兼容:29 种 BlendMode 本质上是 GPU 着色器标准操作,鸿蒙完整支持。
- 动画帧率稳定:在 E-Brufen 测试中,鸿蒙设备 Canvas 动画帧率稳定性略优于同级 Android 设备。
- 保留 Widget 降级方案:对于核心功能(如呼吸球),Canvas 版本和 Widget 版本并存,通过平台检测选择。
dart
// 调试用:切换渲染后端对比效果
void main() {
// debugDisableImpeller = true; // 仅调试用
runApp(const EBrufenApp());
}
十、常见陷阱与最佳实践
10.1 陷阱清单
| # | 陷阱 | 症状 | 正确做法 |
|---|---|---|---|
| 1 | 在 shouldRepaint 中做复杂比较 | 每帧卡顿 | 使用简单值比较 |
| 2 | 在 paint 方法中创建对象 | GC 频繁触发 | 构造时预分配 Paint |
| 3 | 忘记 save/restore 配对 | 状态泄漏 | 每个 save() 必须有 restore() |
| 4 | BlendMode 在 saveLayer 外使用 | 混合无效 | BlendMode 只在 saveLayer 内生效 |
| 5 | saveLayer Rect 过大 | GPU 内存飙升 | Rect 精确到必要范围 |
| 6 | Path.combine 每帧执行 | 复杂路径掉帧 | 缓存结果,仅路径变化时重算 |
| 7 | shouldRepaint 始终 false | 动画不动 | 动画场景比较动画值 |
| 8 | Shader 每帧创建 | 性能开销 | 将不变 Shader 逻辑放构造时 |
10.2 调试 Canvas 绘制
dart
// 在 debug 模式下可视化绘制边界
void paint(Canvas canvas, Size size) {
// ... 实际绘制 ...
assert(() {
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()
..color = Colors.red.withValues(alpha: 0.3)
..style = PaintingStyle.stroke,
);
return true;
}());
}
10.3 最佳实践模板
dart
class OptimizedPainter extends CustomPainter {
// 1. 预分配不变对象
final Paint _bgPaint = Paint()..color = const Color(0xFF0D1B2A);
final List<_Star> _stars; // 外部注入,不在 paint 中创建
final double animationValue;
OptimizedPainter({required this.animationValue, required this._stars});
@override
void paint(Canvas canvas, Size size) {
// 2. paint 中不做对象分配
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), _bgPaint);
for (final star in _stars) {
canvas.drawCircle(star.position, star.radius, _bgPaint);
}
}
@override
bool shouldRepaint(covariant OptimizedPainter oldDelegate) =>
oldDelegate.animationValue != animationValue; // 3. 简单比较
}
十一、总结
核心观点
- Path.combine 是矢量图形运算的瑞士军刀------四种运算(intersect、union、difference、xor)覆盖了大部分组合形状需求,避免了手动计算贝塞尔控制点的繁琐。月牙形只需"外圆减去内圆"一行代码。Path.combine 的返回值是一个全新的 Path 对象,不会修改传入的源路径。对于简单的几何形状(几十个线段),combine 的开销在 1ms 以内,可以放心在 paint 方法中调用。但对于包含数百个顶点的复杂路径,应当缓存 combine 的结果,避免每帧重复执行昂贵的布尔运算。
- 三种渐变各有主场------LinearGradient 主宰卡片和背景,通过 begin/end 控制方向,tileMode 可以实现条纹重复效果。RadialGradient 主宰光晕和辉光,focal 参数可以偏移光源中心,创造出更自然的光照感。SweepGradient 主宰仪表盘和进度环,是色环选择器和雷达扫描效果的不二选择。理解它们的适用场景是高效选择的关键,合理使用 stops 参数能让渐变层次更加丰富。
- BlendMode 让 Canvas 有了图层能力------29 种混合模式中,screen(发光)、multiply(阴影)、dstIn(遮罩裁剪)是最常用的三种。plus 模式直接将颜色值相加,适合亮度增强;screen 模式产生柔和的提亮效果;overlay 模式保留纹理细节的同时增强对比。掌握了它们,就能在 Canvas 上实现接近图层面板的效果。关键前提:BlendMode 必须在 saveLayer 内使用,因为混合操作需要一个明确的背景图层作为计算参照。每个 saveLayer 都会分配一块 GPU 离屏纹理,所以应当将 Rect 范围缩小到必要的区域,并将嵌套深度控制在合理范围内。
- Canvas vs Widget 的选择取决于规模------少量元素(< 30)两者差别不大,大量元素(> 50)Canvas 碾压 Widget,但单元素场景 Widget 的缓存优化更智能。没有绝对优劣,只有场景适配。在实际项目中,我们建议采用"渐进式迁移"策略:先从最需要自定义绘制的部分开始(如光晕、粒子效果),保持已有的 Widget 实现作为参考基线。这样既能获得 Canvas 的视觉表现力,又能在鸿蒙平台上保留稳定的降级路径。
- 鸿蒙 Impeller 兼容性总体良好------29 种 BlendMode 和所有 Gradient 类型都得到完整支持。在 E-Brufen 的真机测试中,我们发现 OHOS 4.0 早期版本对 xor 和 reverseDifference 运算存在偶发异常,建议优先使用 union、intersect 和 difference 三种运算以避免兼容性问题。大尺寸 saveLayer 需要特别关注性能,建议控制嵌套深度在 2 层以内。对于关键功能(如呼吸球的核心绘制),保留 Widget 版本作为鸿蒙平台的降级方案是一个经过验证的工程实践。
从 E-Brufen 项目中得到的启示
在 E-Brufen 的开发过程中,我们经历了从"能用就行"到"视觉惊艳"的转变。最初的情绪热力图使用纯色 Container 堆叠,虽然功能完整但缺乏表现力。引入 RadialGradient 和 BlendMode 后,数据可视化的信息密度和视觉吸引力都得到了质的提升。同样,呼吸球的 BoxShadow 光晕升级为多层 Canvas 辉光后,用户反馈"这个呼吸球像是在面前发光一样"。
这种转变的核心在于:用户感知到的品质,往往来自细节的叠加。一个精心调校的 RadialGradient stops 数组、一个恰到好处的 BlendMode.screen 叠加、一个精确计算的 saveLayer Rect------这些细节单独看都不起眼,但累积起来就构成了用户口中的"精致感"。
Canvas 高级绘制的学习曲线比基础 Widget 组合更陡峭,但一旦掌握了 Path.combine、Shader 渐变和 BlendMode 这三大核心工具,你就能在 Flutter 的画布上自由表达任何视觉创意------无论是流光溢彩的粒子动画,还是静谧深邃的星空背景,都不再是从零开始的手工劳动,而是一个有机的技术组合过程。
一句话总结
Canvas 绘制的进阶之路,就是从"画形状"到"造光效"的转变。Path 定义边界,Shader 填充质感,BlendMode 控制光影------三者协同,你可以在 Flutter 画布上实现让用户惊叹的视觉效果,而不必引入任何第三方图形库。
作者简介:E-Brufen Dev,Flutter 与鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化。E-Brufen 是一个完全自绘 UI、零原生依赖的情绪健康应用。关注 Flutter Canvas 自定义绘制、动画系统和鸿蒙平台适配技术。
项目地址 :AtomGit Flutter 鸿蒙客户端