用 Flutter 造一台掌机

引子:被一句日文卡住的下午

去年某个周末,我在玩一款 GBA 经典 RPG,卡在了一个 NPC 对话------全是日文。手机截图,切出去翻译,再切回来,游戏已经自动跳过了。

这个打断感让我很难受。

我手边正好有一个 GBA 模拟器项目 GoGBA。我想:能不能在不离开游戏的情况下,按一个按钮,AI 直接读懂画面、翻译给我看?

这篇文章就是这个想法从零到落地的完整记录。技术栈:Flutter + mGBA + Firebase AI (Gemini) + Riverpod + Clean Architecture。全部真实生产代码。

GoGBA 现已在 App Store 和 Google Play 上线,搜索 GoGBA 即可下载。


一、架构决策:Flutter 能跑模拟器吗?

为什么不用纯原生

模拟器对性能敏感,很多人第一反应是"Flutter 不够快"。但 GoGBA 的核心是 libretro/mGBA------一个成熟的 C/C++ 模拟器核心,Flutter 本身只负责 UI 和调度,不跑模拟逻辑。

这让跨平台成为可能:

scss 复制代码
Flutter UI (Dart)
      ↓  MethodChannel / EventChannel
Kotlin (Android) / Swift (iOS)
      ↓  JNI / C FFI
libretro mGBA (C/C++)

Flutter 渲染游戏画面用的是 Texture widget ------native 层把 mGBA 帧缓冲写入 SurfaceTexture(Android)/ CVPixelBuffer(iOS),Flutter 直接贴图,零拷贝,60fps 完全够用。

MethodChannel 的边界设计

GoGBA 设计了三条 channel:

dart 复制代码
static const MethodChannel _channel =
    MethodChannel('go_gba/emulator');
static const MethodChannel _audioChannel =
    MethodChannel('go_gba/audio');
static const EventChannel _eventChannel =
    EventChannel('go_gba/emulator_events');
  • _channel:指令通道(加载 ROM、存档、金手指)
  • _audioChannel:独立出来,避免音频调用阻塞游戏主循环
  • _eventChannel:native 主动推送(RetroAchievements 成就解锁、排行榜更新)

EventChannel 是这里最容易被忽视的设计 。原生模拟器事件是异步发生的,用 MethodChannel 轮询很蠢;用 EventChannel 把它变成 Dart Stream,Riverpod provider 直接 watch,完全响应式。


二、Clean Architecture 在 Flutter 里真的能落地吗?

为什么要做分层

GoGBA 初期代码都堆在 PlayPage 里------模拟器调用、存档逻辑、UI 状态混在一起。后来要加云存档、金手指、AI 翻译,每次改都牵一发动全身。

Clean Architecture 的核心价值不是"设计美",是让功能可以独立演化

GoGBA 的分层:

bash 复制代码
pages / widgets / providers   ← Presentation
        ↓
domain/usecases               ← Application(业务规则)
        ↓
domain/entities, ports,       ← Domain(纯 Dart,无 Flutter/dart:io)
repositories (interfaces)
        ↑ implements
data/repositories, core/emulator  ← Data / Infra
        ↓ MethodChannel
Kotlin / Swift / mGBA (native)

