HarmonyOS NEXT 实战:从零开发「骰子模拟器」应用

本文记录了使用 HarmonyOS NEXT 和 ArkTS 开发一个功能完整的骰子模拟器应用的完整过程,涵盖动画效果、主题切换、历史记录等核心功能实现。

一、项目背景

骰子是生活中常见的随机工具,从桌游到决策场景都有广泛应用。开发一个骰子模拟器应用,不仅能学习到 HarmonyOS 的核心开发技能,还能深入理解动画系统、状态管理等重要概念。

项目亮点

  • 滚动动画:模拟真实骰子的翻滚效果
  • 🎨 主题切换:支持 5 种精心设计的配色方案
  • 📊 历史记录:自动记录最近 10 次投掷结果
  • 💡 交互设计:支持点击骰子或按钮两种操作方式
  • 🎯 状态管理:多状态协同控制,确保交互流畅

二、开发环境

项目 版本/说明
操作系统 Windows 10/11
开发工具 DevEco Studio 5.0+
HarmonyOS SDK API 23 (6.1.0) 最低版本
目标 SDK API 24 (6.1.1)
开发语言 ArkTS (TypeScript 超集)
运行环境 HarmonyOS NEXT 手机/模拟器

三、项目结构解析

MyApplication/
├── AppScope/                    # 应用全局资源
│   ├── app.json5               # 应用配置
│   └── resources/base/
│       └── element/
│           └── string.json      # 应用名称等
├── entry/                       # 主模块
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/
│   │   │   │   └── EntryAbility.ets  # 应用入口
│   │   │   └── pages/
│   │   │       └── Index.ets         # 主页面 ⭐
│   │   └── resources/
│   │       ├── base/
│   │       │   ├── element/
│   │       │   │   ├── color.json
│   │       │   │   └── string.json
│   │       │   ├── media/
│   │       │   └── profile/
│   │       │       └── main_pages.json
│   │       └── dark/                 # 暗色主题
│   ├── build-profile.json5
│   └── module.json5
├── build-profile.json5
└── hvigorfile.ts

核心文件:

  • Index.ets:主页面,包含所有 UI 和业务逻辑
  • module.json5:模块配置,声明 Ability 和权限
  • main_pages.json:页面路由表

四、功能设计与数据模型

4.1 功能需求分析

功能模块 说明
骰子显示 显示当前点数,支持 1-6 点
投掷动画 模拟骰子翻滚的视觉效果
主题切换 5 种配色方案可选
历史记录 记录最近 10 次投掷结果和时间
统计显示 显示总投掷次数

4.2 数据模型设计

4.2.1 投掷记录接口
interface DiceRecord {
  value: number;   // 点数:1-6
  time: string;    // 投掷时间:HH:MM:SS
}
4.2.2 主题配置接口
interface DiceTheme {
  name: string;    // 主题名称
  bg: string;      // 背景颜色
  dot: string;     // 点数颜色
  border: string;  // 边框颜色
}

这种设计将数据和样式分离,便于后续扩展和维护。

4.3 状态变量设计

变量名 类型 初始值 说明
currentValue number 1 当前显示的点数
isRolling boolean false 是否正在投掷
rollCount number 0 当前投掷次数(动画用)
totalRolls number 0 总投掷次数
history DiceRecord[] [] 历史记录数组
diceColor string ‘#FFFFFF’ 骰子背景色
dotColor string ‘#1C1C1E’ 点数颜色

五、核心代码实现

5.1 组件初始化

@Entry
@Component
struct Index {
  // ========== 状态变量 ==========
  @State currentValue: number = 1;
  @State isRolling: boolean = false;
  @State rollCount: number = 0;
  @State totalRolls: number = 0;
  @State history: DiceRecord[] = [];
  @State diceColor: string = '#FFFFFF';
  @State dotColor: string = '#1C1C1E';

  // ========== 主题配置 ==========
  readonly diceColors: DiceTheme[] = [
    { name: '经典白', bg: '#FFFFFF', dot: '#1C1C1E', border: '#D1D1D6' },
    { name: '复古红', bg: '#FF3B30', dot: '#FFFFFF', border: '#C41A1A' },
    { name: '深邃蓝', bg: '#007AFF', dot: '#FFFFFF', border: '#0040DD' },
    { name: '翡翠绿', bg: '#34C759', dot: '#FFFFFF', border: '#248A3D' },
    { name: '暗夜黑', bg: '#1C1C1E', dot: '#FFFFFF', border: '#000000' },
  ];

