
从最开始有这个想法到最终把成品部署上线整个过程其实比我想象中有意思得多。起因很简单我想找一个能快速上手、又不需要应付三端审核的小项目练手2048 作为规则清晰、逻辑完整的经典小游戏几乎是练手的最佳选择。但问题在于我对 Flutter 不算熟对 Web 端的部署更是心里没底。于是我用 Trae 作为主力编辑器通过对话式开发逐段把游戏逻辑、界面布局、动画和部署流程全部打通最后在浏览器里稳定跑了起来。这篇内容会把我的选型思考、环境搭建、核心代码逻辑、UI 实现、AI 辅助开发的实际体验、部署验证以及中途踩过的坑完整记录下来希望给同样想从 0 到 1 做一个小型 Flutter Web 项目的朋友提供一份可以直接参考的路线图。1. 为什么是 2048、Flutter Web 和 Trae 这个组合先聊聊技术选型。这可能是做项目之前最值得花时间想清楚的事因为选型直接决定后面要踩的是坑还是坦途。1.1 2048 项目的天然优势2048 这个游戏说它适合练手是有充分理由的。整个游戏的核心规则只有四条棋盘是 4x4 的格子阵滑动时所有数字向滑动方向移动相邻且相同的数字在移动时合并为它们的和每次移动后随机位生成一个新数字。规则简单但实现起来却覆盖了二维数组操作、状态检测、随机数处理、动画反馈这些编程基础能力。这保证了它做起来不会因为逻辑太复杂而劝退初学者同时又足够考验代码组织能力。还有一个很现实的原因2048 不依赖任何后端服务纯客户端状态就行。这意味着我把 Flutter Web 构建产物丢到任意静态服务器上就能跑不需要设计接口、不需要数据库、不需要用户系统。对于想快速拿到一个“能在线玩的成品”的人而言这种零后端约束的体验太舒服了。1.2 Flutter Web 的成熟度到了可以玩一玩的程度我选择 Flutter Web 而不是传统的前端技术栈主要看重它的跨端一致性和布局能力。Flutter 在移动端的口碑已经足够稳定而 Web 端经过这么多版本的迭代CanvasKit 渲染方案的性能表现已经相当不错动画流畅度和渲染一致性都很有保障。对于 2048 这种动画需求明显、交互需要跟手的小游戏Flutter 的 Widget 体系和动画 API 几乎是为这类场景量身定做的。你不需要操心 CSS 兼容性不需要考虑不同浏览器对动画属性的细微差异只需要把数据和 UI 对应起来框架会帮你处理掉大部分平台差异。事实证明这个项目从搭建到跑通Web 端的开发体验比我想象中顺畅。1.3 Trae 在项目里扮演的角色Trae 在这个项目里不是用来炫技的它承担的是“结对程序员”的角色。最开始我对 2048 的扫盘合并逻辑实现方式只有一个模糊的概念并不确定怎么把 4 个方向的统一抽象做到最干净。Trae 的对话式辅助帮助我快速理清了“所有方向移动都转换为行处理 矩阵旋转”这套思路。后面在写 UI 布局、调整动画细节、排查 Web 端渲染异常时它又充当了可以随时提问的助手。我个人使用后的感受是Trae 比较适合在“我已经想清楚边界条件但需要快速产出结构性代码”的场景下发挥价值。它生成代码的速度很快但你需要具备读懂代码、判断对错的能力。这本质上不是替代关系而是加速关系。2. 环境准备阶段最容易翻车的几个环节很多人兴致勃勃地准备开发结果卡在环境搭建上这几乎是 Flutter Web 项目中最没技术含量但最能消磨耐心的环节。我整理一下从零到能在浏览器里看到 Hello World 的完整路径以及我踩过的一些坑。2.1 Flutter SDK 的下载与安装首先到 Flutter 官网下载对应操作系统的 SDK 压缩包。这里一定要留意版本问题Flutter 的版本迭代很快Web 端在不同版本下的默认渲染器、构建配置是有差异的所以我建议直接选择最新稳定版stable channel避免因为版本过旧导致的兼容性问题。下载完成后解压到某一个固定目录然后配置环境变量。以我用的 macOS 为例需要在~/.zshrc文件里加上一行export PATH$PATH:$HOME/flutter/binWindows 用户则是在“系统属性 - 环境变量”里把flutter/bin目录追加到 Path。配置完成后新开一个终端窗口执行flutter doctor这个命令会检查 Flutter 运行所需的依赖项包括 Android toolchain、Chrome、Visual Studio 等。看到大部分项目前是绿色对勾就说明基础环境没问题。但要注意flutter doctor显示没有安装 Android toolchain 并不影响 Flutter Web 开发因为 Web 端不依赖 Android 工具链不过 Chrome 是必须装的它是 Flutter Web 默认的调试浏览器。2.2 开启 Web 支持如果你安装的 Flutter 版本比较老可能需要手动开启 Web 支持flutter config --enable-web新版本默认已经启用 Web但为了保险起见执行这条命令不会有副作用。然后创建一个项目flutter create game_2048 cd game_2048运行下面的命令确认 Web 设备已经被识别flutter devices输出结果里应该会列出 Chromeweb这个设备。如果看不到说明 web 支持没有被正确激活。此时执行flutter doctor -v检查一下 Flutter 和 Chrome 的版本是否兼容然后重新运行flutter config --enable-web。2.3 检查设备列表后第一时间跑通 Demo环境准备好之后先别急着改代码把模板项目跑起来看看。运行flutter run -d chrome首次运行需要编译会比较耗时属于正常现象。等看到浏览器弹出 Flutter 默认的 Counter 示例页面说明整个开发链路已经打通。这里我建议你注意一下浏览器的开发者工具 Console 面板正常启动时不应该有任何红色报错。如果看到类似 CanvasKit 加载失败之类的提示大概率是网络问题导致官方 CDN 资源拉不下来后面我会细说替代方案。2.4 项目结构的初步梳理跑通 Demo 之后你需要对 Flutter 项目的结构有一个基本认知。核心入口是lib/main.dart应用启动时会执行其中的main()函数然后通过runApp()挂载根组件。项目里的pubspec.yaml文件是依赖管理清单后面如果要用额外的第三方库就在这里声明。对于 2048 这个项目我的建议是把核心逻辑和 UI 层做分离不要把所有代码塞在一个文件里。比如lib/ main.dart // 入口文件 game/ game_2048.dart // 核心游戏逻辑 direction.dart // 方向枚举 ui/ game_board.dart // 棋盘 Widget game_tile.dart // 单个格子 Widget这样分层的好处是游戏逻辑不依赖 Flutter 的 UI 层可以单独测试UI 层只负责把状态渲染出来逻辑改动不至于牵连布局代码。这个习惯在项目小的时候看不出优势一旦逻辑复杂起来能帮你省下大把调试时间。3. 棋盘建模与数字合并核心逻辑的思考路径2048 的核心难度不在于写出能跑的结果而在于把各种边界条件处理干净。任何一小步遗漏比如合并后只剩一个格子、合并链式反应、方向转换错误都会导致游戏行为异常。3.1 用二维数组表达棋盘我选择用ListListint来表示 4x4 的棋盘内层 List 的每个元素对应一个格子0 表示空格非零整数表示该格子上的数字class Game2048 { static const int size 4; final ListListint board; int score 0; bool gameOver false; bool won false; Game2048() : board List.generate(size, (_) List.filled(size, 0)) { _addRandomTile(); _addRandomTile(); } }这里有一个容易被忽略的点List.generate里的(_) List.filled(size, 0)必须写在箭头函数里确保每一行都是独立的新 List。如果直接写List.filled(size, List.filled(size, 0))会导致 4 行引用同一个 List改一行等于改所有行。这个 Bug 我最早还真栽过一次排查了很长时间才发现是引用共享问题。3.2 随机生成新数字的概率控制每轮移动之后需要在所有空格子里随机选一个位置生成新数字。按照 2048 的经典规则2 和 4 出现概率不是各占一半而是大约 90% 的概率生成 210% 的概率生成 4。这样设计是为了让游戏前期节奏不会太快稍微延长局面的发展空间对玩家体验更友好。实现方式不复杂void _addRandomTile() { final empty (int, int)[]; for (var i 0; i Game2048.size; i) { for (var j 0; j Game2048.size; j) { if (board[i][j] 0) { empty.add((i, j)); } } } if (empty.isEmpty) return; final (r, c) empty[_random.nextInt(empty.length)]; board[r][c] _random.nextDouble() 0.9 ? 2 : 4; }空位列表的收集逻辑本身很简单但它保证了每次生成新数字时只会落在真正为空的格子上不会覆盖已有数字。3.3 核心滑动合并算法统一为单行处理整个项目里最值得细讲的是滑动合并的抽象方式。2048 有上下左右四个滑动方向如果为每个方向单独写一套逻辑代码会非常臃肿且容易出 Bug。我的做法是把“向左合并”作为底层原语其他三个方向都通过矩阵旋转或行列反转转换为向左合并。先看清左移合并的逻辑。假设某一行的原始状态是这样的[2, 0, 2, 2]左移的过程分两步走先移除所有 0得到[2, 2, 2]然后从左到右扫描如果相邻两个数字相同就合并成一个注意每个数字只能参与一次合并合并后补 0 保持长度为 4。对[2, 2, 2]处理的结果应该是[4, 2, 0, 0]而不是[4, 4, 0, 0]因为第一个 2 和第二个 2 合并为 4 之后第三个 2 没有相邻的同值数字可以合并了。对应代码如下Listint _mergeRow(Listint row) { final nonZero row.where((v) v ! 0).toList(); final merged int[]; var i 0; while (i nonZero.length) { if (i 1 nonZero.length nonZero[i] nonZero[i 1]) { merged.add(nonZero[i] * 2); score nonZero[i] * 2; i 2; } else { merged.add(nonZero[i]); i; } } while (merged.length Game2048.size) { merged.add(0); } return merged; }这段代码的关键在于i 2的跳跃步进它保证了合并过的数字不会再次参与合并。如果这里写成i你就会发现[2, 2, 2, 2]会被错误地处理成[8, 0, 0, 0]而正确的 2048 规则是它应该变成[4, 4, 0, 0]。3.4 四个方向的统一转换方式现在处理其余三个方向。如果你把棋盘看成一个正方形矩阵那么向右滑动 对每一行水平翻转 - 左移合并 - 再水平翻转回来向上滑动 把整个矩阵逆时针旋转 90 度 - 对每一行左移合并 - 再顺时针旋转 90 度回来向下滑动 把整个矩阵顺时针旋转 90 度 - 对每一行左移合并 - 再逆时针旋转 90 度回来旋转的实现如下ListListint _rotate(ListListint grid) { final newGrid List.generate(Game2048.size, (_) List.filled(Game2048.size, 0)); for (var i 0; i Game2048.size; i) { for (var j 0; j Game2048.size; j) { newGrid[j][Game2048.size - 1 - i] grid[i][j]; } } return newGrid; }水平翻转的函数也类似把grid[i][j]放到grid[i][size - 1 - j]的位置即可。这样四个方向的处理就统一成一套逻辑主方法会清爽很多bool moveLeft() { return _applyMove((row) _mergeRow(row)); } bool moveRight() { _reverseColumns(); final changed _applyMove((row) _mergeRow(row)); _reverseColumns(); return changed; } bool moveUp() { _rotateClockwise(); final changed _applyMove((row) _mergeRow(row)); _rotateCounterClockwise(); return changed; } bool moveDown() { _rotateCounterClockwise(); final changed _applyMove((row) _mergeRow(row)); _rotateClockwise(); return changed; }这里我刻意让每个方向的方法直接暴露给 UI 层调用因为 UI 手势事件和方向对应关系在代码层面越直观越好。内部实现细节被封装起来了比在调用方做转换要省心。3.5 判断移动是否产生了变化还有一个细节很重要每次滑动时都会调用移动方法但并不是每次移动都会产生棋盘变化。比如棋盘上已经没有任何可合并的数字往某个方向滑时所有格子纹丝不动这时候不应该生成新数字。所以_applyMove需要在执行前后比较棋盘状态如果有变化才触发_addRandomTile()和胜负检测bool _applyMove(Listint Function(Listint) mover) { var changed false; for (var i 0; i Game2048.size; i) { final newRow mover(board[i]); if (!listEquals(newRow, board[i])) { changed true; board[i] newRow; } } if (changed) { _addRandomTile(); _checkGameState(); } return changed; }为什么要在移动产生变化后才生成新数字因为如果每次都生成玩家无效滑动时棋盘仍然会被塞入新数字这会极大增加游戏难度也破坏了移动-响应-新数字的经典节奏。3.6 游戏结束与胜利检测还需要界定游戏什么时候结束。游戏结束的充要条件是棋盘上没有空格子且任何相邻格子水平方向或垂直方向都不存在相同数字。因为这意味着玩家无论往哪个方向滑动都不会产生任何位置变化或合并。void _checkGameState() { for (var i 0; i Game2048.size; i) { for (var j 0; j Game2048.size; j) { if (board[i][j] 2048) { won true; return; } } } if (!_canMove()) { gameOver true; } } bool _canMove() { for (var i 0; i Game2048.size; i) { for (var j 0; j Game2048.size; j) { if (board[i][j] 0) return true; if (j 1 Game2048.size board[i][j] board[i][j 1]) return true; if (i 1 Game2048.size board[i][j] board[i 1][j]) return true; } } return false; }这个逻辑虽然不长但是覆盖了所有终止条件。我特别说明一下千万不要只检查棋盘是否已满就算结束因为此时很可能还存在可合并的相邻同数字只是还没有合并而已游戏真正无法继续的状态必须同时满足“满”和“无相邻相同值”两个条件。4. UI 层Flutter 块视图、动画与滑动手势逻辑层打通之后接下来是把棋盘状态可视化的过程。Flutter 在这部分的表现力很强用一套 Widget 树就能完成布局、动画和交互。4.1 整体布局Stack 背景网格 数字格我选择用Stack作为最外层容器底层放一个静态的背景网格用于显示格子的位置和底框上层根据游戏状态动态渲染数字格子。这样每格数字变化时只需要更新上层的内容背景网格保持稳定能减少不必要的 Widget 重建。底层网格用一个简单的GridView.count实现每个子项是一个圆角矩形颜色用浅灰色衬托棋盘底Widget _buildBoardBackground() { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: GridView.count( crossAxisCount: Game2048.size, mainAxisSpacing: 8, crossAxisSpacing: 8, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), children: List.generate( Game2048.size * Game2048.size, (_) Container( decoration: BoxDecoration( color: const Color(0xFFCDC1B4), borderRadius: BorderRadius.circular(4), ), ), ), ), ); }数字格则根据数值显示在对应坐标位置这里用了AnimatedContainer让背景色和字号变化有过渡动画class GameTile extends StatelessWidget { final int value; const GameTile({super.key, required this.value}); Color _backgroundColor() { switch (value) { case 0: return const Color(0xFFCDC1B4); case 2: return const Color(0xFFEEE4DA); case 4: return const Color(0xFFEDE0C8); case 8: return const Color(0xFFF2B179); case 16: return const Color(0xFFF59563); case 32: return const Color(0xFFF67C5F); case 64: return const Color(0xFFF65E3B); case 128: return const Color(0xFFEDCF72); case 256: return const Color(0xFFEDCC61); case 512: return const Color(0xFFEDC850); case 1024: return const Color(0xFFEDC53F); case 2048: return const Color(0xFFEDC22E); default: return const Color(0xFF3C3A32); } } override Widget build(BuildContext context) { return AnimatedContainer( duration: const Duration(milliseconds: 100), decoration: BoxDecoration( color: _backgroundColor(), borderRadius: BorderRadius.circular(4), ), alignment: Alignment.center, child: value 0 ? null : Text( $value, style: TextStyle( fontSize: value 1024 ? 20 : 32, fontWeight: FontWeight.bold, color: value 4 ? const Color(0xFF776E65) : Colors.white, ), ), ); } }这里对大数字做了字号自适应避免 512 或 1024 这种三位数在格子内溢出。4.2 新生成数字的弹出动画二维棋盘上的动画问题其实比初看起来要复杂一些。最简单粗暴的做法是每生成一个数字后直接用 AnimatedContainer 从头到尾刷新。不过这样会丢失“新数字出现时有个缩放弹出”的细节体验。我采用的方案是给每个数字格标记一个isNew属性当前帧渲染时如果该格是新的就套一个TweenAnimationBuilder让值从 0.5 缩放回 1.0TweenAnimationBuilderdouble( tween: Tween(begin: 0.5, end: 1.0), duration: const Duration(milliseconds: 150), builder: (context, value, child) { return Transform.scale(scale: value, child: child); }, child: GameTile(value: cellValue), )这个动画的视觉反馈非常明显新出现的数字会“跳”一下老数字则平稳停留在原地。与移动后的重排动画叠加起来玩家就能清晰感知到每一步的变化。4.3 滑动方向判定手势与逻辑的对接Flutter Web 上处理滑动手势最简单可靠的方案还是GestureDetector的拖拽回调。监听onPanStart和onPanEnd通过起终点坐标差计算方向。enum Direction { up, down, left, right } Direction? _getSwipeDirection(Offset start, Offset end) { final dx end.dx - start.dx; final dy end.dy - start.dy; if (dx.abs() 20 dy.abs() 20) return null; if (dx.abs() dy.abs()) { return dx 0 ? Direction.right : Direction.left; } else { return dy 0 ? Direction.down : Direction.up; } }20 像素的阈值是为了过滤掉点击或轻微抖动触发的误操作。得到方向后对应调用_game.moveLeft()、_game.moveRight()、_game.moveUp()或_game.moveDown()然后用setState()触发界面刷新。在这里还有一个细节值得注意手势判定结束后要重置起终点不然下一次滑动会用到上一次的残留坐标导致方向判断错误。我是在onPanStart和onPanEnd中分别记录和消费完坐标后再把记录的 Offset 清空。4.4 计分面板与重新开始UI 布局除了棋盘本身还需要一个计分面板和重新开始按钮。计分面板放在棋盘上方用一行两个卡片展示 Score 和 BestBest 用SharedPreferences持久化到本地。重新开始按钮则直接调用_game.reset()并刷新界面。考虑到 Flutter Web 的SharedPreferences在浏览器端使用 localStorage跨刷新保留最大值没有问题。不过要记得在pubspec.yaml的依赖里加上shared_preferences然后执行flutter pub get。考虑到按键交互也是 Web 玩家的习惯我额外增加了键盘监听Focus和KeyboardListener监听上下左右方向键来触发移动这让桌面端用户不需要靠鼠标拖拽或触控板模拟滑动体验更接近原生桌面应用。4.5 用 Trae 生成 UI 代码后的 Review 重点利用 Trae 生成 UI 代码时我发现它生成的布局代码结构通常是对的但有一些细节需要手动校正。最典型的问题是它可能会漏掉const关键字或者生成的 GridView 没有设置NeverScrollableScrollPhysics导致棋盘区域在 Web 上意外地变成可滚动区域进而干扰手势判定。我的习惯是让 Trae 生成第一版布局后自己带着三个问题去读代码——层级是否明确、动画参数是否符合预期、状态更新是否走在了setState内部。尤其是第三个问题因为 Flutter 规定只能从 UI 线程通过setState更新界面如果 AI 生成的回调里直接改数据但忘了刷新界面永远不会有变化。5. Trae 在项目里到底帮了什么忙以及它的边界整个项目从零到跑通Trae 确实帮了大忙但我不建议把它神化。把它定位为“高效结对程序员”而不是“万能生成器”用起来体验会好很多。5.1 需求拆解阶段的辅助价值项目最早期我在 Trae 的对话流里贴了一段需求描述“实现一个 2048 小游戏4x4 棋盘支持四方向滑动合并带计分。” Trae 会直接生成一版可运行的代码虽然当时的代码把逻辑和 UI 混在同一个文件里但算法框架是对的尤其是旋转矩阵统一方向的那段实现思路和我手工设计几乎一致。这里我体会最深的是需求描述越具体AI 输出的可用度越高。如果你只说“帮我做个游戏”它给的代码基本没法用但如果你说清楚棋盘尺寸、数字生成规则、合并顺序例外、计分位置它给出的代码就会非常接近你的预期。所以使用 Trae 的第一步其实是“把自己的需求想清楚”这不是技术能力而是表达能力。5.2 编写测试用例和调试定位的效率提升写完核心逻辑后我做的第一件事不是打开浏览器手测而是在 Trae 的协助下用 Dart 的test框架生成了一组覆盖正常合并、链式合并、方向转换、无效移动、游戏结束判断的测试用例。它生成的测试用例基本覆盖到了主路径不过我还是手动补了一个“合并后的数字不应该再次参与本次合并”的用例这属于对规则理解产生的差异AI 并不总能推断出这种边界意图。在调试 Bug 时Trae 的作用体现在它能迅速根据报错日志定位到可疑代码。比如我遇到过浏览器 Console 里报CanvasKit initialization failed的问题它给出了替换渲染器为 HTML 的临时方案。但当我追问为什么默认的 CanvasKit 会初始化失败时它给的回答不如我自己翻日志定位来得快——这本质上是一个网络资源加载问题需要理解 CDN 加载机制。所以我的体会是Trae 对你的问题域理解越深回答质量越高但最终决策和根因分析还是要靠你自己的判断力。5.3 Builder 模式与 Chat 模式的合理使用Trae 有 Builder 模式和 Chat 模式两种交互方式。Builder 模式适合“直接修改项目文件、生成完整代码”的任务Chat 模式适合“问问题、讨论方案、解释代码片段”。我在项目中的实际分工是初版代码用 Builder 模式快速生成之后每次改造成 UI 细节或增加动画时先用 Chat 模式确认方案再切回 Builder 模式让它实施修改。这种混合使用方式避免了 AI 在不理解整体架构时急着动手改代码减少了它引入新 Bug 的概率。5.4 代码量增长的信号2048 这个项目框架代码加逻辑代码大概在 600 行左右。当代码量达到这个规模时Trae 对上下文的记忆会开始出现一些偏差。最明显的表现是它可能会在你让它修改 UI 组件时忘记同步更新动画状态或者忘记更新对应的测试用例。这时我倾向于把整个项目分割成更小的独立修改单元每次只让 Trae 处理一个明确任务而不是一次性说“顺便把那个也改了”。这算是我在实际使用中总结出的协作原则AI 辅助开发时粒度越小可控性越强。6. 本地跑通后要处理的性能、兼容和真机问题本地flutter run -d chrome跑通只是一个里程碑离真正能发布给其他人玩还有一段距离。这一段内容会展示我在实际开发中发现的问题以及对应的解决办法。6.1 渲染器选择CanvasKit 与 HTML 的取舍Flutter Web 支持两种渲染器CanvasKit 和 HTML。CanvasKit 基于 WebAssembly 和 WebGL渲染一致性高动画性能好但首屏需要加载较大的 wasm 文件HTML 渲染器包体更轻但某些复杂绘制效果在不同浏览器下可能有差异。Flutter 3.x 之后默认推荐使用 CanvasKit。但在国内网络环境下CanvasKit 的 wasm 文件默认从 CDN 加载经常会出现拉取超时导致白屏的情况。我的解决办法很简单在web/index.html中把 Flutter Web 的构建资源改成从本地部署加载也就是把渲染器相关的 script 标签指向本地静态资源而不是远程 CDN。具体做法是在项目根目录下flutter build web时Flutter 会默认把 CanvasKit 等文件复制到build/web/assets里你只要确认index.html里的 flutter.js 引用是相对路径即可不需要额外配置。构建完成后把整个build/web目录放到任意静态服务器上就能保证不依赖外部 CDN 完成加载。6.2 Service Worker 缓存不更新的坑Flutter Web 生成的flutter_service_worker.js会做资源缓存实现离线访问能力。这在很多场景下是好事但开发时容易遇到“更新了代码、重新部署后浏览器还在用旧版本”的情况。我遇到这个问题时浏览器 Console 里直接报了一条错误加载 web 视图时出错无法注册 Service WorkerState 无效。从这个问题出发我排查后发现是开发环境的 HTTPS 限制和 Service Worker 的作用域问题综合导致的。在本地用 HTTP 调试时部分浏览器会限制 Service Worker 注册。而线上部署时旧版本的 Service Worker 缓存了旧的资源列表导致新版本不被拉取。解决办法分两头部署时在index.html里给flutter.js的注册代码加上serviceWorkerVersion: null参数或者直接禁用 Service Worker简单粗暴但保证每次都能拿到最新文件生产环境则可以保留注册机制但要在部署后清理一次旧缓存或者给文件名加版本号让浏览器识别内容变更。对于 2048 这种小型项目我个人更倾向于直接禁用 Service Worker毕竟它的离线能力对这个游戏场景意义不大反而容易让人误以为游戏坏了。6.3 文字的渲染对齐问题CanvasKit 模式下Text 的渲染和浏览器原生文本渲染有些微差异。我在 2048 里设置了不同格子大小的字号如果直接写死在小窗口下会显得拥挤甚至溢出。解决思路是用LayoutBuilder根据实际可用空间动态计算字体大小。比较省心的封装方式LayoutBuilder( builder: (context, constraints) { final size constraints.maxWidth; final fontSize value 1024 ? size * 0.32 : size * 0.5; return Text($value, style: TextStyle(fontSize: fontSize)); }, )这样无论是把浏览器窗口拉大还是拖小数字都能贴着格子边缘不会出现溢出。6.4 真机预览与触控灵敏度Web 开发的一个隐形要求是“手机浏览器也得能玩”。虽然 2048 的设计目标主要是 Web但很多玩家会直接用手机浏览器打开链接。我在真机测试时发现手机浏览器上的触摸滑动事件会被 Flutter Web 正常捕获为 PointerEvent但和桌面端的拖拽阈值不一样手机上的触摸轨迹短、速度快容易触发方向误判。后来我在手势判定里加入速度维度的参考onPanEnd里如果速度超过阈值即使位移低于 20 像素也触发移动。这样在手机上轻扫也能快速响应观感接近原生 App。6.5 帧率与性能观察2048 的 UI 负载很低动画也简单理论上不会有卡顿。但我还是做了一次观察打开 Chrome DevTools 的 Performance 面板录制一次融合了多次滑动和动画的过程重点看 FPS 曲线有没有明显掉帧。实测下来在 CanvasKit 模式下长轮滑动动画过程保持在 60 FPS没有出现绘制瓶颈。需要留意的一点是 Flutter Web 首帧渲染时 CPU 占用会比较高这是 CanvasKit 初始化的正常表现不用因此在代码层面做无用优化。7. 发布到 Web部署方案与验证清单开发调试告一段落就可以考虑真正部署到线上让朋友玩一玩了。Flutter Web 的构建和部署本质上是生成一组静态文件然后放到静态托管服务上。7.1 执行构建命令在项目根目录执行flutter build web --release构建产物全部位于build/web目录下包含index.html、main.dart.js、assets、flutter_bootstrap.js等文件。这里有几个检查重点flutter_bootstrap.js存在性它是 Flutter 最新模板里引导应用加载的脚本入口assets/目录是否完整所有字体、CanvasKit 资源都在这里index.html里的 script 路径是否相对路径避免部署到子目录时资源 404。7.2 部署到 Nginx我最终选的部署方式是 Nginx。配置片段大致如下server { listen 80; server_name your-domain.com; root /var/www/game_2048; index index.html; location / { try_files $uri $uri/ /index.html; } # 静态资源启用缓存 location ~* \.(js|wasm|png|jpg|jpeg|gif|ico|css)$ { expires 7d; add_header Cache-Control public; } }try_files的主要作用是让它支持单页应用的路由回退。2048 本身只有一个页面即使没有显式路径也会落到 index.html但加上它以后后续如果扩展多页面会更稳。静态资源设 7 天缓存可以大幅减少重复访问的流量消耗不过注意部署新版时记得清一下缓存或者改文件名。7.3 部署到 GitHub Pages如果不想自己租服务器GitHub Pages 也是不错的选择。做法是把build/web目录内容推到gh-pages分支或者在仓库设置里选择 GitHub Actions 自动构建发布。我个人在实践中更推荐用 GitHub Actions因为流程全自动提交代码后自动构建并部署。参考 workflow 大致如下name: Deploy to GitHub Pages on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: subosito/flutter-actionv2 with: flutter-version: stable channel: stable - run: flutter pub get - run: flutter build web --release - uses: peaceiris/actions-gh-pagesv4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./build/web用subosito/flutter-action的好处是 GitHub 的虚拟环境不用自己装 Flutter它会自动拉对应版本省去大量重复时间。7.4 上线前最后检查清单部署完之后还有几个关键点哪怕漏掉一个都可能被用户秒退出游戏。按我的经验这个清单基本可以覆盖 2048 项目的主要风险面检查项对应操作风险等级首屏能正常加载浏览器验证无白屏、无资源请求 404高无 CanvasKit 加载失败控制台无相关红色报错高滑动与键盘均可操作手机端、桌面端分别验证高刷新页面后分数保留调整 Best 分数后刷新验证中窗口缩放排版不破拖动浏览器窗口从极窄到宽屏切换中新版本能更新到最新部署后强制刷新或清缓存验证中手机浏览器触摸滑动流畅真机或浏览器模拟器验证中无 Service Worker 报错控制台检查 ServiceWorker 注册状态低我在正式发布前按这个清单逐项走了一遍发现最容易被忽略的是“新版本更新”这一项因为本地开发和线上缓存之间有一段时间差人容易下意识以为是自己的问题实际上就是缓存清算没处理好。7.5 部署完之后的性能随手优化部署没有技术上的复杂度问题以后还有一些顺手可以做的小优化。首屏加载时main.dart.js的体积可能会到几百 KB为了减少等待感可以给index.html加一个 loading 过渡应用初始化之后再淡出。另外2048 的核心资源是 CanvasKit 相关文件这些文件体积不小。恰好浏览器缓存可以帮忙。如果你后续想进一步改善性能可以考虑把部分资源拆成子资源加载但对于 2048 这种轻量游戏当前构建产物已经足够轻不需要做过度工程化的拆包。最后说点我个人的体会项目从环境搭建到部署上线前前后后大概用了一个周末的时间。最大的成就感其实不是游戏本身多好玩而是亲眼看到了“Trae 辅助 Flutter Web 一个小而美的逻辑”这三者结合能有多顺畅。用 AI 工具开发项目核心从来不是让 AI 替你决定一切而是你清楚地知道自己要什么然后把它变成能被 AI 理解的结构化指令。2048 的算法说难不难但真正把每个方向的边界条件想明白、把动画做得舒服、把部署链路跑通这个过程带来的收获远比代码行数本身多得多。这个项目后续如果你有兴趣还可以继续加撤销操作、主题换肤、本地排行榜、AI 自动游玩模式每一块都是很好的练习方向。希望这篇记录能帮你少走一些弯路也祝你的第一个 Flutter Web 小游戏顺利跑起来。