在这里插入图片描述

在这里插入图片描述

1. 引言:当布局技术遇上游戏开发

在移动应用开发中,布局系统和游戏引擎通常是两个独立的话题。布局系统关注 UI 元素的排列与自适应,关注的是"组件放在哪里、如何随屏幕变化";而游戏引擎关注帧率循环与实时渲染,关注的是"画面如何流畅地动起来"。但在鸿蒙 ArkUI 框架中,这两者可以通过巧妙的架构设计完美融合——布局层负责稳定的 UI 外壳,Canvas 层负责动态的游戏内核。

当你第一次在手机上打开这个应用时,你会看到一片蓝天绿地的游戏场景,一个蓝色小人站在画面中央,一个巨大的橙色按钮占据了屏幕下方三分之一的空间。点击按钮,小人跃起,障碍物从右侧出现,得分开始跳动——而这一切的背后,是 Column + layoutWeight 弹性布局在默默支撑每一个像素的位置。

本文将以一个"单键跳跃跑酷"小游戏为载体,系统讲解以下核心技术:

  • Column + layoutWeight:ArkUI 中实现弹性自适应布局的核心技术。不同于传统的固定像素或百分比布局,layoutWeight 让子组件按比例瓜分父容器的剩余空间,实现真正的屏幕自适应。
  • Canvas 实时渲染:在 ArkUI 中使用 Canvas 组件实现游戏画面的帧级绘制。Canvas 提供了完整的 2D 绘图 API,包括路径、形状、渐变、文本等,是实现自定义绘图的首选方案。
  • 物理模拟:重力、跳跃和碰撞检测的轻量实现。使用欧拉积分模拟角色跳跃轨迹,轴对齐包围盒(AABB)算法检测角色与障碍物的碰撞,构建了一个简单但完整的物理系统。
  • 状态管理:@State 响应式数据与游戏状态机的配合。将"显示在 UI 文本中的数据"用 @State 管理,将"仅由 Canvas 读取的数据"保持为普通变量,在这个原则下实现了响应式 UI 与高性能 Canvas 绘制的共存。

这个游戏麻雀虽小五脏俱全——一个按钮触发跳跃,角色在 Canvas 场景中奔跑,障碍物随机出现,物理引擎驱动跳跃轨迹,碰撞检测判定生死。所有 UI 布局完全基于 Column + layoutWeight 弹性布局,不依赖任何固定像素值。


2. 项目全景:单键跑酷的架构设计

2.1 项目结构

entry/src/main/ets/pages/RunnerPage.ets   ← 唯一页面,611 行完整代码
entry/src/main/resources/base/profile/
  └── main_pages.json                     ← 页面路由注册
entry/src/main/ets/entryability/
  └── EntryAbility.ets                     ← Ability 入口

整个应用只有一个页面文件 RunnerPage.ets,它既是布局展示页,也是游戏本体。页面通过 main_pages.json 注册路由:

{
  "src": [
    "pages/RunnerPage"
  ]
}

EntryAbility 中加载该页面:

windowStage.loadContent('pages/RunnerPage', (err) => {
  if (err.code) {
    hilog.error(DOMAIN, 'EnglishApp', 'Failed: %{public}s', JSON.stringify(err));
    return;
  }
  hilog.info(DOMAIN, 'EnglishApp', 'Succeeded in loading content.');
});

2.2 SDK 版本确认

build-profile.json5 中明确指定了鸿蒙 NEXT 版本:

{
  "products": [
    {
      "name": "default",
      "targetSdkVersion": "6.1.1(24)",
      "compatibleSdkVersion": "6.1.1(24)",
      "runtimeOS": "HarmonyOS"
    }
  ]
}

targetSdkVersioncompatibleSdkVersion 均为 6.1.1(24),对应 HarmonyOS NEXT 6.1.1,API 24。

2.3 页面布局架构

整个页面采用三段式弹性布局架构:

Column()                                    .width('100%').height('100%')
├── 固定顶部 Row()                          .height(50)
│   🏃 单键跑酷       得分 12  最高 85
├── ★ 弹性区 A Column(Canvas)                .layoutWeight(1.0)  ← 50.0%
│   Canvas 实时绘制游戏画面
├── ★ 弹性区 B Column(状态信息)              .layoutWeight(0.3)  ← 15.0%
│   ⚡速度 4.1  🏃跑酷中...  障碍物 3 个
├── ★ 弹性区 C Column(Button)                .layoutWeight(0.7)  ← 35.0%
│   ┌──────────────────────────────────┐
│   │         🦘 跳跃!                 │  巨大橙色按钮
│   └──────────────────────────────────┘
└── 固定底部 Column(说明面板)                (内容撑高)
    🎯 布局要点 + 💻 核心代码

