引言
这是一个关于 APK 逆向、代码考古和 AI 辅助开发的故事。
我有一款很喜欢的 Android 塔防游戏——《星际塔防》(Robo Defense),MagicWach 开发,Build 2900。但手机屏幕太小,操作不便,而且我想在 PC 上玩。于是我做了一个决定:把它搬到桌面。
一周后,87 次提交,desktop.jar 43MB,完整可玩。这不是我一个人的工作——我指挥了一个 AI 代理团队:5 个同时分析代码,29 个逐任务施工,还有几个专门做代码审查。我是架构师兼"项目经理",它们是我的工程师。
这篇文章不是什么"从零开始学移植"的教程——它是我在这 72 小时里做的每一个技术决策的记录:怎么从 362 个反编译类中找到 60 个核心游戏类,怎么逐方法对比验证,怎么在保住原版玩法的基础上做 UI 重设计,以及——AI 辅助开发的真实体验。
(先叠个甲..界面做的并不好看,还是复用的原本游戏的资源。欢迎来交流或者Github交流)
一、拆解:把 .apk 变成可读的 Java 代码
工具链
jadx --no-res → /tmp/apk_decompiled/sources/com/magicwach/rdefense/
unzip → assets/ (85 张精灵图 + 6 个 OGG 音效)
strings_zh.properties → 897 个唯一汉字(字体生成用)
jadx 是这场游戏的第一把钥匙。它把 classes.dex 反编译回 Java——不是完美的反向,但足够可读。362 个类,从 AchievementActivity 到 VectorLookup,原版游戏的所有秘密都摊在面前。
筛出游戏核心
362 个类中,大量是 Android 框架胶水:
R.java— Android 资源 ID 生成类,无意义*Activity.java(9 个)— Android 生命周期容器GameInput.java— 触控手势→游戏命令翻译器ConcurrentBackground.java— 后台加载动画SDBackup.java— SD 卡数据迁移- 广告/统计库 — 商业 SDK,非游戏逻辑
筛完后真正的游戏核心在 com.magicwach.rdefense 包:60 个纯 Java 类。这个数字成了后续所有工作的基准。
最小可行验证
理论分析够了吗?不。我跑了一遍"最小可行移植"验证:
原始循环 → GameState.nextState() → 调用链 → BattlePhase.update() → 存档加载
验证方法:运行→观察行为→推测逻辑→对比确认。比如 saveScore 的四奖金公式,是在一个完整的游戏胜利-结算-查积分流程里追踪出来的,不是 grep 出来的。
二、对齐:60 类 × 5 域 = 全量差距分析
有了双边源码(原版 60 类 ↔ 当前 63 类),就可以做系统性的差距分析了。这是我的方法论:
方法:分组 + 分级 + 证据
分组:按功能域把 60 类分成 5 组:
| 域 | 数量 | 内容 |
|---|---|---|
| ① 核心玩法数值 | 18 | Enemy, GameTower, Bullet, MovementGrid 等 |
| ② 游戏状态与事件 | 7 | GameState, GameEvent, RewardData 等 |
| ③ UI 界面与控件 | 24 | 9 个 Activity + GameHud, TowerButton 等 |
| ④ 平台服务与工具 | 7 | SoundManager, GameInput, ImageLoader 等 |
| ⑤ 存档与持久化 | 3 | QuickSave, SDBackup, OptionsData |
分级:P0(缺失功能)→ P1(行为偏差)→ P2(平台不适用)→ P3(有意差异)
证据标准:每条 P0/P1 附双边源代码行号,如 原版 GameState.java:661 ↔ 当前 GameState.java:225。
验证流程:代理分析→主线程逐条复核原始源码→确认后写入报告。代理结论不直接采信——AI 在广角扫描时很好,在精准判断时容易出错。
执行方式
这是个经典的并行工作流——5 个分组彼此独立,可以同时进行分析:
同时派发 5 个只读分析代理
↓
主线程逐条复核 P0/P1(打开双边源码确认)
↓
撰写报告(docs/06-APK差距分析报告.md)
最终产出:6 个 P0 缺失 + 41 条 P1 偏差,每条有精确的双边行号证据。
三个最让我震惊的发现
发现 1:得分计算被"优化"成了另一个游戏
原版 enemyDefeated() 里的击杀得分是这样算的:
// 原版:除数 500
int base_score = (maxHealth * scoreMultiplier) / 500;
而当前版本:
// 当前:除数 100 + Math.max 下限
int base_score = Math.max(1, (maxHealth * scoreMultiplier) / 100);
差了 5 倍。但还不止于此——GameRewardCalculator.calculateSimple() 竟然是 难度 × 地图系数 × 100,其中"地图系数"表的命名暴露了它是完全臆造的:
// 这个表里出现了 ICE, LAVA, EXTREME —— 但本游戏根本没有这些地图!
private static final float[] MAP_COEFFICIENTS = {
1.0f, // BASIC ✓
1.2f, // COURTYARD ✓
1.3f, // ICE ?根本不存在
1.5f, // LAVA ?根本不存在
2.0f // EXTREME ?根本不存在
};
发现 2:溅射半径差了 9.8 倍
这是典型的"硬编码代替公式"问题:
// 当前:硬编码 2500
public static final int SPLASH_RADIUS_SQ = 2500;
// 原版:公式计算
SPLASH_RADIUS_SQ = (GRID_PIXEL_SIZE * GRID_PIXEL_SIZE) / 4; // = 256
2500 ÷ 256 = 9.8 倍。这意味着溅射效果覆盖了整个屏幕而不是一个小范围——游戏难度完全不同了。
发现 3:成就弹窗的完整代码已经写好,但被遗忘了
// AchievementRenderer.java —— 完整实现了 5 帧动画 + 音效 + 中文文案
public void showAchievement(int type) { ... }
// 但全代码库里,showAchievement() 和 dequeueEarned() 的调用次数是:0
88 项成就,全部静默达成。不是因为代码没写——代码写得很完整。是因为调用链断了,就像一根电线,两头都接好了,中间缺了 1 厘米。
三、修复:87 次提交,29 个 AI 代理并行施工
有了差距清单,接下来是执行。修复分 7 个组、4 份实施计划,按依赖关系分组推进。
结算体系(计划①):最核心的修复
// 恢复后的 saveScore 四奖金公式:
if (new_run_state == GAME_WON) {
won_bonus = (score * 20) / 100; // +20% 胜利奖金
health_bonus = (score * health) / 100; // +1%/HP 生命奖金
perfect_bonus = (score * 20) / 100; // 满血 +20% 完美奖金
money_bonus = money * difficulty_level * 2; // 金钱×难度×2
}
同时恢复了 RewardData.gameWon:每通一关,难度 +1。这是原版的核心进度机制——在此之前,玩家永远停留在同一难度。
代理施工流水线
每个代理收到精确的任务规格(包含具体文件路径、代码块、编译命令),完成工作后自己跑编译验证,提交。主线程审查差异后再派下一个任务。
这不是"一个 AI 帮我写代码",这是"一个 AI 团队在并行施工,我审核他们的 PR"。
最意外的一个 Bug
审查代码时发现:成就弹窗修复后仍然不触发。追踪数据流发现——GameState.nextState() 的 dequeueAchievements() 和 GamePlayScreen 的轮询同时消费了成就队列。nextState 先把队列掏空了,等 GamePlayScreen 读取时永远得到 -1。
修复:删掉一行调用。但发现这个竞争条件花了 2 小时的根因追踪。
四、重构:不是搬家,是重新装修
4.1 上帝类的解体
重构前,GamePlayScreen.java 有 931 行,承担四种职责:
- 渲染(61.5%)— 9 个 draw* 方法
- 状态管理(19%)— 生命周期/runState 切换
- 输入处理(5%)— ESC/SPACE/F9 按键检测
- 游戏循环(0.2%)— gameLoop.tick 集成
任何一个人的大脑都 hold 不住这种文件。把它拆了:
GamePlayScreen.java(931 行)
↓
├── GameSceneRenderer.java(446 行)—— 渲染
├── GameInputController.java(67 行)—— 输入
└── GamePlayScreen.java(553 行)—— 协调(纯策略模式)
拆解后每个文件职责单一,GamePlayScreen 缩减到 553 行(-41%),成了纯粹的场景编排者。
4.2 渲染管线的真相
常规优化思路是"合并 begin/end 对"——当前每帧 10-19 对。但真正的问题是:
// LibGdxRenderer.drawRect() 的实现
batch.end(); // ← 每次调用 flush GPU!
shapeRenderer.begin();
shapeRenderer.rect(...);
shapeRenderer.end();
batch.begin();
每个 drawRect 调用都触发一次完整的 GPU flush。 单帧的情况下:HUD 7 次 + 敌人血条 ~20 次 + 塔按钮 ~12 次 = ~40 次 GPU flush/帧。这才是帧率不稳的根因——不是 begin/end 碎片化,而是 drawRect 的 ShapeRenderer 切换。
优化策略因此变了:先消除不必要的 drawRect(脏标记),再合并 begin/end。
4.3 Y 排序:一个只有玩家才能发现的 Bug
玩家报告:升级火焰塔到二级后,近处塔被远处塔盖住了。
这是典型的渲染顺序错误——原版 Android 中所有对象通过 GridObjectOrder 链表按 Y 坐标排序绘制。但当前版本用了插入顺序的 tower_list、enemy_list 分别遍历,完全绕过了 Y 排序。
修复:用 grid_order.getSortedList() 单次遍历,按 classType(1=塔/2=敌人)分发绘制。一行排序修复解决了"穿模"问题。
五、设计:从 Android 原生到指挥中心
5.1 为什么不做复刻
移植 UI 有三个选择:
- A. 像素复刻:照抄原版 Layout XML → 用 libGDX Scene2D 重建。工作量极大,且桌面用触屏布局本身就是错的。
- B. 完全原创:不看原版,从零设计。抛弃了"塔防游戏"的视觉遗产,玩家认知成本高。
- C. 提取模式 + 重设计:保留原版的布局逻辑(主菜单有 7 个按钮垂直排列,HUD 顶部信息条等),用桌面端合适的视觉语言重新表达。
我选了 C。原版的交互模式(触屏拖拽放置塔、双指 Pinch 缩放、Android Options 菜单)在桌面上无等价物——这是根本性的差异,不是"调调颜色"能解决的。
5.2 主菜单:指挥中心
┌──────────────────────────────────┐
│ │
│ ★ 星 际 塔 防 ★ │ ← 金色信标标题(唯一暖色)
│ ROBODEFENSE │ ← 全息青副标题
│ ── 扫描线缓缓划过 ── │ ← 唯一动画:极细线条每 4 秒扫一次
│ │
│ ┌──────────────────┐ │
│ │ 开 始 游 戏 │ │ ← 最大的按钮,指挥金边框
│ └──────────────────┘ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 继续游戏 │ │ 载入存档 │ │ ← 钢板蓝,等宽
│ └─────────┘ └─────────┘ │
│ │
│ [成就] [奖励] [制作] [设置] [退出] │ ← 小方块按钮,锈红退出
│ │
└──────────────────────────────────┘
签名元素:一条极细的扫描线从标题上方慢慢扫下、淡出、等待、再循环。不是粒子暴,不是闪烁,是克制的单一动画——就像数据中心的全息屏。
5.3 战斗界面:战术覆盖层
设计纪律:暖色是行动(金钱、攻击),冷色是状态(护盾、科技)。
┌──────────────────────────────────────────────┐
│ 草地 L:5 难12 $12,500 杀+15 HP█░░ PTS:850│ ← 紧凑一行页眉
├──────────────────────────────────────────────┤
│ │
│ ⚔ 战 场 ⚔ │
│ │
│ ┌────────┐ │
│ │ 🏰 │ │ ← 纯图标商店
│ │ 🚀 │ │ 钢板蓝=可买
│ │ ❄️ │ │ 暗深红=钱不够
│ └────────┘ │ 全息青=选中
├──────────────────────────────────────────────┤
│ [1]机枪 [2]冰塔 [3]火箭 空格暂停 F快进 运行中│ ← 脚注
└──────────────────────────────────────────────┘
HP 呼吸脉冲:当血量 ≤ 3 时,HP 条以 60 帧周期缓慢明暗呼吸——不是闪烁,是克制的节奏感。这是战斗界面的唯一动画。
升级弹窗改为半透明玻璃面板(alpha = 0.82),游戏画面透出。玩家可以在升级时看到战场状态。
5.4 底层基础设施:标题栏和返回按钮的"去重"
重构过程中发现:7 个 Screen 的标题栏是逐字复制粘贴——相同的 3 行 drawRect/drawText,仅标题文字不同。6 个 Screen 的返回按钮也是如此。这是典型的"复制即重用"模式。
提取到 GameScreen 基类后:
// 之前:每个 Screen 重复 3 行
renderer.drawRect(0, sh - 34, sw, 34, ...);
renderer.drawRect(0, sh - 1, sw, 2, ...);
renderer.drawText("关卡选择", 16, sh - 20, ...);
// 之后:一行
drawTitleBar("关卡选择");
消除了 17 处硬编码颜色值——这是 DRY 原则最朴素的胜利。
六、AI 协作的真实体验
代理靠谱,但别信
5 个只读分析代理产出了 52KB 的差距分析草稿——速度快得吓人。但主线程复核时发现了:
- 4 条误报("bullet_pool 未重置"——原版同样不重置)
- 7 条分级错误(动画缺失应该是 P3 而非 P0)
AI 在广度扫描上好得惊人——它们能同时阅读 9 个 Activity 类和 11 个 UI 组件类,交叉比较,给出完整的映射表。但在精准判断时会犯错——比如把一个"nice to have"的动画标记为"破坏性缺失"。
所以工作流是:代理发现→主线程复核→确认写入。代理是雷达,人类是炮手。
脏标记是诱惑,也是陷阱
有一个看起来非常正确的优化:塔按钮的背景在大多数帧里是完全不变的。给它加一个脏标记——只有当 money 或 activeTowerId 变化时才重绘。5 行代码,最高 ROI。
然后在运行测试时出现了这个 bug:塔按钮的图标和文字只在杀敌得分时闪现了一下,其他时间全部消失。 因为 money 只有杀敌/造塔时才变,其他时间脏标记 skip 了整帧的绘制。
教训:渲染器和游戏逻辑的职责边界必须分明。脏标记对游戏数据(money)敏感,但 UI 元素需要无条件每帧绘制。这是性能优化和 UI 正确性之间的冲突——UI 正确性赢了。
87 次提交的节奏感
这不是 87 个孤立的修改——它是 9 个计划文件驱动的、5 个阶段的结构化工作:
APK 差距分析(60 类全覆盖)
↓
计划① 结算/数值/健壮性(9 任务)
↓
计划② 成就/存档/事件/HUD(6 任务)
↓
计划③ 混合器/UI/星空(7 任务)
↓
计划④ 微调/死代码(4 任务)
↓
UI 双报告评审(设计 + 架构)
↓
三阶段重构(提取→架构→管线)
↓
设计落地(主菜单 + 战斗界面)
↓
审查闭环 + 发布
每次提交都有明确的"为什么"(commit message 带有双语描述和原版代码引用)。这不是"快糙猛"的移植——这是考古学式的软件工程。
七、你可以跑起来了
# 下载 JAR 后直接运行
java -jar desktop.jar
# JDK 21+ 需要额外参数
java --enable-native-access=ALL-UNNAMED -jar desktop.jar
推荐 JDK 8-17。JDK 21+ 退出时可能报 Lwjgl3Cursor 提示(libGDX 1.12.1 已知兼容性问题),不影响游戏运行。
后记:为什么这件事值得做
塔防游戏是一个微缩的软件工程世界。它包含:状态机(6 种游戏状态)→ 事件系统(12 种事件类型)→ 对象池(敌人/塔/子弹/事件四个链表池)→ 路径算法(BFS 寻路 + 连通性验证)→ 存档系统(SQLite + 魔数版本校验)→ UI 系统(9 个 Screen + 渲染管线)。
把这个小世界从 Android 搬到桌面,是理解"软件移植"的本质——不是"把代码拷贝过来能编译",而是搞清楚每一行代码为什么这样写,然后决定在目标平台上是否保持、如何保持。
还有一个技术人的"私心":我想验证一个假设——一个人 + AI 代理团队,能否在合理时间内完成一个中等规模产品的完整重构? 87 次提交,6 P0 全修,40+ 条 P1 修正,UI 全线重设计——答案是能。
GitHub:github.com/turnarond/R…
相关文档(在 docs/ 下):
06-APK差距分析报告.md— 60 类全覆盖差距分析07-UI设计交互评审报告.md— 设计一致性与交互可用性评审08-UI渲染架构评审报告.md— 渲染性能与代码架构评审
协议:MIT · 仅供学习交流 · 原游戏版权归 MagicWach 所有