  // ========== 辅助方法 ==========
  getCurrentTheme(): DiceTheme {
    for (let i: number = 0; i < this.diceColors.length; i++) {
      if (this.diceColors[i].bg === this.diceColor) {
        return this.diceColors[i];
      }
    }
    return this.diceColors[0];
  }
}

设计说明:

  • @State 装饰器确保状态变化时 UI 自动更新
  • readonly 标记常量数组,避免不必要的响应式开销
  • 主题配置包含背景色、点数色、边框色三个维度

5.2 投掷逻辑实现

rollDice(): void {
  // 防止重复触发
  if (this.isRolling) return;
  this.isRolling = true;

  // 快速切换数字产生滚动动效
  let frame: number = 0;
  const interval = setInterval(() => {
    this.currentValue = Math.floor(Math.random() * 6) + 1;
    frame++;
    
    // 8 帧后停止
    if (frame >= 8) {
      clearInterval(interval);
      
      // 生成最终结果
      const finalValue: number = Math.floor(Math.random() * 6) + 1;
      this.currentValue = finalValue;
      this.isRolling = false;
      this.totalRolls++;

      // 记录历史
      const now = new Date();
      const timeStr =
        `${now.getHours().toString().padStart(2, '0')}:` +
        `${now.getMinutes().toString().padStart(2, '0')}:` +
        `${now.getSeconds().toString().padStart(2, '0')}`;

      const newRecord: DiceRecord = { value: finalValue, time: timeStr };
      
      // 新记录插入到数组开头
      const temp: DiceRecord[] = [newRecord];
      for (let j: number = 0; j < this.history.length; j++) {
        temp.push(this.history[j]);
      }
      this.history = temp;
      
      // 保留最近 10 条记录
      if (this.history.length > 10) {
        const temp2: DiceRecord[] = [];
        for (let k: number = 0; k < 10; k++) {
          temp2.push(this.history[k]);
        }
        this.history = temp2;
      }
    }
  }, 60);  // 每 60ms 切换一次
}

