Flutter三方库适配OpenHarmony【dice_roller】骰子投掷器项目完整实战
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
dice_roller 是一个基于 Flutter 实现的 Dice Roller 骰子投掷器 。项目核心源码位于 lib/main.dart,同时保留了 ohos OpenHarmony 平台工程目录,适合用来学习 随机数生成 、动画控制器 、旋转动画 、Wrap 自适应布局 、按钮状态控制 和 OpenHarmony 小游戏类页面适配。
这个项目的代码量不大,但包含一个完整的交互闭环:用户调整骰子数量,点击投掷按钮,页面进入滚动状态,骰子播放旋转动画,动画完成后生成 1 到 6 的随机点数,并重新计算总点数。它比静态计算器更适合讲解 Flutter 中动画、状态和 UI 的协作方式。
本文重点回答三个问题:
dice_roller如何使用dart:math Random生成骰子点数。- Flutter 如何通过
AnimationController、AnimatedBuilder和Transform.rotate实现投掷动画。 - 这个项目适配 OpenHarmony 时,如何验证动画、按钮边界、骰子布局和总点数展示。

图示说明:本文聚焦 Flutter 骰子投掷器的随机数、动画和 OpenHarmony 工程承载,核心源码位于 lib/main.dart。
一、背景与目标
1.1 项目功能概览
dice_roller 是一个轻量骰子投掷工具。用户可以调整骰子数量,点击 Roll Dice 开始投掷,页面会展示旋转动画,并在投掷结束后显示每个骰子的点数和总点数。
当前项目真实支持的功能包括:
- 默认 1 个骰子。
- 使用加减按钮调整骰子数量。
- 骰子数量最少 1 个,最多 6 个。
- 点击按钮开始滚动动画。
- 滚动中按钮禁用并显示
Rolling...。 - 动画期间骰子旋转。
- 动画完成后生成随机点数。
- 使用
Wrap自适应排列多个骰子。 - 使用
fold计算所有骰子总点数。 - 使用
_buildDice绘制骰子方块。 - 使用
dispose()释放动画控制器。
1.2 技术关键词
| 关键词 | 在项目中的作用 |
|---|---|
dart:math Random |
生成 1 到 6 的随机点数 |
SingleTickerProviderStateMixin |
为动画控制器提供 vsync |
AnimationController |
控制 500ms 投掷动画 |
addStatusListener |
监听动画状态 |
AnimatedBuilder |
根据动画值重建骰子 |
Transform.rotate |
实现骰子旋转 |
Wrap |
自适应排列多个骰子 |
List.generate |
根据骰子数量生成结果列表 |
fold |
汇总所有骰子点数 |
dispose |
释放动画控制器资源 |
1.3 项目目标
这个项目的目标是实现一个完整的骰子投掷交互:
- 用户设置骰子数量。
- 页面同步调整骰子列表。
- 用户点击投掷按钮。
- 页面进入滚动状态。
- 骰子播放旋转动画。
- 动画结束后生成随机点数。
- 页面更新骰子显示和总点数。
关键点:骰子项目的重点不是
random.nextInt(6) + 1这一行,而是"开始动画、禁用按钮、生成结果、更新 UI、汇总总点数"的完整状态流。
二、环境准备
2.1 项目目录结构
当前项目采用 Flutter 标准目录,同时包含 OpenHarmony 平台工程。
bash
dice_roller/
├── lib/
│ └── main.dart
├── ohos/
│ ├── AppScope/
│ └── entry/
├── test/
│ └── widget_test.dart
├── pubspec.yaml
├── analysis_options.yaml
└── README.md
2.2 核心文件说明
| 文件或目录 | 作用 | 本文重点 |
|---|---|---|
lib/main.dart |
应用入口、随机数、动画、布局和骰子 UI | 核心源码 |
pubspec.yaml |
项目名称、版本、依赖和 Flutter 配置 | 环境依赖 |
analysis_options.yaml |
Dart 静态分析规则 | 代码规范 |
test/ |
Flutter 测试目录 | 随机范围和总点数验证 |
ohos/ |
OpenHarmony 平台工程 | 平台承载 |
2.3 pubspec 基础配置
项目名称是 dice_roller,版本是 1.0.0+1,Dart SDK 约束是 ^3.9.2。
yaml
name: dice_roller
description: "A new Flutter project."
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.9.2
publish_to: 'none' 表示这是应用工程,不会被误发布到 pub.dev。
2.4 项目依赖
当前项目只使用 Flutter SDK 和基础图标库。
yaml
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
随机数使用 Dart 标准库 dart:math,不需要额外依赖。
2.5 常用命令
bash
flutter pub get
flutter analyze
flutter test
flutter run
| 命令 | 作用 |
|---|---|
flutter pub get |
获取项目依赖 |
flutter analyze |
执行静态分析 |
flutter test |
运行测试 |
flutter run |
启动调试运行 |
flutter build hap |
构建 OpenHarmony HAP 包 |
三、应用入口与主题配置
3.1 import 语句
当前源码引入了 Flutter Material 组件和 Dart 数学库。
dart
import 'package:flutter/material.dart';
import 'dart:math' as math;
dart:math 被命名为 math,用于调用 math.Random() 和 math.pi。
3.2 main 函数
dart
void main() {
runApp(const DiceRollerApp());
}
runApp(const DiceRollerApp()) 将 DiceRollerApp 放入 Flutter 渲染树根节点。
3.3 DiceRollerApp
dart
class DiceRollerApp extends StatelessWidget {
const DiceRollerApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Dice Roller',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const DiceRollerHomePage(title: 'Dice Roller'),
);
}
}
这里有三个关键信息:
- 应用标题是
Dice Roller。 - 主题种子色使用
Colors.deepPurple。 - 首页是
DiceRollerHomePage,骰子数量、结果和动画状态从首页 State 中维护。
四、页面状态设计
4.1 DiceRollerHomePage
dart
class DiceRollerHomePage extends StatefulWidget {
const DiceRollerHomePage({super.key, required this.title});
final String title;
@override
State<DiceRollerHomePage> createState() => _DiceRollerHomePageState();
}
骰子数量、投掷结果和滚动状态都会变化,因此页面使用 StatefulWidget。
4.2 SingleTickerProviderStateMixin
dart
class _DiceRollerHomePageState extends State<DiceRollerHomePage>
with SingleTickerProviderStateMixin {
// ...
}
SingleTickerProviderStateMixin 为 AnimationController 提供 vsync。这能让动画在页面不可见时减少无效刷新,避免不必要的性能消耗。
4.3 核心状态字段
dart
int _diceCount = 1;
List<int> _results = [1];
bool _isRolling = false;
late AnimationController _controller;
| 字段 | 类型 | 作用 |
|---|---|---|
_diceCount |
int |
当前骰子数量 |
_results |
List<int> |
每个骰子的点数 |
_isRolling |
bool |
是否正在投掷 |
_controller |
AnimationController |
控制旋转动画 |
4.4 状态到 UI 的关系
#mermaid-svg-hlj5f0XzCQYpiV2v{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hlj5f0XzCQYpiV2v .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hlj5f0XzCQYpiV2v .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hlj5f0XzCQYpiV2v .error-icon{fill:#552222;}#mermaid-svg-hlj5f0XzCQYpiV2v .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hlj5f0XzCQYpiV2v .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hlj5f0XzCQYpiV2v .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hlj5f0XzCQYpiV2v .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hlj5f0XzCQYpiV2v .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hlj5f0XzCQYpiV2v .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hlj5f0XzCQYpiV2v .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hlj5f0XzCQYpiV2v .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hlj5f0XzCQYpiV2v .marker.cross{stroke:#333333;}#mermaid-svg-hlj5f0XzCQYpiV2v svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hlj5f0XzCQYpiV2v p{margin:0;}#mermaid-svg-hlj5f0XzCQYpiV2v .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hlj5f0XzCQYpiV2v .cluster-label text{fill:#333;}#mermaid-svg-hlj5f0XzCQYpiV2v .cluster-label span{color:#333;}#mermaid-svg-hlj5f0XzCQYpiV2v .cluster-label span p{background-color:transparent;}#mermaid-svg-hlj5f0XzCQYpiV2v .label text,#mermaid-svg-hlj5f0XzCQYpiV2v span{fill:#333;color:#333;}#mermaid-svg-hlj5f0XzCQYpiV2v .node rect,#mermaid-svg-hlj5f0XzCQYpiV2v .node circle,#mermaid-svg-hlj5f0XzCQYpiV2v .node ellipse,#mermaid-svg-hlj5f0XzCQYpiV2v .node polygon,#mermaid-svg-hlj5f0XzCQYpiV2v .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hlj5f0XzCQYpiV2v .rough-node .label text,#mermaid-svg-hlj5f0XzCQYpiV2v .node .label text,#mermaid-svg-hlj5f0XzCQYpiV2v .image-shape .label,#mermaid-svg-hlj5f0XzCQYpiV2v .icon-shape .label{text-anchor:middle;}#mermaid-svg-hlj5f0XzCQYpiV2v .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hlj5f0XzCQYpiV2v .rough-node .label,#mermaid-svg-hlj5f0XzCQYpiV2v .node .label,#mermaid-svg-hlj5f0XzCQYpiV2v .image-shape .label,#mermaid-svg-hlj5f0XzCQYpiV2v .icon-shape .label{text-align:center;}#mermaid-svg-hlj5f0XzCQYpiV2v .node.clickable{cursor:pointer;}#mermaid-svg-hlj5f0XzCQYpiV2v .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hlj5f0XzCQYpiV2v .arrowheadPath{fill:#333333;}#mermaid-svg-hlj5f0XzCQYpiV2v .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hlj5f0XzCQYpiV2v .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hlj5f0XzCQYpiV2v .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hlj5f0XzCQYpiV2v .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hlj5f0XzCQYpiV2v .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hlj5f0XzCQYpiV2v .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hlj5f0XzCQYpiV2v .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hlj5f0XzCQYpiV2v .cluster text{fill:#333;}#mermaid-svg-hlj5f0XzCQYpiV2v .cluster span{color:#333;}#mermaid-svg-hlj5f0XzCQYpiV2v div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hlj5f0XzCQYpiV2v .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hlj5f0XzCQYpiV2v rect.text{fill:none;stroke-width:0;}#mermaid-svg-hlj5f0XzCQYpiV2v .icon-shape,#mermaid-svg-hlj5f0XzCQYpiV2v .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hlj5f0XzCQYpiV2v .icon-shape p,#mermaid-svg-hlj5f0XzCQYpiV2v .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hlj5f0XzCQYpiV2v .icon-shape .label rect,#mermaid-svg-hlj5f0XzCQYpiV2v .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hlj5f0XzCQYpiV2v .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hlj5f0XzCQYpiV2v .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hlj5f0XzCQYpiV2v :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} _diceCount
骰子数量显示
_results 长度
Wrap 骰子列表
_isRolling
按钮禁用/文案切换
Transform.rotate 是否旋转
_controller.value
fold 计算总点数
五、动画控制器实现
5.1 initState 初始化动画
dart
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_rollDice();
}
});
}
动画控制器的时长是 500ms,vsync 来自 SingleTickerProviderStateMixin。
5.2 addStatusListener
dart
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_rollDice();
}
});
状态监听器用于在动画完成后调用 _rollDice(),生成新的骰子结果。
5.3 dispose 释放资源
dart
@override
void dispose() {
_controller.dispose();
super.dispose();
}
AnimationController 持有动画资源,页面销毁时必须释放。
关键点:动画控制器要成对处理初始化和释放。
initState创建,dispose销毁,这是 Flutter 动画代码的基本要求。
六、随机点数生成
6.1 _rollDice 方法
dart
void _rollDice() {
final random = math.Random();
setState(() {
_results = List.generate(_diceCount, (_) => random.nextInt(6) + 1);
_isRolling = false;
});
}
_rollDice() 使用随机数生成每个骰子的点数,并将 _isRolling 设置为 false。
6.2 Random 范围
dart
random.nextInt(6) + 1
nextInt(6) 的范围是 0 到 5,加 1 后得到 1 到 6。
| 表达式 | 结果范围 |
|---|---|
random.nextInt(6) |
0 到 5 |
random.nextInt(6) + 1 |
1 到 6 |
6.3 List.generate
dart
List.generate(_diceCount, (_) => random.nextInt(6) + 1)
List.generate 根据 _diceCount 生成指定数量的骰子结果。每个元素都是 1 到 6 的随机值。
七、启动投掷流程
7.1 _startRolling 方法
dart
void _startRolling() {
setState(() {
_isRolling = true;
});
_controller.repeat();
}
点击投掷按钮后,页面进入滚动状态,并启动动画控制器。
7.2 投掷状态
_isRolling 会影响两个位置:
| 影响位置 | 行为 |
|---|---|
| 投掷按钮 | 滚动中禁用并显示 Rolling... |
| 骰子动画 | 滚动中根据 _controller.value 旋转 |
7.3 投掷链路
#mermaid-svg-ZrEhPskTaB9ivvl0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZrEhPskTaB9ivvl0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZrEhPskTaB9ivvl0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZrEhPskTaB9ivvl0 .error-icon{fill:#552222;}#mermaid-svg-ZrEhPskTaB9ivvl0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZrEhPskTaB9ivvl0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZrEhPskTaB9ivvl0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZrEhPskTaB9ivvl0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZrEhPskTaB9ivvl0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZrEhPskTaB9ivvl0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZrEhPskTaB9ivvl0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZrEhPskTaB9ivvl0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZrEhPskTaB9ivvl0 .marker.cross{stroke:#333333;}#mermaid-svg-ZrEhPskTaB9ivvl0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZrEhPskTaB9ivvl0 p{margin:0;}#mermaid-svg-ZrEhPskTaB9ivvl0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZrEhPskTaB9ivvl0 .cluster-label text{fill:#333;}#mermaid-svg-ZrEhPskTaB9ivvl0 .cluster-label span{color:#333;}#mermaid-svg-ZrEhPskTaB9ivvl0 .cluster-label span p{background-color:transparent;}#mermaid-svg-ZrEhPskTaB9ivvl0 .label text,#mermaid-svg-ZrEhPskTaB9ivvl0 span{fill:#333;color:#333;}#mermaid-svg-ZrEhPskTaB9ivvl0 .node rect,#mermaid-svg-ZrEhPskTaB9ivvl0 .node circle,#mermaid-svg-ZrEhPskTaB9ivvl0 .node ellipse,#mermaid-svg-ZrEhPskTaB9ivvl0 .node polygon,#mermaid-svg-ZrEhPskTaB9ivvl0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZrEhPskTaB9ivvl0 .rough-node .label text,#mermaid-svg-ZrEhPskTaB9ivvl0 .node .label text,#mermaid-svg-ZrEhPskTaB9ivvl0 .image-shape .label,#mermaid-svg-ZrEhPskTaB9ivvl0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZrEhPskTaB9ivvl0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZrEhPskTaB9ivvl0 .rough-node .label,#mermaid-svg-ZrEhPskTaB9ivvl0 .node .label,#mermaid-svg-ZrEhPskTaB9ivvl0 .image-shape .label,#mermaid-svg-ZrEhPskTaB9ivvl0 .icon-shape .label{text-align:center;}#mermaid-svg-ZrEhPskTaB9ivvl0 .node.clickable{cursor:pointer;}#mermaid-svg-ZrEhPskTaB9ivvl0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZrEhPskTaB9ivvl0 .arrowheadPath{fill:#333333;}#mermaid-svg-ZrEhPskTaB9ivvl0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZrEhPskTaB9ivvl0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZrEhPskTaB9ivvl0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZrEhPskTaB9ivvl0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZrEhPskTaB9ivvl0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZrEhPskTaB9ivvl0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZrEhPskTaB9ivvl0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZrEhPskTaB9ivvl0 .cluster text{fill:#333;}#mermaid-svg-ZrEhPskTaB9ivvl0 .cluster span{color:#333;}#mermaid-svg-ZrEhPskTaB9ivvl0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZrEhPskTaB9ivvl0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZrEhPskTaB9ivvl0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZrEhPskTaB9ivvl0 .icon-shape,#mermaid-svg-ZrEhPskTaB9ivvl0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZrEhPskTaB9ivvl0 .icon-shape p,#mermaid-svg-ZrEhPskTaB9ivvl0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZrEhPskTaB9ivvl0 .icon-shape .label rect,#mermaid-svg-ZrEhPskTaB9ivvl0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZrEhPskTaB9ivvl0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZrEhPskTaB9ivvl0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZrEhPskTaB9ivvl0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击 Roll Dice
_startRolling
_isRolling = true
_controller.repeat
AnimatedBuilder 重建
Transform.rotate 旋转骰子
动画状态监听
_rollDice 生成结果
_isRolling = false
按钮恢复可用,总点数更新
7.4 动画停止优化
当前源码中 _rollDice() 会把 _isRolling 设为 false,但没有显式调用 _controller.stop()。由于旋转角度依赖 _isRolling,视觉上会停止旋转;正式项目中可以在 _rollDice() 中补充停止控制器,避免动画继续 tick。
dart
void _rollDice() {
_controller.stop();
final random = math.Random();
setState(() {
_results = List.generate(_diceCount, (_) => random.nextInt(6) + 1);
_isRolling = false;
});
}
这属于动画资源管理优化,不改变当前 UI 的核心逻辑。
八、骰子数量控制
8.1 数量控制 UI
dart
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Number of Dice: '),
IconButton(...),
Text('$_diceCount'),
IconButton(...),
],
)
顶部区域用于控制骰子数量。
8.2 减少骰子
dart
IconButton(
onPressed: _diceCount > 1
? () {
setState(() {
_diceCount--;
_results = List.generate(_diceCount, (_) => 1);
});
}
: null,
icon: const Icon(Icons.remove_circle),
)
当 _diceCount 大于 1 时可以减少。减少后,结果列表会重置为对应数量的 1。
8.3 增加骰子
dart
IconButton(
onPressed: _diceCount < 6
? () {
setState(() {
_diceCount++;
_results.add(1);
});
}
: null,
icon: const Icon(Icons.add_circle),
)
当 _diceCount 小于 6 时可以增加。新增骰子的默认点数是 1。
8.4 边界表
| 状态 | 减号 | 加号 |
|---|---|---|
_diceCount == 1 |
禁用 | 可用 |
1 < _diceCount < 6 |
可用 | 可用 |
_diceCount == 6 |
可用 | 禁用 |
九、骰子布局与动画
9.1 Wrap 布局
dart
Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: _results.map((result) {
return AnimatedBuilder(...);
}).toList(),
)
Wrap 能在空间不足时自动换行,适合展示 1 到 6 个骰子。
9.2 AnimatedBuilder
dart
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _isRolling ? _controller.value * math.pi * 4 : 0,
child: _buildDice(result),
);
},
)
AnimatedBuilder 监听 _controller,每当动画值变化时重建内部 UI。
9.3 Transform.rotate
dart
angle: _isRolling ? _controller.value * math.pi * 4 : 0
_controller.value 从 0 到 1 变化,乘以 math.pi * 4 后,相当于最多旋转两圈。
9.4 动画参数表
| 参数 | 当前值 | 作用 |
|---|---|---|
| 动画时长 | 500ms | 控制一次动画周期 |
| 旋转公式 | value * pi * 4 |
两圈旋转 |
| 滚动状态 | _isRolling |
控制是否旋转 |
| 布局方式 | Wrap |
多骰子自动换行 |
十、骰子方块实现
10.1 _buildDice 方法
dart
Widget _buildDice(int value) {
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.deepPurple, width: 2),
boxShadow: [
BoxShadow(
color: Colors.deepPurple.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(2, 2),
),
],
),
child: Center(
child: Text(
value.toString(),
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.bold,
color: Colors.deepPurple,
),
),
),
);
}
当前项目使用数字文本展示骰子点数,而不是绘制传统点阵。
10.2 骰子视觉配置
| 属性 | 当前值 | 作用 |
|---|---|---|
| 宽高 | 80 x 80 | 固定骰子尺寸 |
| 背景 | 白色 | 模拟骰子表面 |
| 圆角 | 12 | 方块圆角 |
| 边框 | 深紫色 2px | 强化边界 |
| 阴影 | 深紫半透明 | 提升立体感 |
| 数字字号 | 40 | 突出点数 |
10.3 数字骰子的取舍
数字骰子实现简单、可读性强,也便于测试。后续如果要更接近真实骰子,可以将 _buildDice 改成点阵绘制。
十一、总点数计算
11.1 fold 聚合
dart
Text(
'Total: ${_results.fold(0, (sum, val) => sum + val)}',
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
)
fold 从初始值 0 开始,把 _results 中的点数逐个累加。
11.2 聚合示例
text
results = [2, 5, 3]
sum = 0
sum + 2 = 2
sum + 5 = 7
sum + 3 = 10
Total = 10
11.3 结果展示表
_results |
总点数 |
|---|---|
[1] |
1 |
[3, 4] |
7 |
[6, 6, 6] |
18 |
[1, 2, 3, 4, 5, 6] |
21 |
十二、按钮状态与交互
12.1 投掷按钮源码
dart
ElevatedButton.icon(
onPressed: _isRolling ? null : _startRolling,
icon: Icon(_isRolling ? Icons.hourglass_empty : Icons.casino),
label: Text(_isRolling ? 'Rolling...' : 'Roll Dice'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
backgroundColor: Colors.deepPurple,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
)
按钮根据 _isRolling 切换可用状态、图标和文案。
12.2 状态表
_isRolling |
按钮状态 | 图标 | 文案 |
|---|---|---|---|
false |
可点击 | Icons.casino |
Roll Dice |
true |
禁用 | Icons.hourglass_empty |
Rolling... |
12.3 禁用按钮的意义
滚动中禁用按钮可以避免用户重复触发投掷,减少动画状态和随机结果之间的竞争。
十三、OpenHarmony 适配边界
13.1 Flutter 层职责
当前项目的业务逻辑全部在 Flutter 层完成。
text
Flutter 层:
DiceRollerApp
DiceRollerHomePage
_DiceRollerHomePageState
AnimationController
AnimatedBuilder
Transform.rotate
Random
Wrap
_buildDice
OpenHarmony 层:
AppScope
entry
EntryAbility
Index
resources
Flutter 层负责随机数、动画、按钮和结果展示;OpenHarmony 层负责应用启动、模块配置和页面承载。
13.2 平台侧文件作用
| 层级 | 文件 | 作用 |
|---|---|---|
| 应用级 | AppScope/app.json5 |
描述应用整体信息 |
| 模块级 | entry/src/main/module.json5 |
描述模块入口和页面 |
| Ability | EntryAbility.ets |
应用启动入口 |
| 页面 | Index.ets |
承载 Flutter 页面 |
| 资源 | resources/base |
图标、字符串和配置资源 |
| 插件 | GeneratedPluginRegistrant.ets |
插件注册入口 |
13.3 骰子应用适配特点
当前项目不需要网络、定位、相机、传感器、数据库或文件读写权限。OpenHarmony 侧主要验证:
- 骰子数量加减按钮是否可用。
- 1 到 6 的数量边界是否正确。
- 投掷按钮滚动中是否禁用。
- 旋转动画是否流畅。
Wrap在多骰子时是否正常换行。- 总点数是否随结果更新。
十四、测试与验证
14.1 页面验证路径
骰子投掷器的核心验证路径如下:
- 启动应用,标题显示
Dice Roller。 - 默认骰子数量显示
1。 - 默认总点数显示
Total: 1。 - 点击加号,骰子数量增加。
- 数量增加到 6 后,加号禁用。
- 点击减号,骰子数量减少。
- 数量减少到 1 后,减号禁用。
- 点击
Roll Dice。 - 按钮显示
Rolling...。 - 骰子播放旋转动画。
- 投掷完成后显示 1 到 6 的点数。
- 总点数与所有骰子点数之和一致。
14.2 Widget 测试示例
下面的测试验证页面基础内容。
dart
testWidgets('shows dice roller page content', (WidgetTester tester) async {
await tester.pumpWidget(const DiceRollerApp());
expect(find.text('Dice Roller'), findsOneWidget);
expect(find.text('Number of Dice: '), findsOneWidget);
expect(find.text('Roll Dice'), findsOneWidget);
expect(find.text('Total: 1'), findsOneWidget);
});
14.3 随机范围测试
可以将骰子随机逻辑抽成纯函数后测试。
dart
List<int> rollDice(int count, math.Random random) {
return List.generate(count, (_) => random.nextInt(6) + 1);
}
测试用例:
dart
test('rolls values between 1 and 6', () {
final values = rollDice(100, math.Random(1));
expect(values.every((value) => value >= 1 && value <= 6), isTrue);
});
14.4 总点数测试
dart
int totalDice(List<int> values) {
return values.fold(0, (sum, val) => sum + val);
}
测试用例:
dart
test('sums dice values', () {
expect(totalDice([1, 2, 3]), 6);
expect(totalDice([6, 6, 6]), 18);
});
十五、常见问题与优化建议
15.1 为什么使用 SingleTickerProviderStateMixin
AnimationController 需要 vsync 控制动画刷新节奏。当前页面只有一个动画控制器,因此使用 SingleTickerProviderStateMixin。
15.2 为什么使用 AnimatedBuilder
AnimatedBuilder 可以监听动画值变化,并只重建依赖动画的部分。当前项目用它包裹骰子旋转逻辑。
15.3 为什么使用 Wrap
骰子数量最多 6 个,使用 Row 可能在窄屏上溢出。Wrap 可以自动换行,更适合多骰子布局。
15.4 为什么骰子数量限制为 1 到 6
1 是最小有效数量,6 是当前 UI 下比较合理的展示上限。超过 6 后,骰子布局和总点数仍能计算,但页面可读性会下降。
15.5 为什么使用 fold 计算总点数
fold 能清晰表达"从 0 开始累加列表中所有值"的逻辑。对于骰子点数汇总,这比手写循环更简洁。
15.6 动画控制器可以如何优化
当前源码使用 _controller.repeat() 启动动画。正式项目中可以在生成点数后显式调用 _controller.stop() 或使用 forward(from: 0) 控制单次动画,避免控制器持续运行。
15.7 UI 层可以如何继续优化
- 将
_buildDice改成真实点阵骰子。 - 为投掷结果增加历史记录。
- 增加震动或音效反馈。
- 支持不同面数骰子,例如 D4、D8、D20。
- 将随机逻辑抽成纯函数,方便测试。
- 将骰子 Widget 独立封装,提高可维护性。
十六、完整业务链路复盘
16.1 数量调整链路
- 用户点击加号或减号。
_diceCount在 1 到 6 之间变化。_results同步调整长度。Wrap根据_results重建骰子列表。- 总点数随
_results变化更新。
16.2 投掷动画链路
- 用户点击
Roll Dice。 _startRolling()设置_isRolling = true。_controller.repeat()启动动画。AnimatedBuilder监听动画值。Transform.rotate让骰子旋转。- 按钮变为
Rolling...。
16.3 随机结果链路
- 动画状态监听触发
_rollDice()。 math.Random()创建随机数生成器。List.generate生成_diceCount个点数。- 每个点数范围为 1 到 6。
_isRolling变为false。- 页面显示新结果。
16.4 数据到 UI 的映射
| 数据 | 处理方式 | UI 输出 |
|---|---|---|
_diceCount |
文本插值 | 骰子数量 |
_results |
map |
骰子方块列表 |
_results |
fold |
总点数 |
_isRolling |
条件表达式 | 按钮文案和图标 |
_controller.value |
Transform.rotate |
旋转动画 |
十七、核心源码总览
下面集中展示当前项目最关键的动画和随机数代码。
dart
class _DiceRollerHomePageState extends State<DiceRollerHomePage>
with SingleTickerProviderStateMixin {
int _diceCount = 1;
List<int> _results = [1];
bool _isRolling = false;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_rollDice();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
随机生成骰子结果的代码如下:
dart
void _rollDice() {
final random = math.Random();
setState(() {
_results = List.generate(_diceCount, (_) => random.nextInt(6) + 1);
_isRolling = false;
});
}
旋转动画核心代码如下:
dart
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _isRolling ? _controller.value * math.pi * 4 : 0,
child: _buildDice(result),
);
},
)
这些代码体现了项目主干:动画控制器驱动旋转,随机数生成骰子点数,状态变化更新页面。
总结
dice_roller 是一个适合学习 Flutter 动画和小游戏交互的小项目。它通过 math.Random 生成 1 到 6 的骰子结果,通过 AnimationController 和 AnimatedBuilder 驱动旋转动画,通过 Wrap 自适应展示多个骰子,并使用 fold 汇总总点数。
从源码结构看,项目的技术重点集中在三条线:第一条是数量控制,骰子数量在 1 到 6 之间变化;第二条是动画控制,投掷时禁用按钮并旋转骰子;第三条是结果计算,动画结束后生成随机点数并更新总点数。
从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。它不需要额外权限,但需要重点验证动画流畅度、按钮状态、数量边界、Wrap 布局和结果展示。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
- Flutter 官方文档:https://docs.flutter.dev/
- Dart 官方文档:https://dart.dev/guides
- Flutter Widget 目录:https://docs.flutter.dev/ui/widgets
- Flutter 动画文档:https://docs.flutter.dev/ui/animations
- Flutter AnimationController 文档:https://api.flutter.dev/flutter/animation/AnimationController-class.html
- Flutter Transform 文档:https://api.flutter.dev/flutter/widgets/Transform-class.html
- Dart Random 文档:https://api.dart.dev/stable/dart-math/Random-class.html
- Flutter 测试文档:https://docs.flutter.dev/testing
- Material Design 3:https://m3.material.io/
- pub.dev:https://pub.dev/
- OpenHarmony 官网:https://www.openharmony.cn/
- OpenHarmony Gitee 组织:https://gitee.com/openharmony
- 开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net