引子:被一句日文卡住的下午
去年某个周末,我在玩一款 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 / datagogba_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 的工程工作流有问题,欢迎在评论区聊。