实现要点:

  1. 防重复触发:通过 isRolling 标志位防止动画期间重复点击
  2. 动画效果:使用 setInterval 每 60ms 切换一次点数,共切换 8 次
  3. 时间格式化:使用 padStart 补零,确保格式统一(如 09:05:03
  4. 历史记录管理
    • 新记录插入数组开头
    • 超过 10 条时删除最旧记录
  5. 数组操作:通过临时数组实现插入和截取(ArkTS 数组操作限制)

5.3 主题切换实现

changeTheme(theme: DiceTheme): void {
  this.diceColor = theme.bg;
  this.dotColor = theme.dot;
}

切换主题时,只需更新两个状态变量,UI 会自动响应变化。

5.4 骰子点数绘制

使用 @Builder 装饰器构建可复用的 UI 片段:

@Builder
buildDiceFace() {
  Column() {
    if (this.currentValue === 1) {
      // 单点:居中显示
      Text('●').fontSize(48).fontColor(this.dotColor)
      
    } else if (this.currentValue === 2) {
      // 两点:对角排列
      Row() {
        Column() { Text('●').fontSize(24).fontColor(this.dotColor) }.width('50%')
        Column() { Text('●').fontSize(24).fontColor(this.dotColor) }.width('50%')
      }.width(120).justifyContent(FlexAlign.SpaceAround)
      
    } else if (this.currentValue === 3) {
      // 三点:对角线排列
      Row() {
        Column() { Text('●').fontSize(20).fontColor(this.dotColor) }.width('33%')
        Column() { Text('●').fontSize(24).fontColor(this.dotColor) }.width('33%')
        Column() { Text('●').fontSize(20).fontColor(this.dotColor) }.width('33%')
      }.width(140).justifyContent(FlexAlign.SpaceAround)
      
    } else if (this.currentValue === 4) {
      // 四点:四个角落
      Column({ space: 12 }) {
        Row({ space: 24}) {
          Text('●').fontSize(24).fontColor(this.dotColor)
          Text('●').fontSize(24).fontColor(this.dotColor)
        }
        Row({ space: 24}) {
          Text('●').fontSize(24).fontColor(this.dotColor)
          Text('●').fontSize(24).fontColor(this.dotColor)
        }
      }
      
    } else if (this.currentValue === 5) {
      // 五点:四角 + 中心
      Column({ space: 10 }) {
        Row({ space: 18 }) {
          Text('●').fontSize(20).fontColor(this.dotColor)
          Text('●').fontSize(20).fontColor(this.dotColor)
          Text('●').fontSize(20).fontColor(this.dotColor)
        }
        Row({ space: 18 }) {
          Text('').fontSize(20)
          Text('●').fontSize(24).fontColor(this.dotColor)
          Text('').fontSize(20)
        }
        Row({ space: 18 }) {
          Text('●').fontSize(20).fontColor(this.dotColor)
          Text('●').fontSize(20).fontColor(this.dotColor)
          Text('●').fontSize(20).fontColor(this.dotColor)
        }
      }
      
    } else {
      // 六点:两行三列
      Column({ space: 8 }) {
        Row({ space: 14 }) {
          Text('●').fontSize(20).fontColor(this.dotColor)
          Text('●').fontSize(20).fontColor(this.dotColor)
          Text('●').fontSize(20).fontColor(this.dotColor)
        }
        Row({ space: 14 }) {
          Text('●').fontSize(20).fontColor(this.dotColor)
          Text('●').fontSize(20).fontColor(this.dotColor)
          Text('●').fontSize(20).fontColor(this.dotColor)
        }
        Row({ space: 14 }) {
          Text('●').fontSize(20).fontColor(this.dotColor)
          Text('●').fontSize(20).fontColor(this.dotColor)
          Text('●').fontSize(20).fontColor(this.dotColor)
        }
      }
    }
  }
  .width(160)
  .height(160)
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

设计思路:

点数 布局方式 视觉效果
1 点 单个居中
2 点 Row 水平排列 ● ○ ○ ●
3 点 Row 水平排列 ● ○ ● ○ ●
4 点 Column + Row,2×2 ● ● / ● ●
5 点 Column + Row,3×3 稀疏 ● ● ● / ○ ● ○ / ● ● ●
6 点 Column + Row,2×3 ● ● ● / ● ● ●

六、UI 布局实现

6.1 主容器布局

build() {
  Column() {
    // 标题区域
    // 骰子主体
    // 投掷按钮
    // 主题选择
    // 历史记录
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#FFFFFF')
  .padding(20)
  .alignItems(HorizontalAlign.Center)
}

6.2 标题区域

Text('🎲 骰子模拟器')
  .fontSize(26)
  .fontWeight(FontWeight.Bold)
  .fontColor('#1C1C1E')
  .margin({ top: 30, bottom: 4 })

Text(`已投掷 ${this.totalRolls}`)
  .fontSize(14)
  .fontColor('#8E8E93')
  .margin({ bottom: 20 })

6.3 骰子主体

Column() {
  this.buildDiceFace()
}
.width(180)
.height(180)
.backgroundColor(this.diceColor)
.borderRadius(24)
.border({ width: 3, color: this.getCurrentTheme().border })
.shadow({ radius: 12, color: '#20000000', offsetY: 4 })
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.opacity(this.isRolling ? 0.7 : 1.0)
.animation({ duration: 200 })
.onClick(() => {
  this.rollDice();
})

视觉效果:

  • 圆角卡片(24px)
  • 阴影效果(模拟立体感)
  • 投掷时透明度降低(0.7)
  • 点击触发投掷

6.4 投掷按钮

Button(this.isRolling ? '🎲 投掷中...' : '🎲 掷骰子')
  .type(ButtonType.Capsule)
  .width(200)
  .height(50)
  .backgroundColor('#007AFF')
  .fontSize(18)
  .fontWeight(FontWeight.Bold)
  .margin({ top: 24 })
  .enabled(!this.isRolling)
  .onClick(() => {
    this.rollDice();
  })

交互设计:

  • 投掷时按钮禁用(enabled(!this.isRolling)
  • 文字动态变化:“掷骰子” ↔ “投掷中…”
  • 蓝色胶囊样式,符合 iOS 设计规范

6.5 主题选择器

Text('选择主题')
  .fontSize(14)
  .fontColor('#8E8E93')
  .margin({ top: 20, bottom: 8 })

Row({ space: 10 }) {
  ForEach(this.diceColors, (theme: DiceTheme) => {
    Column() {
      Row() {
        Text(theme.name)
          .fontSize(11)
          .fontColor(theme.bg === '#1C1C1E' ? '#FFFFFF' : '#1C1C1E')
      }
      .width(60)
      .height(28)
      .backgroundColor(theme.bg)
      .borderRadius(14)
      .border({
        width: this.diceColor === theme.bg ? 2 : 1,
        color: this.diceColor === theme.bg ? '#007AFF' : theme.border
      })
      .justifyContent(FlexAlign.Center)
      .onClick(() => {
        this.changeTheme(theme);
      })
    }
  })
}

设计要点:

  • 使用 ForEach 循环渲染主题按钮
  • 当前主题显示蓝色边框高亮
  • 深色主题(暗夜黑)文字显示白色

6.6 历史记录列表

if (this.history.length > 0) {
  // 标题栏
  Row() {
    Text('📋 投掷记录')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1C1C1E')
    Blank()
    Text('清空')
      .fontSize(13)
      .fontColor('#FF3B30')
      .onClick(() => {
        this.clearHistory();
      })
  }
  .width('100%')
  .margin({ top: 20, bottom: 8 })
  .padding({ left: 4, right: 4 })

  // 记录列表
  Column({ space: 4 }) {
    ForEach(this.history, (record: DiceRecord) => {
      Row() {
        Text(`🎲 ${record.value}`)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1C1E')
        Blank()
        Text(record.time)
          .fontSize(12)
          .fontColor('#8E8E93')
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      .backgroundColor('#F2F2F7')
      .borderRadius(10)
    })
  }
  .width('100%')
}

列表项设计:

  • 左侧显示骰子图标和点数
  • 右侧显示投掷时间
  • 灰色圆角卡片背景

6.7 空状态提示

else if (this.totalRolls === 0) {
  Text('点击骰子或按钮开始投掷 🎲')
    .fontSize(14)
    .fontColor('#C7C7CC')
    .margin({ top: 24 })
}

七、配置文件详解

7.1 模块配置(module.json5)

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": [
      "phone"
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": [
              "entity.system.home"
            ],
            "actions": [
              "ohos.want.action.home"
            ]
          }
        ]
      }
    ]
  }
}

