从英文 APK 到中文
本文最后更新于 2026-07-25,文章内容可能已经过时。
App:Android APK 本地化与逆向修改实践
在一次 Android 应用本地化学习过程中,我选择了两款第三方应用作为测试案例。它们的功能本身不差,但全英文界面用起来总有点膈应。于是某天下午,闲来无事,我决定自己动手研究如何做 APK 本地化。
本以为就是改几个字符串重新打包的事,结果一路踩坑,从签名方案到 DEX 反编译再到外科手术式改二进制,硬是把 Android APK 修改流程从资源层摸到了二进制层。趁着记忆还新鲜,把这套流程记录下来,给后来的朋友们一个参考。
注意:本文仅用于学习 Android 应用本地化、无障碍优化以及个人软件定制。请确保你拥有 APK 的修改权限,不要用于绕过授权、破解付费功能或传播未经授权的软件。
一、准备工作
1.1 工具链
其中 zipalign 和 apksigner 如果你安装了 Android SDK,应该在 build-tools/ 下面能找到。如果没有,可以单独下载 Android Build Tools,或者从其他 Android 开发环境附带的工具目录中获取。
1.2 签名密钥
在开始之前,先准备好签名密钥。如果没有的话先生成一个:
keytool -genkey -v -keystore myapp.keystore \
-alias myalias -keyalg RSA -keysize 2048 -validity 36500记好密钥别名和密码,之后每次签名都要用。注意:密钥一旦生成就不要再变,否则安装时会因为签名不一致无法覆盖更新。重新签名后的 APK 与原版已经不是同一个签名身份,因此无法直接覆盖安装官方版本,需要先卸载原版。
二、APK 反编译
拿到一个 APK 后,首先需要了解它的内部结构。APK 本质上是个 ZIP 包,解压后可以看到:
├── AndroidManifest.xml (二进制格式)
├── classes.dex (Java 代码编译产物,可能有多个)
├── resources.arsc (编译后的资源索引表)
├── res/ (资源文件:布局、字符串、图片等)
├── lib/ (native .so 库)
├── META-INF/ (签名信息)
└── assets/ (原始资源文件)差不多就这样:

