从零到一:基于HarmonyOS ArkUI开发经典"别踩白块"游戏的完整技术实践

##项目演示
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

一、项目概述与背景

在移动应用开发领域,休闲小游戏一直是用户获取即时满足感和碎片化娱乐的重要载体。其中,“别踩白块”(Don’t Tap The White Tile)作为一款曾经风靡全球的极简风格游戏,以其极低的学习门槛、极高的反应速度要求和无限的可玩性,成为了移动游戏史上的经典案例。本文将详细记录如何使用华为HarmonyOS的ArkUI框架,从零开始构建一个完整的"别踩白块"游戏应用,深入剖析其中的技术选型、架构设计、核心实现以及性能优化策略。

1.1 项目价值

在当今移动开发环境中,跨平台框架如React Native、Flutter等占据了一定的市场份额,但原生框架在性能和系统集成方面仍然具有不可替代的优势。HarmonyOS作为华为推出的分布式操作系统,其ArkUI声明式UI框架为开发者提供了一种全新的开发范式。通过这个简单游戏项目的实践,我们可以深入理解:

  • ArkUI声明式编程范式:与传统命令式UI开发的差异和优势
  • Stage模型应用架构:HarmonyOS新的应用模型设计理念
  • TypeScript/ETS语言特性:强类型语言在UI开发中的优势
  • 状态管理最佳实践:如何高效管理组件状态和用户交互
  • 游戏循环设计:定时器驱动的游戏逻辑实现方式

1.2 游戏规则与用户体验

"别踩白块"的核心玩法非常简单,但却极具挑战性:

基本规则:

  • 游戏区域被划分为4列N行的网格布局
  • 每一行恰好有一个黑色方块,其余为白色方块
  • 方块持续向下移动(或通过定时器模拟移动效果)
  • 玩家需要在方块移出屏幕前点击黑色方块
  • 点击白色方块或漏掉黑色方块均会导致游戏结束
  • 随着分数的增加,游戏速度逐渐加快,难度递增

用户体验目标:

  • 零学习成本:用户无需阅读复杂的教程即可上手
  • 即时反馈:点击后立即显示视觉反馈(颜色变化、勾选标记)
  • 流畅操作:60fps的流畅动画和响应体验
  • 渐进式难度:确保游戏既有挑战性又不会令人沮丧

1.3 技术选型决策

在开始编码之前,我们需要做出一系列技术决策:

为什么选择ArkUI?

  • 声明式编程:通过描述"UI应该是什么样的"而非"如何创建UI",代码更简洁易懂
  • TypeScript/ETS:强类型支持提供更好的IDE提示和编译时错误检查
  • 高性能:ArkUI引擎经过深度优化,支持复杂动画和交互
  • 分布式能力:未来可扩展到多设备协同游戏

为什么选择Stage模型?

  • 模块化设计:Ability和UI解耦,便于单元测试和维护
  • 生命周期清晰:明确的页面和组件生命周期管理
  • 配置化管理:通过module.json5统一管理应用配置

二、HarmonyOS与ArkUI技术栈深度解析

2.1 HarmonyOS系统架构

HarmonyOS采用分层设计,从底层到上层依次为:

  1. 内核层:基于Linux内核或LiteOS,提供基础操作系统能力
  2. 系统服务层:提供分布式软总线、设备虚拟化、数据管理等核心服务
  3. 框架层:提供应用框架、图形框架、多媒体框架等
  4. 应用层:开发者构建的应用程序

ArkUI位于框架层,是HarmonyOS提供的UI开发框架,支持两种开发范式:

  • ArkTS声明式开发范式:本文采用的方式,适合现代UI开发
  • Java/C++类Web式开发范式:传统的命令式开发方式

2.2 ArkUI声明式编程模型

ArkUI声明式编程模型的核心思想是"UI即状态的函数"。开发者只需要描述在特定状态下UI应该呈现的样子,框架会自动处理状态变化时的UI更新。

核心概念:

  • @Component:装饰器,标记一个类为自定义组件
  • @Entry:装饰器,标记组件为页面入口组件
  • @State:装饰器,标记组件内部状态,状态变化会触发UI重建
  • build():方法,描述组件的UI结构
  • @Builder:装饰器,标记一个方法为可复用的UI构建函数

声明式vs命令式对比:

命令式(传统Android):

Button button = new Button(context);
button.setText("开始游戏");
button.setTextColor(Color.WHITE);
button.setOnClickListener(v -> {
    // 处理点击
});
layout.addView(button);

声明式(ArkUI):

Button('开始游戏')
  .fontColor(Color.White)
  .onClick(() => {
    // 处理点击
  })

可以看到,声明式方式更加简洁,代码更具可读性,且UI结构一目了然。

2.3 Stage模型应用架构

HarmonyOS的Stage模型是一种全新的应用架构,与传统的FA模型相比具有以下优势:

组件化设计:

  • UIAbility:应用的基本入口组件,负责管理页面栈和生命周期
  • Page:应用中的单个页面,对应用户可见的一个屏幕
  • Component:可复用的UI组件