2.4 核心数据模型

/** 游戏状态枚举 */
enum GameState {
  READY = 0,     // 等待开始
  PLAYING = 1,   // 游戏中
  OVER = 2,      // 游戏结束
}

/** 障碍物数据 */
interface Obstacle {
  x: number;     // 障碍物右边缘 x 坐标
}

整个游戏只有这两个数据结构——一个三值枚举和一个单字段接口。简洁性是本设计的核心原则。


3. Column + layoutWeight 弹性布局深度解析

3.1 layoutWeight 的定位与设计哲学

在 ArkUI 中,不存在一个名为 <Expanded /> 的独立组件标签——这一点与 Flutter 等框架有根本区别。弹性自适应的正确写法是:在 Column(或 Row)的直属子组件上设置 .layoutWeight(权重值) 属性。

为什么 ArkUI 不采用 <Expanded> 包裹组件的设计?原因在于 ArkUI 的装饰器语法和链式调用风格。在 ArkTS 中,组件的布局属性通过链式方法调用设置,而不是通过嵌套标签声明。这种设计让代码更加扁平,减少了不必要的嵌套层级:

// Flutter 方式(有 Expanded 包裹组件)
Column(
  children: [
    Container(height: 50),
    Expanded(flex: 1, child: Container()),
    Expanded(flex: 3, child: Container()),
  ],
)

// ArkUI 方式(无 Expanded,直接用 layoutWeight)
Column() {
  Row() { }.height(50)
  Column() { }.layoutWeight(1.0)
  Column() { }.layoutWeight(0.3)
  Column() { }.layoutWeight(0.7)
}

二者的语义等价,但 ArkUI 的方式更加简洁——子组件直接声明自己的布局行为,而不需要额外的包裹层。这与 CSS Flexbox 的 flex: 1 属性更为接近,都是直接在子元素上声明弹性比例。

3.2 layoutWeight 的分配规则

当 Column 的某个子组件设置了 layoutWeight,该组件会参与 Column 剩余空间的分配。

某弹性区高度 = (Column总高度 - 所有固定高度之和)
              × (该区 layoutWeight / 所有弹性区 layoutWeight 之和)

固定高度是指通过 .height(px) 锁定的子组件,它们不参与弹性分配,优先占用空间。弹性区则瓜分剩余空间。

3.3 本应用中的弹性分配

本页面有 3 个弹性区,权重分别为 1.0、0.3、0.7,总和 2.0。

以 800vp 屏幕高度为例:

弹性空间合计 = 800 - 50(顶部) - 说明区(≈120) ≈ 630vp

游戏场景(权重1.0) = 630 × 1.0/2.0 = 315vp  → 约占 50%
状态信息(权重0.3) = 630 × 0.3/2.0 ≈  95vp  → 约占 15%
跳跃按钮(权重0.7) = 630 × 0.7/2.0 ≈ 220vp  → 约占 35%

3.4 为什么 height(‘100%’) 是必须的?

这是开发者最容易犯的错误。ArkUI 的 Column 如果没有明确高度,其高度由子组件撑开。在这种情况下,剩余空间为零,layoutWeight 无法分配任何空间。

正确写法:

Column() {
  // 子组件使用 .height() 或 .layoutWeight() 分配空间
}
.width('100%')
.height('100%')      // ← 必须!没有这个,layoutWeight 全部失效

错误写法:

Column() {
  // layoutWeight 不会生效
}
.width('100%')
// 没有设置 height!高度由内容撑开,剩余空间 = 0

3.5 固定段 + 弹性段组合的代码模式

build() {
  Column() {
    // 固定段 1:.height(50)
    Row() { /* 顶部状态栏 */ }
      .height(50)

    // 弹性段 A:.layoutWeight(1.0)
    Column() { /* Canvas 游戏场景 */ }
      .layoutWeight(1.0)

    // 弹性段 B:.layoutWeight(0.3)
    Column() { /* 状态信息 */ }
      .layoutWeight(0.3)

    // 弹性段 C:.layoutWeight(0.7)
    Column() { /* 跳跃按钮 */ }
      .layoutWeight(0.7)

    // 固定段 2:末尾无 layoutWeight,由内容撑高
    Column() { /* 说明面板 */ }
      // 没有 layoutWeight,也没有 height → 内容撑高
  }
  .width('100%')
  .height('100%')
}

