把一个服务已死的 2015 年 Android 应用(doit.im v4.4.5)通过”apktool 反编译树 + 最小侵入 smali 补丁”复活为纯本地版,并新增 AI、GitHub 同步、统计等功能,迭代 58 个版本的全部工程经验。源头是 49 条”坑→原因→解法”格式的踩坑记录。
总纲:三种视图各司其职
读代码用 jadx,改代码用 smali,验证用回解。
- jadx 反编译结果只读不改(控制流还原不完美,会有
Code decompiled incorrectly和access$N合成方法干扰); - 一切修改打在 apktool 的 smali 树上,两边对照看;
- 构建后回解成品 APK 逐项 grep 验证补丁真的进了包。
工具链
- apktool 2.9.1(资源 + smali 树)、jadx 1.5.3(读代码)、javac → d8 → baksmali(新功能链)。
- baksmali 没有可用 CLI:apktool fat jar 只带引擎类不带运行器。写 5 行驱动类直接调引擎,永远可用:
// Bsm.java —— javac -cp apktool.jar 编译,java -cp .:apktool.jar 运行
DexFile dex = new DexBackedDexFile(Opcodes.forApi(19), Files.readAllBytes(new File(in).toPath()));
BaksmaliOptions opt = new BaksmaliOptions();
opt.apiLevel = 19;
Baksmali.disassembleDexFile(dex, new File(outDir), 1, opt);
// 注意:DexBackedDexFile 构造参数是 byte[],不是流- 构建三步:
apktool b→zipalign -f 4→apksigner sign+verify。
往 smali 工程加新功能(核心方法论)
- 永远不手写大段 smali:新功能用 Java 写 → javac → d8 → baksmali → 拷 smali 合入树。把 smali 当编译产物,不是手写语言。
- R.id/R.layout 引用不了(apktool 树没有 R.java):运行时
getResources().getIdentifier(name, type, pkg)解析;新 id 往res/values/ids.xml加<item type="id" />。 - 引用原版内部类用 stub 编译法:写同名同包空壳 stub 只参与编译不进 APK(d8 需
--classpath stubs-classes);baksmali 输出里 stub 自己的 smali 绝不拷进基线树(同名会顶掉原版)。 - stub 方法签名必须逐字节核对真身:曾因 stub 里
getActivity()返回Activity而真身(support-v4 Fragment)返回FragmentActivity,运行时 NoSuchMethodError 秒崩。凡涉及继承体系的方法,先 grep 真实类及其父链的.method签名。 - 跨包静态调用必须 public:smali 调用绕过 Java 编译期访问检查,包私有方法运行时 IllegalAccessError。
- 重写生命周期方法必须保留 invoke-super:删掉 super.onCreate 当时正常,返回键触发后续生命周期就 NPE。
- 复用原版 API 前看三样:签名、@Deprecated 注解、方法体是否只有 return 常量(空壳方法恒返回 null 也能让审计通过)。
- 字段语义看写入方,别按建表类型猜:如
completed列建表 NUMERIC,实际 DAO 存的是 Calendar 完成时间戳(未完成=0),按 0/1 查询永远为空。 - 统计类需求直查 SQLite 绕开 DAO:
openDatabase(OPEN_READONLY)只读连接与主连接共存,免 stub、免空壳陷阱。
隐形地雷(排查口诀)
android:onClick无声失效的头号原因:原版自定义控件在构造器里 setOnClickListener 覆盖了 XML 监听器。解法:代码接线(原版组件调它的 setOnLayoutClickListener,stub 里声明;或遍历视图树按文本找)。- 静态排查被”字符串只在 R 类引用”骗:业务代码引用的是数字资源 ID(如
0x7f0c0092)。查”谁触发了这文案”:先查res/values/public.xml把资源名映射成十六进制 ID,再用 ID 反查 smali。 - 清除数据 = 首次真实验收:此前一切正常可能依赖旧版安装残留的登录态/偏好;假用户对象字段全空,清数据后才走到。假 User 必须给完整、稳定、合理的身份(uuid/timezone 等),并在模型 getter 层兜底救已入库的脏数据。
- 无 adb 环境取崩溃日志:Application.onCreate 里装
Thread.setDefaultUncaughtExceptionHandler(装在原处理器之后保留链),堆栈落盘到外部文件目录 + 公共 Download 目录,用户用文件管理器就能取。 - 自定义 versionName 前全局搜 parseInt:原版版本检查按点切分 Integer.parseInt,
4.4.5-local-r10会 NumberFormatException 潜伏多日才爆。 - 同屏同名按钮穷举:用户说”XX 按钮”,布局文件和 menu 目录都要搜,再用资源 ID 反查点击处理类。
- Fragment 里接线不能用 getView():onCreateView 返回前 getView() 为 null,NPE 被 try/catch 吞掉表现为”点了没反应”;把 inflate 出来的 layoutView 直接传给接线方法。
- 借原版”协议化”接缝:原版附件模型通用(uuid/type/title/url)+ setResult 回传管线自动去重落库刷新——新页面按同样协议 setResult,劫持点小到一行。先找协议化接缝,比硬改 UI 层优雅一个数量级。
- 换控件类型 = 闪退:原版代码对布局控件有 check-cast 硬转换。文案可以随便换,控件类不能随便换;改前 grep 资源 ID 找出全部消费方。
老 App 上新 Android 的兼容补丁清单
| 问题 | 修法 |
|---|---|
| Android 14+ 装不上 | apktool.yml targetSdkVersion: 23(minSdk 不动) |
| HttpClient NoClassDefFoundError | manifest 加 <uses-library android:name="org.apache.http.legacy"/> |
| 长屏信箱黑边 | android:maxAspectRatio="2.4" + resizeableActivity="true" |
| 闹钟 Doze 下不响 | AlarmManager.set() 同签名替换为 setExactAndAllowWhileIdle(smali 一行 sed)+ 补 POST_NOTIFICATIONS 权限;国产 ROM 还需用户侧加自启动白名单 |
| 升级锁死 | versionCode 必须在原版序列上递增(14144xx);Android 不允许降级安装,“回退” = git checkout 旧代码 + 打更高 versionCode 重发 |
versionName 用 4.4.5-local-rN 让用户能在系统设置里肉眼确认装的哪版——用户报”没变化”时先问版本号,再怀疑代码。
构建后验证(固定动作)
aapt2 dump badging查版本号;unzip -p apk classes.dex | strings | grep 关键类确认 smali 补丁;aapt2 dump xmltree/resources确认 res 补丁与新 id 分配。- 分发后重新下载线上包校验字节数与哈希(CDN ~10 分钟缓存,下载可能截断——先比字节数再比哈希)。
原始素材
- playbook
- REPORT
- AGENTS
相关页面
- GitHub版本化备份与SQLite快照(本工程的同步方案)
- OpenAI兼容LLM接入通用模式(给无源码 App 加 AI 功能)
- CUA图形界面自动化实战(无源码 App 的另一种自动化路径)