关键配置说明:

字段 说明
deviceTypes 支持的设备类型(phone/tablet/tv)
pages 页面路由配置文件
abilities Ability 声明
skills 定义可响应的 Intent
exported 是否允许其他应用调用

7.2 构建配置(build-profile.json5)

{
  "app": {
    "products": [
      {
        "name": "default",
        "signingConfig": "default",
        "targetSdkVersion": "6.1.1(24)",
        "compatibleSdkVersion": "6.1.0(23)",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": {
            "caseSensitiveCheck": true,
            "useNormalizedOHMUrl": true
          }
        }
      }
    ]
  }
}

版本说明:

  • compatibleSdkVersion:最低兼容版本(API 23)
  • targetSdkVersion:目标 SDK 版本(API 24)
  • strictMode:启用严格模式,提升代码质量

7.3 字符串资源(string.json)

{
  "string": [
    {
      "name": "module_desc",
      "value": "骰子模拟器"
    },
    {
      "name": "EntryAbility_desc",
      "value": "掷骰子应用,支持多种主题"
    },
    {
      "name": "EntryAbility_label",
      "value": "骰子模拟器"
    }
  ]
}

资源引用:

  • 在代码中使用 $string:module_desc 引用
  • 便于国际化和统一管理

八、运行与调试

在这里插入图片描述

九、功能演示

9.1 初始状态

应用启动后,骰子显示 1 点,提示"点击骰子或按钮开始投掷"。

9.2 投掷过程

  1. 点击骰子:直接点击骰子区域触发投掷
  2. 点击按钮:点击"掷骰子"按钮触发投掷
  3. 动画效果:骰子点数快速切换 8 次,最终停在随机点数

9.3 主题切换

点击主题按钮,骰子颜色立即切换:

主题 背景色 点数色 视觉效果
经典白 白色 黑色 简洁经典
复古红 红色 白色 醒目热烈
深邃蓝 蓝色 白色 沉稳专业
翡翠绿 绿色 白色 清新活力
暗夜黑 黑色 白色 神秘酷炫

9.4 历史记录

每次投掷后,结果自动记录到历史列表:

  • 最新记录显示在最上方
  • 显示点数和投掷时间
  • 最多保留 10 条记录
  • 可点击"清空"按钮清除所有记录

十、踩坑记录与解决方案

10.1 动画防抖问题

问题描述: 快速连续点击骰子,导致动画混乱。

解决方案:

rollDice(): void {
  if (this.isRolling) return;  // 防止重复触发
  this.isRolling = true;
  // ... 动画逻辑
}

要点: 使用 isRolling 标志位作为锁,确保动画期间不响应新的点击。

