Flutter三方库适配OpenHarmony【dice_roller】骰子投掷器项目完整实战

Flutter三方库适配OpenHarmony【dice_roller】骰子投掷器项目完整实战

前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

dice_roller 是一个基于 Flutter 实现的 Dice Roller 骰子投掷器 。项目核心源码位于 lib/main.dart,同时保留了 ohos OpenHarmony 平台工程目录,适合用来学习 随机数生成动画控制器旋转动画Wrap 自适应布局按钮状态控制OpenHarmony 小游戏类页面适配

这个项目的代码量不大,但包含一个完整的交互闭环:用户调整骰子数量,点击投掷按钮,页面进入滚动状态,骰子播放旋转动画,动画完成后生成 1 到 6 的随机点数,并重新计算总点数。它比静态计算器更适合讲解 Flutter 中动画、状态和 UI 的协作方式。

本文重点回答三个问题:

  1. dice_roller 如何使用 dart:math Random 生成骰子点数。
  2. Flutter 如何通过 AnimationControllerAnimatedBuilderTransform.rotate 实现投掷动画。
  3. 这个项目适配 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 项目目标

这个项目的目标是实现一个完整的骰子投掷交互:

  1. 用户设置骰子数量。
  2. 页面同步调整骰子列表。
  3. 用户点击投掷按钮。
  4. 页面进入滚动状态。
  5. 骰子播放旋转动画。
  6. 动画结束后生成随机点数。
  7. 页面更新骰子显示和总点数。

关键点:骰子项目的重点不是 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'),
    );
  }
}

这里有三个关键信息:

  1. 应用标题是 Dice Roller
  2. 主题种子色使用 Colors.deepPurple
  3. 首页是 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 {
  // ...
}

SingleTickerProviderStateMixinAnimationController 提供 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. 骰子数量加减按钮是否可用。
  2. 1 到 6 的数量边界是否正确。
  3. 投掷按钮滚动中是否禁用。
  4. 旋转动画是否流畅。
  5. Wrap 在多骰子时是否正常换行。
  6. 总点数是否随结果更新。

十四、测试与验证

14.1 页面验证路径

骰子投掷器的核心验证路径如下:

  1. 启动应用,标题显示 Dice Roller
  2. 默认骰子数量显示 1
  3. 默认总点数显示 Total: 1
  4. 点击加号,骰子数量增加。
  5. 数量增加到 6 后,加号禁用。
  6. 点击减号,骰子数量减少。
  7. 数量减少到 1 后,减号禁用。
  8. 点击 Roll Dice
  9. 按钮显示 Rolling...
  10. 骰子播放旋转动画。
  11. 投掷完成后显示 1 到 6 的点数。
  12. 总点数与所有骰子点数之和一致。

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 数量调整链路

  1. 用户点击加号或减号。
  2. _diceCount 在 1 到 6 之间变化。
  3. _results 同步调整长度。
  4. Wrap 根据 _results 重建骰子列表。
  5. 总点数随 _results 变化更新。

16.2 投掷动画链路

  1. 用户点击 Roll Dice
  2. _startRolling() 设置 _isRolling = true
  3. _controller.repeat() 启动动画。
  4. AnimatedBuilder 监听动画值。
  5. Transform.rotate 让骰子旋转。
  6. 按钮变为 Rolling...

16.3 随机结果链路

  1. 动画状态监听触发 _rollDice()
  2. math.Random() 创建随机数生成器。
  3. List.generate 生成 _diceCount 个点数。
  4. 每个点数范围为 1 到 6。
  5. _isRolling 变为 false
  6. 页面显示新结果。

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 的骰子结果,通过 AnimationControllerAnimatedBuilder 驱动旋转动画,通过 Wrap 自适应展示多个骰子,并使用 fold 汇总总点数。

从源码结构看,项目的技术重点集中在三条线:第一条是数量控制,骰子数量在 1 到 6 之间变化;第二条是动画控制,投掷时禁用按钮并旋转骰子;第三条是结果计算,动画结束后生成随机点数并更新总点数。

从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。它不需要额外权限,但需要重点验证动画流畅度、按钮状态、数量边界、Wrap 布局和结果展示。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

相关推荐
痕忆丶1 小时前
openharmony北向开发问题之HDC端口8710被svchost占用问题
harmonyos
AI2中文网2 小时前
App Inventor 2 鸿蒙先行版开发进展:从 Android 到 HarmonyOS 的积木编程迁移实录
android·低代码·华为·harmonyos·app inventor
nashane2 小时前
HarmonyOS 6学习:DevEco Studio跨平台开发环境深度排障指南
学习·华为·harmonyos
韩曙亮2 小时前
【Flutter】Flutter 组件 ① ( StatelessWidget 无状态组件 与 StatefulWidget 有状态组件 )
flutter·statefulwidget·statelesswidget
恋猫de小郭2 小时前
Flutter 最好的 AI 自动化测试工具:Patrol
android·前端·flutter
不爱吃糖的程序媛2 小时前
使用 hionic 将 Web 应用部署到鸿蒙PC平台
flutter·华为·harmonyos
慧海灵舟2 小时前
鸿蒙零基础实战教程Day0:HarmonyOS NEXT 项目创建与环境准备
华为·harmonyos
G_dou_2 小时前
Flutter三方库适配OpenHarmony【age_calculator】年龄计算器项目完整实战
flutter·harmonyos