鸿蒙 ArkTS Canvas 实战:手写一座会生长的「情绪疗愈花园」

在上一篇文章里我讲了 Folia 的整体架构。这一次换个角度,钻进一个具体的模块——情绪疗愈花园
它要解决的问题很朴素:用户每记录一次心情,花园里就长出一朵对应情绪的花。
难点在于——这花不能是 emoji,也不能是切图,得是代码一笔一笔算出来的。
全文围绕 ArkTS 的 Canvas 2D 展开,附可运行代码与踩坑记录。


目录


一、为什么放弃 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;
}
  • 离屏不摆:通过 onVisibleAreaChangegardenVisible 置 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;
}

这样做的好处:

  1. 双端同构:主应用页面和桌面卡片引用同一份函数、同一份配置,数值不可能对不上
  2. 可单测:纯函数无副作用,全部逻辑都能写单元测试
  3. 可调参:所有"魔法数字"收敛到一张配置表,调整枯萎天数只改一处

我还把「活力值 → 生长加速」做成了一个纯函数,让"花园越活跃,花开得越快"这条规则显式可读:

/** 花园活力档位 → 种植周期缩减比例 */
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 亮度加权系数(灰阶转换)
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