HarmonyOS Canvas自绘情绪花园实战
鸿蒙 ArkTS Canvas 实战:手写一座会生长的「情绪疗愈花园」
在上一篇文章里我讲了 Folia 的整体架构。这一次换个角度,钻进一个具体的模块——情绪疗愈花园。
它要解决的问题很朴素:用户每记录一次心情,花园里就长出一朵对应情绪的花。
难点在于——这花不能是 emoji,也不能是切图,得是代码一笔一笔算出来的。
全文围绕 ArkTS 的Canvas 2D展开,附可运行代码与踩坑记录。
目录
- 一、为什么放弃 emoji 和切图
- 二、设计:5 种情绪 → 5 种花 → 5 个生长阶段
- 三、绘制管线:拆解一朵花的诞生过程
- 四、花瓣的数学:椭圆、贝塞尔与参数化
- 五、动画分层:把「摆动」和「生长」彻底拆开
- 六、稀有花的层叠美学:玫瑰螺旋与四芒星光
- 七、ArkTS 26 踩坑清单
- 八、工程化:把花园逻辑抽成「零依赖纯模块」
- 九、效果与复盘
一、为什么放弃 emoji 和切图
第一版花园用的是最省事的方案:一个圆底 + 一个 emoji(🌻🌸🌺💧🌶️)+ 一个几何进度条。
上线自测后立刻发现问题:
| 方案 | 问题 |
|---|---|
| emoji | 各家 ROM 渲染不一致,深浅色模式不可控,无法表达"生长阶段"(emoji 只有一种形态) |
| PNG 切图 | 5 种花 × 5 个阶段 × 明暗 2 套 = 50 张图,包体积爆炸,色彩无法随主题联动 |
| SVG / Lottie | 需要额外运行时,且动画与状态强耦合,改一个参数要重新导出资源 |
而我们真正的需求是:花要能"长"——随着成长值从 0 到 100,花要从小变大、从种子到盛开;还要支持主题换色、剧情变灰、稀有变体。
切图做不到,emoji 更做不到。于是决定:用 CanvasRenderingContext2D 纯代码绘制。
好处立竿见影:
- 零资源体积:一朵花 = 一段绘制函数,颜色/尺寸/形状全是运行时参数
- 状态即画面:
growthValue一变,下一帧就是新形态,不需要准备任何中间帧 - 主题自由:深浅色、剧情变体、稀有配色,全部通过传参切换,无需换图
二、设计:5 种情绪 → 5 种花 → 5 个生长阶段
先定数据契约。花朵的配置集中在一张表里,包含花瓣数、花瓣形状、色板和成长阈值:
/** 花瓣形状(Canvas 自绘参数) */
export enum PetalShape {
ROUND = 'round', // 圆润花瓣(樱花 / 雨滴草)
POINTED = 'pointed', // 尖瓣(扶桑 / 辣椒)
SUNFLOWER_LAYERED = 'sunflower', // 双层花瓣(向日葵)
ROSE_SPIRAL = 'rose', // 玫瑰式螺旋层叠(星辉蓝玫瑰)
}
export const FLOWER_CONFIGS: FlowerConfig[] = [
{ type: FlowerType.SUNFLOWER, name: '向日葵', moodKey: 'happy',
colors: ['#FFE082', '#FFB300'], petalCount: 14, petalShape: PetalShape.SUNFLOWER_LAYERED },
{ type: FlowerType.CHERRY, name: '樱花', moodKey: 'calm',
colors: ['#F8BBD0', '#EC407A'], petalCount: 5, petalShape: PetalShape.ROUND },
{ type: FlowerType.HIBISCUS, name: '扶桑花', moodKey: 'excited',
colors: ['#FFAB91', '#FF5722'], petalCount: 5, petalShape: PetalShape.POINTED },
{ type: FlowerType.RAINDROP, name: '雨滴草', moodKey: 'sad',
colors: ['#B3E5FC', '#0288D1'], petalCount: 6, petalShape: PetalShape.ROUND },
{ type: FlowerType.CHILI, name: '红辣椒花', moodKey: 'angry',
colors: ['#FFCDD2', '#D32F2F'], petalCount: 6, petalShape: PetalShape.POINTED },
];
没有为每种花写独立的绘制函数。真正区分它们的只有三个变量:花瓣数量、花瓣形状、主色板。这就是参数化绘制的核心——一套管线,五种外观。
生长阶段则是另一个维度,由成长值推导(阈值 [25, 55, 85]):
export enum GrowthStage {
SEED = 0, // 种子(刚萌芽)
SPROUT = 1, // 幼苗
BUDDING = 2, // 含苞待放
FULL_BLOOM = 3, // 盛开
WITHERING = 4, // 枯萎(长时间未呵护)
}
export function stageOf(growthValue: number): GrowthStage {
const t = GARDEN_ENGINE_CONFIG.growthThresholds; // [25, 55, 85]
if (growthValue >= t[2]) return GrowthStage.FULL_BLOOM;
if (growthValue >= t[1]) return GrowthStage.BUDDING;
if (growthValue >= t[0]) return GrowthStage.SPROUT;
return GrowthStage.SEED;
}
于是「情绪 × 成长值」两个坐标,映射到「哪种花的哪个阶段」,一共 5 × 5 = 25 种画面,全部由代码算出。
三、绘制管线:拆解一朵花的诞生过程
组件用 ArkUI V2 写法,@Param 接收外部参数,@Local 管理内部动画状态:
@ComponentV2
export struct FlowerCanvasView {
@Param flowerType: FlowerType = FlowerType.SUNFLOWER;
@Param stage: GrowthStage = GrowthStage.FULL_BLOOM;
@Param colors: string[] = ['#FFE082', '#FFB300'];
@Param growthValue: number = 100; // 成长值 0~100
@Param spawnTick: number = 0; // 播种出土弹跳触发计数
@Param swingEnabled: boolean = false;
@Param isRare: boolean = false;
@Param rareVariant: RareVariant = RareVariant.RAINBOW;
@Param canvasSize: number = 56; // 画布边长(vp)
/** 开启抗锯齿,否则小尺寸花瓣边缘会有锯齿 */
private renderSettings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderSettings);
@Local private grow: number = 1; // 生长补间进度 0~1
@Local private spawnScale: number = 1; // 出土弹跳缩放
@Local private spawnRotate: number = 0; // 出土轻微旋转
}
Canvas 尺寸很小(花圃格子是 52vp,图鉴缩略 38~48vp),所以每个像素都要精打细算。
主绘制入口先清屏,再按阶段分流:
private drawFlower(): void {
const ctx = this.ctx;
const size = this.canvasSize;
ctx.clearRect(0, 0, size, size);
const grow = this.grow;
if (this.stage === GrowthStage.SEED) {
this.drawSeed(ctx, grow); // 种子:土壤小丘 + 种子点
return;
}
const cx = this.center;
const groundY = size * 0.86; // 土壤线
const stemLen = size * 0.30 + size * 0.22 * grow; // 茎长随生长
const headY = groundY - stemLen; // 花头中心
this.drawStem(ctx, cx, groundY, headY, grow); // 茎(二次贝塞尔弯曲)
if (this.stage !== GrowthStage.WITHERING) {
this.drawLeaves(ctx, cx, groundY, stemLen, grow); // 叶片
}
if (this.stage === GrowthStage.SPROUT) return; // 幼苗:茎 + 叶即可
if (this.stage === GrowthStage.WITHERING) {
this.drawWitheredHead(ctx, headY, grow); // 枯萎:灰阶垂头
this.drawBudLeaves(ctx, cx, groundY, stemLen, '#9E9E9E');
return;
}
if (this.stage === GrowthStage.BUDDING) {
this.drawBud(ctx, headY, grow); // 含苞:收拢椭圆
} else {
this.drawBloom(ctx, headY, grow); // 盛开:环形花瓣
}
this.drawBudLeaves(ctx, cx, groundY, stemLen, '#81C784'); // 花头下方萼叶
}
关键点:所有几何量都是 size 的比例。茎长 = 0.30 + 0.22 × grow,土壤线在 0.86 高度……这样同一份代码在 38vp 的缩略图和 72vp 的详情大图上都能正确缩放,无需写两套。
茎用二次贝塞尔画一条轻微弯曲的线:
private drawStem(ctx: CanvasRenderingContext2D, cx: number, groundY: number,
headY: number, grow: number): void {
const size = this.canvasSize;
ctx.save();
ctx.lineWidth = Math.max(1.5, size * 0.028); // 最小 1.5px,避免小尺寸下茎消失
ctx.lineCap = 'round';
ctx.strokeStyle = this.stage === GrowthStage.WITHERING ? '#9E9E9E' : '#4CAF50';
ctx.beginPath();
ctx.moveTo(cx, groundY);
ctx.quadraticCurveTo(cx + size * 0.03, (groundY + headY) / 2, cx, headY);
ctx.stroke();
ctx.restore();
}
注意 ctx.save() / ctx.restore() 的成对使用——这是 Canvas 编程的纪律。任何一次 translate / rotate / globalAlpha 的修改,都必须被 save/restore 包裹,否则状态会污染后续绘制。在这个 300 多行的组件里,save/restore 出现了十几次。
四、花瓣的数学:椭圆、贝塞尔与参数化
盛开是整个组件的视觉核心。思路很简单:以花心为原点,把 N 片花瓣均匀铺在一个圆上。
private drawBloom(ctx: CanvasRenderingContext2D, headY: number, grow: number): void {
const size = this.canvasSize;
const cx = this.center;
const colors = this.effectiveColors();
const petalR = size * 0.15 * (0.55 + 0.5 * grow); // 花瓣半径随生长放大
const headR = petalR + size * 0.045;
ctx.save();
ctx.translate(cx, headY); // 1. 原点搬到花心
const count = this.petalCount;
for (let i = 0; i < count; i++) {
const angle = (i * 2 * Math.PI) / count - Math.PI / 2; // 2. 均匀角度,从正上方开始
const color = colors[i % colors.length];
ctx.save();
ctx.rotate(angle); // 3. 旋转坐标系到花瓣朝向
this.drawPetal(ctx, petalR, color); // 4. 在 +Y 轴方向画一片花瓣
ctx.restore();
}
// 5. 花蕊:深色圆 + 左上角白色高光点
const coreR = headR * 0.38;
ctx.beginPath();
ctx.arc(0, 0, coreR, 0, Math.PI * 2, false);
ctx.fillStyle = this.coreColor();
ctx.fill();
ctx.beginPath();
ctx.arc(-coreR * 0.25, -coreR * 0.25, coreR * 0.3, 0, Math.PI * 2, false);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fill();
ctx.restore();
}
这个「translate → 循环 rotate → 画固定朝向的图形」是一个可以套用到任何放射状图形的通用套路,画花瓣、画仪表盘刻度、画雷达图都是同一个模式。
真正区分花种的地方在 drawPetal——圆瓣用椭圆,尖瓣用二次贝塞尔:
private drawPetal(ctx: CanvasRenderingContext2D, r: number, color: string): void {
if (this.petalShape === PetalShape.POINTED) {
// 尖瓣:上下两条二次贝塞尔曲线闭合,形成柳叶 / 辣椒形
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(r * 0.55, -r * 0.38, r, -r * 0.9);
ctx.quadraticCurveTo(r * 0.85, -r * 0.02, 0, 0);
ctx.quadraticCurveTo(-r * 0.85, -r * 0.02, -r, -r * 0.9);
ctx.quadraticCurveTo(-r * 0.55, -r * 0.38, 0, 0);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
} else {
// 圆瓣:椭圆沿 Y 轴偏移(因为椭圆中心在自身中心,需向上推 0.72r 让根部贴住花心)
ctx.beginPath();
ctx.ellipse(0, -r * 0.72, r * 0.38, r * 0.66, 0, 0, Math.PI * 2, false);
ctx.fillStyle = color;
ctx.fill();
}
}
这里有个小细节值得说:ellipse 的圆心在椭圆正中,如果把圆心放在花心 (0,0),花瓣根部和花心之间会空一块。所以我把它沿 Y 轴上推 0.72r,让椭圆的下缘刚好抵住花心。
向日葵在此基础上多一层内瓣,用半透明制造立体感:
if (this.petalShape === PetalShape.SUNFLOWER_LAYERED) {
const innerR = petalR * 0.55;
for (let i = 0; i < 10; i++) {
const angle = (i * 2 * Math.PI) / 10 - Math.PI / 2 + Math.PI / 10; // 错位半格
ctx.save();
ctx.rotate(angle);
ctx.beginPath();
ctx.ellipse(0, -innerR * 0.7, innerR * 0.35, innerR * 0.7, 0, 0, Math.PI * 2, false);
ctx.fillStyle = colors[Math.min(1, colors.length - 1)];
ctx.globalAlpha = 0.55; // 半透明内瓣
ctx.fill();
ctx.globalAlpha = 1; // 记得还原!
ctx.restore();
}
}
枯萎的处理则体现了另一套思路——饱和度清空。用标准亮度公式把主色板转成灰阶:
private grayScale(color: string): string {
const hex = color.replace('#', '');
if (hex.length < 6) return '#9E9E9E';
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// ITU-R BT.601 亮度权重:人眼对绿色最敏感、蓝色最迟钝
const gray = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
const gHex = gray.toString(16).padStart(2, '0');
return `#${gHex}${gHex}${gHex}`;
}
用
0.299 / 0.587 / 0.114而不是简单的(r+g+b)/3,是因为人眼对绿色最敏感。直接取平均会让暖色(辣椒红)看起来偏暗、冷色(雨滴蓝)偏亮,灰阶结果不自然。
五、动画分层:把「摆动」和「生长」彻底拆开
这是整个组件最值得讲的设计。
花园里十几朵花要同时轻轻摇摆,营造"活着的花园"氛围。如果每一朵花的摆动都去重绘 Canvas,十几块画布每帧回调整理路径,性能会很吃紧。
我的做法是把动画分成两层,各用一套机制:
┌─────────────────────────────────────────┐
│ 外层 Column —— 摆动层 │
│ .rotate(摆动角) │
│ .animation({ iterations: -1, │
│ playMode: Alternate }) │
│ → 隐式无限动画,仅做「渲染层变换」 │
│ → 不重绘画布,GPU 直接变换矩阵,极省 │
├─────────────────────────────────────────┤
│ 内层 Canvas —— 内容层 │
│ .scale(spawnScale) │
│ .rotate(spawnRotate) │
│ → 出土弹跳用显式 animateTo(可中断) │
│ → 生长补间也用显式 animateTo │
└─────────────────────────────────────────┘
代码长这样:
build() {
Stack() {
// ---- 外层摆动层:无限交替隐式动画 ----
Column() {
// ---- 内层 Canvas:静态绘制 + 出土弹跳 ----
Canvas(this.ctx)
.width(this.canvasSize)
.height(this.canvasSize)
.onReady(() => { this.initDraw(); })
.scale({ x: this.spawnScale, y: this.spawnScale })
.rotate({ x: 0, y: 0, z: 1, angle: this.spawnRotate })
}
.width(this.canvasSize)
.height(this.canvasSize)
.rotate({
x: 0, y: 0, z: 1,
angle: this.swingEnabled
? (this.swingUp ? this.swingSwingAngle() : -this.swingSwingAngle())
: 0,
centerX: '50%',
centerY: '100%' // 以「根部」为轴心摆动,像真花一样
})
.animation(this.swingEnabled
? { duration: 1800, curve: Curve.EaseInOut, iterations: -1, playMode: PlayMode.Alternate }
: { duration: 0 })
}
.width(this.canvasSize)
.height(this.canvasSize)
}
关键设计点:
1. centerY: '100%' —— 轴心在根部。
如果以中心为轴,花会像风车一样原地转;把轴心放到最底部,才是"花茎在风中摇曳"的物理直觉。
2. 无限摆动只翻转一次状态。
@Local swingUp 是一个布尔值,初始化时翻转一次,隐式动画就会自动开始 Alternate 无限往复——后续不需要任何定时器去驱动:
private initDraw(): void {
this.grow = this.growthToProgress();
this.lastSpawnTick = this.spawnTick;
this.inited = true;
this.drawFlower();
// 翻转一次方向,触发外层无限交替动画
if (this.swingEnabled && this.swingSwingAngle() > 0) {
this.getUIContext().animateTo({ duration: 0 }, () => {
this.swingUp = false;
});
}
}
3. 摆动幅度随阶段变化。
盛开的花摆得大(5°),含苞和枯萎摆得小(3°),种子/幼苗完全不摆:
private swingSwingAngle(): number {
if (this.stage === GrowthStage.FULL_BLOOM) return 5;
if (this.stage === GrowthStage.BUDDING || this.stage === GrowthStage.WITHERING) return 3;
return 0;
}
4. 生长补间用显式 animateTo,天然可中断。
成长值可能在用户连续操作中快速变化(写日记 +15、施肥 +10……)。animateTo 的特性是——新动画会自动从当前值接管旧动画,不会跳变、不会排队:
@Monitor('growthValue')
onGrowthValueChanged(): void {
if (!this.inited) return;
this.animateGrow();
}
private animateGrow(): void {
this.getUIContext().animateTo({ duration: MOTION_DURATION_NORMAL, curve: Curve.EaseOut }, () => {
this.grow = this.growthToProgress(); // grow 变化 → @Monitor 触发重绘 → 逐帧画出新形态
});
}
5. 出土弹跳:用 Curve.Friction 近似弹簧。
播种时希望花"啵"地弹出来。ArkTS 的 Curve 枚举没有 Spring 成员,我选择了 Friction(快速减速 + 轻微回弹),效果最接近:
@Monitor('spawnTick')
onSpawnTickChanged(): void {
if (!this.inited || this.spawnTick <= this.lastSpawnTick) return;
this.lastSpawnTick = this.spawnTick;
this.spawnScale = 0.2;
this.spawnRotate = -10;
this.getUIContext().animateTo({
duration: MOTION_DURATION_FAST,
curve: Curve.Friction // 枚举无 Spring,用 Friction 近似弹簧出图
}, () => {
this.spawnScale = 1;
this.spawnRotate = 0;
});
}
6. 重绘的触发完全交给 @Monitor。
组件不主动"请求重绘",而是声明"这些状态变了就重画":
@Monitor('grow', 'flowerType', 'stage', 'isRare', 'rareVariant')
onRenderStateChanged(): void {
if (!this.inited) return;
this.drawFlower();
}
动画期间 grow 逐帧变化,@Monitor 逐帧触发 drawFlower(),视觉上就是平滑生长。状态驱动绘制,而不是手动调 invalidate——这是 ArkUI 声明式范式的正确用法。
页面级的第二层性能优化
单朵花再省,一屏十几朵同时摆动也有开销。所以 HealingGardenPage 做了两件事:
// 只有页面可见 + 花朵处于「盛开/含苞」时才允许摆动
swingEnabled: this.gardenVisible && this.canSwing(flower),
private canSwing(flower: FlowerState): boolean {
return flower.stage === GrowthStage.FULL_BLOOM || flower.stage === GrowthStage.BUDDING;
}
- 离屏不摆:通过
onVisibleAreaChange把gardenVisible置 false,页面不可见时所有无限动画暂停 - 只摆该摆的:种子、幼苗、枯萎的花不参与摆动,一屏下来真正在跑的无限动画大幅减少
六、稀有花的层叠美学:玫瑰螺旋与四芒星光
除了 5 种基础花,花园里还有 3 种稀有品种(积分兑换 / 月度满勤派发)。它们共用同一套绘制管线,但通过 RARE_FLOWER_DEFS 配置表注入差异化参数:
{
variant: RareVariant.STARLIGHT,
name: '星辉蓝玫瑰 · 星语',
colorsLight: ['#A7BDFF', '#718FF7', '#4E5FE0', '#333FBE'], // 4 级蓝,外层浅内层深
petalCount: 12,
petalShape: PetalShape.ROSE_SPIRAL,
coreColor: '#FFD966',
sparkle: true, // 花瓣尖端缀四芒星光
price: 50,
}
玫瑰的辨识度来自"层叠":外层 12 片浅蓝花瓣,内层 8 片深蓝花瓣错位半格,再在花心点一个透光小圆:
if (this.petalShape === PetalShape.ROSE_SPIRAL) {
const innerCount = 8;
const innerR = petalR * 0.6;
for (let i = 0; i < innerCount; i++) {
const angle = (i * 2 * Math.PI) / innerCount - Math.PI / 2 + Math.PI / innerCount; // 错位半格
ctx.save();
ctx.rotate(angle);
ctx.beginPath();
ctx.ellipse(0, -innerR * 0.72, innerR * 0.34, innerR * 0.62, 0, 0, Math.PI * 2, false);
ctx.fillStyle = colors[Math.min(colors.length - 1, 2 + (i % 2))]; // 取更深的两级蓝
ctx.globalAlpha = 0.9;
ctx.fill();
ctx.globalAlpha = 1;
ctx.restore();
}
// 花心透光点
ctx.beginPath();
ctx.arc(0, 0, headR * 0.16, 0, Math.PI * 2, false);
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.fill();
}
玫瑰花瓣本身也不是椭圆,而是用三次贝塞尔画的泪滴形:
private drawRosePetal(ctx: CanvasRenderingContext2D, r: number, color: string): void {
ctx.beginPath();
ctx.moveTo(0, 0); // 底部收拢于花心
ctx.bezierCurveTo(-r * 0.62, -r * 0.08, -r * 0.62, -r * 0.6, 0, -r * 0.98); // 左半边
ctx.bezierCurveTo(r * 0.62, -r * 0.6, r * 0.62, -r * 0.08, 0, 0); // 右半边
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
四芒星光则用四段二次贝塞尔画出一个星形,并在花瓣尖端叠一层:
private drawStarSparkle(ctx: CanvasRenderingContext2D, radius: number, alpha: string): void {
const s = radius;
ctx.beginPath();
ctx.moveTo(0, -s * 1.5);
ctx.quadraticCurveTo(s * 0.3, -s * 0.3, s * 1.5, 0); // 右上 → 右尖
ctx.quadraticCurveTo(s * 0.3, s * 0.3, 0, s * 1.5); // 右下 → 下尖
ctx.quadraticCurveTo(-s * 0.3, s * 0.3, -s * 1.5, 0); // 左下 → 左尖
ctx.quadraticCurveTo(-s * 0.3, -s * 0.3, 0, -s * 1.5); // 左上 → 上尖
ctx.closePath();
ctx.fillStyle = 'rgba(255,255,255,' + alpha + ')';
ctx.fill();
}
星点在绘制循环里逐花瓣调用,仅在盛开阶段启用(配置项
sparkle),是一种"华丽但克制"的性能取舍——含苞的玫瑰不需要星光。
七、ArkTS 26 踩坑清单
这一节是真实踩出来的,希望对正在迁移 ArkTS 的同学有帮助。
坑 1:对象字面量必须带类型注解
ArkTS 开启 arkts-no-untyped-obj-literals 后,裸写字面量会直接编译报错:
// ❌ 报错
const seed = { type: FlowerType.CHERRY, mood: 'calm' };
// ✅ 先声明 interface,再赋字面量
interface PlantSeed { type: FlowerType; mood: string; growthValue: number; isRare: boolean; }
const seed: PlantSeed = { type: FlowerType.CHERRY, mood: 'calm', growthValue: 0, isRare: false };
更严的是结构化类型传递也被禁止(arkts-no-structural-typing)。即便两个接口字段完全一致,也不能互传。所以项目里出现了大量"显式重构对象"的辅助函数,比如把 PlantedFlowerView 转成枯萎判定入参时,必须逐字列出字段:
const stage = isWithered({
lastGrowDate: f.lastGrowDate,
lastWaterDate: f.lastWaterDate,
plantedAt: f.plantedAt
}, today) ? GrowthStage.WITHERING : computedStage;
坑 2:Curve 枚举没有 Spring
想做弹簧回弹却找不到 Curve.Spring。最终用 Curve.Friction(快速减速)近似,视觉效果可接受。别去猜枚举里有什么,查文档。
坑 3:@ComponentV2 下 @Param 变化要靠 @Monitor
V1 的 @Watch 在 V2 里换成了 @Monitor,且可以一次监听多个字段:
@Monitor('grow', 'flowerType', 'stage', 'isRare', 'rareVariant')
onRenderStateChanged(): void { this.drawFlower(); }
注意:@Monitor 回调里必须判断"是否已初始化",因为 onReady 之前的属性赋值也会触发回调,此时 ctx 还没准备好:
private inited: boolean = false;
// 所有 @Monitor 回调第一行都是:
if (!this.inited) return;
坑 4:ForEach 的 key 决定了格子会不会刷新
这是花了我半天的 bug:播种后花朵不显示,退出重进才有。根因是 ForEach 对稳定 key 会复用旧节点,如果 key 不含状态字段,节点就不会重建。
// 修复:把状态字段、tick、refreshTick 全部编进 key
const flowerKey = flower === null ? 'e' : `${flower.id}_${flower.stage}_${flower.growthValue}`;
const tick = this.getSpawnTick(plot.row * this.PLOT_COLS + plot.col);
return `plot_${plot.row}_${plot.col}_${flowerKey}_t${tick}_r${this.refreshTick}`;
key 不是随便写的字符串,它是"这个节点代表什么状态"的声明。
坑 5:animateTo / px2vp 要用 getUIContext()
API 18 起全局函数废弃,必须走 UIContext 实例:
// ❌ 废弃
animateTo({ duration: 300 }, () => { this.grow = 1; });
const w = px2vp(displayInfo.width);
// ✅ 推荐
this.getUIContext().animateTo({ duration: 300 }, () => { this.grow = 1; });
const w = this.getUIContext().px2vp(displayInfo.width);
坑 6:小尺寸画布的抗锯齿
new RenderingContextSettings(true) 开启抗锯齿。花瓣只有十几像素大时,不开抗锯齿边缘会非常毛糙——这一行几乎零成本,但收益极高。
八、工程化:把花园逻辑抽成「零依赖纯模块」
绘制之外,花园还有一整套规则:成长阈值、枯萎判定、连续记录天数、稀有解锁、成熟时间……
最初这些逻辑散落在页面和 Service 里,导致一个尴尬的问题:桌面卡片(Form)和后端要显示同一个"花园状态",但两边算出来的结果不一致——因为卡片进程和主应用进程无法共享同一个 DatabaseManager 实例。
解决方案是抽出 GardenEngine.ets,一个零 import 的纯 TypeScript 模块:
/**
* 设计约束:
* - 纯 TypeScript 模块:零 import、不依赖 UI、不依赖 DatabaseManager 实例,
* 可在主应用进程与 Form 卡片进程分别编译调用
* - 统一口径:成长阈值/枯萎天数/稀有判定全部收敛到 GARDEN_ENGINE_CONFIG
*/
export const GARDEN_ENGINE_CONFIG: GardenEngineConfig = {
growthThresholds: [25, 55, 85],
witherNoGrowDays: 5,
witherNoWaterDays: 5,
diaryGrowth: 15,
fertilizeGrowth: 10,
luckyWaterDays: 3,
luckyBonus: 5,
autoGardenMaxFlowers: 12,
sadStoryConsecutiveDays: 3,
angerStoryMaxLevel: 3,
};
/** 枯萎判定:超过 N 天无成长 且 超过 N 天未浇水 → 枯萎 */
export function isWithered(flower: WitherCheckInput, today: string): boolean {
const cfg = GARDEN_ENGINE_CONFIG;
let noGrowDays: number;
if (flower.lastGrowDate.length > 0) {
noGrowDays = daysBetween(flower.lastGrowDate, today);
} else if (flower.plantedAt !== undefined && flower.plantedAt.length > 0) {
// 从未成长时从「种植日」起算,避免「种植即枯萎」
noGrowDays = daysBetween(flower.plantedAt, today);
} else {
noGrowDays = cfg.witherNoGrowDays + 1;
}
const noWaterDays = flower.lastWaterDate.length === 0
? cfg.witherNoWaterDays + 1
: daysBetween(flower.lastWaterDate, today);
return noGrowDays > cfg.witherNoGrowDays && noWaterDays > cfg.witherNoWaterDays;
}
这样做的好处:
- 双端同构:主应用页面和桌面卡片引用同一份函数、同一份配置,数值不可能对不上
- 可单测:纯函数无副作用,全部逻辑都能写单元测试
- 可调参:所有"魔法数字"收敛到一张配置表,调整枯萎天数只改一处
我还把「活力值 → 生长加速」做成了一个纯函数,让"花园越活跃,花开得越快"这条规则显式可读:
/** 花园活力档位 → 种植周期缩减比例 */
export function growthCycleBonusOf(vitality: number): number {
const v = Math.max(0, Math.min(1, vitality));
if (v >= 1.0) return 0.25; // 满活力:周期 -25%
if (v >= 0.75) return 0.2;
if (v >= 0.5) return 0.15;
if (v >= 0.25) return 0.1;
return 0.05;
}
/** 成熟所需小时数:普通花 24h / 稀有花 48h,再按活力缩减 */
export function maturityHoursOf(isRare: boolean, vitality: number): number {
const baseHours = isRare ? 48 : 24;
return Math.max(1, Math.round(baseHours * (1 - growthCycleBonusOf(vitality))));
}
数字说话:普通花约 24 小时自然成熟,稀有花约 48 小时;满活力时分别缩短到 18 小时和 36 小时。 这是"坚持记录"在游戏化层面的正反馈。
九、效果与复盘
最终这个模块的产出:
| 维度 | 数值 |
|---|---|
| 绘制组件 | 1 个(FlowerCanvasView,约 300 行) |
| 画面组合 | 5 种基础花 × 5 阶段 + 3 种稀有花 = 28 种外观 |
| 美术资源 | 0 张切图 |
| 纯逻辑引擎 | 1 个零依赖模块(GardenEngine) |
| 绘制对象 | 约 300~320 个(12 格花圃 + 图鉴/详情/播种选择缩略) |
复盘:值得保留的三个决策
1. 参数化 > 资源化。
把"花"抽象成「花瓣数 + 花瓣形状 + 色板」三元组后,新增一种花只是往配置表加一行。后来加稀有品种时,从想法到上线只花了改配置的时间。
2. 动画分层是性能的关键。
把"氛围摆动"放在渲染层的隐式动画、把"状态生长"放在 Canvas 的显式动画,两者互不干扰。一屏十几朵花的摆动几乎不产生额外绘制开销。
3. 逻辑与绘制分离。
GardenEngine 不 import 任何 UI,FlowerCanvasView 不含任何业务规则。这种切分让规则可单测、可双端复用,也让绘制组件保持纯粹。
还可以更好的地方
- 未做对象池:每帧
clearRect全量重绘。小尺寸下够用,但如果要做"花园背景整体动画",需要考虑脏矩形或离屏 Canvas 缓存。 - 贝塞尔控制点是手调的:花瓣形状靠调参数逼近,没有物理化的曲线生成。若要更自然的花瓣,可以引入超椭圆或参数化叶形曲线。
- 星点闪烁是静态的:目前四芒星光固定亮度,未来可以加一个低频呼吸式
globalAlpha动画让星辉"活"起来。
小结
这个小模块让我重新理解了 Canvas 在 ArkUI 里的定位:它不是"画图工具",而是一个"把数据状态翻译成视觉"的函数。
当你把「一朵花」拆成 translate → rotate → 画固定朝向图形 的循环,把「生长」拆成 growthValue → grow → 重绘 的状态流,把「摆动」和「生长」拆成两层互不干扰的动画之后,代码就变得非常干净——新增一种花、一种阶段、一种主题,都只是加参数,而不是加代码。
参考
- HarmonyOS 开发者文档:Canvas 组件 / CanvasRenderingContext2D
- HarmonyOS 开发者文档:显式动画 animateTo / 隐式动画 animation
- HarmonyOS 动效设计指南:时长与缓动曲线规范
- ITU-R BT.601 亮度加权系数(灰阶转换)
更多推荐



所有评论(0)