10.2 数组操作限制

问题描述: ArkTS 不支持 unshift()splice() 等数组方法。

解决方案:

// ❌ 错误写法(不支持)
this.history.unshift(newRecord);
this.history.splice(10);

// ✅ 正确写法(创建新数组)
const temp: DiceRecord[] = [newRecord];
for (let j: number = 0; j < this.history.length; j++) {
  temp.push(this.history[j]);
}
this.history = temp;

要点: 通过临时数组实现插入和截取操作。

10.3 主题边框高亮

问题描述: 如何在选中主题时显示高亮边框?

解决方案:

.border({
  width: this.diceColor === theme.bg ? 2 : 1,  // 选中时加粗
  color: this.diceColor === theme.bg ? '#007AFF' : theme.border  // 选中时蓝色
})

要点: 使用条件表达式动态设置边框样式。

10.4 时间格式化

问题描述: 如何确保时间格式统一(如 09:05:03)?

解决方案:

const now = new Date();
const timeStr =
  `${now.getHours().toString().padStart(2, '0')}:` +
  `${now.getMinutes().toString().padStart(2, '0')}:` +
  `${now.getSeconds().toString().padStart(2, '0')}`;

要点: 使用 padStart(2, '0') 补零。

10.5 @Builder 装饰器使用

问题描述: 如何复用复杂的 UI 片段?

解决方案:

@Builder
buildDiceFace() {
  // 骰子点数绘制逻辑
}

// 在 build() 中调用
Column() {
  this.buildDiceFace()
}

要点: @Builder 定义的 UI 片段可以在多处复用,减少重复代码。

10.6 条件渲染嵌套

问题描述: 多层条件渲染逻辑混乱。

解决方案:

if (this.history.length > 0) {
  // 显示历史记录
} else if (this.totalRolls === 0) {
  // 显示空状态提示
}

要点: 使用 if-else if 结构,确保同一时间只显示一种状态。

十一、项目总结

11.1 技术要点回顾

技术点 说明
@State 响应式状态管理
@Builder 可复用 UI 片段
setInterval 定时器实现动画
ForEach 循环渲染列表
条件渲染 if 语句控制显示
接口定义 interface 数据模型

11.2 架构设计

用户交互
    ↓
状态管理 (@State)
    ↓
业务逻辑 (rollDice / changeTheme)
    ↓
UI 渲染 (@Builder + build)
    ↓
历史记录

11.3 性能优化

  1. 避免过度状态化diceColors 使用 readonly,不触发响应式
  2. 动画流畅性:使用 setInterval 而非递归,避免调用栈溢出
  3. 列表渲染优化:限制历史记录最多 10 条,避免内存泄漏
  4. 防抖处理:通过 isRolling 标志位防止重复触发

11.4 扩展方向

  1. 多骰子支持:支持同时投掷 2 个或多个骰子
  2. 自定义点数:支持 D4、D8、D10、D12、D20 等多种骰子类型
  3. 震动反馈:投掷时触发手机震动
  4. 音效支持:添加骰子滚动音效
  5. 数据持久化:使用 Preferences 保存历史记录和主题偏好
  6. 统计图表:展示各点数出现频率的饼图或柱状图

附录:常见问题 FAQ

Q1: 动画卡顿怎么办?

A: 检查 setInterval 的间隔时间,建议不低于 50ms。同时确保动画期间没有执行耗时操作。

Q2: 主题切换后历史记录消失?

A: 主题切换只改变样式,不影响历史记录数据。检查是否有其他代码错误导致状态重置。

Q3: 如何适配不同屏幕尺寸?

A: 使用百分比宽度(如 width('100%'))和相对单位,避免使用固定像素值。

Q4: 如何添加更多主题?

A:diceColors 数组中添加新的主题配置即可:

readonly diceColors: DiceTheme[] = [
  // 现有主题...
  { name: '紫罗兰', bg: '#AF52DE', dot: '#FFFFFF', border: '#8B3AA8' },
];

Q5: 如何持久化历史记录?

A: 使用 Preferences API:

import preferences from '@ohos.data.preferences';

// 保存数据
await preferences.put('history', JSON.stringify(this.history));

// 读取数据
const data = await preferences.get('history', '[]');
this.history = JSON.parse(data);

项目信息:

  • 最低 SDK:API 23 (6.1.0)
  • 目标 SDK:API 24 (6.1.1)
  • 开发工具:DevEco Studio 5.0+

Logo

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

更多推荐