扫雷数字显示:Flutter for OpenHarmony游戏实战解析

发布时间:2026/9/24 18:44:03
扫雷数字显示:Flutter for OpenHarmony游戏实战解析 我最早接触扫雷还是在PC上那时候的Windows系统自带游戏简简单单一个格子面板却总能让人一玩就是一下午。后来做移动端开发接触了Flutter又陆续研究OpenHarmony的生态一个念头就很自然地冒出来能不能把扫雷这类经典小游戏搬到一个自研的游戏集合App里顺便验证一下Flutter在OpenHarmony上的工程能力这个项目就这么立项了而整个开发过程中最考验UI基本功的恰恰是“数字显示”这个看似不起眼的功能。这篇文章就围绕“Flutter for OpenHarmony游戏集合App实战之扫雷数字显示”展开我会把扫雷的数字从生成、计算到渲染的完整链路拆开讲包括数据模型怎么设计、数字背后的8邻域算法怎么写、UI怎么选型、动画怎么做以及在这套技术栈上踩过的坑。适合对Flutter有一定基础、同时又对OpenHarmony跨端开发感兴趣的读者当然如果你只是想把扫雷这个小游戏做扎实里面的大部分方案也是通用的。1. 项目整体设计与思路拆解1.1 为什么第一个模块选扫雷游戏集合App的模块可以有很多选择俄罗斯方块、贪吃蛇、五子棋、消消乐哪个不比扫雷看起来“热闹”但我最后还是把扫雷定为第一个模块原因其实很实际扫雷是最适合验证框架能力的2D网格游戏。扫雷的核心交互是“点一下格子根据数字推理出地雷位置”。它需要的技术能力覆盖了Flutter开发的几个核心面网格布局GridView或者自定义布局、手势处理点击、长按区分、状态管理格子状态的流转、条件渲染翻开数字与旗帜的不同视觉、动画效果翻开时的过渡。把这些能力在一个模块里跑通后续再往集合App里加俄罗斯方块、黑白棋这些模块时底层的架构和经验都可以直接复用。而且扫雷的规则极其简单不需要美术资源和复杂的音频设计一个人从零开始写一个周末就能跑通核心玩法。它不像俄罗斯方块那样依赖固定帧率的主循环也不像物理游戏那样需要引擎级支持对OpenHarmony这种新兴平台来说越少依赖平台能力兼容性问题就越少。所以我把它当作整个App的“试金石”。1.2 数字显示在整个扫雷里的位置扫雷的“数字显示”听起来像是一个很小的UI点实际做起来才发现它是整个游戏从逻辑到界面的关键枢纽。先理清扫雷的规则翻开一个格子如果格子下面是地雷游戏结束如果是空白格它会显示周围8个格子中地雷的数量也就是数字1到8如果周围没有地雷则显示空白并自动递归翻开周围的格子。所以数字不是随便画上去的它承载了三层信息这个格子已经翻开了、周围有雷、周围具体有几个雷。从数据层看数字来自布雷后的邻域计算这是游戏逻辑的核心之一。从UI层看数字的颜色、位置、比例直接影响游戏的可读性玩过扫雷的人都知道数字的颜色是强记忆点1是蓝色2是绿色3是红色4是深蓝这些颜色帮助玩家快速分辨数字大小。从交互层看当玩家点开一个已经翻开的数字格时如果周围旗帜数量等于该数字会触发“chord”连带翻开操作一次性翻开周围所有非旗帜格子而这个操作的触发条件判断就依赖于数字。所以数字显示做得好不好决定了扫雷玩起来是“流畅推理”还是“一脸懵”。这也是我写这篇文章想重点展开的部分。1.3 技术选型上的考量这个项目的技术路线是Flutter for OpenHarmony。为什么要用Flutter而不是其他方案OpenHarmony支持多种应用开发方式有ArkTS声明式开发也有C的Native开发还有兼容Android的Java/Kotlin方案。但我的目标很简单同一套代码既能跑在OpenHarmony上也能在未来很方便地回到Android/iOS或者做桌面端的适配。Flutter在这方面天然有优势UI层完全自绘一套Dart代码可以跨平台复用。扫雷这种游戏本质上不需要太多平台特有能力基本就是Canvas绘制、手势输入和计时器这正好是Flutter的舒适区。相比较而言如果用ArkTS原生写虽然和OpenHarmony系统结合最紧密但代码就跟Android平台的Kotlin绑定了未来换平台基本要重写。而Flutter for OpenHarmony是社区维护的分支SDK层面的适配已经比较成熟hap包构建、真机调试这些流程都能走通。当然选型也不是没有代价。OpenHarmony的Flutter分支在插件生态上还没有Android/iOS那么丰富部分第三方库要手动适配。好在这个游戏集合App几乎不依赖平台插件需要用的功能Flutter框架层都自带所以风险完全可控。2. 扫雷核心逻辑数字是怎么算出来的2.1 格子数据模型设计数字显示的第一步不是画UI而是把数据模型设计好。扫雷的棋盘本质上是一个二维数组我把它定义成Cell类enum CellState { hidden, // 未翻开 revealed, // 已翻开 flagged, // 已标记旗帜 questioned, // 标记问号可选 } class Cell { final int row; final int col; bool isMine false; // 是否是地雷 int adjacentMines 0; // 周围地雷数决定显示数字 CellState state CellState.hidden; Cell(this.row, this.col); }这里有个容易忽视的细节adjacentMines只有在格子不是雷的时候才有意义如果格子本身是雷这个字段可以不用管。但在初始化时我还是把它统一赋值为0避免读取时出现空安全的问题。棋盘用一个二维List保存ListListCell board。我建议不要用一维数组加下标换算虽然代码上更紧凑但扫雷逻辑里经常需要判断“某个坐标的邻居”二维数组的语义更直观排查问题也方便。后续优化性能时再考虑拍平到一维也不迟。2.2 布雷算法与首点保护布雷逻辑是所有数字计算的源头。业界经典的布雷方式是洗牌法把棋盘所有格子的索引放进一个列表随机打乱然后取前mineCount个格子作为地雷。我用的方式类似但增加了一个非常重要的处理首点保护。玩家第一次点击的格子及其周围8个格子不能有雷否则第一下就踩雷体验极差。这个需求看起来简单但实现时有个小坑如果地图很小而雷很多比如9x9的棋盘有10个雷首点保护后剩余的可用格子可能刚刚够放雷所以逻辑上要先计算安全区再从候选区里选雷。void placeMines(int firstRow, int firstCol) { // 首点保护firstRow/firstCol 周围 3x3 区域不布雷 final safeZone int{}; for (var dr -1; dr 1; dr) { for (var dc -1; dc 1; dc) { final r firstRow dr; final c firstCol dc; if (r 0 r rows c 0 c cols) { safeZone.add(r * cols c); } } } final candidates int[]; for (var i 0; i rows * cols; i) { if (!safeZone.contains(i)) { candidates.add(i); } } candidates.shuffle(Random()); var placed 0; for (final index in candidates) { if (placed mineCount) break; final r index ~/ cols; final c index % cols; board[r][c].isMine true; placed; } }这里我做了个细节优化先用safeZone集合存安全格子的索引再用一次循环生成候选列表这样避免在洗牌后再逐个过滤导致的地雷数量不足问题。很多新手实现首点保护时习惯先布雷再检查首点是否是雷不是雷就重新洗牌这种做法在棋盘小、地雷密的时候可能死循环或效率极差建议直接采用排除法。2.3 邻域计数与翻开联动布雷完成后就要计算每个非雷格子周围的数字。这个逻辑没有捷径就是遍历8个方向逐个判断是否越界、是否是雷void calculateAdjacentMines() { for (var r 0; r rows; r) { for (var c 0; c cols; c) { if (board[r][c].isMine) continue; var count 0; for (var dr -1; dr 1; dr) { for (var dc -1; dc 1; dc) { if (dr 0 dc 0) continue; final nr r dr; final nc c dc; if (nr 0 nr rows nc 0 nc cols board[nr][nc].isMine) { count; } } } board[r][c].adjacentMines count; } } }这个双层循环的时间复杂度是 O(rows x cols x 8)对于扫雷这种最大也就 16x30 的棋盘来说性能完全不是问题。我在开发初期还曾经想过用卷积核或者预计算前缀和来优化后来发现完全没必要——棋盘规模决定了暴力遍历就是最优解可读性还更好。翻开逻辑是整个数字显示的核心联动。当玩家点击一个格子如果它是空白格adjacentMines 0需要自动向四周扩散翻开直到遇到有数字的格子为止。这一步用深度优先搜索DFS或广度优先搜索BFS都可以实现。我用的DFS递归代码更简洁void revealCell(int row, int col) { final cell board[row][col]; if (cell.state CellState.revealed || cell.state CellState.flagged) { return; } cell.state CellState.revealed; revealedCount; // 空白格自动展开 if (cell.adjacentMines 0 !cell.isMine) { for (var dr -1; dr 1; dr) { for (var dc -1; dc 1; dc) { if (dr 0 dc 0) continue; final nr row dr; final nc col dc; if (nr 0 nr rows nc 0 nc cols) { revealCell(nr, nc); } } } } }这里有个值得注意的地方递归在棋盘很大的时候可能会有栈溢出的风险但扫雷棋盘最高不超过480个格子16x30)递归深度完全可控。如果未来你把它扩展到超大棋盘就需要改成显式的Stack迭代或者用队列做BFS。2.4 chord操作数字的高级交互扫雷高手一定不会不知道chord操作在一个已经翻开的数字格上如果周围旗帜数量等于这个数字双按或同时按左右键可以自动翻开周围所有未标记旗帜的格子。这个功能极大提升游戏效率也让数字显示在交互层面有了更深的含义。实现chord操作的关键在于“先验证旗帜数量再批量翻开”。我写了一个独立的触发方法void chordCell(int row, int col) { final cell board[row][col]; if (cell.state ! CellState.revealed || cell.adjacentMines 0) return; var flagCount 0; for (var dr -1; dr 1; dr) { for (var dc -1; dc 1; dc) { if (dr 0 dc 0) continue; final nr row dr; final nc col dc; if (nr 0 nr rows nc 0 nc cols) { if (board[nr][nc].state CellState.flagged) { flagCount; } } } } if (flagCount cell.adjacentMines) { for (var dr -1; dr 1; dr) { for (var dc -1; dc 1; dc) { if (dr 0 dc 0) continue; final nr row dr; final nc col dc; if (nr 0 nr rows nc 0 nc cols) { if (board[nr][nc].state ! CellState.flagged board[nr][nc].state ! CellState.revealed) { if (board[nr][nc].isMine) { gameOver(); // 旗帜标错了踩雷 return; } revealCell(nr, nc); } } } } } }这个功能在移动端的实现有个交互层面的问题移动设备没有双键同时按所以我在数字格上做了一个双击手势用GestureDetector的onDoubleTap来触发chord。开发时还要注意一点如果双击数字格之前系统把第一次点击也当作了一次普通翻开就会引发状态冲突。我的处理方式是在数字格上屏蔽单击翻开只保留双击chord这样交互语义更清晰。3. 数字显示的UI实现细节3.1 数字渲染方案的选型数字显示是扫雷UI的重头戏。Flutter里渲染数字有几种常见方案我一开始列了个对比表反复权衡后才确定最终方案方案优点缺点适用场景Text组件 富文本实现简单、语义化强、字体系统完善每个格子一个Widget大量数字时Widget树偏重常规项目首选CustomPaint自绘性能最优、动画扩展空间大、完全控制绘制代码复杂度高、需要自己处理字体和抗锯齿大面积网格、复杂动效图片资源Sprite图集渲染速度极快资源占用大、分辨率适配差、改颜色麻烦像素风复古游戏我的结论是扫雷的网格通常在 9x9 到 16x30 之间最多也就480个格子远远达不到Flutter Widget树的性能瓶颈用Text组件完全没问题。而且Text组件天然支持富文本、字体复用和无障碍语义团队后续维护的成本最低。所以我最终选择用Text作为基础但在顶部信息栏的“七段数码管”部分使用CustomPaint自绘这个稍后详细说。数字格子的UI我封装成一个独立的Widget叫CellView它根据Cell的state和adjacentMines来决定渲染样式这样UI和逻辑的耦合度很低修改样式时不会碰逻辑层。3.2 经典配色的还原扫雷数字的颜色是有“记忆点”的。玩过经典Windows扫雷的玩家看到蓝色1、绿色2、红色3肌肉记忆立刻就被唤醒了。我在还原配色的过程中特意对照了经典版本的视觉规范数字颜色十六进制值1蓝色0xFF1963D62绿色0xFF0E9F443红色0xFFD0312D4深蓝0xFF2E4E9E5深红0xFF8B1A1A6青色0xFF2E8B8B7黑色0xFF1A1A1A8灰色0xFF808080这里要注意一个细节数字8的颜色不是最深的而是灰色因为8代表“周围全是雷”视觉上应该给人一种“信息最多”的密集感用高亮色反而抢戏。数字3用红色则是因为它在推理中出现的频率高红色最容易引起注意提醒玩家优先处理。这些设计心理学层面的考量让经典配色不仅仅是一种“复古怀旧”更是一种经过验证的可用性设计。在Flutter里实现配色映射很简单我用一个静态Mapconst Mapint, Color mineNumberColors { 1: Color(0xFF1963D6), 2: Color(0xFF0E9F44), 3: Color(0xFFD0312D), 4: Color(0xFF2E4E9E), 5: Color(0xFF8B1A1A), 6: Color(0xFF2E8B8B), 7: Color(0xFF1A1A1A), 8: Color(0xFF808080), };3.3 格子状态机的视觉表达数字显示不只包括“数字本身”还包括数字出现之前的格子外观状态。一个未被翻开的格子、一个被插了旗帜的格子、一个显示数字的格子在视觉上和交互上必须有足够明显的区分。我定义了四种格子外观未翻开凸起的灰色方块模拟老式扫雷的立体按钮效果。Flutter里我用Container加BoxShadow来做凸起Container( decoration: BoxDecoration( color: const Color(0xFFBDBDBD), border: Border.all(color: const Color(0xFF7B7B7B), width: 0.5), boxShadow: [ BoxShadow( color: Colors.white.withOpacity(0.8), offset: const Offset(-1.5, -1.5), blurRadius: 0, ), BoxShadow( color: Colors.black.withOpacity(0.25), offset: const Offset(1.5, 1.5), blurRadius: 0, ), ], ), )已翻开数字凹下去的浅灰背景数字按adjacentMines选择颜色居中显示。旗帜用红底白字或一个旗帜图标我为了减少依赖直接在格子里画一个小旗子红色三角形加竖线用CustomPaint实现避免加载图片资源。问号标记为问号的格子表示“不确定”展示为灰色背景上的黑色问号视觉上比旗帜更弱一些。这里有一个我一开始忽略的细节翻开格子的背景色和未翻开格子的背景色必须有一个从“凸起”到“凹陷”的视觉转变否则玩家会分不清哪些格子已经点过了。我在实现时让翻开的格子背景色改成更深的0xFFE0E0E0同时去掉左上角的白色高光保留右下角阴影这样层级感就出来了。3.4 数字翻开动画的实现数字显示不应该只是“啪”一下直接出现一个自然的“弹入”动画能让整个游戏的手感提升一个档次。我当时给格子翻开做了两个动画效果第一个是缩放入场数字从0.5倍缩放到1.0倍同时透明度从0到1。用Flutter的AnimatedScale和AnimatedOpacity组合实现AnimatedScale( scale: cell.state CellState.revealed ? 1.0 : 0.5, duration: const Duration(milliseconds: 120), curve: Curves.easeOutBack, child: AnimatedOpacity( opacity: cell.state CellState.revealed ? 1.0 : 0.0, duration: const Duration(milliseconds: 80), child: Text( ${cell.adjacentMines}, style: TextStyle( color: mineNumberColors[cell.adjacentMines], fontSize: 18, fontWeight: FontWeight.bold, ), ), ), )第二个是按下反馈用手指点击未翻开的格子时格子会有一个轻微的“按下去”动画模拟实体按钮按下再弹起。这个用GestureDetector的onTapDown和onTapUp状态控制即可让交互有物理感。动画设计中要特别注意的是大规模翻开也就是点开一个空白格后周围几十个格子同时翻开时如果让每个格子都延迟播放动画整体视觉会很乱。我当时的处理是空白格扩散翻开时取消动画直接显示最终状态只有玩家单点翻开带数字的格子时才播放动画。这样既保留了关键交互的反馈感又避免了大面积翻开时的闪屏。3.5 顶部信息栏的七段数码管扫雷除了雷区中央的数字顶部信息栏还有两个经典的数字显示剩余雷数计数器和计时器。它们用红色七段数码管风格显示用来还原老街机的那种质感。我用CustomPaint实现了这个七段数码管。七段数码管的原理是把一个数字拆成7个“段”a到g每个数字点亮不同的段位组合。我定义了一个段位映射const Mapint, ListString sevenSegmentMap { 0: [a, b, c, d, e, f], 1: [b, c], 2: [a, b, g, e, d], 3: [a, b, g, c, d], 4: [f, g, b, c], 5: [a, f, g, c, d], 6: [a, f, g, e, c, d], 7: [a, b, c], 8: [a, b, c, d, e, f, g], 9: [a, b, c, d, f, g], };绘制时在CustomPaint的paint方法里根据映射点亮对应段class SevenSegmentPainter extends CustomPainter { final int value; final Color activeColor; final Color inactiveColor; SevenSegmentPainter({required this.value, required this.activeColor, required this.inactiveColor}); override void paint(Canvas canvas, Size size) { final paint Paint() ..style PaintingStyle.fill; final segments sevenSegmentMap[value] ?? []; final segWidth size.width * 0.15; // 定义各段的路径略 // 这里用 Rectangle 简化为示例 void drawSegment(String name, Rect rect) { final visible segments.contains(name); paint.color visible ? activeColor : inactiveColor.withOpacity(0.15); canvas.drawRRect( RRect.fromRectAndRadius(rect, Radius.circular(1)), paint, ); } // a段顶部横线 drawSegment(a, Rect.fromLTWH(segWidth * 2, 0, size.width - segWidth * 4, segWidth * 0.6)); // b段右上竖线 drawSegment(b, Rect.fromLTWH(size.width - segWidth * 0.8, segWidth, segWidth * 0.6, size.height / 2 - segWidth)); // 其他段类似... } override bool shouldRepaint(covariant SevenSegmentPainter oldDelegate) { return oldDelegate.value ! value || oldDelegate.activeColor ! activeColor; } }这个面板的实现让顶部显示非常还原老扫雷的风格同时也是项目里一个很棒的“数字显示”例子同样是数字但用途、风格、绘制方式可以和棋盘里的数字完全不同。顶部计数器的逻辑也不复杂剩余雷数 总雷数 - 当前插旗数。当玩家插旗时递减拔旗时递增。显示范围限制在0到999之间超出则显示999我一开始没有做这个裁切结果旗子插得比雷还多的时候计数器出现了个位数和百位数的多段闪烁排查半天才发现是取模逻辑写错了。4. Flutter for OpenHarmony 的搭建与踩坑4.1 环境准备SDK与工具链OpenHarmony上的Flutter开发最核心的一步是SDK选型。这里要说明OpenHarmony官方并没有像Android那样把Flutter作为一等公民支持Flutter for OpenHarmony是社区维护的分支版本节奏会比官方Flutter慢一些。我当时采用的是社区发布的OpenHarmony 4.0兼容分支。环境准备的大致步骤是准备OpenHarmony SDK用DevEco Studio作为IDE。下载Flutter的OpenHarmony分支SDK配置到环境变量里。配置FLUTTER_STORAGE_BASE_URL环境变量指向国内镜像地址这一步国内开发者必不可少否则依赖下载会非常慢。用flutter doctor验证环境是否正常。这里有一个比较大的坑Flutter项目默认的.gitignore和构建产物机制在OpenHarmony上不完全适用。OpenHarmony的构建产物是hap包需要用sdk里的工具链进行签名不像Android可以直接生成debug包安装。开发阶段我们可以用flutter run配合开放的签名配置来调试但上架前正式签名流程还是要走一遍。4.2 工程改造与hap构建创建OpenHarmony的Flutter工程官方推荐还是用flutter create命令创建标准Flutter工程然后手动添加OpenHarmony平台目录。我当时执行的命令是flutter create --org com.example.minesweeper --project-name mine_sweeper_app .然后在工程根目录执行flutter build hap --debug或者--release来构建OpenHarmony应用包。这里要注意flutter build hap是OpenHarmony分支特有的命令官方Flutter SDK是没有这个能力的所以如果不小心切回了稳定分支命令会直接报错。构建过程中我遇到过一个典型问题You are applying Flutters main Gradle plugin imperatively using the apply method这个警告。它指的是项目里的Gradle配置还在用老式方式应用Flutter插件。在OpenHarmony分支中推荐使用声明式插件应用方式我修改了工程下的build.gradle文件把插件声明改为plugins { id com.huawei.ohos.plugin.flutter }这个调整之后构建产物就正常了。大家在网络搜索这个提示时能看到大量相关讨论但一定要记得结合自己用的分支版本去处理。4.3 真机调试的关键差异真机调试是我在这个项目里花时间最多的部分。首先连接OpenHarmony设备不能使用adb而是要用hdcOpenHarmony Device Connector工具。它和adb的用法非常相似常用命令有hdc list targets查看设备列表、hdc shell进入设备shell、hdc file send推送文件。我记得第一次运行flutter run时终端提示找不到设备排查了一圈才发现是hdc工具的版本和OpenHarmony系统版本不匹配重新配置工具链后问题解决。其次OpenHarmony的Flutter分支在渲染引擎上仍然以Skia为主Impeller引擎的适配还在推进中。实际跑起来后我发现简单的扫雷界面在Skia渲染下完全流畅但如果你要做大规模粒子效果的动画可能就要等Impeller在OpenHarmony上稳定了。这个我们在开发时已经用“翻牌动画复杂度控制”做了规避避免UI层过度消耗渲染性能。最后是包体积问题。Flutter for OpenHarmony构建出来的hap包会比Android APK略大因为带了一套完整的Flutter引擎。我实测下来一个最简单的扫雷Apprelease包大约在15MB到20MB之间在OpenHarmony生态里算中等体积可以接受。如果后续要做上架发布建议开启--split-debug-info和--obfuscate两个参数能有效减少包体并增强代码安全性。5. 常见问题与排查技巧实录5.1 深色模式下数字“消失”项目初期我在系统设置为深色模式的设备上测试发现扫雷棋盘上的数字几乎看不清数字1的蓝色在深色背景下对比度骤降深色背景下的灰色格子和灰色数字混成一片。问题的根源在于我的CellView没有对深色模式做适配。解决方案有两个思路一是强制游戏界面始终使用浅色主题因为扫雷的经典视觉就是浅色棋盘深色模式下也保持这个设计不会显得突兀二是给棋盘设计一套深色主题的数字配色。我最终选择了方案二理由是这个游戏集合App未来会有多款游戏全局主题统一以后单款游戏不能随意破坏系统主题连贯性。深色主题的数字配色需要提高颜色的亮度比如数字1从深蓝0xFF1963D6调亮到0xFF4F8DFF数字3从红色调亮到亮红。实现时我用一个isDarkMode标志位在构建颜色映射时做切换。记住一个原则数字显示的可读性永远优先于“复古还原”。5.2 快速点击导致的界面错乱扫雷游戏里有个经典操作场景玩家在短短几秒内快速点击多个格子甚至对同一个格子连点两下。这在逻辑层带来一个隐患revealCell方法在异步回调里检查了格子状态但两个点击事件可能在同一个状态快照上同时操作导致棋盘出现“重复翻开”或者“明明标记了旗帜却还能翻开”的错乱。排查后发现问题出在我的手势层是独立于逻辑层的没有加锁机制。解决方法是给逻辑层加一个简单的操作标志位isProcessing当一次点击处理完成前屏蔽后续点击事件。同时把格子状态检查前置到点击事件入口而不是放到revealCell内部void onCellTap(Cell cell) { if (isProcessing || gameOver || gameWin) return; if (cell.state ! CellState.hidden) return; isProcessing true; try { if (cell.isMine) { gameOver(); } else { revealCell(cell.row, cell.col); } } finally { isProcessing false; } }这样既能防止连点错乱也为后续动画和状态刷新留出了缓冲时间。实测下来连续快速点击格子界面表现稳定多了。5.3 首点踩雷体验差首点保护我一开始就加了但开发中发现了一个边界场景如果在玩家第一次点击后通过“重新开始”功能开始新一局第一次点击又会被当作首点处理这本是正确的。但如果玩家在第一次点击后又开了“自定义难度”并修改了棋盘大小安全区的计算尺寸和新棋盘不一致就会导致首点照样踩雷。这个问题的本质是新棋局重置时没有同步重置“首次点击”标志。我的做法是在resetGame()方法里将isFirstMove重置为true同时确保棋盘尺寸变化时placeMines里的安全区计算使用最新的rows和cols。玩扫雷的老玩家对首点保护非常敏感这个细节值得反复测试。5.4 计时器在App退到后台后继续计时扫雷的计时器逻辑游戏开始后每秒刷新一次顶部七段码显示。但在OpenHarmony上App切到后台再回到前台时计时器可能会出现两个问题一是计时器线程被系统挂起恢复前台后时间跳跃二是计时器继续运行导致游戏时间虚高。正确的做法是在App生命周期回调中暂停和恢复计时器。标准Flutter工程里用WidgetsBindingObserver监听AppLifecycleState即可override void didChangeAppLifecycleState(AppLifecycleState state) { if (state AppLifecycleState.resumed) { timer?.start(); } else if (state AppLifecycleState.paused || state AppLifecycleState.inactive || state AppLifecycleState.hidden) { timer?.stop(); } }OpenHarmony分支对生命周期状态的支持和Android一致性很好只要在State类里正确注册WidgetsBinding.instance.addObserver(this)就能稳定收到状态回调。这个坑如果不及时处理用户玩一局50秒的游戏可能因为切出去回个微信结束时就变成了5分钟非常影响体验。5.5 不同分辨率下的布局适配扫雷的棋盘是固定行列数的网格但不同设备的屏幕宽高比差异很大。我在项目早期用固定边长渲染格子结果在平板设备上格子巨大在窄屏手机上又挤成一团。解决方案是根据屏幕可用尺寸和棋盘行列数动态计算格子边长。final screenWidth MediaQuery.of(context).size.width; final screenHeight MediaQuery.of(context).size.height; final boardPadding 8.0; final availableWidth screenWidth - boardPadding * 2; final availableHeight screenHeight - appBarHeight - infoPanelHeight - boardPadding * 2; final cellSize min(availableWidth / cols, availableHeight / rows);这里的关键是取min而不是max这样保证棋盘在任何屏幕上都能完整显示不会溢出。同时我在AspectRatio的约束下用GridView.builder渲染配合NeverScrollableScrollPhysics禁用滚动保证棋盘始终固定在一个可视区域内。数字字体大小也要根据格子大小动态调整我使用cellSize * 0.5作为字体大小测试下来在不同尺寸设备上基本都能保持数字在格子内的舒适比例。我个人在做完这个项目后最大的体会是数字显示是一个非常典型的前端问题看起来简单但真正做好需要从算法、状态、视觉、动画、适配多个层面去打磨。扫雷这款经典游戏之所以能穿越这么多年很大程度归功于它把“数字信息”这种枯燥的东西用最直观、最省脑力的方式呈现给了玩家。当你在Flutter for OpenHarmony上把这个数字显示做到位你会对这个框架在复杂状态和UI交织场景下的表现有了很深的把握这个经验对后续做任何App模块都是有价值的。如果你也在尝试用Flutter做OpenHarmony游戏或者工具类应用不妨先试一个扫雷模块它真的是一个性价比极高的练手项目。