
这几年移动端搞跨平台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 flutters 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还是StackPositioned骰子的点阵布局是第一个可以发挥细节的地方。网上很多教程推荐在 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 以上的 releaseFlutter 侧使用支持 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 Listint 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 flutters 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 toolchainFlutter 检测 Windows 桌面环境执行flutter config --no-enable-windows-desktop运行时画面颜色灰暗withAlpha兼容性问题改用withOpacity或十六进制Color值动画切换出现残影绘制范围未裁剪在Stack外层加ClipRRect或RepaintBoundary热重载不生效Flutter 引擎未同步代码快照改用 hot restart或者断开重连混编时资源找不到AssetManifest 未刷新执行flutter clean后重新构建骰子面切换时没有动画setState同时触发动画和状态更新增加延迟先让动画进入中间状态再更新值这张表算是这个项目从搭建到上真机整个过程里最常碰到的几个关卡每一个我都实际踩过。把这些坑提前列出来至少能帮你省下半天蹲在报错日志前面的时间。5. 后续扩展从单骰子到多骰子与自定义皮肤做完单骰子只是第一步这个项目的结构其实已经预留了很好的扩展面。我简单说两个你可以继续尝试的方向。多骰子是最直接的扩展。只需要把_currentFace改成ListDiceFace页面里用横向Row包住多个DiceFaceBoard每次点击按钮时对每个骰子分别生成随机数即可。注意的点是多个骰子同时动画会让性能开销翻倍如果你只有两三个骰子AnimatedContainer完全扛得住如果要做五六颗以上的骰子建议把每颗骰子的动画改成同一组AnimationController驱动减少重复的动画监听开销。自定义皮肤也不难。目前点阵布局是硬编码在DiceFaceBoard里的你可以把它改成接受一个外部传入的 Widget builder这样每颗骰子的点可以是圆形、方形、星星甚至是你自己设计的图案。这个思路其实就是把数据和 UI 完全解耦后续不管是换主题还是做 3D 骰子效果都不需要动随机数和状态管理的代码。如果要追求更丝滑的滚动效果还可以引入AnimationController做一组 3D 旋转的序列帧让骰子在 500 毫秒内转 3 到 4 圈再停下这比目前的单次翻转更接近真实掷骰子的体验但这些都属于锦上添花了。从我的实际体验来看Flutter for OpenHarmony 的适配成熟度已经能支撑这类交互型小工具了UI 渲染、事件分发、异步延迟这些核心能力都没有明显短板。拿这个掷骰子项目跑通一遍你对 Flutter 在 OpenHarmony 上的开发节奏、调试方式和踩坑点基本就有底了。后续如果再深入做复杂应用也建议保持同样的思路先把项目拆小把每一层的能力边界摸清楚再一步步往上叠加复杂度。