这几年移动端搞跨平台,Flutter 已经是绕不开的一个选择了。平时大家都在 Android 和 iOS 上玩,但自从 OpenHarmony 这边也开始支持 Flutter 以后,很多做应用层开发的兄弟就开始琢磨:同一套代码,到底能不能在鸿蒙生态里也跑起来?我在本地搭好环境之后,决定拿一个最简单的“掷骰子”练练手,不碰复杂的系统能力,也不碰平台通道,就看 Flutter 的 UI 层、交互层和随机数逻辑在 OpenHarmony 上是不是一样顺滑。
这个项目特别适合两类人:一类是刚接触 OpenHarmony 又想复用 Flutter 技能的,另一类是准备做鸿蒙应用但不想从头学 ArkUI 的。掷骰子虽然看起来只有几个点和一个随机数,但它背后涉及到 Flutter 的布局、状态管理、动画触发、随机数生成以及事件响应,刚好把日常开发里最常用的一整套东西都覆盖到了。做完这个项目,你基本就能判断出来 Flutter for OpenHarmony 的坑在哪里、能用到什么程度。
1. 项目思路拆解与方案选型
1.1 为什么选“掷骰子”作为 Flutter for OpenHarmony 的练手项目
很多人在尝鲜 OpenHarmony 的时候,一上来就搞播放器、搞地图、搞蓝牙,结果被各种系统 API 差异卡得死死的。我的建议是,第一次跑通 Flutter for OpenHarmony,项目颗粒度越小越好,但要能覆盖一个完整应用的主链路。
掷骰子这个项目刚好满足所有条件:界面显示是纯 Flutter Widget,不依赖任何平台视图;交互只有点击按钮,不涉及复杂的触摸事件冲突;业务逻辑就是生成随机数,不需要申请权限;渲染量很小,方便排查环境问题而不是代码问题。换句话说,如果这个项目在 OpenHarmony 上跑通了,那说明你的 Flutter 环境、工程结构、构建链路和基础渲染都是正常的,后续再去做复杂应用,遇到问题你也能判断出到底是 Flutter 层的问题还是 OpenHarmony 适配层的问题。
1.2 环境选型:Flutter SDK、OpenHarmony SDK 与 IDE 的搭配思路
先说一个容易踩坑的点:Flutter for OpenHarmony 不是官方 Flutter 分支直接就能用的,它依赖 OpenHarmony 团队维护的 Flutter 引擎适配版本。我本地用的是 OpenHarmony 官方的 DevEco Studio 来管理 SDK,同时把 Flutter SDK 切换到支持鸿蒙的那个版本,然后通过 fvm 做了多版本隔离。
为什么要用 fvm?因为 Flutter 官方的版本更新很快,而 OpenHarmony 适配版的引擎更新节奏不一定跟得上。如果你直接用系统全局的 Flutter 版本,很可能某天执行flutter upgrade之后,发现 OpenHarmony 构建工具的插件不兼容了,整个项目全部报错。我本地就遇到过you are applying flutter's main gradle plugin imperatively using the apply这类 Gradle 插件冲突提示,最后是靠 fvm 锁定版本解决的。
DevEco Studio 主要负责 OpenHarmony 的 SDK 管理和真机签名,实际写 Flutter 代码我还是用的 VS Code。两个 IDE 各管一段:VS Code 写 Dart、调 Flutter 插件,DevEco Studio 用来做鸿蒙工程侧的联调和签名配置。一开始我也试过全用 DevEco,但它的 Flutter 插件支持还是不如 VS Code 顺手,尤其是热重载的响应速度,明显差一截。
1.3 Flutter 随机数方案的选择:为什么用Random()而不是其他方案
掷骰子的核心逻辑就是随机数生成,这里有两种选择:Dart 自带的Random类,或者接入 native 的随机数能力。考虑到 OpenHarmony 的 Flutter 引擎适配层已经封装好了底层的随机数来源,直接用 Dart 的Random就够用了。
Random()默认带一个随机种子,在大部分场景下不需要手动指定种子。但如果是在测试环境,需要复现一次具体的投掷结果,可以用Random(seed)这种方式固定种子。实际开发里还有一个容易被忽略的点:不同平台的随机数质量不一定一致,Random()在每个平台上底层拿到的熵源可能不一样,所以如果做在线游戏类的应用,随机数必须走后端,不能只靠客户端。
本项目是本地单机掷骰子,不涉及公平性问题,所以我直接用默认的Random(),没有做任何加密级随机处理。
2. 页面布局与视觉反馈设计
2.1 从“物理骰子”到“界面状态”的抽象过程
掷骰子的界面看起来简单,但如果你真的上手做,会发现一个核心问题:骰子的点数和界面状态不是一一对应的,它有一个中间层要做映射。
我的抽象方式是这样的:骰子的六个面分别对应 1 到 6,每个面有固定的点阵排布。界面层不关心“现在是数字几”,只关心两个问题:当前应该显示哪一个面的点阵,以及这个面在切换过程中要不要做翻转动效。所以我在代码里单独定义了一个枚举DiceFace,把每个面的点阵坐标和 UI 展示解耦。
这样设计的好处是,后续如果想把骰子换成扑克牌、转盘、抽奖轮盘,只需要换掉数据映射层,UI 层的动画和布局完全不用动。很多人写小工具的时候直接在三目运算符里写死数字,当时看着挺快,后面想加动画、想换皮肤,就不得不重写整个页面。
2.2 点阵布局:用GridView还是Stack+Positioned
骰子的点阵布局是第一个可以发挥细节的地方。网上很多教程推荐在 3x3 的网格里做点阵,这个方案听起来简单,但实现起来有个问题:GridView 是等分排列的,可骰子的点阵并不是所有面都是均匀分布的。
举个例子,数字 1 的点在正中心,数字 4 的点在四个角落,数字 6 的点是两列三行。如果你用一个统一的 3x3 网格模板去套所有面,播放动画的切面效果会很别扭,因为每个点从哪来回哪去的位置变化不够自然。
我最后选的是Stack加Positioned手动画点。每个骰子面定义好九个位置(上、中、下和左、中、右的组合),数字 1 只启用中心点,数字 2 启用左上和右下,数字 4 启用四个角,数字 5 再额外加中心点,以此类推。
这样写会稍微啰嗦一点,但换来的是每个面的点阵和真实的骰子完全一致,同时动画切换的时候每个点都能准确地从旧位置过渡到新位置。如果你测试下来发现点数布局错了,就肯定是你某个面的坐标启停条件写错了,排查起来非常快。
2.3 动效方案:为什么要用AnimatedContainer而不是AnimationController
掷骰子的动效用AnimatedContainer其实已经足够了,很多人一上来就上AnimationController,这是把问题复杂化了。
AnimationController适合需要精确控制动画时长、插入中间帧、做自定义插值器的场景。但掷骰子这种点击一下就换一个面的情况,更像是一个状态切换,而不是连续动画。用AnimatedContainer配合curve: Curves.easeOutBack,就能实现骰子面上的点轻微弹跳的效果,代码量少很多。
按我经验,判断标准很简单:如果动画的目的是“从状态 A 变到状态 B,变完就结束”,那用隐式动画;如果动画需要逐帧监听、中途停止、跟随手势,那才需要AnimationController。在这个项目里,我只做了旋转 180 度和点点弹跳,AnimatedContainer全都能搞定。
3. 完整实现过程与核心代码讲解
3.1 搭建 Flutter for OpenHarmony 工程骨架
环境准备阶段需要注意版本匹配,我自己用的是一套经过验证的组合:DevEco Studio 4.0 以上的版本,配合 OpenHarmony SDK 4.0 以上的 release,Flutter 侧使用支持 OpenHarmony 的 3.x 版本。每个人下载到的 SDK 可能有细微差别,我这里只讲思路,实际的版本号以你本地安装的为准。
创建工程有两种方式:一种是在 DevEco Studio 里新建标准 OpenHarmony 工程,再手动集成 Flutter;另一种是用 Flutter 命令行创建工程,再添加 OpenHarmony 平台目录。我第一次用的是第二种,因为这样可以保留完整的 Flutter 目录结构,后续加插件、改依赖都方便。
创建完工程之后,我发现默认生成的ohos目录里其实已经包含了一些预置的鸿蒙工程文件,比如entry模块和AppScope,这部分不建议手工改,保持原样就好。真正需要改的是 Flutter 侧的页面文件,也就是lib/目录下的内容。
3.2 骰子状态的定义:用StatefulWidget管理当前点数
先来定义骰子状态。这个部分看着基础,但它是整个页面的数据基础。我建了一个DicePage,继承StatefulWidget,然后在对应的State里维护一个_currentValue变量和_isRolling变量。
enum DiceFace { one(1, [4]), two(2, [0, 8]), three(3, [0, 4, 8]), four(4, [0, 2, 6, 8]), five(5, [0, 2, 4, 6, 8]), six(6, [0, 2, 3, 5, 6, 8]); const DiceFace(this.value, this.positions); final int value; final List<int> positions; }这里的positions是 0 到 8 的索引,对应 3x3 网格的九个点。数字 0 是左上,4 是中心,8 是右下。每次生成随机数后,我就拿随机数去匹配对应的DiceFace,再通过setState刷新页面。
为什么用枚举而不是直接用整数?因为后面动画和布局都需要知道点位的具体排布,枚举里既能存数字值又能存点位信息,比在 UI 层再做一次 if-else 判断干净很多。如果后面你想支持多颗骰子,这个枚举依然可以直接复用。
3.3 随机数生成与防重复逻辑
随机数生成是掷骰子的灵魂逻辑。我这边用了Random()的nextInt(6) + 1来生成 1 到 6 的点数。
int _rollDice() { final random = Random(); return random.nextInt(6) + 1; }nextInt(6)返回的是 0 到 5,所以加 1 就是 1 到 6。这是一个很基础但是很容易出错的点,有人直接写nextInt(7),导致偶尔出现 0;也有人写nextInt(6)不加 1,导致永远没有 6 点。
还有一个细节是连点问题。用户可能会很快地连续点击按钮,如果每次都直接生成新随机数,界面会闪得很难看。我给按钮加了一个_isRolling判断,动画还没结束的时候,按钮的点击事件直接忽略。这个不是硬性要求,但加完之后,整个交互手感会好很多。
3.4 页面布局实现:骰子区域与按钮区的组合
页面的布局我分成了三块:底部留一部分作为安全区,中间是骰子显示区,再往下是操作按钮区。这里用了Column加Expanded的组合,确保骰子区域能占据尽可能多的空间,按钮位置固定在底部。
@override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF2D2D2D), body: SafeArea( child: Column( children: [ const SizedBox(height: 24), Text( '$_currentValue', style: const TextStyle( color: Colors.white70, fontSize: 72, fontWeight: FontWeight.bold, ), ), Expanded( child: Center( child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeOutBack, transform: Matrix4.identity() ..rotateY(_isRolling ? 3.14 : 0) ..scale(_isRolling ? 0.85 : 1.0), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(28), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(60), blurRadius: 18, offset: const Offset(0, 10), ), ], ), child: SizedBox( width: 180, height: 180, child: DiceFaceBoard(face: _currentFace), ), ), ), ), Padding( padding: const EdgeInsets.all(24), child: FilledButton( onPressed: _isRolling ? null : _roll, style: FilledButton.styleFrom( minimumSize: const Size(double.infinity, 56), textStyle: const TextStyle(fontSize: 20), ), child: const Text('掷骰子'), ), ), ], ), ), ); }这版代码有几个细节可以讲一下:transform属性里我同时用了rotateY和scale,这样切面的时候骰子先轻微缩小、翻转,再弹回原来的大小,比单纯换数字更接近真实掷骰子的感觉;Matrix4.identity()不能漏,不然变换矩阵会累计叠加导致骰子越转越远。
3.5 点阵绘制:用LayoutBuilder动态计算点位
骰子面上的 3x3 点阵绘制,我用Stack加Positioned手动实现。核心逻辑是在LayoutBuilder里拿到容器的宽高,再按比例计算每个点的中心位置。
class DiceFaceBoard extends StatelessWidget { const DiceFaceBoard({super.key, required this.face}); final DiceFace face; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final size = constraints.maxWidth; final padding = size * 0.12; final gap = (size - padding * 2) / 2; final dotSize = size * 0.13; Offset dotCenter(int index) { final row = index ~/ 3; final col = index % 3; return Offset( padding + col * gap, padding + row * gap, ); } return Stack( children: face.positions.map((pos) { final center = dotCenter(pos); return Positioned( left: center.dx - dotSize / 2, top: center.dy - dotSize / 2, child: Container( width: dotSize, height: dotSize, decoration: const BoxDecoration( color: Color(0xFF333333), shape: BoxShape.circle, ), ), ); }).toList(), ); }, ); } }LayoutBuilder的好处是能自动适配不同屏幕尺寸,不管是手机、平板还是鸿蒙的折叠屏,点阵都能自动居中。pos ~/ 3和pos % 3是把一维索引还原成二维行列坐标,这个写法在其他类似的网格布局中也很实用。
3.6 点击事件的完整闭环:从按钮到 UI 刷新
最后把整个事件闭环串起来。点击“掷骰子”按钮后,先判断是否正在滚动中,避免重复触发;然后设置_isRolling = true,生成随机数,更新当前面的状态,最后在 400 毫秒后把_isRolling置为false。
void _roll() { if (_isRolling) return; setState(() { _isRolling = true; }); Future.delayed(const Duration(milliseconds: 150), () { final nextValue = _rollDice(); setState(() { _currentValue = nextValue; _currentFace = DiceFace.values[nextValue - 1]; _isRolling = false; }); }); }这里为什么要把随机数生成延迟 150 毫秒?因为如果随机数和动画的初始setState同时触发,AnimatedContainer会直接从旧状态跳到新状态,中间的翻转动效就看不到了。先让容器进入_isRolling = true的缩小翻起状态,再在动画进行到一半时切换骰子面,视觉上就自然得多。
在 OpenHarmony 真机上测试下来,Future.delayed的时序是准确的,没有出现掉帧或者回调延迟异常的问题,这块适配做得比我想象中好。
4. 常见问题与排查技巧实录
4.1 OpenHarmony 环境搭建阶段的报错与修复
环境搭建阶段最容易碰到的是 SDK 和插件版本匹配问题。最典型的就是执行 flutter 构建时提示找不到sdkmanager,或者 DevEco Studio 无法识别本地 Flutter SDK。我这边汇总一下遇到的三个主要问题,以及对应的解决方向。
第一个是 Gradle 插件冲突报错。报错信息里包含you are applying flutter's main gradle plugin imperatively using the apply,这通常是因为项目里同时用apply plugin和 plugin DSL 两种方式应用了 Flutter 插件。修复的方法是统一用plugins代码块方式,在settings.gradle或build.gradle里把旧的apply移除。
第二个是找不到 Visual Studio toolchain 的报错。这个在 Windows 上比较常见,报错信息类似unable to find suitable visual studio toolc...。如果项目本身不涉及 C++ 插件,这个报错通常不影响 OpenHarmony 构建,因为它只是在检测 Windows 桌面端的编译环境。可以直接用flutter config --no-enable-windows-desktop关掉 Windows 支持,就不会再报这个错了。
第三个是 OpenHarmony 的 SDK 路径没配置。需要在环境变量里新增DEVECO_SDK_HOME,指向 DevEco Studio 自带的 SDK 目录,否则用命令行执行构建时会出现找不到ohos工具链的错误。
4.2 渲染异常:画面花屏、点阵错位的处理思路
OpenHarmony 的画面渲染异常和 Android 上的表现不太一样。我用真机调试的时候,遇到过两次渲染问题。
第一次是骰子颜色显示异常,整个骰子看起来灰蒙蒙的。后来发现不是代码问题,是 OpenHarmony 4.0 的某些版本对Color类里的withAlpha方法兼容性有偏差,导致透明度计算不对。解决办法是改用withOpacity,或者直接用十六进制颜色值,比如Color(0x3D000000)。
第二次是点阵在切换动画的时候偶尔会出现残影。排查后确认是AnimatedContainer的状态切换时,旧 position 的 Widget 没有及时销毁。解决办法是在Stack外层加一个ClipRRect或者RepaintBoundary,把绘制范围限制在骰子区域内,残影立刻消失。
所以建议在 OpenHarmony 上做自定义绘制、动画时,多考虑一层图层裁剪和绘制边界隔离,很多渲染问题就是这么解决的。
4.3 真机调试时热重载不生效的问题
热重载在 OpenHarmony 上的支持和 Android 还是有一点差异的。我遇到的情况是修改了lib目录下的代码,执行r之后页面确实会刷新,但有时需要手动点击一下屏幕才能看到变化,尤其是动画相关的代码,重启后动画状态没有完全重置。
这个其实可以理解,因为 OpenHarmony 的 Flutter 引擎需要重新同步 Dart 侧的代码快照,热重载的生效速度比 Android 略慢。如果你遇到热重载不生效的情况,优先检查 DevEco Studio 的调试终端有没有报错。如果只是 UI 没更新,可以先按R执行 hot restart,把整个 Flutter 引擎重置一遍,基本都能解决。如果连 hot restart 都没反应,那就是 DevEco Studio 和 Flutter 插件的连接断了,断开重新运行一次即可。
4.4 Flutter 工程混编到 OpenHarmony 原生工程时的资源路径问题
如果你的项目不是纯 Flutter 应用,而是在已有的 OpenHarmony 工程里集成 Flutter 模块,就需要注意资源路径的问题。Flutter 侧的图片、字体等资源默认打包在assets目录下,这个在纯 Flutter 工程里没问题,但混编时 OpenHarmony 的module.json和resources目录会参与一起打包,容易造成资源路径错乱。
我在集成测试时就遇到过骰子背景图片找不到的问题,排查后发现是 Flutter 的 AssetManifest 和 OpenHarmony 的资源映射表没有同步刷新。解决方法是在 Flutter 侧执行一次flutter clean,然后重新构建。如果还不行,检查pubspec.yaml里的 assets 路径是否以assets/开头,不要写相对路径。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 构建时报 Gradle 插件冲突 | apply plugin和 plugin DSL 混用 | 统一用plugins代码块,删除旧式 apply |
| 报错无法找到 Visual Studio toolchain | Flutter 检测 Windows 桌面环境 | 执行flutter config --no-enable-windows-desktop |
| 运行时画面颜色灰暗 | withAlpha兼容性问题 | 改用withOpacity或十六进制Color值 |
| 动画切换出现残影 | 绘制范围未裁剪 | 在Stack外层加ClipRRect或RepaintBoundary |
| 热重载不生效 | Flutter 引擎未同步代码快照 | 改用 hot restart,或者断开重连 |
| 混编时资源找不到 | AssetManifest 未刷新 | 执行flutter clean后重新构建 |
| 骰子面切换时没有动画 | setState同时触发动画和状态更新 | 增加延迟,先让动画进入中间状态再更新值 |
这张表算是这个项目从搭建到上真机整个过程里最常碰到的几个关卡,每一个我都实际踩过。把这些坑提前列出来,至少能帮你省下半天蹲在报错日志前面的时间。
5. 后续扩展:从单骰子到多骰子与自定义皮肤
做完单骰子只是第一步,这个项目的结构其实已经预留了很好的扩展面。我简单说两个你可以继续尝试的方向。
多骰子是最直接的扩展。只需要把_currentFace改成List<DiceFace>,页面里用横向Row包住多个DiceFaceBoard,每次点击按钮时对每个骰子分别生成随机数即可。注意的点是:多个骰子同时动画会让性能开销翻倍,如果你只有两三个骰子,AnimatedContainer完全扛得住;如果要做五六颗以上的骰子,建议把每颗骰子的动画改成同一组AnimationController驱动,减少重复的动画监听开销。
自定义皮肤也不难。目前点阵布局是硬编码在DiceFaceBoard里的,你可以把它改成接受一个外部传入的 Widget builder,这样每颗骰子的点可以是圆形、方形、星星,甚至是你自己设计的图案。这个思路其实就是把数据和 UI 完全解耦,后续不管是换主题还是做 3D 骰子效果,都不需要动随机数和状态管理的代码。
如果要追求更丝滑的滚动效果,还可以引入AnimationController做一组 3D 旋转的序列帧,让骰子在 500 毫秒内转 3 到 4 圈再停下,这比目前的单次翻转更接近真实掷骰子的体验,但这些都属于锦上添花了。
从我的实际体验来看,Flutter for OpenHarmony 的适配成熟度已经能支撑这类交互型小工具了,UI 渲染、事件分发、异步延迟这些核心能力都没有明显短板。拿这个掷骰子项目跑通一遍,你对 Flutter 在 OpenHarmony 上的开发节奏、调试方式和踩坑点基本就有底了。后续如果再深入做复杂应用,也建议保持同样的思路:先把项目拆小,把每一层的能力边界摸清楚,再一步步往上叠加复杂度。