用Trae+Flutter Web从0到1开发2048小游戏全流程解析

前阵子突发奇想,想用 Trae 从 0 到 1 做一个 Flutter Web 小游戏。想着与其写那些无聊的管理后台,不如挑一个逻辑闭环、界面直观、还能覆盖前后端核心痛点的练手项目,最后选中了 2048。这个项目特别适合放到 Web 端跑:不需要服务端,状态管理简单,棋盘逻辑又是典型的“看着简单、写对不容易”的类型。而且有了 Trae 这种带 AI 编程模式的 IDE 以后,整个开发节奏跟以往手写完全不一样——很多样板代码、重复性的 UI 搭建、细节报错,都可以通过自然语言直接跟它在编辑器里协作完成。

这篇文章会把我从项目初始化、游戏算法设计、Trae 辅助开发,到 Flutter Web 打包部署的完整过程都过一遍。如果你正好在学 Flutter Web,或者想找一个不复杂但能体现“完整项目思维”的小游戏作为入门练习,这篇应该能给你省不少时间。我会把核心算法、布局、事件处理这些最容易被卡住的点拆开讲,也会把我踩过的坑和跟 Trae 协作时总结出的提效方法写在后面。

1. 为什么选 Trae + Flutter Web 来做 2048

1.1 Trae 在这条开发链路里的角色

Trae 定位是 AI IDE,跟普通代码编辑器最大的区别是它把 AI 能力直接做进了开发流程里,而不只是在旁边挂一个聊天窗口。我自己最常用的两个功能:一个是对话式的 Chat,可以直接问“2048 的合并算法怎么写”,它会给出思路和代码;另一个是 Build 模式,AI 可以直接改你当前项目里的文件,改完你检查 diff,没问题就接受。这个交互方式很像带了一个干活快、但偶尔也会自作聪明的实习生,你给它指令,它动手改,你来验收。

我这次项目的分工很明确:整体架构和算法设计由我自己掌控,Trae 负责实现 UI 布局、生成重复性代码、修一些明显报错。用下来最大的体会是,AI 不是替你思考,而是替你省掉那些“打 40 行相似代码”的时间。比如 2048 里每个格子在不同数字下要变颜色,这种纯粹的映射关系就是 Trae 的舒适区:你把规则说清楚,它直接给你一个 switch 或 map,准确率很高。

1.2 为什么用 Flutter Web 而不是原生 JS 或游戏引擎

做 2048 可选的技术栈其实不少:纯 JavaScript + Canvas 是最常见的,Phaser 这种 2D 游戏引擎也能做,还有像 React + CSS 过渡动画的路子。我最后选了 Flutter Web,核心原因有三个。

第一,Flutter 本身就是一套 UI 框架,用 Widget 描述界面,布局思路统一;写 Web 时不需要在 HTML、CSS、JS 三套语言之间来回切换。2048 这种密集网格型界面,恰好是 Flutter 的强项——GridView 几行代码就能铺好 4x4 棋盘,不需要手写大量样式表。

第二,Flutter Web 有 CanvasKit 渲染引擎,动画和绘制性能比我预期好不少。2048 的方块移动和合并虽然不用特别复杂的动画,但如果没有渲染层帮忙,手写 requestAnimationFrame 的动画循环也够喝一壶的。Flutter 自带的 AnimatedContainer、AnimatedPositioned 就能完成平滑过渡。

第三,Dart 语言的强类型对游戏状态管理非常友好。2048 的棋盘本质上是一个二维数组,所有操作都是对这个数组做变换。类型一明确,重构时心里就有底,不会像弱类型脚本那样改一处崩三处。

1.3 2048 作为 Flutter Web 练手项目的独特价值

我向不少人推荐过 2048 作为“第一个 Flutter Web 小游戏”,因为它难度曲线非常平滑,但又不至于毫无挑战。它需要你掌握的几项能力,刚好是 Flutter 开发的核心基本功:

  • 用二维数组建模游戏状态,并理清“移动—合并—生成”这个状态流转。
  • 用 StatefulWidget 管理界面状态,把游戏棋盘映射成 Widget 树。
  • 处理键盘事件和触摸手势,对应 Web 端和移动端的输入差异。
  • 用动画类组件完成方块移动效果,理解隐式动画和显式动画的基本用法。
  • 最后打包成静态资源部署到 Web 服务器,打通发布链路。