3.6 弹性布局的屏幕自适应效果

当应用运行在不同屏幕尺寸(手机、平板、折叠屏)或不同方向(横竖屏)时:

  • 固定区保持 50vp 不变——标题栏始终可见
  • 弹性区按比例伸缩——游戏场景越大越好(角色可见范围更大),按钮区也按比例放大(防止小屏误触)

这正是弹性布局的核心价值:适配不靠写死,靠比例分配。


4. Canvas 游戏引擎:从零开始的渲染循环

游戏的实时画面由 Canvas 组件绘制。Canvas 是 ArkUI 中用于自定义绘图的组件,支持路径、形状、文本、渐变等完整的 2D 绘图 API。

4.1 Canvas 的创建与上下文

// 创建 Canvas 上下文
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D();

// 在 layoutWeight 弹性区中使用
Column() {
  Canvas(this.ctx)
    .width('100%')
    .height('100%')
    .onReady(() => {
      this.canvasW = this.ctx.width;
      this.canvasH = this.ctx.height;
      this.drawScene();
    })
}
.layoutWeight(1.0)

CanvasRenderingContext2D 是 Canvas 的绘图上下文,所有绘图操作都通过它完成。onReady 回调在 Canvas 组件初始化完成后触发,此时才能获取其实际宽高。

4.2 游戏主循环

使用 setInterval 实现固定频率的循环更新,每 24ms 执行一次(约 42 FPS):

private startGame(): void {
  this.gameState = GameState.PLAYING;
  if (this.timerId < 0) {
    this.timerId = setInterval(() => {
      this.gameLoop();
    }, 24);  // ≈ 42fps
  }
}

gameLoop() 是每一帧的核心函数,执行三个步骤:

gameLoop()
  ├── updatePhysics()    → 重力、移动、碰撞
  ├── checkCollision()   → AABB 碰撞检测
  └── drawScene()        → Canvas 绘图

4.3 帧率选择:为什么是 24ms?

60fps 对应约 16ms,30fps 对应约 33ms。选择 24ms(约 42fps)是出于以下考虑:

  • 游戏逻辑不复杂,30fps 的画面已经流畅
  • 42fps 在 30-60 之间,给物理更新提供了更细的时间粒度
  • 减少定时器回调频率有利于降低功耗

如果追求更高帧率,可以改为 16ms(60fps);如果更关注省电,可以改为 33ms(30fps)。

4.4 清除定时器

页面销毁时,必须清理定时器,否则会造成内存泄漏:

aboutToDisappear(): void {
  if (this.timerId >= 0) {
    clearInterval(this.timerId);
    this.timerId = -1;
  }
}

aboutToDisappear 是 ArkUI 组件的生命周期回调,在页面即将销毁时触发,类似于 Android 的 onDestroy() 或 iOS 的 viewDidDisappear()


5. 物理系统:重力、跳跃与碰撞检测

5.1 物理常量与参数调优

const GRAVITY: number = 0.55;        // 重力加速度
const JUMP_VEL: number = -9.0;       // 跳跃初速度(负值=向上)
const BASE_SPEED: number = 2.5;      // 基础障碍物移动速度
const MAX_SPEED: number = 7.0;       // 最大速度
const SPEED_STEP: number = 0.08;     // 每次得分增加的速度

这些常数构成了游戏的"物理引擎"。调整这些值可以微妙地改变游戏手感——这是游戏开发中被称为"参数调优"的重要环节:

  • GRAVITY(重力加速度):每帧叠加到垂直速度上的增量。值为 0.55 意味着约 22 帧(约 0.5 秒)后,跳跃上升速度会被重力完全抵消。GRAVITY 越大,角色下落越快,跳跃手感越"重";越小则手感越"飘"。0.55 是一个经过多次测试的平衡值——它让跳跃弧线清晰可辨,但又不会让玩家觉得"掉得太快"。
  • JUMP_VEL(跳跃初速度):跳跃瞬间赋予的垂直速度。负值代表向上。与 GRAVITY 配合决定了跳跃高度。理论最高跳跃高度大致为 JUMP_VEL² / (2 × GRAVITY) = 81 / 1.1 ≈ 74 像素。这个高度恰好能越过障碍物(28 像素高)的两倍以上,为玩家提供了充足的操作冗余。
  • BASE_SPEED(基准速度):障碍物每帧向左移动的像素数。2.5 意味着每帧移动 2.5 像素,约每秒 105 像素。这是游戏初期的难度基准。
  • SPEED_STEP(速度增长率):每得一分增加的速度。0.08 意味着每得 10 分速度增加 0.8,大约每 30 分速度翻倍(从 2.5 到 4.9)。这是一个温和但可感知的增长曲线。
  • MAX_SPEED(速度上限):防止速度无限增长导致游戏在后期变得不可能。7.0 的上限意味着最高速度约为初始速度的 2.8 倍,给玩家一个明确的难度天花板。

