Flutter打地鼠游戏开发实战:setState、Timer与动画性能优化
做 Flutter 做久了身边朋友老问我一句话Flutter 天生是写业务界面的你拿它做游戏不是找罪受吗说实话我一开始也这么觉得直到我完整做了一个打地鼠游戏才彻底改观。打地鼠看着是个小项目但它把游戏开发里最核心的三件难事全占齐了实时交互、定时器管理、高性能动画设计。这三个词听起来是游戏领域的事儿可在 Flutter 里做起来背后全是 UI 刷新机制、事件分发、动画生命周期这些日常开发最容易翻车的东西。这篇文章我就拿这个 Flutter 打地鼠游戏当案例把我自己的设计思路、代码结构、调优过程还有踩过的坑全部摊开说。适合正在学 Flutter、想搞明白 setState 该怎么用才能不卡页面的人也适合已经写了一阵子业务代码、想用小游戏项目练手进阶的朋友。看完你不仅能照着写一个能玩的打地鼠还能顺手搞懂 Flutter 的 Timer 该怎么管才不会泄漏、动画怎么写才不掉帧。1. 项目概述为什么拿打地鼠来练手1.1 打地鼠游戏的本质是什么打地鼠的核心玩法很简单3×3 的洞里随机冒出来地鼠你在它缩回去之前点中它得分点空或者点慢了扣命或者不加分。听起来就是个“点得快”的小游戏但拆开看它覆盖了游戏开发的几个基本矛盾。第一是随机性。地鼠什么时候出现、出现在哪个洞、停留多久都不能让玩家摸到规律否则游戏就没意思了。这背后是随机算法的设计以及随机事件和 UI 状态之间的联动。第二是计时。整个游戏有倒计时每个地鼠个体有自己的出现-停留-消失时间线多个时间线还要并发管理。这就是典型的定时器管理问题。第三是反馈。玩家点中地鼠的那一瞬间要看到地鼠变脸、缩小、加分数字飘起来最好还有震动和音效。这些反馈如果慢了哪怕 100 毫秒手感就会像在“打棉花”这个细节决定了游戏好不好玩。第四是动画并发。九个洞不会乖乖排队表演可能同时有两三只地鼠处于不同动画状态。每个地鼠的弹出和缩回都是独立动画这些动画互相叠加还要保持 60 帧流畅非常考验性能设计。这四个矛盾合在一起恰好就是 Flutter 开发里最容易被忽视的四块短板。业务界面通常不需要这么密集的交互和高频刷新但打地鼠游戏逼着你必须正面处理它们。1.2 技术方案选型不要一上来就上重型架构很多朋友一听到做游戏第一反应就是上 Flame 引擎。Flame 是 Flutter 生态里很成熟的 2D 游戏引擎做飞行射击、跑酷那种项目确实合适。但打地鼠这个项目我的建议是不要用 Flame纯 Flutter 原生 Widget 就够了。为什么因为打地鼠本质上还是以 UI 控件交互为主地鼠就是一张图或者一组动画洞的位置是固定网格不需要复杂的碰撞检测系统也不需要游戏引擎的场景管理。用原生 Widget 实现代码更轻而且能把 Flutter 的渲染机制吃得更透。状态管理方面我没有用 Riverpod 或者 Bloc 这类重型框架只用了 ChangeNotifier 局部刷新。原因是游戏状态变化非常高频如果套一层复杂的状态管理框架反而要花大量精力去处理框架本身的性能开销。ChangeNotifier 是 Flutter 自带的够轻、够快配合 ValueListenableBuilder 或者 ListenableBuilder 做局部刷新性能足够。动画方案上我以显式动画 AnimationController 为主部分简单效果用隐式动画。为什么这样选后面我会专门用一整节讲。1.3 游戏规则的明确设计动手写代码之前我把游戏规则定了下来这个规则直接影响后面的数据结构设计。游戏总共 60 秒。3×3 共九个洞每次最多同时出现两只地鼠。地鼠出现后停留 1.2 秒到 2 秒不等点击命中加 10 分连续命中触发连击加成最高 1.5 倍。点击到空洞会扣掉连击数但不扣命。生命值一共三点如果让地鼠自然缩回洞里三次游戏提前结束。这套规则不是拍脑袋定的。60 秒时长是为了让一局游戏在通勤间隙能打完最多两只地鼠同时出现是为了在“有挑战”和“不混乱”之间找平衡连击机制是为了让玩家有持续正反馈。规则定了之后后面所有代码的数据模型、定时器策略、动画参数都要围绕这套规则来设计。2. 实时交互让每一次点击都有“毫米级”反馈2.1 命中检测与手势分发的细节打地鼠的交互核心是点击判定。这里有个很多人会忽略的问题到底把 GestureDetector 放在哪一层常见的错误做法是给整个游戏区域包一个 GestureDetector然后在 onTapUp 里通过坐标换算判断点到了哪个洞。这种做法不是不行但很蠢。坐标换算要处理不同屏幕尺寸的缩放还得自己维护洞的位置矩形一旦布局调整一点点判定区域就全错了。我的做法是给每个洞独立绑定 GestureDetector。九个洞就是一个 3×3 的 GridView 或者 Column/Row 组合每个洞的 Widget 自己处理点击事件。这样点击命中的判断天然精确不需要任何坐标计算而且每个洞可以独立控制自己的交互状态比如地鼠已经缩回去了这洞就自动不响应点击。高手的做法还会再加一层防护在同一只地鼠被点击之后给它加一个 200 毫秒左右的冷却时间。因为玩家手速快的时候可能在同一个洞里连续点两下第一次点中了加过分第二次如果又触发判定会出现“一次冒头被扣两次命”的 bug。冷却时间本质上是让每次点击事件在业务逻辑上是幂等的同类问题在实战里非常常见。2.2 点击反馈的层次设计不只是换张图一个打地鼠游戏好不好玩核心就在点击反馈的层次上。我总结下来至少要做三层反馈视觉层、触觉层、听觉层。视觉层最容易理解。点中地鼠后地鼠要有一个“被打懵”的表情和缩小动画然后从洞里掉下去。同时分数要飘出来最好是往上方飘然后淡出这样玩家不用看顶部计分板也知道自己加了分。如果连击了飘出的数字颜色要变化从白色到黄色再到红色形成递进刺激。触觉层是很多新手最容易忽略的。移动端设备都有震动马达Flutter 里用 HapticFeedback.lightImpact() 就能调起来。打中地鼠时震一下点空时震得更轻微一点这个手感差异是实实在在的。我实测过在真机上开着震动玩和关掉震动玩完全两个体验震动带来的“击打感”是无法用视觉替代的。听觉层看你项目需求如果方便打包音效资源可以用 audioplayers 插件在命中时播一个短促的“啪”声。音效资源控制在几十 KB 以内加载几乎无感。但要注意音效不要做得太长超过 0.3 秒就会拖节奏。这三层反馈本质上都在做同一件事降低感知延迟。玩家手指落下的瞬间如果视觉、触觉、听觉同时给反馈大脑会觉得游戏“很跟手”。哪怕真实响应速度没变感受上也快了很多。2.3 为什么我坚持不用全局 setState这是这个项目里最重要的一条性能经验。很多 Flutter 新手写代码习惯上来就是 setState(() { score 10; })整个页面全部 rebuild。在简单页面上没问题但在打地鼠这种高频交互场景里全局 setState 是掉帧的头号元凶。原因在于 Flutter 的 setState 会触发当前 State 的 build 方法而 build 方法里如果引用了整个游戏页面的组件树那么每点一下九个洞、计分板、倒计时文本全都要重新执行 build。虽然 Flutter 有 Widget 复用的机制但 build 方法本身的执行是有开销的尤其是当里面有图片解码、复杂布局计算的时候。我的做法是把游戏页面拆成两个部分。全局计分板、倒计时这种低频变化的部分用 ListenableBuilder 监听控制器而九个洞各自独立每个洞用 ValueListenable 监听自己的状态。这样点中其中一个洞只会重绘那一个洞组件其他八个洞完全不动。打一个不恰当的比方全局 setState 等于每次改动都让全公司所有人重新开一次会局部刷新是只让相关小组碰个头。小公司无所谓公司大了肯定得拆。3. 定时器管理游戏节奏的地基3.1 地鼠出洞节奏的核心逻辑定时器管理是打地鼠项目里最容易踩坑的部分。地鼠的出现不是写死每隔多少秒出一次而是要用“随机间隔”来生成节奏。我的做法是维护一个调度函数 _scheduleNextMole()每次执行完一只地鼠的出现逻辑后立刻创建下一个 Timer间隔取一个随机值。比如基础间隔 500 毫秒上下浮动 300 毫秒这样玩家的肌肉记忆没法形成规律。这里有个关键的细节不要让同一只地鼠消失的瞬间立刻在同一个洞出现新地鼠。否则玩家会形成“盯着一个洞打”的策略游戏失去随机性。我的处理是在随机选洞时排除掉刚消失的那个洞如果可选洞太少比如只剩一个再放开限制。随机间隔的 Timer 和固定周期的 Timer 用起来差别很大。固定周期用 Timer.periodic随机间隔必须用单次 Timer 自己递归调度。原因很简单periodic 的周期是固定的没法在每次回调里重新计算下一次的间隔。递归调度虽然在代码结构上看着绕一点但它给了你最大的灵活性。3.2 倒计时的正确写法别用 Timer.periodic 数秒游戏倒计时是最容易写出 bug 的地方。很多人第一反应是用 Timer.periodic(Duration(seconds: 1))每触发一次就减一秒。这个写法在理想情况下没问题但实际运行中Timer 的回调并不保证精确按 1 秒触发。前后台切换、主 isolate 忙的时候回调可能延迟导致秒数累计偏差玩到后面明显感觉时间不对。我推荐的做法是记录游戏开始时间然后让定时器负责周期性刷新 UI但每秒剩余时间永远从当前时间和开始时间的差来计算startTime DateTime.now(); remainingSeconds gameDuration - DateTime.now().difference(startTime).inSeconds;这样无论定时器什么时候回调、延迟多少剩余时间都是真实准确的。定时器只是个“负责刷新 UI 的闹钟”而不是“负责计时的秒表”。这个思路在任何需要倒计时的场景里都通用游戏、秒杀、验证码都适用。3.3 生命周期管理Timer 不取消等于内存泄漏Timer 是 Flutter 里最容易泄漏的对象之一因为它在创建之后不会因为你页面销毁而自动取消。只要 Timer 还活着它的回调就可能在 State 已经销毁的情况下继续执行轻则抛出异常重则造成内存泄漏。具体到打地鼠项目有三个地方必须处理。第一是页面 dispose 的时候。游戏页面的 State.dispose() 里要取消所有 Timer并且把 AnimationController 也 dispose 掉。这一步做不到整个页面的资源就不会被释放。第二是游戏结束的时候。不管是倒计时结束还是生命值归零都要把所有地鼠的 Timer 取消并把当前冒头的地鼠状态复位。否则可能出现游戏已经结束了地鼠还在洞里一伸一缩。第三是应用退到后台的时候。如果用户玩到一半切到别的 App游戏还在后台用 Timer 跑逻辑浪费电不说回到前台时状态可能已经乱套了。这里需要用 WidgetsBindingObserver 监听 AppLifecycleState在退到后台时暂停游戏回到前台时恢复。这一点很多小项目都不做但真机上体验差异很大。4. 高性能动画设计流畅度的命门4.1 显式动画和隐式动画的取舍Flutter 的动画体系分两层隐式动画和显式动画。很多人搞不清什么时候用哪个我的经验就一句话动画之间有并发关系、需要精确控制节奏的用显式简单的一次性过渡、不需要中途干预的用隐式。打地鼠里的地鼠弹出和缩回我用的是显式动画 AnimationController。因为地鼠的状态不是单纯“弹一下”它可能弹到一半被玩家点中此时要立刻切换成“被打中”的动画这需要能随时反向、随时终止的控制器。显式动画给了你这把控制钥匙。而得分飘字这个效果我用的是隐式动画。调用 AnimatedOpacity 和 AnimatedSlide把目标值一改Widget 自己平滑过渡到新状态然后利用 onEnd 回调把飘字组件移除。这个效果是一次性的没有中途干预需求隐式动画写起来更短更清晰。用错层次就会出现两种典型问题都用隐式导致地鼠动画无法被打断点击命中时机总慢半拍都用显式飘字这种简单效果也要写一堆控制器代码徒增维护成本。4.2 并发动画性能调优RepaintBoundary 是关键打地鼠一局里最多会有两只地鼠同时在洞上做弹出动画再加上可能飘出来的加分数字整个屏幕的动画是并发的。性能调优的重点不在于单只地鼠的动画多复杂而在于避免动画影响范围被放大。最重要的性能武器是 RepaintBoundary。它的作用是给子组件划定一个独立的图层当子组件自身重绘时不会触发父级组件和其他兄弟组件重绘。我把每个洞都包了一层 RepaintBoundary这样洞 A 里的地鼠在动画时洞 B 到洞 I 的图层都不受影响。调试的时候可以用 Flutter 自带的 Performance Overlay 查看重绘区域。如果发现整个屏幕都在闪那就说明 RepaintBoundary 的位置不对或者根本没生效。这里有个注意点RepaintBoundary 不是越多越好每个 RepaintBoundary 都有自己的图层缓存数量过多反而会占用额外内存。我的经验是只给动画频率高的组件包静态的布局不要包。另外一个很多人忽略的点做位移和缩放动画时尽量用 Transform 而不是改变布局属性。比如地鼠从洞里弹出来如果调整的是 Padding 或者 Align每次动画帧都会触发一次布局计算而用 Transform.translate 或者 scale只是视觉上的矩阵变换不触发布局。差别就像一个是在改房子结构一个只是用投影仪换个角度投射性能差一个量级。4.3 资源优化与内存控制打地鼠的地鼠形象如果使用图片资源一定要控制图片尺寸和格式。我建议用矢量图或者合理尺寸的缓存图片不要在动画里频繁加载大图否则会出现明显的卡顿和内存飙升。优化思路是启动游戏前把地鼠图片加载进内存游戏过程中直接复用动画过程中不做任何 IO 操作控制器用完之后及时 dispose整个游戏实例销毁时确保所有图片资源和动画资源可以回收。这里插一句关于 Flutter isolate 的适用边界。很多人一听到性能优化就想到 isolate但在打地鼠这个项目里所有游戏逻辑都跑在主 isolate 上就够完全不需要 isolate。isolate 适合的是密集计算、大数据处理比如复杂的寻路算法、图片处理。游戏 UI 层面的实时交互核心是减少主 isolate 的重复工作而不是把工作搬走搬到一个需要通信开销的地方反而更慢。4.4 手感与动画时长的调参经验动画时长和缓动曲线直接决定手感这个只能靠真机反复试。我调出来的参数可以作为参考地鼠弹出120msCurves.easeOutBack带一点回弹感 地鼠缩回150msCurves.easeIn干净利落 被击中80ms 缩放到 0.6同时切换为“被打”状态图 得分飘字500ms 上移 40 像素并淡出这些数值不是凭空来的弹出比缩回慢是因为弹出需要一点弹性让玩家视觉上抓住目标缩回要快因为缩回是失败惩罚不能让玩家觉得有机会补救击中的 80ms 是“瞬间反馈”的极限值再短看不清再长会觉得肉。调参的时候一定要开 Profile 模式测不要在 Debug 模式下判断流畅度。Debug 模式有很多额外的检查逻辑会干扰时序感受而且掉帧问题在 Debug 模式下是掩盖了很多真实问题的。5. 核心代码结构与实操实录5.1 工程结构划分代码组织上我把游戏逻辑和 UI 层完全分开。lib 目录下分了三块lib/ models/ mole_data.dart // 地鼠的数据模型 game_config.dart // 游戏配置参数 controllers/ game_controller.dart // 游戏总控计时、计分、调度 pages/ game_page.dart // 游戏主界面 widgets/ mole_cell.dart // 单个地鼠洞组件 score_board.dart // 计分板组件这个结构跟常规 Flutter 业务项目一脉相承不搞特殊设计。关键点是 game_controller.dart 承担了所有动态逻辑页面里的 Widget 只负责渲染和转发事件。5.2 数据模型与控制器实现地鼠的数据模型很简单只保存状态和位置enum MoleState { idle, appearing, active, hit, gone } class MoleData { final int index; MoleState state; bool isHit false; int displayId 0; // 用于切换不同表情资源 MoleData(this.index, this.state); }状态设计是 game state machine 的简化版。每个洞的状态从 idle 到 appearing、active、hit、gone 依次流转UI 只根据 state 渲染对应的动画。这样控制器改状态UI 自动响应逻辑不会乱。游戏控制器是整个项目的核心我把它贴在下面class GameController extends ChangeNotifier { Timer? _moleTimer; Timer? _countdownTimer; final ListMoleData moles []; final Random _random Random(); int score 0; int combo 0; int lives 3; int remainingSeconds 60; DateTime _startTime DateTime.now(); bool _isRunning false; int _lastMoleIndex -1; GameController() { for (var i 0; i 9; i) { moles.add(MoleData(i, MoleState.idle)); } } void startGame() { _isRunning true; _startTime DateTime.now(); _scheduleNextMole(); _countdownTimer Timer.periodic(const Duration(milliseconds: 200), (_) { final elapsed DateTime.now().difference(_startTime).inSeconds; remainingSeconds (60 - elapsed).clamp(0, 60); if (remainingSeconds 0) { endGame(); } notifyListeners(); }); } void _scheduleNextMole() { _moleTimer?.cancel(); final delay Duration(milliseconds: 400 _random.nextInt(500)); _moleTimer Timer(delay, () { if (!_isRunning) return; _activateRandomMole(); _scheduleNextMole(); }); } void _activateRandomMole() { final availableIndexes moles .where((m) m.state MoleState.idle m.index ! _lastMoleIndex) .map((m) m.index) .toList(); if (availableIndexes.isEmpty) { availableIndexes.addAll( moles.where((m) m.state MoleState.idle).map((m) m.index), ); } if (availableIndexes.isEmpty) return; final index availableIndexes[_random.nextInt(availableIndexes.length)]; final mole moles[index]; mole.state MoleState.appearing; _lastMoleIndex index; // 地鼠停留一段时间后缩回 final keepDuration Duration(milliseconds: 1200 _random.nextInt(800)); Future.delayed(keepDuration, () { if (mole.state MoleState.active) { mole.state MoleState.gone; lives--; if (lives 0) { endGame(); } notifyListeners(); } }); notifyListeners(); } void onMoleTapped(int index) { final mole moles[index]; if (mole.state ! MoleState.active) return; mole.state MoleState.hit; combo; final multiplier 1.0 (combo - 1) * 0.05; score (10 * multiplier).round(); notifyListeners(); } void endGame() { _isRunning false; _moleTimer?.cancel(); _countdownTimer?.cancel(); notifyListeners(); } override void dispose() { endGame(); super.dispose(); } }注意几个细节倒计时定时器用了 200 毫秒的周期而不是 1 秒因为剩余时间直接从时间差算刷新频率快一些能让 UI 上最后一秒的变化更顺滑。地鼠停留用的是 Future.delayed这里有个隐患后面排坑部分我会专门讲。另外我在 onMoleTapped 里加了状态校验只有 active 状态的地鼠才能被点中这个幂等保护杜绝了连点导致的重复计分。5.3 单个地鼠组件与动画绑定地鼠组件用 StatefulWidget SingleTickerProviderStateMixin核心动画逻辑是监听外部传入的状态变化class MoleCell extends StatefulWidget { final MoleData mole; final VoidCallback onTapped; const MoleCell({Key? key, required this.mole, required this.onTapped}) : super(key: key); override StateMoleCell createState() _MoleCellState(); } class _MoleCellState extends StateMoleCell with SingleTickerProviderStateMixin { late final AnimationController _scaleController; late final Animationdouble _scale; override void initState() { super.initState(); _scaleController AnimationController( vsync: this, duration: const Duration(milliseconds: 120), ); _scale Tweendouble(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _scaleController, curve: Curves.easeOutBack), ); } override void didUpdateWidget(covariant MoleCell oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.mole.state ! widget.mole.state) { _onStateChanged(widget.mole.state); } } void _onStateChanged(MoleState state) { switch (state) { case MoleState.appearing: _scaleController.forward(from: 0); break; case MoleState.gone: _scaleController.reverse(); break; case MoleState.hit: _scaleController.animateTo(0.9, duration: const Duration(milliseconds: 80)); break; default: break; } } override Widget build(BuildContext context) { return RepaintBoundary( child: GestureDetector( onTap: widget.mole.state MoleState.active ? widget.onTapped : null, child: Stack( alignment: Alignment.bottomCenter, children: [ // 洞的背景 Container( width: 100, height: 100, decoration: BoxDecoration( color: Colors.brown.shade300, borderRadius: BorderRadius.circular(50), ), ), // 地鼠本体用ScaleTransition控制弹出效果 ScaleTransition( scale: _scale, child: _buildMoleFace(), ), ], ), ), ); } }这里最关键的设计是将状态变化封装在 didUpdateWidget 里处理而不是在 build 方法里判断。原因在于 build 方法可能在无关 rebuild 发生时被频繁调用如果在 build 里启动动画控制器的 forward就会重复触发动画。用 didUpdateWidget 只监听状态变化的时机逻辑才干净。5.4 参数配置集中管理把游戏参数全部集中到一个 GameConfig 类里调试起来会省很多事class GameConfig { static const gameDuration 60; static const maxConcurrentMoles 2; static const moleMinIntervalMs 400; static const moleMaxIntervalMs 900; static const moleMinKeepMs 1200; static const moleMaxKeepMs 2000; static const baseScore 10; static const maxLives 3; }集中管理的意义在于换地鼠出现频率、调整游戏难度只需要改配置文件不需要在代码里找散落的魔法数字。6. 常见问题与排坑速查6.1 构建环境和工具链问题做这个项目的过程中我遇到了一些 Flutter 环境层面的报错网上的解释五花八门我把我遇到的解决方法整理一下。一个是 VS Code 里创建 Flutter Android 项目时直接报错提示 unable to find suitable visual studio toolc。这个报错名字看着吓人实际原因是系统里缺少 Android 构建需要的 C 工具链多出现在 Windows 环境。解决方案是去 Visual Studio 安装时勾选“使用 C 的桌面开发”工作负载或者直接改用 Android Studio 来创建和运行 Flutter 项目后者自带的 SDK 管理更齐全。另一个是编译时提示 you are applying flutters main gradle plugin imperatively using the apply script。这是 Gradle 插件应用方式的兼容性警告。旧项目通常在 settings.gradle 或者 build.gradle 里手动 apply 插件新版 Flutter 推荐使用声明式插件管理。这个问题不影响调试运行但如果要上架或者升级 Gradle 版本最好还是按官方结构性配置改造。我自己平时用 Android Studio 作为主力环境版本管理用 FVM 来切换不同 Flutter 版本不同项目之间互不干扰。FVM 的好处是安装和切换版本一条命令搞定团队协作时还能锁版本避免出现“本地能跑同事拉下来就报错”的经典事故。6.2 Timer 与异步回调的三类疑难打地鼠项目里 Timer 相关的坑最多我总结了三种高频问题。第一种是 Timer 回调在页面销毁后仍然执行。表现是页面已经退出控制台还在打印游戏日志甚至偶发空指针崩溃。解决方案是先取消 Timer 再销毁页面而且在回调里加 _isRunning 状态判断。只要你严格按照顺序做 dispose这类问题基本不会出现。第二种是 Future.delayed 和 Timer 混用导致的竞态。在地鼠“停留后缩回”的逻辑里我用了 Future.delayed 而不是 Timer这是有讲究的。如果在这里也创建一个 Timer那么在游戏结束时你还要单独维护一个“缩回 Timer 列表”才能全部取消。用 Future.delayed 配合状态判断游戏结束时 _isRunning 置为 false回调执行时发现状态不对就直接 return省掉一个维护成本。第三种是快速连续点击导致的计分错乱。这个问题的根因是地鼠的状态没有及时刷新旧状态还在处理中新点击又进来了。解决办法就是我在 onMoleTapped 里的状态校验只有 active 状态才允许点击生效出现一次点击只处理一次不会累加。6.3 掉帧与卡顿的定位方法如果游戏在真机上跑起来掉帧先开 Profile 模式确认问题存在不要凭感觉。然后打开 Flutter DevTools 的 Timeline 面板查看每一帧的耗时曲线。如果帧耗时有明显的尖峰点开看是 build 阶段耗时还是 raster 阶段耗时。如果是 build 耗时高基本可以断定是组件树重建太频繁。回到代码里检查是不是有全局 setState是不是图片资源每次都重新加载。如果是 raster 耗时高多半是绘制层的问题检查 RepaintBoundary 有没有生效有没有触发 saveLayer 这种昂贵的绘制操作。另一个很容易忽略的卡顿源是图片的实时解码。如果地鼠图片是首次显示时才从磁盘解码会造成明显的掉帧。解决方法是提前预热缓存我习惯在游戏启动画面停留的几百毫秒里用预加载的方式把地鼠图片提前解码到内存中。6.4 内存优化与资源释放这个项目虽然不大但如果不注意资源释放反复进入退出游戏页几十次后内存曲线会一直往上爬。重点检查三块AnimationController 有没有 disposeTimer 有没有 cancel图片资源有没有被反复创建。AnimationController 的释放可以通过 State.dispose 完成用 with SingleTickerProviderStateMixin 创建的控制器一定要在 dispose 里调用 _controller.dispose()。如果你创建了多个控制器建议用一个 List 统一管理dispose 时循环释放。地鼠的图片资源我采取的策略是使用 ImageCache 预加载把常用的地鼠表情保持在缓存里并且给每个显示尺寸的图片做一次 Resize避免大图的像素浪费。这里不建议用 isolate 处理图片图片不大预加载的开销远比 isolate 通信小。结尾做完这个项目之后的一些体会说句老实话这个打地鼠游戏做完之后我最大的变化是写业务页面时对性能有了更敏感的直觉。以前写列表页、详情页觉得 setState 随便用用没关系现在会下意识地拆组件、加 RepaintBoundary会思考哪些页面区域需要独立重绘。这种直觉不是看书看来的就是在做打地鼠这种高频交互项目里被逼出来的。最后再分享一个小技巧如果你想系统地练 Flutter不要只做静态界面找这种带实时交互、带计时器、带动画的小游戏项目一个顶十个。做完打地鼠之后你可以尝试加音效、加关卡难度曲线、加排行榜对接后端接口每一步都是在现有架构上做扩展比从零开始新项目要顺得多。我自己做下来最大的感受就是Flutter 的 UI 性能瓶颈绝大多数都是代码组织方式造成的而不是框架本身的锅。