这些能力不是一个 Todo App 能完全覆盖的。2048 的算法逻辑不算难,但第一次写很容易在“合并顺序”上翻车,这恰好是训练逻辑思维的好机会。等你把这套流程走通了,后续再去做别的 Web 小游戏,底子就有了。

2. 环境准备与项目初始化

2.1 Flutter 环境安装与版本选择

工欲善其事,必先利其器。做 Flutter Web 第一步是装好 Flutter SDK。这里建议直接选 stable 版本,不要追 beta 或 dev。Web 端的功能在 stable 上已经很稳定,没必要为了新特性给自己添乱。安装完之后用flutter doctor检查环境,重点看 Chrome 有没有被识别到,因为 Flutter Web 的调试默认依赖 Chrome。

如果你电脑上同时有多个 Flutter 项目,而且它们依赖的 SDK 版本不一样,可以考虑用 FVM 做多版本管理。比如项目 A 用 3.19,项目 B 用 3.22,用 FVM 可以随时切换,避免互相干扰。我这次用的是 3.22+ 版本,主要看中的是它对 Web 端 CanvasKit 的默认支持更成熟。

安装完成后,记得打开终端跑一下:

flutter --version flutter config --enable-web flutter doctor

flutter config --enable-web这一步现在一般默认开启,但如果你是从旧版本升上来的,最好确认一下。flutter doctor会列出 Flutter、Chrome、Android Toolchain 的状态,全部打勾就说明基础环境没问题。

2.2 在 Trae 中创建 Flutter Web 项目的几种方式

理论上创建项目的姿势有很多,但我比较推荐用终端命令先建骨架,再用 Trae 打开目录。原因是flutter create生成的工程结构最标准,该有的平台目录、配置文件都在,后续不容易出幺蛾子。

flutter create flutter_2048 cd flutter_2048 flutter run -d chrome

直接用 Trae 打开flutter_2048文件夹,编辑器就能识别项目结构。你在 Trae 里打开任意一个 Dart 文件,它会自动分析 import 关系、提示补全,还内置了 Flutter 相关的代码片段,对新手来说非常省心。

如果你懒得记忆命令,也可以直接在 Trae 的终端面板里执行上述命令,效果一样。我个人的习惯是:终端建项目,Trae 写代码,AI 补逻辑,三者配合起来效率最高。

2.3 项目目录结构规划

项目骨架有了,接下来要规划代码结构。不要把所有代码都堆到main.dart里,那以后改起来很痛苦。我的目录结构如下:

lib/ ├── main.dart # 应用入口和页面主体 ├── game/ │ ├── game_model.dart # 游戏状态、棋盘数据和移动合并逻辑 │ └── game_controller.dart # 状态管理,对外暴露操作接口 └── widgets/ ├── game_board.dart # 棋盘网格 ├── tile_widget.dart # 单个数字块 └── score_panel.dart # 分数面板

这样拆分的核心思路是:业务逻辑和 UI 分离。game_model.dart里只放纯数据结构和算法,不依赖 Flutter 的 Widget;game_controller.dart负责把用户操作转成模型变更,再通知界面刷新;widgets目录只管怎么把数据显示出来。将来如果你想把它移植到移动端,或者换一套 UI 重做,只需要动 widgets 目录,核心算法完全不用碰。

3. 2048 游戏核心逻辑拆解

3.1 棋盘数据模型怎么设计

2048 的棋盘就是 4x4 的格子,用List>建模最直观:

class GameModel { static const int size = 4; late List> grid; int score = 0; bool gameOver = false; bool won = false; GameModel() { reset(); } void reset() { grid = List.generate(size, (_) => List.filled(size, 0)); score = 0; gameOver = false; won = false; _addRandomTile(); _addRandomTile(); } }

为什么选择二维数组而不是一维数组或者 Map?因为 2048 的“移动”操作本质上是按行(或按列)做变换,二维数组的索引直观,写合并逻辑时思路清晰,调试时也能一眼看出哪一行哪一列出了问题。0表示空格,其他数字就是格子里的方块值。

3.2 合并算法的核心思路:先移动,再合并

2048 最核心的算法是移动合并,这里的坑最多。很多人第一次写会这样想:遍历每一行,遇到相邻相同数字就合并,然后继续往前移。听起来没问题,但实际跑起来会出各种幺蛾子。

