1. 项目起点:为什么是 Flutter + OpenHarmony + 骰子工具
开发三国杀攻略 App 这个念头,最初源于一个很实际的痛点:身边朋友开局经常因为谁先手、判定哪张牌、几点伤害这类问题争得面红耳赤,旁边又没有实体骰子。手机自带的随机数工具要么界面简陋,要么广告满天飞,用起来非常别扭。当时我正好在研究 OpenHarmony 上的跨平台方案,想着与其等官方生态慢慢补齐工具类应用,不如自己动手把核心功能做出来——于是就有了这个“骰子工具”模块。
选 Flutter 而不是 ArkTS 原生,原因很直接:Flutter 的渲染管线在 OpenHarmony 上有完整适配,我的代码库还能同时覆盖 Android 和 iOS,一套逻辑多处复用,对个人开发者来说性价比极高。ArkTS 虽然更贴近 OpenHarmony 系统能力,但它绑定在 DevEco Studio 的工程体系里,组件生态和跨端复用能力都还处于起步阶段。如果你只是给 OpenHarmony 做单一设备应用,ArkTS 没问题;但如果是“一套代码多端跑”的需求,Flutter 明显更成熟。
骰子工具听起来简单,其实踩坑点比想象中多:动画流畅度、随机数质量、组件通信、多设备适配,每一个都能让人折腾半天。这篇实战文章不做全局攻略 App 的流水账,只聚焦骰子工具从零到一的过程,把环境搭建、核心代码、状态管理、真机调试的完整链路讲透。适合三类读者:第一次在 OpenHarmony 上跑 Flutter 的人、想给攻略类应用加交互小工具的开发者、以及打算把 Flutter 项目移植到非 Android 平台的人。
2. 环境搭建与工程创建:在 OpenHarmony 上跑 Flutter 的第一步
2.1 OpenHarmony 的 Flutter 适配现状
很多人第一步就卡在环境上,因为 OpenHarmony 的 Flutter SDK 不是从官网直接下载的,而是社区维护的 fork 版本。当前主流做法是使用某厂推出的 OpenHarmony 分支 SDK,它基于 Flutter 的稳定版本做了系统能力适配,包括图形渲染、输入事件、生命周期管理。下载后不能覆盖你原有的 Flutter 目录,最好单独放一个路径,比如D:\flutter_ohos,然后通过环境变量切换。
注意:OpenHarmony SDK 与标准 Flutter SDK 的版本号并不完全对应,不要用
flutter --version去判断是否“最新”。稳定优先,选经过社区验证的版本即可。
创建工程不需要用 DevEco Studio,直接用 Flutter 命令行就行。进入flutter_ohos/bin目录,执行flutter create dice_tool,生成的工程结构和标准 Flutter 工程几乎一致,只是多了一个ohos目录。这个目录就是 OpenHarmony 的原生工程壳子,由 Flutter 插件自动生成,不能删。
2.2 构建配置的关键参数
OpenHarmony 的构建走的是hvigor工具链,不是 Gradle。flutter build并不能直接产出.hap包,得先构建出中间产物,再用 DevEco Studio 打开ohos目录完成打包签名。
具体步骤是:先用 Flutter 命令行编译出libapp.so和flutter_assets,然后打开 DevEco Studio 导入ohos目录,配置签名信息后构建出 HAP 包。整个过程比较绕,我第二次踩这个坑时才发现可以直接在ohos工程里配置自动调用 Flutter 构建的脚本,省去手动多次切换。
还有一个很容易被忽略的点:ohos工程的build-profile.json5文件里要声明signingConfigs,否则无法安装到真机。模拟器虽然可以用,但相机、传感器这类能力在模拟器上支持不完整,骰子工具如果依赖震动反馈,最好还是真机调试。
2.3 热重载机制与标准 Flutter 的差异
OpenHarmony 分支的热重载目前不支持添加新原生插件,只能改 Dart 代码。这意味着:如果你在pubspec.yaml里新增了一个依赖,必须完全重启应用,热重载不会自动加载原生模块。这个限制在做骰子工具时影响不大,但如果你后续要加摄像头、传感器这类原生能力,设计架构时就要提前划分好模块边界,避免频繁增加原生插件导致调试效率太低。
3. 骰子工具的核心设计:需求拆解与技术选型
3.1 先用一页纸想清楚功能边界
骰子工具本质上是一个随机数生成器,但它不是“点击一下出个数字”那么肤浅。三国杀场景里,骰子的用途很明确:判定先后手、决定技能目标、模拟伤害波动。所以产品层面需要三个交互模式:
- 单骰模式:点击按钮,生成 1~6 的随机数,显示点数动画。
- 双骰模式:同时生成两个随机数,支持相加、比较大小,用于先后手判定。
- 计数模式:连续掷骰多次,统计每个点数出现的次数,方便做概率验证。
技术层面,核心组件有四个:随机数生成器、动画控制器、状态管理、音效与震动反馈。其中随机数质量是重灾区,后面单独讲。
3.2 为什么选 Provider 而不是 Riverpod 或 Bloc
社区里问 “flutter provider 怎么用” 的人最多,这个热度不是没道理的。Provider 这套状态管理方案在 OpenHarmony 分支下兼容性极好,因为它不依赖 Flutter 内部私有实现,只是用InheritedWidget做依赖注入,这层 API 在 fork 分支里没有改动。Riverpod 虽然更严谨,但它对代码生成和静态分析的依赖更深,在 OpenHarmony 分支下偶尔会出现代码生成器版本不匹配的问题。Bloc 则偏重,事件流和状态流的抽象对一个小工具模块来说是过度设计。
我的选择是 Provider +ChangeNotifier。骰子工具的状态很简单:当前点数列表、动画是否进行中、历史记录。这些状态用一个DiceViewModel extends ChangeNotifier管理绰绰有余,组件树顶层套一个ChangeNotifierProvider,子组件用Consumer或context.watch<T>()监听变化。
实操心得:在 OpenHarmony 上使用 Provider 有个隐藏坑——不要用
Provider.of<T>(context)的listen: false写法去读取状态。fork 分支的InheritedWidget实现里,listen: false的依赖阻断逻辑偶尔会失效,导致界面刷新异常。用Consumer是安全的选择。
3.3 随机数质量:Random() 到底够不够用
这是骰子工具里最容易被低估的问题。dart:math的Random()默认实现是线性同余生成器,周期有限且分布在高位比特上较弱。对于游戏场景,玩家连掷几十次就会察觉规律,这在桌游类应用里是致命的体验缺陷。
我的方案是混合使用两类随机源:
Random.secure():基于系统熵池,质量高但速度慢,适合决定每次掷骰的结果。Random():速度快,适合生成动画过程中的中间帧数据,不影响最终结果。
核心代码如下:
int rollDie() { final secure = Random.secure(); return secure.nextInt(6) + 1; }如果对随机性有更高要求,比如做概率模拟测试,可以引入crypto包的sha256对时间戳、硬件标识、上次结果拼接后取哈希,再做模运算。这种做法虽然有点杀鸡用牛刀,但在某些特殊规则玩法里确实能避免玩家“读心”。
3.4 动画实现:不用 Flutter 内置骰子组件,用 AnimatedBuilder 硬啃
Flutter 没有现成的骰子组件,网上找的第三方包往往在 OpenHarmony 上有兼容问题。我的做法是用AnimatedBuilder驱动一个旋转加位移动画,掷骰时生成 6~10 帧乱序的点数图案,最后停在真正结果上。
动画的核心逻辑是:先用一个AnimationController控制总时长 300ms,然后在AnimatedBuilder的builder里根据Curve.elasticOut计算当前的旋转角度和偏移量:
AnimationController _controller = AnimationController( vsync: this, duration: Duration(milliseconds: 300), ); late Animation<double> _curve = CurvedAnimation( parent: _controller, curve: Curves.elasticOut, );在 UI 层,骰子图案用CustomPaint绘制,六个面的圆点坐标写死成一个二维数组。通过Transform.rotate加上Transform.translate组合变换,模拟骰子滚动效果。我试过用AnimatedRotation组件,但在 OpenHarmony 的 GPU 适配层,多个 transform 嵌套时的渲染优先级有问题,会出现闪烁,改用Transform手动控制反而稳定。
4. 实操过程:从零到一实现骰子工具核心代码
4.1 工程目录结构设计
我在lib目录下按功能划分,不按类型划分:
lib/ main.dart # 应用入口 models/ dice_result.dart # 骰子结果数据模型 providers/ dice_view_model.dart # 状态管理与业务逻辑 views/ dice_screen.dart # 骰子工具主界面 widgets/ dice_widget.dart # 单个骰子的绘制与动画组件 dice_history_list.dart # 历史记录列表 utils/ secure_random.dart # 随机数工具封装按功能分包的好处是,以后加“武将攻略”模块时直接平行加一个providers/hero_view_model.dart,不会污染骰子模块的代码。
4.2 骰子结果模型与 ViewModel 实现
骰子结果模型很简单,但注意要重写==和hashCode,因为历史记录列表要用集合去重以及判断相同结果连续出现的情况:
class DiceResult { final int leftValue; final int rightValue; final DateTime createdAt; DiceResult({ required this.leftValue, required this.rightValue, required this.createdAt, }); int get sum => leftValue + rightValue; @override bool operator ==(Object other) => other is DiceResult && other.leftValue == leftValue && other.rightValue == rightValue; @override int get hashCode => Object.hash(leftValue, rightValue); }ViewModel 负责生成随机数、维护历史列表、控制动画状态:
class DiceViewModel extends ChangeNotifier { int? _leftValue; int? _rightValue; bool _isRolling = false; final List<DiceResult> _history = []; int? get leftValue => _leftValue; int? get rightValue => _rightValue; bool get isRolling => _isRolling; List<DiceResult> get history => List.unmodifiable(_history); Future<void> roll() async { if (_isRolling) return; _isRolling = true; notifyListeners(); // 先播放动画帧,最后生成随机结果 await Future.delayed(Duration(milliseconds: 300)); _leftValue = SecureRandom.rollDie(); _rightValue = SecureRandom.rollDie(); _history.insert(0, DiceResult( leftValue: _leftValue!, rightValue: _rightValue!, createdAt: DateTime.now(), )); _isRolling = false; notifyListeners(); } void clearHistory() { _history.clear(); notifyListeners(); } }注意:
roll()方法没有做并发锁,而是用_isRolling标志位拦截重复点击。这个设计是故意的,因为 Flutter 的点击事件是单线程的,用户狂点按钮时,if (_isRolling) return;会比锁更高效,且不会阻塞 UI 渲染。
4.3 骰子绘制组件:CustomPaint 与响应式布局适配
绘制骰子点数的核心是CustomPainter。六个面的圆点坐标我直接硬编码:
class DicePainter extends CustomPainter { final int value; final Color dotColor; static const Map<int, List<Offset>> _dotPositions = { 1: [Offset(0.5, 0.5)], 2: [Offset(0.3, 0.3), Offset(0.7, 0.7)], 3: [Offset(0.3, 0.3), Offset(0.5, 0.5), Offset(0.7, 0.7)], 4: [Offset(0.3, 0.3), Offset(0.7, 0.3), Offset(0.3, 0.7), Offset(0.7, 0.7)], 5: [Offset(0.3, 0.3), Offset(0.7, 0.3), Offset(0.5, 0.5), Offset(0.3, 0.7), Offset(0.7, 0.7)], 6: [Offset(0.3, 0.3), Offset(0.7, 0.3), Offset(0.3, 0.5), Offset(0.7, 0.5), Offset(0.3, 0.7), Offset(0.7, 0.7)], }; @override void paint(Canvas canvas, Size size) { final rect = Offset.zero & size; final rrect = RRect.fromRectAndRadius(rect, Radius.circular(size.width * 0.15)); canvas.drawRRect(rrect, Paint()..color = Colors.white); // 画圆点 final dotPaint = Paint()..color = dotColor; for (final pos in _dotPositions[value]!) { canvas.drawCircle( Offset(pos.dx * size.width, pos.dy * size.height), size.width * 0.07, dotPaint, ); } } @override bool shouldRepaint(covariant DicePainter oldDelegate) { return oldDelegate.value != value; } }这里有几个细节要提醒:
- 圆点半径
size.width * 0.07不要写死成固定像素,否则在平板设备上骰子会显得太空,在手机上又会挤在一起。用相对比例才能在不同屏幕尺寸下保持一致观感。 shouldRepaint只比较value,因为骰子颜色固定不变。如果你后续支持骰子主题切换,这里要加上颜色比较。- 带圆角的矩形背景我用
RRect.fromRadius绘制,比Container加BoxDecoration更灵活,因为CustomPaint可以直接拿到 Canvas 控件,方便后续扩展纹理、阴影效果。
4.4 动画与随机数的协同:先转后停,手感优先
用户体验上有一个反直觉的优化点:随机结果不要在点击的瞬间生成,而是先播放一小段乱序动画,最后再定格到真实结果。这样操作反馈更“扎实”,玩家会感觉是自己的操作影响了结果,而不是被随机数牵着走。
我的做法是在DiceWidget内部维护一个_displayValue,动画过程中每 40ms 随机切换一次显示值,动画结束后才显示真正的结果:
class DiceWidget extends StatefulWidget { final DiceViewModel viewModel; // ... } class _DiceWidgetState extends State<DiceWidget> with SingleTickerProviderStateMixin { late final AnimationController _controller; int _displayValue = 6; Timer? _timer; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: Duration(milliseconds: 300), ); _controller.addStatusListener((status) { if (status == AnimationStatus.completed) { _timer?.cancel(); _timer = null; } }); } void _onRoll() { if (_controller.isAnimating) return; _controller.forward(from: 0); _timer = Timer.periodic(Duration(milliseconds: 40), (timer) { setState(() { _displayValue = SecureRandom.rollDie(); }); }); } }这里用Timer.periodic做乱序刷新,而不是setState里每次调用rollDie(),是因为动画期间频繁创建随机数对象会触发垃圾回收,在 OpenHarmony 的低端设备上可能造成掉帧。定时器只维护一个_displayValue的赋值操作,开销最小。
4.5 音效与震动反馈:小巧但提升质感的细节
用户对骰子工具的好感度,很大程度来自声音和震动。OpenHarmony 的 Flutter 分支提供了systemSettings插件可以控制震动,但调用链比较深。我实际用的是社区维护的flutter_ohos_vibrator插件,API 和 Android 版本的VibratePlugin类似:
import 'package:flutter_ohos_vibrator/flutter_ohos_vibrator.dart'; void _buzz() { FlutterOhosVibrator.vibrate(50); // 50ms 短震 }音效方面,我没有用音频文件,而是用SystemSound.play(SystemSoundType.click)代替。OpenHarmony 分支对接的是系统提示音,延迟比播放 MP3 低,而且在静音模式下会自动走无声路径,不用自己处理音量策略。这个特性在三国杀场景里很实用,因为玩家经常是在安静环境中开黑,系统音效会自动降低干扰。
5. 状态管理实战:Provider 组件通信与调试技巧
5.1 组件通信的三种场景
骰子工具这个界面看似简单,实际上有至少三种组件通信场景:
- 父组件(屏幕)向子组件(骰子)传递 ViewModel:直接通过构造函数传入即可。
- 子组件(骰子)向父组件(屏幕)报告动画结束:用
callback回调,在动画 status 变为 completed 时触发。 - 非父子组件(历史记录列表与骰子面板)同步状态:通过共享同一个 ViewModel 实现。
前两种场景很多人都会,但第三种在开发中容易脑抽。我最早的做法是每个组件自己Provider.of<T>读取 ViewModel,结果历史列表组件在更新时导致整个页面重建,浪费性能。后来改成Consumer精确监听,把重建范围缩小到真正依赖数据的 widget 上。
关键代码:
Consumer<DiceViewModel>( builder: (context, vm, child) { return DiceWidget( value: vm.leftValue ?? 1, isRolling: vm.isRolling, onRoll: vm.roll, ); }, )Consumer的builder只有在 ViewModel 通知时才会执行,不影响child参数里的静态组件。对比直接context.watch<T>()的写法,Consumer在大型组件树中的性能优势更明显。
5.2 Provider 在 OpenHarmony 上的兼容性问题
前面提到listen: false的坑,这里再扩展两个实际遇到的兼容问题:
第一,MultiProvider在 fork 分支下的嵌套顺序讲究。如果把ChangeNotifierProvider放在顶层,但某个子模块需要独立初始化数据,直接用ProxyProvider做依赖注入即可。OpenHarmony 分支对ProxyProvider的支持完全没问题,因为它底层走的是InheritedWidget的标准生命周期,没有用到任何实验性 API。
第二,ChangeNotifier的removeListener在组件销毁时经常报空指针。原因是 fork 分支的内存回收时机和标准 Flutter 不同。最稳妥的方法是:在StatefulWidget的dispose()里显式调用widget.viewModel.removeListener(_onChanged)。如果用了Consumer,它会自动处理这个逻辑,所以我强烈建议不要在 OpenHarmony 上写手动的addListener来替代Consumer。
5.3 调试技巧:Provider 的依赖注入时点
用 Provider 调试时最烦的问题就是“找不到 Provider”这个运行时错误。常见原因不是写法错误,而是你把Provider.of调用放在了一个理论上应该存在 Provider 的 widget 构建阶段,但 Provider 的初始化还没有完成。
我的排查流程是这样的:先确认MaterialApp的builder里是否正确嵌套了MultiProvider,再看报错堆栈指向的是哪个 widget。如果指向是DiceScreen,那大概率是DiceScreen在路由跳转时没有拿到 Provider,因为 Provider 在MaterialApp外层不具备跨路由传递能力。解决方案是把 Provider 放MaterialApp的builder里,这样每个路由都能共享同一个实例。
6. 常见问题与排查技巧实录
6.1 OpenHarmony 真机上的黑屏问题
我遇到过最诡异的问题:模拟器上运行正常,真机上应用启动后黑屏。排查半天发现是 OpenHarmony 的图形渲染层对 Flutter 的高刷新率模式支持不完整。解决办法是在ohos工程的module.json5里把requestOrientation字段设为portrait-primary,同时把 Flutter 的frameRate限制在 60fps。
设置帧率的代码:
WidgetsFlutterBinding.ensureInitialized(); FlutterOhosRenderPlugin.setFrameRate(60);社区有人说是 Impeller 渲染引擎在 OpenHarmony 上的兼容问题。如果你是用的新版本 Flutter 分支,可以一行代码切回 Skia:
flutter run --enable-software-rendering这个方法能快速定位问题,但软件渲染性能较差,只能调试,不能发布。最终还是要靠系统渲染层适配。
6.2 随机数结果分布不均的测试与修正
骰子工具必须过概率测试这一关。我写了简单的统计脚本,连续掷 10000 次,统计每个点数出现次数。最开始用Random()时,1 和 6 的出现频率显著偏高,偏差超过 8%,在桌游场景里已经能被玩家感知。换成Random.secure()之后,偏差降到了 1% 以内,符合预期。
如果你也遇到分布不均,有两层排查思路:第一,确认没有在动画定时器里调用随机数生成导致重复消耗随机池;第二,检查自定义随机源是否用了低质量的伪随机种子。最简单的做法就是直接用Random.secure(),它使用的是系统 CSPRNG,不需要自己去实现混合随机算法。
6.3 组件通信中的状态闪烁
开发中经常遇到这种情况:点击掷骰,骰子动了,但右侧历史记录列表里的新记录一直闪烁后才会稳定。这个 bug 的根源在DiceResult的==运算符里有DateTime字段,导致每次notifyListeners时,历史列表认为这是新数据,触发重建。我后来把createdAt从==比较中剔除,只比较leftValue和rightValue,问题立刻消失。
这个案例给了一个启示:设计数据模型时,记录创建时间和业务唯一标识要分开。时间戳用于展示和排序,唯一性比较需要用业务字段,否则状态管理永远在重建。
6.4 真机部署签名配置
OpenHarmony 真机部署需要证书签名,这块文档稀缺,特别容易劝退新手。我在第一次部署时卡了很久,后来总结出最小可行配置:
- 在 DevEco Studio 里配置自动签名,它会自动生成测试证书。
- 在
build-profile.json5里填入signingConfigs.name,并关联到构建配置。 - 真机需要开启开发者模式,并在“设置-关于本机”连续点击版本号激活。
如果签名配置正确,点击构建按钮产出.hap包,然后通过 hdc 工具安装到设备:
hdc install entry-default-signed.hap注意:hdc 是 OpenHarmony 的命令行工具,不是 adb。两个工具的命令参数不同,不要混用。安装失败时先看设备是否被识别,执行
hdc list targets。
7. 个人体会与扩展方向
骰子工具看起来是个小功能,但它逼迫我把 Flutter 在 OpenHarmony 上的整个运行链路走了一遍:SDK 分支选择、构建工具链、原生工程桥接、状态管理、渲染适配、真机调试。做完之后,我再去看标准的 Flutter 项目会觉得所有环节都“透明”了,因为 OpenHarmony 分支不能走捷径,逼着你理解底层原理。
接下来如果要继续扩展攻略 App,我会优先做两个方向:一是把骰子工具接上武将技能事件,比如特定武将技能触发时自动附加判定骰;二是做端云同步,把对战记录同步到服务端,做胜率统计。前者需要更细的领域建模,后者则要引入网络层和持久化方案,到那时再来写一篇端到端的实战分享。
最后再分享一个小技巧:骰子动画的乱序帧如果做得太规律,玩家会觉得“有规律可循”,影响信任感。我的做法是每帧切换时不仅换点数,还稍微调整骰子的旋转角度和偏移量,让整个动画看起来更真实。这个小改动在朋友实测里收到了一致好评——细节决定体验,工具类应用尤其如此。