5.2 跳跃与重力模拟

每次 gameLoop() 调用时,物理系统执行两步计算:第一步是重力对速度的累加,第二步是速度对位置的更新。这种逐帧积分的方式在游戏物理中被称为"欧拉积分"(Euler Integration),是最简单也最常用的物理模拟方法。

// 第 1 步:重力作用 — 速度每帧增加(模拟持续向下的拉力)
this.playerVY = this.playerVY + GRAVITY;

// 第 2 步:位置更新 — 位置随速度变化(模拟运动轨迹)
this.playerY = this.playerY + this.playerVY;

// 第 3 步:地面碰撞 — 触地后归零,防止穿模
if (this.playerY >= 0) {
  this.playerY = 0;
  this.playerVY = 0;
}

这三个步骤构成了完整的跳跃物理循环。让我们追踪一次跳跃的全过程(假设初始状态:playerY=0, playerVY=0):

帧 0:玩家按下跳跃 → playerVY = -9.0
      重力作用后:playerVY = -9.0 + 0.55 = -8.45
      位置更新后:playerY = 0 + (-8.45) = -8.45  → 正在上升

帧 1:重力作用后:playerVY = -8.45 + 0.55 = -7.90
      位置更新后:playerY = -8.45 + (-7.90) = -16.35  → 继续上升

帧 2:重力作用后:playerVY = -7.90 + 0.55 = -7.35
      位置更新后:playerY = -16.35 + (-7.35) = -23.70  → 继续上升

...(持续上升直到速度被重力减为零)

帧 16:重力作用后:playerVY ≈ -0.05 + 0.55 = 0.50  ← 速度变正!开始下降
       位置更新后:playerY ≈ -73.0(最高点附近)

帧 17:重力作用后:playerVY = 0.50 + 0.55 = 1.05
       位置更新后:playerY = -73.0 + 1.05 = -71.95  → 开始下降

...(持续下降直到触地)

帧 33:playerY >= 0 → 触地!归零

从按下跳跃到落回地面,整个跳跃过程大约持续 33 帧(约 0.8 秒)。跳跃最高点约在 -74 像素处。这个节奏对于横版跑酷来说是合适的——跳跃时间足够让玩家越过障碍物,但又不至于漫长到让玩家感到等待。

跳跃触发时:

private doJump(): void {
  if (this.gameState === GameState.PLAYING) {
    if (this.playerY >= -2) {        // 只有在地面附近才能跳跃
      this.playerVY = JUMP_VEL;      // 赋予向上的初速度
    }
  }
}

playerY >= -2 的条件防止了"空中二段跳"——玩家必须落回地面附近才能再次跳跃。

5.3 障碍物移动与生成

障碍物从右向左移动:

// 移动所有障碍物
const moveDist: number = this.curSpeed;
for (let i: number = 0; i < this.obstacles.length; i++) {
  this.obstacles[i].x = this.obstacles[i].x - moveDist;
}

速度随得分线性增长:

this.curSpeed = BASE_SPEED + this.score * SPEED_STEP;
if (this.curSpeed > MAX_SPEED) {
  this.curSpeed = MAX_SPEED;
}

障碍物通过屏幕左侧后,触发加分并被移除:

let alive: Obstacle[] = [];
for (let i: number = 0; i < this.obstacles.length; i++) {
  if (this.obstacles[i].x > -O_W) {
    alive[alive.length] = this.obstacles[i];
  } else {
    this.score = this.score + 1;     // 安全通过 → 加分
    this.speedDisp = this.curSpeed.toFixed(1);
  }
}
this.obstacles = alive;

新障碍物随机生成:

this.spawnCD = this.spawnCD - 1;
if (this.spawnCD <= 0) {
  this.spawnCD = SPAWN_MIN + Math.floor(Math.random() * (SPAWN_MAX - SPAWN_MIN));
  this.obstacles[this.obstacles.length] = { x: this.canvasW };
}

5.4 AABB 碰撞检测

碰撞检测使用轴对齐包围盒(Axis-Aligned Bounding Box,AABB)算法。这种算法检测两个矩形是否重叠,是游戏开发中最基础也最高效的碰撞检测方式。

const px: number = this.canvasW * 0.2;           // 角色 X(固定)
const py: number = this.canvasH * P_GROUND + this.playerY - P_SIZE;

for (let i: number = 0; i < this.obstacles.length; i++) {
  const ox: number = this.obstacles[i].x;
  const oy: number = this.canvasH * P_GROUND - O_H;

  // AABB 四条件判断
  if (px < ox + O_W && px + P_SIZE > ox) {
    if (py < oy + O_H && py + P_SIZE > oy) {
      this.gameOver();  // 碰撞!
      return;
    }
  }
}

AABB 原理:两个矩形发生碰撞的条件是——在 X 轴上投影重叠在 Y 轴上投影重叠。每个轴上的重叠条件是:

矩形 A 的左边界 < 矩形 B 的右边界
&& 矩形 A 的右边界 > 矩形 B 的左边界

这个算法简单、快速、精确,非常适合像素级的碰撞判断。


6. 游戏状态机:从 READY 到 PLAYING 再到 OVER

游戏有三个状态,通过一个枚举管理:

enum GameState {
  READY = 0,     // 等待开始
  PLAYING = 1,   // 游戏中
  OVER = 2,      // 游戏结束
}

6.1 状态转换图

READY ───点击跳跃───→ PLAYING ───碰撞───→ OVER
  ↑                                        │
  └──────────────点击跳跃───────────────────┘

6.2 状态驱动的 UI 变化

按钮的文案和颜色随状态切换:

Button(this.btnLabel)   // btnLabel 是 @State 变量
  .backgroundColor('#FF6F00')

状态变化在 startGame()gameOver() 中同步更新:

private startGame(): void {
  this.gameState = GameState.PLAYING;
  this.stateLabel = '🏃 跑酷中...';
  this.btnLabel = '🦘 跳跃!';
  // 启动定时器...
}

private gameOver(): void {
  this.gameState = GameState.OVER;
  this.stateLabel = '💥 游戏结束!点击重新开始';
  this.btnLabel = '🔄 重新开始';
  if (this.score > this.bestScore) {
    this.bestScore = this.score;
  }
}

6.3 按钮的多重职责

单键设计意味着一个按钮承担了三种操作:

private doJump(): void {
  if (this.gameState === GameState.READY || this.gameState === GameState.OVER) {
    this.startGame();   // 开始 / 重新开始
    return;
  }
  if (this.gameState === GameState.PLAYING) {
    if (this.playerY >= -2) {
      this.playerVY = JUMP_VEL;  // 跳跃
    }
  }
}

这种设计让用户不需要寻找"开始"、“重试”、"跳跃"三个不同的按钮——永远只有一个按钮,做当前最需要的事。


7. @State 响应式数据与 UI 联动

7.1 @State 变量的声明与作用

@State private score: number = 0;
@State private bestScore: number = 0;
@State private stateLabel: string = '🔄 点击跳跃开始';
@State private speedDisp: string = '2.5';
@State private btnLabel: string = '🦘 点击跳跃!';

@State 是 ArkTS 响应式系统的核心装饰器。当 @State 变量变化时,框架自动更新所有依赖该变量的 UI 部分。

7.2 非 @State 的游戏状态

与 UI 显示无关的游戏内部状态不使用 @State 装饰:

private gameState: GameState = GameState.READY;
private playerY: number = 0;
private playerVY: number = 0;
private obstacles: Obstacle[] = [];
private curSpeed: number = BASE_SPEED;
private spawnCD: number = 0;
private frameCnt: number = 0;
private timerId: number = -1;
private canvasW: number = 0;
private canvasH: number = 0;

这些变量由 Canvas 每帧读取并绘制,不触发 UI 组件重绘。这是一个重要的性能优化——如果把 playerYobstacles 标记为 @State,那么每帧都会触发全 UI 重建,导致严重卡顿。

7.3 响应式与 Canvas 的协作模式

@State 变量变化
  → 框架自动重绘 UI 文本(得分、状态标签、按钮文字)
  → UI 文本更新