正确的思路是拆成两步:先移动,再合并。以“向左移动”为例,每一行做的事是:

  1. 把这一行里的所有非 0 数字取出来,按原有顺序排列。
  2. 从左往右扫描,如果相邻两个数字相等,就合并成一个 2 倍的数字,并把合并后的数字放到目标位置。
  3. 剩下的位置用 0 填充。

举个例子,一行原始数据是[2, 2, 2, 0],按这个流程走一遍:

  • 去掉 0 后得到[2, 2, 2]。
  • 从左往右合并:第一对2和2合并成4,下一个2因为不是相邻相同数字,保留。
  • 把[4, 2]放到行首,后面补 0,得到[4, 2, 0, 0]。

这里的关键是合并时不能回头重复合并。比如[2, 2, 2, 2]要合并成[4, 4, 0, 0],而不是[8, 0, 0, 0]。也就是说,每个数字每轮最多参与一次合并。

代码实现时,最稳妥的方式是“先压缩再合并再压缩”:

List mergeLine(List line) { // 第一步:去掉所有 0 List compressed = line.where((v) => v != 0).toList(); // 第二步:合并相邻相同项 List merged = []; for (int i = 0; i < compressed.length; i++) { if (i + 1 < compressed.length && compressed[i] == compressed[i + 1]) { merged.add(compressed[i] * 2); score += compressed[i] * 2; i++; // 跳过下一个已被合并的元素 } else { merged.add(compressed[i]); } } // 第三步:补 0 回 4 格 while (merged.length < 4) { merged.add(0); } return merged; }

这个函数有个细节值得注意:我用了i++来跳过被合并的元素,这样能保证每个元素只参与一次合并。如果你在循环里对原数组做修改,很容易索引越界或者重复合并。先压缩成紧密数组再操作,完全避免了这些麻烦。

上下左右的移动本质上是同一套逻辑:左移直接按行处理;右移先把每行反转,处理完再反转回来;上移先转置矩阵,按行处理后再转置回来;下移先转置再反转,处理完再反转回去再转置。这样写代码量少,而且不容易出错。

3.3 随机生成新块的概率设计

每次有效移动后,棋盘上要随机生成一个新数字。标准规则是 90% 概率生成2,10% 概率生成4。实现方式也很直白:

void _addRandomTile() { List emptyTiles = []; for (int r = 0; r < size; r++) { for (int c = 0; c < size; c++) { if (grid[r][c] == 0) { emptyTiles.add(r * size + c); } } } if (emptyTiles.isEmpty) return; int index = emptyTiles[Random().nextInt(emptyTiles.length)]; int row = index ~/ size; int col = index % size; grid[row][col] = Random().nextDouble() < 0.9 ? 2 : 4; }

关键在于:一定要先判断这次移动是否有效。如果用户按了方向键,但棋盘完全没有变化(比如向上滑时最上面一行已经全是数字),这时候不能生成新块,否则会破坏游戏公平性。

3.4 游戏结束和胜利判断

游戏结束的条件是:棋盘满了,且任意相邻格子(上下左右)没有相同数字。也就是说,不存在可以合并的相邻对,也没有空格,游戏就结束了。

胜利判断更简单:只要棋盘上出现 2048,就是胜利。虽然有些玩家追求 4096 甚至更高,但界面提示默认按 2048 来。

这里有一个我没写进代码但值得提醒的点:判断游戏结束不能只看“有没有空格”,还要看“有没有可合并的相邻对”。否则会出现明明两个相邻数字相同,却被判了游戏结束的尴尬情况。我在第一次实现时就漏了这个条件,之后加了一个函数:

bool canMove() { for (int r = 0; r < size; r++) { for (int c = 0; c < size; c++) { if (grid[r][c] == 0) return true; if (c < size - 1 && grid[r][c] == grid[r][c + 1]) return true; if (r < size - 1 && grid[r][c] == grid[r + 1][c]) return true; } } return false; }

这个函数会在每次移动后调用,如果是false就置gameOver = true。

4. 用 Trae 智能体辅助开发的全过程

4.1 让 Trae 生成第一版游戏的三个关键指令

我实际用 Trae 开发时,并不是让它一次性生成整个项目,而是分步骤提问。第一次提问时,我给的指令是:

帮我生成一个 Flutter Web 的 2048 游戏,要求: 1. 使用 4x4 棋盘; 2. 支持上下左右方向键操作; 3. 每次移动后随机生成 2 或 4; 4. 有分数显示; 5. 游戏结束时有弹窗提示。

Trae 很快生成了一版可运行的代码。但说实话,这一版更像是一个“能跑的骨架”,动画很生硬,UI 比较粗糙。于是我又给了第二轮指令:

在现有代码基础上,把每个数字格子的颜色区分开,2 用浅黄色,4 用橙色,8 用红色,2048 用金色。格子要有圆角和阴影,字体白色加粗。

这一轮 Trae 做得很好,因为这是纯 UI 映射,只要规则说清楚,它基本不需要我手动改。第三轮我让它帮忙加个“重新开始”按钮,也就是在界面上加一个TextButton,事件回调绑定 reset 方法。这三轮下来,一个基础版 2048 就已经能玩了。

这里我得强调一下:不要让 AI 一次性把所有细节都做掉。原因很简单,AI 生成的代码越复杂,越容易出现逻辑漏洞,而排查漏洞的时间往往比你自己写还长。把大需求拆成小指令,每步验证可运行,再进入下一步,这是用 AI 编程最稳的节奏。

4.2 在 Trae 中调试和处理问题

Trae 的 Build 模式可以主动修改代码,我在调试阶段用得很多。比如有一次我把方向键监听写在了某个 Widget 上,结果页面加载后只有点击输入框之后按键才生效。我把这个现象描述给 Trae:

页面加载后按方向键没反应,点击页面任意位置后方向键才生效,可能是焦点问题,怎么解决?

它给出的方案是把键盘监听放到根 Widget,并调用FocusScope或FocusNode让页面自动获取焦点。这个思路是对的。我依据它的建议改完代码,问题就解决了。

在调试过程中,我自己还总结了一个原则:AI 给出的代码,必须逐行读懂后再合入。不要因为是 AI 生成的就不加思考地接受。你可以让它解释某一行代码的作用,如果解释得含糊其辞,那这里大概率有问题。我遇到过 Trae 把grid[r][c]写成grid[c][r]的情况,单看很难发现,但运行一局就能暴露。

4.3 跟 Trae 高效协作的三条经验

用了一整天 Trae 之后,我对“AI 辅助编程”这件事的看法变具体了。它确实能提高效率,但前提是你会提问。我总结出三条经验:

第一,指令里说清楚背景。不要只说“帮我写一个 2048”,而是告诉它“我用 Flutter 实现 2048,棋盘是 4x4 二维数组,现有逻辑在 game_model.dart 里”。背景越清楚,AI 给出的代码越符合你的架构。

第二,一次只改一个点。让 AI 同时加动画、改颜色、优化布局,它很容易顾此失彼。我见过它为了加动画,把整个棋盘布局重写了,结果原有逻辑全乱了。这种大规模重构必须你来把控方向,不要让 AI 自由发挥。

第三,善用“解释”功能。当你不确定 AI 为什么这样改时,直接问它“解释一下这段代码”,让它把思路写出来。一方面你能发现它是否真的理解需求,另一方面这也是很好的学习材料,比自己翻文档高效。

5. Flutter Web 运行与适配踩坑记录

5.1 Web 端渲染引擎:CanvasKit 还是 HTML

Flutter Web 有两种渲染方式:HTML renderer 和 CanvasKit。HTML 模式体积小、加载快,但复杂动画性能一般;CanvasKit 基于 WebAssembly,渲染效果跟原生 Flutter 接近,动画流畅,但首次加载需要下载较重的资源文件。

在调试阶段可以先用 HTML renderer,因为它加载快,改完代码刷新一下就能看效果。发布时建议切成 CanvasKit,用户体验更接近原生应用。切换方式也很简单:

flutter run -d chrome --web-renderer html # 调试 flutter build web --web-renderer canvaskit # 发布

需要注意高版本 Flutter 已经推荐使用默认的 autorenderer 模式,它会根据设备能力自动选择。所以如果你用的是 3.22 以上版本,其实可以把渲染引擎的选择交给框架处理,不需要手动指定。我这里提这个命令,主要是给那些还在用旧版本的同学一个参考。

5.2 Web 端热重载并不总可靠

用过 Flutter 移动端热重载的人会习惯改完代码按R看效果。但在 Web 端,热重载偶尔会丢失状态,尤其是你改了初始化逻辑或全局变量时,页面上可能看到一堆奇怪的残留。我的习惯是:改 UI 时用热重载,改逻辑时直接刷新浏览器。

如果遇到改动后界面完全不对,先试试刷新页面而不是继续热重载,这能省去很多“我改完没反应”的困惑。如果刷新后问题依旧,再回头检查代码本身。

5.3 Service Worker 缓存导致的旧版本问题

Flutter Web 发布时会在web/目录里生成一个flutter_bootstrap.js,并且默认注册 Service Worker 做资源缓存。这个缓存在开发环境下问题不大,但发布后更新版本时,用户浏览器可能还在用旧资源。

解决方法是发布新版本时改一下资源路径或者在 Service Worker 配置里做版本更新。对于学习项目,一个比较省事的办法是在web/index.html里临时禁用 Service Worker 注册,或者部署时更新version.json。不过更规范的方案是使用 Flutter 官方提供的 Service Worker 更新逻辑,避免修改生成文件。

以下是web/index.html中禁用 Service Worker 的示例(仅调试用,上线不建议):

5.4 打包部署到 Nginx 的注意事项

flutter build web成功后,会在build/web目录生成一堆静态文件。把这些文件部署到 Nginx 即可。这里有一个高频问题:如果你把项目部署在域名根路径下,一切正常;但如果你是部署在http://example.com/game/2048/这种子路径下,Flutter 的资源路径会全部 404。

解决办法是在构建时指定--base-href:

flutter build web --base-href=/game/2048/

部署到 Nginx 后的一个最小配置示例:

server { listen 80; server_name example.com; location /game/2048/ { alias /var/www/flutter_2048/; try_files $uri $uri/ /game/2048/index.html; } }

try_files很重要,Flutter Web 是单页应用模式,刷新页面时不能让服务器返回 404,而是要回退到index.html。

6. 完整代码解析

6.1 main.dart 主入口与棋盘界面

这里给出一个可运行的简化版main.dart,它整合了洞口模型、核心界面和键盘事件。篇幅原因,真实项目里可以把逻辑拆分到多个文件,但核心思路是一致的:

import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; void main() { runApp(const Game2048App()); } class Game2048App extends StatelessWidget { const Game2048App({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: '2048', debugShowCheckedModeBanner: false, theme: ThemeData( useMaterial3: true, fontFamily: 'monospace', ), home: const GamePage(), ); } } class GamePage extends StatefulWidget { const GamePage({super.key}); @override State createState() => _GamePageState(); } class _GamePageState extends State { static const int gridSize = 4; late List> grid; int score = 0; bool gameOver = false; @override void initState() { super.initState(); resetGame(); } void resetGame() { setState(() { grid = List.generate(gridSize, (_) => List.filled(gridSize, 0)); score = 0; gameOver = false; _addRandomTile(); _addRandomTile(); }); } void _addRandomTile() { final empty = []; for (int r = 0; r < gridSize; r++) { for (int c = 0; c < gridSize; c++) { if (grid[r][c] == 0) empty.add(r * gridSize + c); } } if (empty.isEmpty) return; final index = empty[Random().nextInt(empty.length)]; final r = index ~/ gridSize; final c = index % gridSize; grid[r][c] = Random().nextDouble() < 0.9 ? 2 : 4; } void handleMove(String direction) { setState(() { bool moved = _move(direction); if (moved) { _addRandomTile(); if (!_canMove()) gameOver = true; } }); } // 这里 _move 会调用 _mergeLine,核心逻辑前面已经讲过,这里不重复贴 bool _move(String direction) { ... } bool _canMove() { ... } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFFAF8EF), body: Focus( autofocus: true, onKeyEvent: (node, event) { if (event is KeyDownEvent) { switch (event.logicalKey.keyLabel) { case 'Arrow Up': handleMove('up'); break; case 'Arrow Down': handleMove('down'); break; case 'Arrow Left': handleMove('left'); break; case 'Arrow Right': handleMove('right'); break; } } return KeyEventResult.handled; }, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ ScorePanel(score: score, onRestart: resetGame), const SizedBox(height: 16), GameBoard( grid: grid, gameOver: gameOver, onRestart: resetGame, ), ], ), ), ), ); } }

这段代码包含了几个关键细节:

  • Focus+onKeyEvent监听方向键,autofocus: true保证页面加载后不需要额外点击就能接收键盘事件。
  • handleMove里先判断这次移动是否真的改变了棋盘,只有 changed 才生成新块。
  • resetGame里先清空再添加两个初始块,保证每次重开都从标准状态开始。

6.2 键盘与触摸手势的移动端适配

上面代码处理了键盘,但如果你的 2048 也要在手机上打开,触摸滑动就必不可少。Flutter 里可以用GestureDetector的onPanUpdate来判断滑动方向,根据手指移动的delta.dx和delta.dy哪个方向幅度大来决定移动方向:

GestureDetector( onPanUpdate: (details) { if (details.delta.dx.abs() > details.delta.dy.abs()) { handleMove(details.delta.dx > 0 ? 'right' : 'left'); } else { handleMove(details.delta.dy > 0 ? 'down' : 'up'); } }, child: const GameBoard(...), )

注意details.delta是两次回调之间的位移增量,不是从按下到松开的累计位移。所以这段逻辑会让游戏在手指滑动过程中连续触发多次移动。如果想只触发一次,需要在onPanStart记录起点,在onPanEnd计算总位移。我实际项目里用的就是后者。

6.3 界面布局与动画细节

格子颜色的映射直接用switch完成:

Color tileColor(int value) { 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); } }

