用 Flutter 造一台掌机

24 阅读7分钟

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

去年某个周末,我在玩一款 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 和调度,不跑模拟逻辑。

这让跨平台成为可能:

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:

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 的分层:

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 把这个约束变成编译期错误:

# 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 的解法:

// 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 里完成编码。

// 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:

// 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 下发,不需要发版调整:

// 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 层的完整流程

// 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 的工程工作流有问题,欢迎在评论区聊。