2.1 反编译资源
对于近年来高 targetSdk、高混淆程度以及采用新构建链的 APK,推荐使用 APKEditor:
java -jar APKEditor.jar d -i input.apk -o output_dir解码后的 output_dir 目录中:
resources/package_1/res/→ 资源文件(可直接编辑)smali/→ 反编译后的 DEX 代码(smali 格式)AndroidManifest.xml→ 明文清单文件
2.2 逆向前先摸清底细
在动手之前,建议先做三件事:
# 1. 备份原版 APK
cp input.apk input.apk.bak
# 2. 查看签名方案(后面签名时要用)
java -jar apksigner.jar verify -v input.apk
# 3. 判断是否有加壳/混淆/完整性校验
# 如果发现 lib/ 下有 libjiagu.so、libprotect.so 等,说明应用加了壳
# 这种情况通常需要额外分析处理,不在本文展开讨论三、翻译资源文件
3.1 strings.xml
这是最主要的工作量所在。每个字符串对应一个 <string name="xxx">value</string> 条目:
<!-- 翻译前 -->
<string name="app_name">MyApp</string>
<string name="login_welcome">Welcome to MyApp</string>
<!-- 翻译后 -->
<string name="app_name">MyApp汉化版</string>
<string name="login_welcome">欢迎使用 MyApp</string>直接用文本编辑器搜索替换即可。但有几个坑要注意:
不要翻译格式占位符:
%1$s、%d、%2$d这类 Java 格式化占位符保持原样。不要翻译 HTML 标签:
<b>、<font>、<br>等保持原样。不要翻译特殊语法:
<annotation link="xxx">这类自定义标签保持原样。
3.2 快速定位要翻译的内容
新手最头疼的是不知道哪些文件需要改。几个实用的搜索技巧:
Linux / macOS / WSL:
# 搜索资源中的英文界面文本
grep -r "Settings" res/
grep -r "Enable" res/
grep -r "Disable" res/
# 搜索 smali 中的硬编码字符串
grep -r 'const-string.*"Login"' smali/
grep -r 'const-string.*"Welcome"' smali/Windows PowerShell:
Select-String -Path res\values\strings.xml -Pattern "Settings"
Select-String -Path smali\**\*.smali -Pattern "Login"关键词建议:从常见 UI 词汇入手——Login、Settings、Enable、Disable、Confirm、Cancel、Save、Delete、OK。
3.3 plurals.xml
复数形式字符串也需要翻译:
<!-- 翻译前 -->
<plurals name="new_notifications">
<item quantity="one">%d new notification</item>
<item quantity="other">%d new notification(s)</item>
</plurals>
<!-- 翻译后 -->
<plurals name="new_notifications">
<item quantity="one">%d 条新通知</item>
<item quantity="other">%d 条新通知</item>
</plurals>3.4 两种思路:标准本地化 vs APK 修改
如果是自己开发 App,推荐标准做法:新增 values-zh 等语言资源目录,Android 会根据系统语言自动切换。但对于已经发布的第三方 APK,通常没有预留完整的多语言结构,直接替换默认资源或修改语言逻辑是更实际的方案。本文以 APK 修改路线为主。
3.5 新增字符串资源
如果在 strings.xml 中新增了自创字符串(比如汉化署名),使用 apktool 等传统方式时可能需要同步处理资源 id 映射。APKEditor 等较新的工具通常可以自动管理,但遇到资源引用错误时,可以通过资源 dump 工具或 APKEditor 的资源引用保持功能进行排查。
四、翻译 Smali 硬编码字符串
有些字符串不在 strings.xml 中,而是直接写死在 Java/Kotlin 代码里。反编译后以 const-string 形式出现在 .smali 文件中:
# 翻译前
const-string v0, "Basic incubator"
# 翻译后
const-string v0, "基础孵化器"关键判断依据:只改「显示标签」类的字符串,不改「存储键值」和「比较用常量」。怎么判断?
如果这个字符串被
setText()或TextView使用 → 可以翻译(是 UI 显示)如果这个字符串被
equals()比较或存入SharedPreferences的 key → 不要改(是逻辑代码)
举例,在实际案例的某个 smali 文件中:
# 这三行是显示用的下拉选项,可以翻译
const-string v4, "Random" → "随机"
const-string v4, "Long Distance First" → "长距离优先"
const-string v4, "Short Distance First"→ "短距离优先"但类似 "xxx_auto_xxx_" 这种功能配置键值绝对不能动——改了键值会导致所有已保存的配置失效。
五、处理服务端本地化
不少现代 App 会把动态内容(比如实体名称、属性描述等)从服务器拉取,本地只缓存一份。例如某些 App 会在代码中固定请求语言:
setLocalizationLocale("en_US");这种情况下,即使 strings.xml 已经翻译完成,动态数据仍可能保持英文。
以某第三方应用为例,其初始化请求中固定使用了 "en_US",同时缓存读取模块也锁定了 "en_US" 作为查找前缀。导致动态字符串(名称、描述等)始终是英文。
解决方案:把本地化请求语言改成 zh_CN,同时修改缓存键值的读取逻辑:
// 翻译前
setLocalizationLocale("en_US");
// 翻译后
setLocalizationLocale("zh_CN");需要注意的是,这个方法的前提是服务端确实支持目标语言。实际中服务器可能只支持部分语言(比如仅 en_US 和 ja_JP),如果目标语言不在支持列表中,修改 locale 后拿到的可能仍是英文甚至空数据。这种情况下需要额外做本地映射或数据替换,不在本文讨论范围内。
六、回编译与签名
6.1 回编译
APKEditor 回编译:
java -jar APKEditor.jar b -i output_dir -o app_unsigned.apk6.2 对齐
zipalign -f 4 app_unsigned.apk app_aligned.apk注意:zipalign 必须在签名前执行。v2/v3/v4 签名会校验 APK 内容,签名后再做 zipalign 会导致签名失效。
6.3 签名
这是最大的一个坑。Android 7.0+ 引入了 APK Signature Scheme v2,Android 9.0+ 又加了 v3,Android 11+ 加 v4。签名方案需要和原版保持一致,否则安装时会报「安装包解析失败」。
查看原版 APK 的签名方案:
java -jar apksigner.jar verify -v original.apk输出示例:
Verified using v1 scheme (JAR signing): false
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): false根据原版的方案来指定你的签名参数。比如原版只用 v2,那就:
java -jar apksigner.jar sign \
--ks myapp.keystore --ks-key-alias myalias \
--ks-pass pass:yourpassword --key-pass pass:yourpassword \
--v1-signing-enabled false \
--v2-signing-enabled true \
--v3-signing-enabled false \
--in app_aligned.apk --out app_signed.apk最后验证:
java -jar apksigner.jar verify -v app_signed.apk重要:重新签名后的 APK 与官方 APK 已经不是同一个签名身份。因此:
无法直接覆盖安装官方版本,需要先卸载原版;
如果需要与原版共存,可以修改
applicationId(包名)生成独立应用,即可与官方版本同时安装。
七、踩坑实录
7.1 第一次签名:信心满满,换来五个字
第一次尝试的时候,想法非常简单:改几行 XML,重新打包,签名,完事。
于是照着网上的教程:
apktool 解包
修改 strings.xml
apktool 回编译
jarsigner签名传到手机安装
结果,Android 只回了我五个字:
安装包解析失败
这五个字大概是我学逆向以来最讨厌的提示。不说哪里错,不说为什么,不说怎么修。
反复检查了 XML 有没有语法错误,重新打包了三遍,依然失败。最后偶然发现——不是翻译内容的问题,是签名方案不对。原版 APK 用的是 APK Signature Scheme v2,而我用的 jarsigner 只能打 v1。系统一看签名方案对不上,直接拒了。
解决之后我以为终于可以了,结果传到手机上——
安装包解析失败……又来。
7.2 第二次签名:方案对了,工具错了
真的哭死了