为了让方块移动看起来不那么生硬,我用了AnimatedContainer来给每个格子加过渡动画:

AnimatedContainer( duration: const Duration(milliseconds: 100), curve: Curves.easeInOut, decoration: BoxDecoration( color: tileColor(value), borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: value == 0 ? null : Text('$value', style: TextStyle(...)), )

AnimatedContainer有一个小坑:当数字从2变成4时,它会自动过渡颜色;但数字本身是直接变的,没有“数字跳动”的效果。如果想让数字也有一个缩放动画,可以用TweenAnimationBuilder或者ScaleTransition,这属于进阶玩法,基础版本不做也完全能玩。

7. 常见问题速查表与避坑清单

7.1 常见问题速查表

问题主要原因解决办法
按方向键没反应Focus 没获取到在根节点加Focus(autofocus: true),或用FocusScope
刷新页面后一直是旧版本Service Worker 缓存部署时更新version.json,或临时禁用 SW
发布到子路径后资源 404base-href 没配构建时加--base-href=/子路径/
手机浏览器滑动没反应只在电脑上测了键盘加GestureDetector,用onPanStart/onPanEnd判断滑动
游戏结束弹窗无限弹弹窗出现后状态没冻结检查gameOver状态,弹窗前加判断
热重载后页面状态错乱Web 端热重载不稳定刷新浏览器,而不是继续热重载
打包体积过大CanvasKit 引擎加载 wasm这是正常现象,需要做加载动画或 CDN 加速

7.2 避坑清单

  • 不要用List.generate生成二维数组时共享同一行。List.filled(size, 0)如果直接嵌套在List.generate里,每一行会指向同一个数组,改一行等于改所有行。正确做法是List.generate(size, (_) => List.filled(size, 0)),每一行单独创建。
  • 合并逻辑里不要在原地修改数组。尽量先取非 0 值生成新列表,再操作新列表,避免索引错乱。这不是性能问题,而是可维护性问题。
  • 在setState里做完整的移动+生成逻辑,而不是在外部改完再调 setState。这能保证所有状态变更都在一次重建中完成,避免界面显示中间状态。
  • 发布前跑一遍flutter analyze和dart format。这两步能发现大部分低级错误,也能让代码风格统一。Trae 里按快捷键就能执行,不费时间。
  • Web 端不要依赖dart:io库。有些 Flutter 插件只在移动端可用,一旦在 Web 端调用就会报错。选插件时先确认它是否支持 Web 平台。

最后再分享一个小技巧:如果你想让 AI 帮你看代码问题,直接把报错信息和相关文件贴过去,并告诉它“这是 2048 项目,我只改了某个函数,现在报错”,它通常能很快定位。如果直接让它“看整个项目”,它会因为上下文太长而忽略重点。我发现把问题描述得越具体,AI 修复的准确率就越高——这一点在你以后用 Trae 写更大项目时会越来越有体会。