Canvas 绘图(非 @State)
  → gameLoop() 每帧调用 drawScene()
  → Canvas 上绘制角色、障碍物、场景
  → 不触发 UI 组件重绘

这种"响应式 UI + Canvas 手动绘制"的混合模式,兼顾了 UI 更新的便利性和游戏渲染的性能。


8. Canvas 绘图详解:角色、障碍物与场景渲染

8.1 天空与地面

// 天空渐变
const grad: CanvasGradient = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, '#87CEEB');   // 天蓝色
grad.addColorStop(0.7, '#E0F7FA'); // 浅青色
grad.addColorStop(1, '#A5D6A7');   // 浅绿(地面附近)
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);

// 棕色地面
const groundY: number = h * P_GROUND;
ctx.fillStyle = '#5D4037';
ctx.fillRect(0, groundY, w, h - groundY);

// 草地边沿
ctx.fillStyle = '#66BB6A';
ctx.fillRect(0, groundY - 4, w, 4);

使用 createLinearGradient 创建从上到下的渐变背景,从天空蓝过渡到地面绿。地面位置由常量 P_GROUND = 0.78 控制,即地面位于 Canvas 78% 的高度处。

8.2 动态地面纹理

地面上的线条会产生"滚动"效果,让玩家感知角色的移动:

ctx.strokeStyle = '#4E342E';
ctx.lineWidth = 1;
for (let i: number = 0; i < 6; i++) {
  const lx: number = (this.frameCnt * 2 + i * 60) % (w + 40) - 20;
  ctx.beginPath();
  ctx.moveTo(lx, groundY + 6);
  ctx.lineTo(lx + 20, groundY + 6);
  ctx.stroke();
}

frameCnt 每帧递增,乘以 2 后取模运算,产生持续向左滚动的效果。这模拟了跑步机上视觉参考线的移动。

8.3 障碍物绘制

每个障碍物是一个棕色木箱,带有 X 装饰纹:

// 主体
ctx.fillStyle = '#8D6E63';
ctx.fillRect(ox, oy, O_W, O_H);

// 边框
ctx.strokeStyle = '#5D4037';
ctx.lineWidth = 1.5;
ctx.strokeRect(ox, oy, O_W, O_H);

// X 装饰
ctx.strokeStyle = '#D7CCC8';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ox + 3, oy + 5);
ctx.lineTo(ox + O_W - 3, oy + O_H - 5);
ctx.moveTo(ox + O_W - 3, oy + 5);
ctx.lineTo(ox + 3, oy + O_H - 5);
ctx.stroke();

8.4 角色绘制

角色是一个蓝色圆角方块,带有眼睛和微笑:

const px: number = w * 0.2;
const py: number = groundY + this.playerY - P_SIZE;

// 身体(圆角矩形)
ctx.fillStyle = '#1565C0';
ctx.beginPath();
ctx.roundRect(px, py, P_SIZE, P_SIZE, 4);
ctx.fill();

// 眼睛(白色圆 + 黑色瞳孔)
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(px + 6, py + 8, 3, 0, Math.PI * 2);
ctx.arc(px + 16, py + 8, 3, 0, Math.PI * 2);
ctx.fill();

// 瞳孔
ctx.fillStyle = '#000000';
ctx.beginPath();
ctx.arc(px + 7, py + 8, 1.5, 0, Math.PI * 2);
ctx.arc(px + 17, py + 8, 1.5, 0, Math.PI * 2);
ctx.fill();

// 微笑
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(px + 11, py + 14, 4, 0.1, Math.PI - 0.1);
ctx.stroke();

roundRect 是 ArkUI Canvas API 中绘制圆角矩形的便捷方法,接受四个参数:x、y、宽、高、圆角半径。

跳跃时,角色底部显示黄色"喷气"特效:

if (this.playerY < -2) {
  ctx.fillStyle = '#FFEB3B';
  ctx.beginPath();
  ctx.arc(px + 11, py + P_SIZE + 4, 3, 0, Math.PI * 2);
  ctx.fill();
}

8.5 游戏状态覆盖层

在 READY 和 OVER 状态下,Canvas 上覆盖半透明蒙层并显示状态文字:

// 游戏结束
if (this.gameState === GameState.OVER) {
  ctx.fillStyle = '#00000080';
  ctx.fillRect(0, 0, w, h);
  ctx.fillStyle = '#ffffff';
  ctx.font = '20px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('💥 游戏结束', w / 2, h / 2 - 20);
  ctx.fillText('得分:' + this.score + '  |  最高:' + this.bestScore, w / 2, h / 2 + 16);
}