配置管理:

每个应用模块通过module.json5文件进行配置,包括:

  • 模块名称和类型
  • 支持的设备类型
  • 页面路由配置
  • Ability配置
  • 权限声明

本文项目的module.json5配置中,我们可以看到:

  • 模块类型为entry(主入口模块)
  • 支持设备类型为phone
  • 主Ability为EntryAbility
  • 页面配置引用$profile:main_pages

资源管理:

HarmonyOS采用基于资源限定符的资源管理机制:

  • base目录:默认资源,任何情况都可使用
  • dark目录:深色模式专用资源
  • zh_CN等:语言和地区专用资源

资源引用方式:

  • $r('app.string.app_name'):引用应用级资源
  • $r('app.media.background'):引用媒体资源
  • $r('app.color.primary'):引用颜色资源

2.4 ETS/TypeScript语言特性

ArkUI使用ETS(Extended TypeScript)作为开发语言,它是TypeScript的超集,支持所有TypeScript特性,并添加了一些UI开发专用的装饰器。

关键语言特性:

  1. 类型系统
// 枚举类型定义游戏状态
enum GameState {
  START,
  PLAYING,
  END
}

// 类定义数据模型
class BlockData {
  isBlack: boolean
  clicked: boolean
  
  constructor(isBlack: boolean) {
    this.isBlack = isBlack
    this.clicked = false
  }
}
  1. 装饰器模式
@Entry
@Component
struct GamePage {
  @State score: number = 0
  @State gameState: GameState = GameState.START
  // ...
}
  1. 箭头函数:用于事件处理和数组操作,保持上下文绑定
this.rows.some(block => block.isBlack)
  1. 模板字符串:用于动态文本内容
Text(`分数: ${this.score}`)

三、项目架构与文件结构

3.1 项目目录结构

一个标准的HarmonyOS Stage模型项目具有以下目录结构:

xxx/
├── AppScope/                    # 应用全局配置
│   ├── resources/               # 应用级资源
│   │   └── base/
│   │       ├── element/
│   │       │   └── string.json  # 应用字符串资源
│   │       └── media/           # 应用媒体资源
│   └── app.json5                # 应用全局配置
├── entry/                       # 主模块
│   └── src/
│       └── main/
│           ├── ets/             # ETS源码
│           │   ├── entryability/
│           │   │   └── EntryAbility.ets  # 应用入口Ability
│           │   └── pages/
│           │       └── Index.ets         # 主页面(游戏实现)
│           ├── resources/       # 模块级资源
│           │   └── base/
│           │       ├── element/
│           │       │   ├── color.json
│           │       │   ├── float.json
│           │       │   └── string.json
│           │       ├── media/
│           │       └── profile/
│           │           ├── backup_config.json
│           │           └── main_pages.json  # 页面路由配置
│           └── module.json5     # 模块配置
├── oh-package.json5             # 依赖配置
├── build-profile.json5          # 构建配置
└── hvigorfile.ts                # 构建脚本

3.2 关键配置文件解析

app.json5 - 应用全局配置:

{
  "app": {
    "bundleName": "com.example.xxx",    // 应用包名,全球唯一
    "vendor": "example",                // 开发者/公司标识
    "versionCode": 1000000,             // 版本号(整数,用于升级判断)
    "versionName": "1.0.0",             // 版本名(显示给用户)
    "icon": "$media:layered_image",     // 应用图标
    "label": "$string:app_name"         // 应用名称
  }
}

module.json5 - 模块配置:

{
  "module": {
    "name": "entry",                    // 模块名称
    "type": "entry",                    // 模块类型:entry主模块/feature特性模块
    "deviceTypes": ["phone"],           // 支持的设备类型
    "pages": "$profile:main_pages",     // 页面路由配置
    "abilities": [                      // Ability配置
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "skills": [                     // 声明能处理的Intent
          {
            "entities": ["entity.system.home"],
            "actions": ["ohos.want.action.home"]
          }
        ]
      }
    ]
  }
}

main_pages.json - 页面路由配置:

{
  "src": [
    "pages/Index"  // 页面路径,不包含.ets后缀
  ]
}

3.3 组件架构设计

本游戏应用采用单层架构设计,所有逻辑集中在一个页面组件中。这种设计对于简单游戏来说是合理的,但对于更复杂的应用,可能需要考虑进一步的模块化。

组件层次结构:

Index (页面入口)
├── Row (标题栏)
│   └── Text ("别踩白块")
├── Row (分数栏)
│   └── Text ("分数: X")
└── Stack (游戏区域容器)
    ├── Column (方块网格)
    │   └── ForEach (渲染每一行)
    │       └── Row (一行方块)
    │           └── ForEach (渲染每个方块)
    │               └── Column (单个方块)
    ├── Column (开始界面覆盖层) [条件渲染]
    └── Column (结束界面覆盖层) [条件渲染]

状态管理架构:

// 组件状态(响应式)
@State gameState: GameState = GameState.START
@State score: number = 0
@State rows: BlockData[][] = []