这次签名是 v2 了,为什么还失败?
排查了一圈,发现是 apktool 回编译的产物本身有问题。某些设备的 ROM(尤其国产厂商深度定制的系统)对 apktool 重新生成的 resources.arsc 兼容性很差。哪怕一个字都没改,只是解包→回编译→签名,照样装不上。
但同样的 APK,直接对原版重签名,不经过 apktool,就能装。
这说明什么?问题不在签名,在 回编译工具链产出的文件结构和系统期望不一致。
解决办法:换工具。改用 APKEditor 之后,回编译产物的结构更接近原版,总算通过了那台设备的安装校验。
7.3 高级方案:绕开编译,直接修改二进制资源
APKEditor 解决问题后,我又遇到了新情况。
有些功能字符串不在 XML 里,写在 smali 代码中。用 APKEditor 改 smali 虽然可以,但每次改完都要重新回编译、对齐、签名,改一行就要跑一次完整流程,非常耗时。
于是我想了一个更"暴力"的办法——不对 APK 做反编译和回编译,直接修改原版 APK 二进制中的 resources.arsc 字符串池。
流程是:
用 Python 的 zipfile 读出 resources.arsc
用 struct 解析字符串池的二进制结构
找到所有英文文本,替换为中文
新字符串池补齐到和原来一样长(不能改变文件大小,否则 ZIP 偏移全乱)
覆盖回去,更新 CRC-32 校验
重签名
这个方法绕开了 aapt2 的重新编译,完全不碰 smali 和布局文件,理论上兼容性最好。
但是这个"外科手术"有两个巨坑。
7.4 styles 终止符:一个字节都不许错
resources.arsc 的字符串池 chunk 有一个规定:styles 块必须位于 chunk 末尾,并且最后 4 个字节必须是 0xFFFFFFFF 作为终止标记。
我最早写脚本的时候,替换完字符串后池子变短了,就在最后补了一堆 \x00,然后自信地跑 aapt2 验证。
结果:
corrupt string pool看了半天才发现,padding 补在了 styles 块后面,把 0xFFFFFFFF 终止符顶到中间去了。正确做法是把 padding 放在 styles 前面,让 styles 始终紧贴末尾。
这个教训让我明白:二进制资源格式没有容错机制。差一个字节,整个 APK 就废了。除了 styles 终止符,字符串池相关的计数和偏移信息也需要保持一致,否则解析同样会失败。
7.5 emoji 长度:Python 也会骗你
UTF-8 字符串池中,每个字符串除了存储原始 UTF-8 字节,还要记录它在 UTF-16-LE 编码下的单元数(不是字节数)。
大部分中英文字符在 UTF-16 中都是 1 个单元。所以我一开始直接用了 Python 的 len(s):
utf16_len = len(s) # 天真翻译进展顺利,直到我遇到一个带 emoji 的字符串(比如 🎉)。Python 的 len() 返回的是 Unicode 码点数,但 emoji 这类非 BMP 字符在 UTF-16 中占 2 个单元。len() 返回 1,实际需要 2。
于是 aapt2 又报:
Bad string block正确写法:
utf16_len = len(s.encode('utf-16-le')) // 2一行代码改了三个小时。
后来我发现,很多逆向问题不是难在原理,而是难在这些看似不起眼的小细节上。
7.6 翻完不等于翻对:那些不能碰的字符串
字符串翻译完成后,我把改好的 APK 装上了。打开一看,界面中文了,高兴了大概两分钟——然后发现有些按钮点了没反应。
排查发现,我翻译了不该翻译的东西。
有些 smali 中的 const-string 不是给用户看的,是代码逻辑用的——比如 SharedPreferences 的存取键值、条件判断的比较字符串、广播 action 名。我把它们也"汉化"了,结果是代码找不到对应的 key,默默失败,没有报错,没有崩溃,只是功能静悄悄地废掉了。
血的教训:看到英文不要急着翻译,先判断它是给用户看的,还是给程序看的。
如果是给机器看的,留着它。
7.7 动画错乱:资源 id 漂移
还有一个诡异的问题:回编译后某些页面切换动画变得卡顿,或者图形位置莫名其妙偏移。
一开始以为是性能问题,后来发现是资源 id 在重新编译时发生了偏移。原来引用 @id/someView 的地方,因为 public.xml 重新生成了 id,指向了错误的目标。画面不崩,只是引用了不对的动画资源,看起来像慢了半拍。
APKEditor 的 --ref-res 参数可以固定引用关系,避免这种漂移。
八、流程总结
原始 APK
│
├── 保存备份
├── 查看签名方案 (apksigner verify -v)
└── 判断是否有加壳/完整性校验
│
▼
APKEditor 反编译 ────────→ 搜索定位英文字符串
│ │
│ ├── strings.xml / plurals.xml
│ ├── smali const-string
│ │ (判断是显示标签还是逻辑键值)
│ └── 服务端本地化(修改 locale)
│ │
│ 新增字符串 → 检查 public.xml
│ │
▼ ▼
APKEditor 回编译 ←────────── 所有修改完成
│
▼
zipalign 对齐
│
▼
apksigner 签名(与原版方案一致)
│
▼
验证:apksigner verify -v
│
▼
安装测试写在最后
整件事花了我不少时间,从一无所知到能独立完成汉化,最关键的不是技术本身——Android 逆向工程的工具已经很成熟了——而是耐心排查问题的心态。每一个「安装包解析失败」背后都藏着一个具体的原因,找到了就是一层窗户纸。
本文仅讨论 Android 应用本地化和逆向分析技术,不提供任何第三方应用修改版本、补丁文件或下载地址。请遵守软件作者协议以及相关平台规则。
- 感谢你赐予我前进的力量