// 等待开始
if (this.gameState === GameState.READY) {
  ctx.fillStyle = '#00000040';
  ctx.fillRect(0, 0, w, h);
  ctx.fillStyle = '#ffffff';
  ctx.font = '22px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('🏃 单键跑酷', w / 2, h / 2 - 30);
  ctx.fillText('点击下方按钮跳跃,越过障碍物得分', w / 2, h / 2 + 10);
  ctx.fillText('点击 跳跃 开始', w / 2, h / 2 + 40);
}

这种直接在 Canvas 上绘制状态文字的方式,比使用 ArkUI 的 Text 组件覆盖在 Canvas 上更高效——避免了额外的组件层级。


9. 生命周期管理:定时器的正确清理

9.1 定时器泄漏的风险

setInterval 创建的定时器会持续运行,直到显式调用 clearInterval 销毁。如果页面跳转或返回时没有清理定时器:

  • 定时器回调仍在执行 → 访问已销毁的组件 → 运行时错误
  • Canvas 绘图操作向已释放的内存写入 → 潜在崩溃
  • 多个页面实例各自创建定时器 → 内存持续增长

9.2 正确的清理时机

ArkUI 提供了 aboutToDisappear 生命周期回调,在组件销毁前触发:

aboutToDisappear(): void {
  if (this.timerId >= 0) {
    clearInterval(this.timerId);
    this.timerId = -1;
  }
  hilog.info(0x0000, TAG, 'page destroyed, timer cleaned');
}

同时,每次 startGame() 调用时检查定时器是否已存在,避免重复创建:

private startGame(): void {
  if (this.timerId < 0) {
    this.timerId = setInterval(() => { this.gameLoop(); }, 24);
  }
}

9.3 ArkTS 的完整生命周期

aboutToAppear()  →  组件即将创建(初始化数据)
build()          →  组件首次渲染
onPageShow()     →  页面显示(Router 场景)
onPageHide()     →  页面隐藏
aboutToDisappear()  →  组件销毁(清理资源)

对于游戏类应用,aboutToDisappear 是最重要的清理入口,必须确保所有定时器、动画、资源流在此处释放。


10. 性能优化与最佳实践

10.1 @State 最小化原则

只将直接显示在 UI 文本中的数据标记为 @State,游戏内部状态(角色位置、障碍物列表、帧计数器)保持为普通私有变量。

好(本应用做法):

@State private score: number = 0;         // 在 Text 中显示 → @State
private playerY: number = 0;              // Canvas 自己读取 → 非 @State
private obstacles: Obstacle[] = [];       // Canvas 遍历绘制 → 非 @State

坏(会导致卡顿):

@State private playerY: number = 0;       // 每帧变化 → 每帧触发 UI 重绘
@State private obstacles: Obstacle[] = [];// 每帧变化 → 每帧重建 UI 树

10.2 Canvas 尺寸获取时机

Canvas 组件的 widthheight 不是在构造函数中确定的,而是在 onReady 回调触发时才可用。

Canvas(this.ctx)
  .onReady(() => {
    this.canvasW = this.ctx.width;   // 此时才拿到实际尺寸
    this.canvasH = this.ctx.height;
    this.drawScene();
  })

onReady 之前调用 this.ctx.width 会返回 0。

10.3 帧率与性能的平衡

42fps 是本应用的平衡选择。如需调整:

// 60fps(更流畅,更高功耗)
this.timerId = setInterval(() => { this.gameLoop(); }, 16);

// 30fps(更省电,适合简单场景)
this.timerId = setInterval(() => { this.gameLoop(); }, 33);

10.4 单键交互的可用性

整个游戏只有一种交互——点击按钮。这种设计有三个好处:

  • 零学习成本:用户不需要记住多个操作
  • 防误触:跳跃按钮独占整个弹性区,面积大,点击精确度高
  • 状态合一:按钮文案随状态变化(跳跃/开始/重开),自动引导用户

10.5 使用 ArkTS 严格模式的注意事项

ArkTS 对 TypeScript 做了严格的限制以确保编译时优化:

  • 变量必须显式标注类型,不使用 any
  • 不使用 evalwith 等动态特性
  • 数组操作使用基础 for 循环而非 forEach/map(本应用中的 for 循环写法)
  • 字符串拼接使用 + 运算符而非模板字符串(部分版本不支持)