// 私有状态(非响应式)
private interval: number = -1
private speed: number = 1000
private rowCount: number = 5

四、核心功能实现详解

4.1 游戏状态管理

状态管理是任何交互式应用的核心。在ArkUI中,我们使用装饰器来标记哪些数据变化会触发UI更新。

状态设计原则:

  1. 最小化响应式状态:只将真正需要触发UI更新的数据标记为@State
  2. 数据类型选择:使用合适的数据类型(number, boolean, enum, object, array)
  3. 状态一致性:确保相关状态同时更新,避免中间状态

游戏状态枚举设计:

enum GameState {
  START,   // 开始状态:显示开始界面
  PLAYING, // 游戏中:方块移动,接收用户输入
  END      // 结束状态:显示最终得分
}

使用枚举而非字符串或数字的优势:

  • 类型安全:编译器会检查枚举值的有效性
  • 可读性GameState.START0"start"更清晰
  • 重构友好:修改枚举值不影响代码逻辑

核心状态变量:

@State gameState: GameState = GameState.START
  • 控制当前显示的界面(开始/游戏中/结束)
  • 决定是否响应用户点击
  • 控制游戏循环的启动和停止
@State score: number = 0
  • 实时更新分数显示
  • 用于结束界面的最终得分展示
  • 作为难度递增的判断依据
@State rows: BlockData[][] = []
  • 存储所有方块数据的二维数组
  • 第一层索引代表行,第二层代表列
  • @State装饰器确保数组修改时UI能够重新渲染

4.2 方块数据模型设计

数据模型的设计直接影响到游戏逻辑的复杂度和可维护性。

BlockData类设计:

class BlockData {
  isBlack: boolean  // 是否为黑色方块
  clicked: boolean  // 是否已被点击
  
  constructor(isBlack: boolean) {
    this.isBlack = isBlack
    this.clicked = false
  }
}

设计决策分析:

  1. 为什么不用接口?

    • 接口只定义类型,不能包含初始化逻辑
    • 类的构造函数可以统一初始化clickedfalse
    • 未来扩展时可以添加方法
  2. 为什么需要clicked字段?

    • 防止重复点击同一方块
    • 提供视觉反馈(显示✓标记)
    • 作为漏掉黑色方块的判断依据
  3. 为什么是二维数组?

    • 自然对应网格布局的行和列
    • 便于通过索引访问特定位置的方块
    • ForEach组件可以直接嵌套渲染

4.3 页面初始化与生命周期

ArkUI组件提供了完整的生命周期钩子,允许我们在合适的时机执行初始化和清理操作。

生命周期钩子:

aboutToAppear() {
  this.initGame()
}

aboutToDisappear() {
  this.stopGame()
}

生命周期流程图:

组件创建
    ↓
aboutToAppear() [初始化游戏数据]
    ↓
build() [首次渲染UI]
    ↓
用户交互 / 定时器触发
    ↓
状态变化 → build()重新执行
    ↓
用户离开页面
    ↓
aboutToDisappear() [清理定时器]
    ↓
组件销毁

初始化逻辑实现:

initGame() {
  this.rows = []
  for (let i = 0; i < this.rowCount; i++) {
    this.addRow()
  }
}

这里使用unshift方法将新行添加到数组开头,确保方块从上到下显示。

4.4 方块生成与布局渲染

游戏的核心视觉元素是4列的方块网格。让我们深入了解其实现原理。

添加新行逻辑:

addRow() {
  const blackIndex = Math.floor(Math.random() * 4)  // 随机0-3
  const row: BlockData[] = []
  for (let col = 0; col < 4; col++) {
    row.push(new BlockData(col === blackIndex))
  }
  this.rows.unshift(row)  // 添加到数组开头
}

随机数生成解析:

  • Math.random():生成[0, 1)之间的随机小数
  • Math.random() * 4:生成[0, 4)之间的随机小数
  • Math.floor(...):向下取整,得到0、1、2、3中的一个

为什么固定4列?

  • 经典游戏设计,经过市场验证
  • 手指覆盖范围适合操作
  • 难度与趣味性的最佳平衡点

UI渲染实现:

Column() {
  ForEach(this.rows, (row: BlockData[], rowIndex: number) => {
    Row() {
      ForEach(row, (block: BlockData, colIndex: number) => {
        Column() {
          Text(block.clicked ? '✓' : '')
            .fontSize(36)
            .fontColor(Color.White)
        }
        .layoutWeight(1)
        .height('100%')
        .backgroundColor(block.clicked ? '#888888' : (block.isBlack ? Color.Black : Color.White))
        .border({ color: '#CCCCCC', width: 1 })
        .onClick(() => {
          this.handleClick(rowIndex, colIndex)
        })
      })
    }
    .width('100%')
    .layoutWeight(1)
  })
}

ForEach组件详解:

ForEach是ArkUI提供的列表渲染组件,它的签名如下:

ForEach(
  arr: any[],                   // 要遍历的数组
  itemGenerator: (item: any, index: number) => void,  // 每个元素的UI构建函数
  keyGenerator?: (item: any) => string  // 可选的key生成函数(性能优化)
)

layoutWeight属性的作用:

在Row中使用.layoutWeight(1)可以让子组件平分剩余空间:

  • 4个方块都设置layoutWeight(1),每个占1/4宽度
  • 5行都设置layoutWeight(1),每行占1/5高度
  • 响应式布局,适应不同屏幕尺寸

样式链式调用:

ArkUI采用链式调用的方式设置组件属性,这种方式借鉴了SwiftUI和Flutter的设计理念:

Column()
  .layoutWeight(1)
  .height('100%')
  .backgroundColor(Color.Black)
  .border({ color: '#CCCCCC', width: 1 })

这种风格的优势:

  • 代码结构清晰,UI描述一目了然
  • 无需创建中间变量
  • 支持代码提示和类型检查

4.5 游戏循环与方块"下落"实现

"别踩白块"的核心游戏体验来自于方块不断向下移动带来的紧迫感。让我们分析几种实现方案。

方案对比:

方案 实现方式 优点 缺点
方案A:translate动画 每个方块设置y偏移,定时增加 视觉效果流畅,真实下落感 实现复杂,需精确计算边界
方案B:定时器替换 定时移除底部行,顶部添加新行 实现简单,逻辑清晰 视觉效果是"闪烁"而非滑动
方案C:混合方式 短间隔translate + 边界替换 视觉效果好 最复杂,调试困难

本文采用方案B的实现:

startTimer() {
  if (this.interval !== -1) {
    clearInterval(this.interval)
  }
  this.interval = setInterval(() => {
    this.moveDown()
  }, this.speed)
}

moveDown() {
  const bottomRow = this.rows[this.rows.length - 1]
  let hasUnclickedBlack = false
  for (let col = 0; col < 4; col++) {
    if (bottomRow[col].isBlack && !bottomRow[col].clicked) {
      hasUnclickedBlack = true
      break
    }
  }

  if (hasUnclickedBlack) {
    this.gameOver()
    return
  }

  this.rows.pop()      // 移除底部行
  this.addRow()        // 顶部添加新行
}

JavaScript定时器机制:

setInterval是浏览器/JavaScript环境提供的定时执行函数:

setInterval(callback, delay)
  • callback:要周期性执行的函数
  • delay:执行间隔(毫秒)
  • 返回值:定时器ID,用于后续取消

为什么要先clearInterval?

if (this.interval !== -1) {
  clearInterval(this.interval)
}
  • 防止重复启动导致多个定时器同时运行
  • 确保重新开始游戏时使用新的速度值
  • 这是一种防御性编程实践

游戏逻辑流程:

定时器触发
    ↓
获取最底部一行
    ↓
检查是否有未点击的黑色方块
    ↓
        ┌─ 有 → 游戏结束 → return
        │
        └─ 无 → 继续
    ↓
移除底部行
    ↓
顶部添加新行(随机黑色位置)
    ↓
等待下一次定时器触发

4.6 用户输入处理

游戏的交互核心是方块点击事件。让我们分析点击处理逻辑。

点击处理实现:

handleClick(rowIndex: number, colIndex: number) {
  if (this.gameState !== GameState.PLAYING) return

  const block = this.rows[rowIndex][colIndex]

  if (block.clicked) return

  if (block.isBlack) {
    block.clicked = true
    this.score++

    if (this.score > 0 && this.score % 5 === 0 && this.speed > 300) {
      this.speed -= 100
      this.startTimer()
    }
  } else {
    this.gameOver()
  }
}

防御性检查解析:

if (this.gameState !== GameState.PLAYING) return
  • 在开始/结束界面点击方块不响应
  • 防止游戏状态不一致
if (block.clicked) return
  • 防止重复点击
  • 避免分数异常增加
  • 已经点击的方块视觉上应该是灰色的

正确点击处理流程:

点击黑色方块
    ↓
标记为已点击
    ↓
分数+1
    ↓
检查是否达到难度提升条件
    ↓
        ┌─ 是(每5分)→ 速度加快 → 重启定时器
        │
        └─ 否 → 等待下一次点击

难度递增算法:

if (this.score > 0 && this.score % 5 === 0 && this.speed > 300) {
  this.speed -= 100
  this.startTimer()
}
  • 初始速度:1000ms(1秒)
  • 速度变化:每次-100ms
  • 最小速度:300ms
  • 触发条件:分数是5的倍数
分数区间 间隔时间 每秒行数
0-4 1000ms 1行/秒
5-9 900ms ~1.1行/秒
10-14 800ms 1.25行/秒
35+ 300ms ~3.3行/秒

为什么最小限制300ms?

  • 人类反应速度极限约为200-250ms
  • 还需要加上手指移动到目标位置的时间
  • 保持游戏的可玩性而非纯粹的测试反应极限

错误点击处理:

else {
  this.gameOver()
}
  • 点击白色方块立即结束游戏
  • 这是"别踩白块"名称的由来
  • 简单直接的失败条件

4.7 游戏结束与重新开始

结束处理:

gameOver() {
  this.gameState = GameState.END
  this.stopGame()
}

stopGame() {
  if (this.interval !== -1) {
    clearInterval(this.interval)
    this.interval = -1
  }
}

关键操作:

  1. 将状态改为END,触发结束界面渲染
  2. 清除定时器,停止方块移动
  3. 将interval重置为-1(哨兵值)

重新开始:

startGame() {
  this.gameState = GameState.PLAYING
  this.score = 0
  this.speed = 1000
  this.rows = []
  this.initGame()
  this.startTimer()
}

关键操作:

  1. 状态重置为PLAYING
  2. 所有状态变量恢复初始值
  3. 重新初始化方块数组
  4. 启动游戏循环

4.8 条件渲染与界面覆盖层

游戏有三个不同的界面状态,我们使用条件渲染来控制显示。

条件渲染语法:

if (this.gameState === GameState.START) {
  // 渲染开始界面
}

if (this.gameState === GameState.END) {
  // 渲染结束界面
}

ArkUI的条件渲染与普通TypeScript条件语句完全一致,但有一个重要区别:

  • 编译时转换:ArkUI编译器会将if语句转换为条件节点
  • 惰性计算:条件不满足时,内部组件不会实例化

开始界面实现:

Column() {
  Column() {
    Text('别踩白块')
      .fontSize(36)
      .fontWeight(FontWeight.Bold)
      .fontColor(Color.Black)
      .margin({ bottom: 20 })

    Text('游戏规则:')
      .fontSize(18)
      .fontColor('#666666')
      .margin({ bottom: 10 })

    Text('点击黑色方块得分')
      .fontSize(16)
      .fontColor('#666666')
      .margin({ bottom: 5 })
    // ... 更多规则说明

    Button('开始游戏')
      .width(160)
      .height(50)
      .fontSize(20)
      .backgroundColor(Color.Black)
      .fontColor(Color.White)
      .onClick(() => {
        this.startGame()
      })
  }
  .padding(30)
  .borderRadius(20)
  .backgroundColor(Color.White)
  .shadow({ radius: 10, color: '#999999' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.3)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)

视觉层级设计:

使用Stack容器实现覆盖层效果:

Stack (层叠容器)
├── 底层:方块网格(始终存在)
├── 中层:开始界面(条件显示)
└── 顶层:结束界面(条件显示)

Stack的特性:

  • 子组件按顺序层叠,后添加的在上层
  • alignContent参数控制对齐方式
  • 适合实现弹窗、覆盖层等效果

半透明遮罩层:

.backgroundColor('rgba(0,0,0,0.3)')
  • rgba:红绿蓝+透明度
  • 0,0,0:黑色
  • 0.3:30%不透明度
  • 突出显示中央的白色卡片

卡片式设计:

内层Column的样式:

  • 圆角20px
  • 白色背景
  • 阴影效果
  • 内边距30px

这是现代UI设计中常用的"卡片"模式,能够有效区分内容区域和背景。

五、界面UI与用户体验设计

5.1 色彩体系设计

颜色方案分析:

用途 颜色值 设计考量
标题栏背景 #333333 深灰色,与纯黑形成层次
分数栏背景 #444444 比标题栏稍浅,形成区分
黑色方块 Color.Black 纯黑,视觉目标明确
白色方块 Color.White 纯白,对比强烈
已点击方块 #888888 灰色,表示已处理
边框 #CCCCCC 浅灰色,分隔方块
遮罩层 rgba(0,0,0,0.3) 半透明,不遮挡底层
标题文字 Color.Black/Red 黑色为开始,红色为结束(警示)
规则文字 #666666 中灰色,不抢夺注意力

色彩心理学应用:

  • 红色(结束界面标题):代表警告、停止、危险,符合游戏结束的语义
  • 黑色(方块):代表目标、重点,引导用户点击
  • 白色(方块):代表危险区域,需要避开
  • 灰色(已点击/遮罩):代表非活跃、已处理状态

5.2 布局系统详解

ArkUI提供了多种布局容器,本项目主要使用了Column、Row和Stack。

Column容器:

Column() {
  // 子组件垂直排列
}
.justifyContent(FlexAlign.Center)  // 主轴(垂直)对齐
.alignItems(HorizontalAlign.Center) // 交叉轴(水平)对齐

常用的justifyContent值:

  • FlexAlign.Start:顶部对齐
  • FlexAlign.Center:居中对齐
  • FlexAlign.End:底部对齐
  • FlexAlign.SpaceBetween:两端对齐
  • FlexAlign.SpaceEvenly:均匀分布

Row容器:

Row() {
  // 子组件水平排列
}
.justifyContent(FlexAlign.Center)  // 主轴(水平)对齐
.alignItems(VerticalAlign.Center)  // 交叉轴(垂直)对齐

Stack容器:

Stack({ alignContent: Alignment.TopStart }) {
  // 子组件层叠排列
}

alignContent参数控制所有子组件的对齐方式:

  • Alignment.TopStart:左上角
  • Alignment.Center:居中
  • Alignment.BottomEnd:右下角
  • 等等

弹性布局(Flexbox):

ArkUI的布局系统基于CSS Flexbox规范,但进行了简化和优化。

layoutWeight属性的作用:

Row容器,宽度400px
├── 子组件A,layoutWeight(1) → 宽度100px
├── 子组件B,layoutWeight(1) → 宽度100px
├── 子组件C,layoutWeight(1) → 宽度100px
└── 子组件D,layoutWeight(1) → 宽度100px

总计权重4,每个权重对应100px。

如果权重不同:

Row容器,宽度400px
├── 子组件A,layoutWeight(1) → 宽度80px
├── 子组件B,layoutWeight(1) → 宽度80px
├── 子组件C,layoutWeight(2) → 宽度160px
└── 子组件D,layoutWeight(1) → 宽度80px

总计权重5,每个权重对应80px。

5.3 文本样式系统

Text组件属性详解:

Text('别踩白块')
  .fontSize(24)           // 字体大小
  .fontWeight(FontWeight.Bold)  // 字重
  .fontColor(Color.White) // 字体颜色

字体大小设计:

元素 大小 作用
大标题(开始界面) 36 吸引注意
结束标题 32 警示作用
页面标题 24 标准标题
得分 24/20 清晰可见
按钮文字 20 易于点击
规则标题 18 次级标题
规则内容 16 正文阅读

字重选择:

  • FontWeight.Bold:标题、按钮等重要元素
  • 默认(Regular):正文、辅助信息

颜色对比:

  • 深色背景 + 白色文字:高对比度,易于阅读
  • 白色背景 + 黑色文字:标准阅读配置
  • 红色标题:强调警示

5.4 按钮与交互反馈

Button组件用法:

Button('开始游戏')
  .width(160)           // 固定宽度
  .height(50)           // 固定高度
  .fontSize(20)
  .backgroundColor(Color.Black)
  .fontColor(Color.White)
  .onClick(() => {
    this.startGame()
  })

按钮设计原则:

  1. 足够大的点击区域:50px高度,160px宽度,符合移动设备交互规范
  2. 高对比度:黑底白字,清晰可见
  3. 明确的语义:“开始游戏”、“再来一局”,操作意图明确

点击反馈设计:

本项目采用了两种视觉反馈方式:

  1. 方块点击:黑色→灰色,显示✓标记
  2. 状态切换:界面整体变化(开始→游戏中→结束)

未来可优化的反馈方式:

  • 点击时的缩放动画
  • 触觉反馈(震动)
  • 音效反馈

5.5 阴影与圆角设计

阴影效果:

.shadow({
  radius: 10,      // 模糊半径
  color: '#999999' // 阴影颜色
})

阴影的作用:

  • 制造层次感,卡片从背景"浮起"
  • 引导视觉焦点
  • 现代UI设计的标配元素

圆角设计:

.borderRadius(20)

20px的圆角属于"中圆角"范畴:

  • 比小圆角更柔和
  • 比大圆角落差更小
  • 卡片式设计的经典选择

六、性能优化与最佳实践

6.1 状态管理优化

最小化响应式状态

// ✅ 好的实践
@State score: number = 0  // 必须响应式,要更新UI

// ❌ 不需要的响应式
private speed: number = 1000  // 非响应式,足够
private interval: number = -1

为什么rows需要@State?

@State rows: BlockData[][] = []

因为:

  • this.rows.pop():数组变化需要触发重新渲染
  • this.rows.unshift():添加元素需要UI更新
  • block.clicked = true:数组元素属性变化

数组更新的注意事项

在ArkUI中,修改数组的某些操作可能无法自动触发UI更新。本项目使用的方式:

this.rows = []  // 重新赋值(可靠)
this.rows.pop()  // 方法调用(应该能触发)
this.rows.unshift(row)  // 方法调用

更可靠的方式是创建新数组:

// 替代方案:创建新数组
this.rows = [...this.rows.slice(1), newRow]

6.2 定时器资源管理

资源泄漏风险

aboutToDisappear() {
  this.stopGame()
}

如果忘记在页面销毁时清理定时器:

  • 定时器继续运行,占用CPU
  • 回调函数引用组件实例,导致内存泄漏
  • 后台运行影响其他应用性能

哨兵值模式

private interval: number = -1  // -1表示无定时器

if (this.interval !== -1) {
  clearInterval(this.interval)
  this.interval = -1  // 重置为哨兵值
}

使用-1而非nullundefined的优势:

  • 数值类型,无需类型检查
  • setInterval返回值(正整数)明确区分
  • 初始化时不需要条件判断

6.3 条件渲染性能

避免频繁的条件切换

本项目的三个状态是互斥的,符合最佳实践:

START ──→ PLAYING ──→ END
  ↑                    │
  └───── 重新开始 ─────┘

状态转换简单清晰,没有复杂的状态叠加。

避免嵌套过深的条件

当前结构:

if (START) { ... }
if (END) { ... }

良好的扁平化结构,易于理解和维护。

6.4 ForEach渲染优化

ForEach的工作原理

当数组变化时,ArkUI会对比新旧数组,计算差异,然后更新UI。

影响性能的因素

  1. 数组大小:本项目只有5行,完全不成问题
  2. key生成:如果提供keyGenerator,性能更好
  3. 组件复杂度:子组件越简单,渲染越快

可选的性能优化

ForEach(
  this.rows,
  (row: BlockData[], rowIndex: number) => { /* ... */ },
  (row: BlockData[], index: number) => index.toString()  // key生成
)

对于简单数组,index作为key是可以接受的。但如果数组元素会移动,应该使用元素的唯一标识。

6.5 代码组织与可维护性

单一职责原则

本项目的函数职责清晰:

函数 职责
initGame() 初始化游戏数据
addRow() 添加一行方块
startGame() 开始游戏流程
startTimer() 启动游戏循环
stopGame() 停止游戏循环
moveDown() 执行一次移动
handleClick() 处理点击事件
gameOver() 结束游戏

每个函数只做一件事,符合单一职责原则。

代码复用

使用@Builder装饰器可以提取重复的UI代码。本项目虽然UI部分较长,但都是一次性使用,提取可能反而增加复杂度。

命名规范

  • 组件名:Index(PascalCase)
  • 状态变量:gameState, score(camelCase)
  • 私有变量:interval, speed(camelCase)
  • 函数名:initGame, startGame(camelCase,动词开头)
  • 类名:BlockData, GameState(PascalCase)
  • 常量:无(可以考虑ROW_COUNT, INITIAL_SPEED等)

6.6 TypeScript类型安全

枚举的优势

// 使用枚举
if (this.gameState === GameState.START) { ... }

// 不使用枚举(容易出错)
if (this.gameState === 0) { ... }  // 0是什么意思?
if (this.gameState === 'start') { ... }  // 拼写错误?

类型注解的价值

ForEach(this.rows, (row: BlockData[], rowIndex: number) => {
  // row自动具有BlockData[]类型提示
  // rowIndex自动具有number类型
})

IDE可以提供:

  • 代码补全
  • 类型错误检查
  • 重构支持

七、测试与质量保证

7.1 单元测试策略

HarmonyOS项目集成了Hypium测试框架。

项目依赖

{
  "devDependencies": {
    "@ohos/hypium": "1.0.25",
    "@ohos/hamock": "1.0.0"
  }
}

可测试的单元

  1. BlockData类:构造函数初始化是否正确
  2. 游戏逻辑
    • addRow()是否正确生成4个方块,1个黑色
    • handleClick()对黑/白方块的处理
    • 难度递增计算是否正确
  3. 边界条件
    • 速度降到300ms后是否继续降低
    • 游戏结束条件触发

7.2 测试用例设计

测试用例1:方块生成

it('addRow should generate 4 blocks with exactly 1 black', () => {
  // 模拟调用
  const row = generateRow()
  // 断言
  expect(row.length).assertEqual(4)
  const blackCount = row.filter(b => b.isBlack).length
  expect(blackCount).assertEqual(1)
})

测试用例2:点击处理

it('clicking black block should increase score', () => {
  // 初始化
  const score = 0
  // 操作
  handleBlackBlockClick()
  // 断言
  expect(score).assertEqual(1)
})

it('clicking white block should trigger game over', () => {
  // 操作
  handleWhiteBlockClick()
  // 断言
  expect(gameState).assertEqual(GameState.END)
})

测试用例3:难度递增

it('speed should decrease every 5 points until 300ms', () => {
  // 测试分数5、10、15时的速度变化
  // 测试分数40时速度是否为300
})

7.3 手动测试用例

除了自动化测试,还需要进行手动测试:

功能测试

  • 启动应用显示开始界面
  • 点击"开始游戏"进入游戏
  • 点击黑色方块得分+1
  • 点击白色方块游戏结束
  • 漏掉黑色方块(等待定时器)游戏结束
  • 结束界面显示最终得分
  • 点击"再来一局"重新开始
  • 每得5分速度明显加快

边界测试

  • 快速连续点击同一方块
  • 在状态切换瞬间点击
  • 长时间运行游戏(内存泄漏检测)

设备适配测试

  • 不同屏幕尺寸
  • 深色模式(如有)
  • 横竖屏切换(如支持)

八、项目扩展与未来展望

8.1 功能扩展方向

当前功能

  • 经典模式:无限下落,直到失误

可扩展功能

  1. 多种游戏模式

    • 经典模式:已有
    • 限时模式:30秒内尽可能多得分
    • 街机模式:速度持续增加
    • 禅模式:没有时间限制,没有速度增加
  2. 音效系统

    • 点击音效
    • 错误音效
    • 背景音乐
  3. 视觉增强

    • 点击动画
    • 连击特效
    • 粒子效果
  4. 数据持久化

    • 本地最高分记录
    • 游戏统计
    • 成就系统
  5. 社交功能

    • 排行榜
    • 分享战绩

8.2 技术架构升级

当前架构:单页面组件

未来架构方向

Index (页面)
├── GameLogicService (游戏逻辑服务)
├── ScoreManager (分数管理)
├── AudioManager (音效管理)
├── StorageManager (存储管理)
└── UI Components
    ├── StartScreen
    ├── GameBoard
    ├── GameOverScreen
    └── Block

模块化重构

将游戏逻辑提取为独立服务:

class GameService {
  score: number = 0
  gameState: GameState = GameState.START
  
  startGame() { /* ... */ }
  endGame() { /* ... */ }
  handleClick(row: number, col: number): boolean { /* ... */ }
}

这样做的好处:

  • 逻辑与UI分离
  • 便于单元测试
  • 可在多个页面复用

8.3 性能优化方向

动画优化

当前使用定时器+数组替换模拟移动,未来可以:

  • 使用ArkUI动画API实现平滑过渡
  • 使用requestAnimationFrame替代setInterval
  • 实现帧率控制
// 使用animateTo实现平滑动画
animateTo({ duration: 500 }, () => {
  this.offsetY += 100
})

渲染优化

  • 实现对象池,避免频繁创建BlockData
  • 减少不必要的状态更新
  • 使用@Observed@ObjectLink优化对象状态
@Observed
class BlockData {
  @ObjectLink isBlack: boolean
  // ...
}

8.4 分布式能力探索

HarmonyOS的核心优势是分布式能力,游戏可以扩展为:

多设备协同

  • 手机显示游戏画面
  • 平板作为控制器
  • 多人同屏对战

跨端迁移

  • 在手机上开始游戏
  • 无缝迁移到平板继续
  • 游戏状态同步

能力分享

  • 调用其他设备的震动马达
  • 使用智慧屏的更大屏幕

九、总结与反思

9.1 项目技术价值回顾

通过这个简单的游戏项目,我们深入了解了:

技术收获

  1. ArkUI声明式开发:掌握了@Component、@State、@Entry等核心装饰器的用法
  2. Stage模型架构:理解了Ability、Page、Component的层次关系
  3. TypeScript/ETS:熟练运用枚举、类、类型注解等特性
  4. 游戏循环设计:定时器驱动的状态机模式
  5. UI布局系统:Column、Row、Stack的灵活运用

项目亮点

  • 代码简洁:不到300行实现完整游戏
  • 逻辑清晰:状态机设计易于理解
  • 性能良好:没有复杂计算,UI更新最小化
  • 用户体验:即时反馈,渐进式难度

9.2 开发经验总结

最佳实践

  1. 状态设计要谨慎

    • 只将必要的数据标记为@State
    • 思考状态变化的触发条件
    • 保持状态一致性
  2. 资源管理要严格

    • 定时器、订阅等必须释放
    • 使用哨兵值标记无效状态
    • 在生命周期钩子中清理
  3. 代码组织要清晰

    • 函数单一职责
    • 命名规范统一
    • 适当的注释说明
  4. 用户体验要重视

    • 即时视觉反馈
    • 清晰的操作指引
    • 合理的难度曲线

常见陷阱

  1. 状态更新死循环

    • 在build中修改状态
    • 状态变化触发无限重建
  2. 资源泄漏

    • 忘记清理定时器
    • 回调函数持有强引用
  3. UI闪烁

    • 不必要的状态更新
    • 数组直接修改不触发更新

9.3 对初学者的建议

对于刚接触ArkUI和HarmonyOS开发的同学,建议:

  1. 从小项目开始

    • 像"别踩白块"这样的简单游戏是绝佳练习
    • 功能有限但覆盖核心技术点
    • 可以快速获得成就感
  2. 深入理解响应式

    • @State的工作原理
    • 状态变化如何触发UI更新
    • 状态管理的最佳实践
  3. 多读官方文档

    • ArkUI组件库
    • 生命周期管理
    • 性能优化指南
  4. 动手实践最重要

    • 阅读代码与编写代码是两回事
    • 遇到问题先尝试自己解决
    • 在错误中学习成长

9.4 结语

"别踩白块"是一个看似简单实则蕴含丰富技术点的项目。通过本文的深入剖析,我们不仅了解了如何用ArkUI实现这个游戏,更重要的是掌握了:

  • 声明式UI开发的思维方式
  • 游戏应用的架构设计模式
  • 状态管理和资源管理的最佳实践
  • 用户体验设计的基本原则

HarmonyOS和ArkUI代表了移动开发的一个新方向。声明式编程、分布式能力、一次开发多端部署等特性,为开发者提供了强大的工具。相信随着生态的不断完善,会有越来越多的优秀应用诞生在这个平台上。

希望本文能对你的HarmonyOS开发之旅有所帮助。记住,最好的学习方式是动手实践,从一个简单的项目开始,逐步扩展,你会发现编程的乐趣和成就感。


参考资源

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