关键约束:domain/ 不能 import package:flutter/**dart:io、或 data/ 层。 这是强制规则,不是建议。

用 custom_lint 让架构规则自动执行

光靠 code review 守架构边界,迟早会漏。GoGBA 用 custom_lint 把这个约束变成编译期错误:

yaml 复制代码
# analysis_options.yaml
analyzer:
  plugins:
    - custom_lint

项目内置了两条自定义规则:

  • gogba_domain_layer_dependencies:domain 层禁止 import flutter / dart:io / data
  • gogba_presentation_no_data_imports:presentation 层禁止直接 import data 层

现在如果有人在 domain/ 里写了 import 'package:flutter/material.dart'flutter analyze 直接报错,CI 挂掉。规则不在人脑里,在工具里。

这是我在真实 Flutter 项目里用过的、最有效的架构守护方式。

Port/Adapter 模式处理跨层依赖

Riverpod provider 需要读写配置,但不应该直接 import ConfigDatasource(data 层)。GoGBA 的解法:

dart 复制代码
// domain/ports/app_config_storage_port.dart(接口,纯 Dart)
abstract class AppConfigStoragePort {
  Future<AppConfig> load();
  Future<void> updateConfig(AppConfig config);
}

// data/adapters/(实现,组合根注入)
class ConfigDatasourceAppConfigStorageAdapter
    implements AppConfigStoragePort { ... }

// providers/(Riverpod 组合根)
final appConfigStoragePortProvider = Provider<AppConfigStoragePort>((ref) {
  return ConfigDatasourceAppConfigStorageAdapter();
});

Presentation 层只依赖 Port 接口,测试时换 fake 实现,生产用真实 datasource。Widget 测试不再需要 mock 文件系统。


三、AI 实时翻译:一个按钮,三层技术

这是 GoGBA 里我最喜欢的功能,也是工程上最有趣的一段。

第一层:截当前游戏画面

GBA 画面是 native texture,不是普通 Flutter widget,不能直接截图。

GoGBA 的方案:用 RepaintBoundary 把游戏画面包起来,通过 RenderRepaintBoundary.toImage() 捕获当前帧,再在独立 isolate 里完成编码。

dart 复制代码
// lib/core/utils/game_texture_capture.dart
Future<Uint8List?> captureGameTextureAsJpeg(
  GlobalKey key, {
  int? targetWidth,
  int? targetHeight,
}) async {
  final boundary = key.currentContext
      ?.findRenderObject() as RenderRepaintBoundary?;
  if (boundary == null) return null;

  final image = await boundary.toImage(pixelRatio: 1);
  final byteData =
      await image.toByteData(format: ui.ImageByteFormat.png);
  if (byteData == null) return null;

  final pngBytes = byteData.buffer.asUint8List();

  // PNG → resize → JPEG,在 isolate 里跑,不卡主线程
  return compute(_encodePngBytesToJpeg, (
    pngBytes: pngBytes,
    targetWidth: targetWidth,
    targetHeight: targetHeight,
  ));
}

关键细节 :图像编码和缩放用 compute() 扔进独立 isolate,主线程不阻塞,游戏继续跑,用户无感。

第二层:Gemini multimodal 翻译

GoGBA 用 Firebase AI Logic(Vertex AI on Firebase),模型是 Gemini:

dart 复制代码
// lib/core/services/game_screen_translation_service.dart
final GenerativeModel _model = FirebaseAI.vertexAI(location: 'global')
    .generativeModel(
      model: 'gemini-3.1-flash-lite-preview',
      generationConfig: GenerationConfig(
        maxOutputTokens: 512,
        temperature: 0.1,
        topP: 0.95,
        // 翻译不需要推理,关掉节省延迟和 token
        thinkingConfig: ThinkingConfig.withThinkingBudget(0),
      ),
    );

Future<String> translateJpeg({
  required List<int> jpegBytes,
  required String targetLanguageTag,
}) async {
  final prompt =
      'GBA screenshot: pixel UI. Transcribe all visible on-screen text, '
      'then translate it into "$targetLanguageTag". '
      'Use natural RPG/menu phrasing. '
      'Output only the translation text, no scene summary or extra commentary. '
      'If no readable text, reply exactly: No text detected.';

  final response = await _model.generateContent([
    Content.multi([
      InlineDataPart('image/jpeg', Uint8List.fromList(jpegBytes)),
      TextPart(prompt),
    ]),
  ]);
  return response.text?.trim() ?? '';
}

几个 prompt 工程的选择值得展开说:

  • Use natural RPG/menu phrasing:让翻译贴合游戏语境,不会把"HP"译成"健康点数"
  • Output only the translation text:去掉模型自带的废话前缀
  • If no readable text, reply exactly: No text detected.:结构化 fallback,客户端判断方便
  • temperature: 0.1:翻译是确定性任务,高温度只会产生不稳定输出
  • ThinkingConfig.withThinkingBudget(0):Gemini 2.x 默认开启 thinking,对翻译无意义,显式关掉

第三层:按月配额管理

AI 调用有成本,GoGBA 的 AI 翻译是独立订阅功能,月度上限通过 Firebase Remote Config 下发,不需要发版调整:

dart 复制代码
// lib/domain/services/game_screen_translation_quota_service.dart
class GameScreenTranslationQuotaService {
  static String _currentUtcYm() {
    final u = DateTime.now().toUtc();
    return '${u.year.toString().padLeft(4, '0')}'
        '-${u.month.toString().padLeft(2, '0')}';
  }

  Future<bool> isExhausted(int monthlyLimit) async {
    if (monthlyLimit <= 0) return true;
    final uses = await getUsesThisMonth();
    return uses >= monthlyLimit;
  }

  Future<void> recordSuccessfulTranslation() async {
    final prefs = await SharedPreferences.getInstance();
    final count = await _usesForCurrentUtcMonth(prefs);
    await prefs.setInt(_keyCount, count + 1);
  }
}

用 UTC 月份而非本地时间:用户跨时区,本地时间会导致配额在不同时区的人在不同时刻重置。UTC 是唯一公平的计量基准。

串联:UI 层的完整流程

dart 复制代码
// lib/pages/play/widgets/game_translation_bottom_sheet.dart
Future<void> _run() async {
  // 1. 截帧
  final jpeg = await captureGameTextureAsJpeg(
    key, targetWidth: vw, targetHeight: vh,
  );

  // 2. 调 Gemini 翻译(跟随系统语言)
  final target =
      LocaleSettings.currentLocale.flutterLocale.toLanguageTag();
  final text = await GameScreenTranslationService.instance.translateJpeg(
    jpegBytes: jpeg,
    targetLanguageTag: target,
  );

  // 3. 展示结果 + 记录配额
  setState(() {
    _phase = _TranslationPhase.success;
    _resultText = text;
  });
  if (widget.recordUsageOnSuccess) {
    await GameScreenTranslationQuotaService.instance
        .recordSuccessfulTranslation();
  }
}

用户看到的是:按下翻译按钮 → bottom sheet 弹出 → loading 转一两秒 → 翻译结果出现。背后是截帧、isolate 编码、multimodal AI 调用、配额记录的完整链路,全部异步,游戏不停。


四、工程工具链:AI 辅助开发的真实体感

GoGBA 的开发工作流深度使用了 Claude Code(Anthropic 的 AI 编程助手)。作为独立开发者,这让我一个人能维持通常需要团队才能覆盖的工程规范。

几个真实的使用场景:

架构守护:把 SKILL.md(包含 domain 层禁止规则、forbidden patterns)放进项目,Claude Code 在每次改动时都会参考这份约束,不会给你写出违反分层的代码。

i18n 自动化 :GoGBA 支持 24 种语言,新增功能时 Claude Code 自动往 l10n/*.i18n.json 里补全所有语言的翻译占位,再触发 dart run slang 重新生成。

Fastlane 发版:从 bump build number、生成 changelog,到提交 App Store,全部脚本化,Claude Code 负责执行和检查。

这不是"AI 替代开发者",而是 AI 把工程纪律的执行成本降到接近零。规范写好了,工具帮你守。


五、踩过的坑,留给你

坑 1:ref.read()dispose() 里会崩

Riverpod 的 ref.read() / ref.watch() 不能在 widget dispose() 之后调用,widget 已经卸载,provider 可能已经释放。GoGBA 早期有几个 crash 就是这个原因,后来写进 SKILL.md 的 forbidden patterns,custom_lint 同步加了检测。

坑 2:invalidate(provider) 会触发 AsyncLoading 闪烁

更新配置后直接 invalidate provider,会让 UI 瞬间进入 loading 状态再恢复,用户会看到闪屏。正确做法是在 notifier 里直接 state = newValue,让 Riverpod 做局部 diff 更新。

坑 3:Gemini 的 thinking 模式默认开着

firebase_ai 的 Gemini 2.x 模型默认开启 thinking,对翻译这种确定性任务是纯负担------延迟变长、token 变多、输出不稳定。必须显式 ThinkingConfig.withThinkingBudget(0) 关掉。


结语

GoGBA 是我用来验证工程想法的实验场:Flutter 做高性能跨平台 App 的边界在哪,Clean Architecture 在真实项目里能不能不沦为"面试题架构",AI 功能在 App 里怎么设计才不是玩具。

结论是:Flutter 已经足够成熟,AI 工具链正在把个人开发者的上限往上推。

代码里每一个选择------custom_lint 守住 domain 边界、compute() 防止 isolate 阻塞主线程、UTC 月份计量配额------都是踩坑之后留下的痕迹。希望对你有帮助。

搜索 GoGBA 下载体验。如果你在玩日文或英文 GBA 游戏,AI 翻译功能应该对你有用。


如果你对 Flutter 跨平台、Firebase AI Logic 集成、或者独立 App 的工程工作流有问题,欢迎在评论区聊。

相关推荐
小碗细面4 小时前
Claude Code 在大型项目中的最佳实践指南
ai编程·claude
全栈人月5 小时前
让Codex成为真正的"团队伙伴"
ai编程
k09335 小时前
Oh My OpenAgent (OMO) 介绍与使用指南
aigc·ai编程
甲维斯6 小时前
掌门日记之Opus4.7测评报告!
ai编程
canonical_entropy7 小时前
NOP Chaos Flux 架构演变史:从 AMIS 重写到现代低代码运行时
前端·aigc·ai编程
夜雪闻竹7 小时前
Cursor 对话导入:解析 SQLite 里的宝藏
数据库·sqlite·ai编程
ZengLiangYi9 小时前
Embedding 模型选型与配置
ai编程
程序员辉哥9 小时前
深入 OpenSpec 源码,我发现了控制 AI 行为的三层架构
openai·ai编程·claude
Mr_hwt_1239 小时前
Windows安装Claude Code详细教程(含apikey配置)
windows·ai编程·claude code
_大学牲10 小时前
从零实现自己的agent第五期:子代理实现
github·agent·ai编程