HarmonyOS NEXT 单键跑酷游戏开发实战 —— Column + layoutWeight 弹性布局与 Canvas 游戏引擎


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"
}
]
}
targetSdkVersion 和 compatibleSdkVersion 均为 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 组件重绘。这是一个重要的性能优化——如果把 playerY 或 obstacles 标记为 @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 组件的 width 和 height 不是在构造函数中确定的,而是在 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 - 不使用
eval、with等动态特性 - 数组操作使用基础
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 开发的开发者,本文涉及的知识点可以作为你的学习路线图:
- 第一阶段(布局基础):掌握 Column、Row、Stack 三大容器,理解主轴和交叉轴的概念
- 第二阶段(弹性布局):掌握 layoutWeight 属性,能够搭建"固定头 + 弹性体 + 固定底"的三段式架构
- 第三阶段(状态管理):理解 @State、@Prop、@Link 装饰器,能够设计响应式数据流
- 第四阶段(自定义绘制):掌握 Canvas 和 CanvasRenderingContext2D,能够实现自定义图形和动画
- 第五阶段(综合应用):将布局、状态、绘图三者融合,构建完整的交互应用
从 Column 布局到 Canvas 绘图,从状态管理到物理引擎,鸿蒙 ArkTS 提供了一套完整的工具链来构建从简单 UI 到复杂游戏的各种应用。核心思路始终如一:
- 布局:用
height()锁定固定区,用layoutWeight()分配弹性区 - 状态:用
@State管理 UI 数据,用普通变量管理性能敏感数据 - 渲染:用 ArkUI 组件绘制静态 UI,用 Canvas 绘制动态画面
这个"单键跑酷"小游戏证明了:即使是最简单的交互设计,只要底层架构合理,也能呈现出流畅、有趣的用户体验。而 Column + layoutWeight 弹性布局正是这个架构的基石——它让应用在不同屏幕尺寸下始终表现出色,让开发者可以专注于游戏逻辑本身。
更多推荐



所有评论(0)