11. 从同一起跑线出发:总结与扩展思考

11.1 核心要点回顾

本文通过一个单键跑酷游戏,系统讲解了以下技术:

布局技术:

知识点 核心方法 说明
Column 弹性布局 layoutWeight(flex) 按比例分配 Column 剩余空间
固定区设置 height(px) 锁定高度,不参与弹性分配
全屏撑满 .width('100%').height('100%') layoutWeight 生效的前提

游戏技术:

知识点 实现方式 说明
游戏循环 setInterval 固定频率循环执行 gameLoop
Canvas 渲染 CanvasRenderingContext2D 2D 绘图 API
物理模拟 欧拉积分 重力 + 初速度 → 跳跃轨迹
碰撞检测 AABB 轴对齐包围盒重叠判断
地面滚动 帧计数器取模 模拟背景运动

数据管理:

知识点 使用位置 说明
@State score/bestScore/状态文字 触发 UI 文本更新
非 @State playerY/obstacles Canvas 每帧读取,不触发重绘
生命周期 aboutToDisappear 清理定时器,防止泄漏

11.2 扩展方向

这个极简的游戏框架可以方便地扩展:

  • 双段跳跃:允许在空中再跳一次,增加操作深度
  • 道具系统:增加加速、护盾、磁铁等道具
  • 分数排行榜:使用 @State + 本地存储实现历史最高分持久化
  • 难度曲线调整:使用更复杂的公式(如指数增长)替代线性增长
  • 音效与振动:集成 AudioKit 和 Vibrator 提供感官反馈
  • 多角色选择:在顶层增加角色选择页面,通过路由跳转

11.3 从单键到多键:交互复杂度演进

“单键跑酷"的核心设计理念是极简交互——用户只需要关注一个按钮,不需要思考"该按哪个键”。这种设计哲学可以沿用到更复杂的场景:

  • 双段跳跃:允许玩家在空中再按一次跳跃键完成二段跳。物理引擎只需要增加一个"跳跃计数"变量和一帧内的二次跳跃检测。这是从"单键"到"双键"的最自然的演进。
  • 长按蓄力:长按按钮蓄力,松开发射。按钮标签从"跳跃"变为"蓄力中…",@State 更新按钮文案,物理引擎根据蓄力时间赋予不同的初速度。
  • 滑动控制:在按钮区增加左右滑动检测,控制角色左右移动。这需要引入手势识别,但布局结构不变——按钮区仍然占用 layoutWeight(0.7) 的弹性空间。

无论交互如何演进,底层的 Column + layoutWeight 布局架构始终保持不变。这就是弹性布局的价值——它为交互扩展提供了稳定的结构基础。

11.4 鸿蒙 NEXT 开发者的成长路径

如果你是一位刚接触鸿蒙 NEXT 开发的开发者,本文涉及的知识点可以作为你的学习路线图:

  1. 第一阶段(布局基础):掌握 Column、Row、Stack 三大容器,理解主轴和交叉轴的概念
  2. 第二阶段(弹性布局):掌握 layoutWeight 属性,能够搭建"固定头 + 弹性体 + 固定底"的三段式架构
  3. 第三阶段(状态管理):理解 @State、@Prop、@Link 装饰器,能够设计响应式数据流
  4. 第四阶段(自定义绘制):掌握 Canvas 和 CanvasRenderingContext2D,能够实现自定义图形和动画
  5. 第五阶段(综合应用):将布局、状态、绘图三者融合,构建完整的交互应用

从 Column 布局到 Canvas 绘图,从状态管理到物理引擎,鸿蒙 ArkTS 提供了一套完整的工具链来构建从简单 UI 到复杂游戏的各种应用。核心思路始终如一:

  • 布局:用 height() 锁定固定区,用 layoutWeight() 分配弹性区
  • 状态:用 @State 管理 UI 数据,用普通变量管理性能敏感数据
  • 渲染:用 ArkUI 组件绘制静态 UI,用 Canvas 绘制动态画面

这个"单键跑酷"小游戏证明了:即使是最简单的交互设计,只要底层架构合理,也能呈现出流畅、有趣的用户体验。而 Column + layoutWeight 弹性布局正是这个架构的基石——它让应用在不同屏幕尺寸下始终表现出色,让开发者可以专注于游戏逻辑本身。

Logo

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

更多推荐