Skip to content

Commit e522dca

Browse files
author
Donglu
committed
docs: add post about android multi-channel packaging via python binary patching
1 parent 0b04e73 commit e522dca

1 file changed

Lines changed: 184 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
---
2+
layout: post
3+
title: Android 多渠道打包终极方案:用 Python 直接原位修改 AXML 二进制
4+
date: 2026-07-10 13:30:00 +0800
5+
categories: [Android, 逆向工程, 自动化打包]
6+
tags: [Android, Python, AXML, 自动化, 多渠道打包]
7+
---
8+
9+
在 Android 的自动化多渠道打包流程中,最核心的一步往往是修改 `AndroidManifest.xml` 中特定的 `meta-data` 值(即渠道 ID)。
10+
11+
市面上常见的做法有两种:
12+
1. **Apktool 反编译**:全量反编译出纯文本的 XML,修改后再回编译。缺点是极其耗时,不适合 CI 环境里动辄打几十上百个渠道包的场景。
13+
2. **轻量级解析工具(如 xml2axml)**:将 Android 二进制 XML(AXML)解码为普通 XML,用正则修改后再重新编码。这种做法虽然快,但依赖第三方工具,且容易踩坑(例如笔者曾遇到 `xml2axml` 在解析第三方 SDK 注入的超长纯数字 ID 时,抛出 `Integer.parseInt` 溢出崩溃的 Bug)。
14+
15+
为了彻底摆脱上述痛点,本文介绍一种**极速、零依赖、高容错**的多渠道打包方案:**通过 Python 脚本,直接读取并原位修改(In-place Patching)APK 的二进制 AXML 流**
16+
17+
## 方案原理:为什么可以原位修改?
18+
19+
Android 编译后的 `AndroidManifest.xml` 是 AXML 二进制格式。渠道 ID 通常存放在类似下面这样的 `meta-data` 标签里:
20+
21+
```xml
22+
<meta-data
23+
android:name="MY_APP_CHANNEL_ID"
24+
android:value="1" />
25+
```
26+
27+
在二进制结构中,这个 `<meta-data>` 标签会被转换为一个 `START_TAG` 数据块。其中,`android:value="1"` 这个属性的值,在编译期如果被识别为整数,就会以 `TYPE_INT_DEC``0x10`)的类型存储,其真正的值(`1`)直接保存在一个 4 字节的 `typed_data` 字段中。
28+
29+
**核心思路:**
30+
既然只是要修改这个数字,我们完全不需要解压整个字符串池(String Pool),也不需要改变文件大小和任何二进制偏移量。我们只需要:
31+
1. 解析 AXML 头部,跳过字符串池。
32+
2. 找到名为 `MY_APP_CHANNEL_ID` 的属性所属的数据块。
33+
3. **直接覆盖那 4 个字节的 `typed_data`,将新渠道号写入进去**
34+
35+
这种方案不仅绕过了复杂的解包和打包流程,而且只需 Python 标准库即可完成,执行时间在毫秒级别。
36+
37+
## Python 核心代码实现
38+
39+
下面是实现原位修改 AXML 的完整 Python 核心代码(已做脱敏处理)。你可以将其无缝集成到你的多渠道打包 Shell 脚本或 CI 流程中:
40+
41+
```python
42+
#!/usr/bin/env python3
43+
import struct
44+
import sys
45+
import os
46+
47+
TYPE_STRING = 0x03
48+
TYPE_INT_DEC = 0x10
49+
TYPE_INT_HEX = 0x11
50+
CHUNK_START_TAG = 0x0102
51+
52+
def read_string_from_pool(data, strings_data_start, rel_offset, is_utf8):
53+
"""从字符串池读取一个字符串"""
54+
abs_offset = strings_data_start + rel_offset
55+
if is_utf8:
56+
b = data[abs_offset]
57+
abs_offset += 2 if b & 0x80 else 1
58+
b = data[abs_offset]
59+
if b & 0x80:
60+
byte_count = ((b & 0x7F) << 8) | data[abs_offset + 1]
61+
abs_offset += 2
62+
else:
63+
byte_count = b
64+
abs_offset += 1
65+
return data[abs_offset:abs_offset + byte_count].decode('utf-8')
66+
else:
67+
char_count = struct.unpack_from('<H', data, abs_offset)[0]
68+
abs_offset += 2
69+
return data[abs_offset:abs_offset + char_count * 2].decode('utf-16-le')
70+
71+
def parse_string_pool(data, sp_offset):
72+
sp_type, sp_header_size, sp_chunk_size = struct.unpack_from('<HHI', data, sp_offset)
73+
sp_string_count, sp_style_count = struct.unpack_from('<II', data, sp_offset + 8)
74+
sp_flags = struct.unpack_from('<I', data, sp_offset + 16)[0]
75+
sp_strings_start = struct.unpack_from('<I', data, sp_offset + 20)[0]
76+
77+
is_utf8 = bool(sp_flags & (1 << 8))
78+
offsets_start = sp_offset + sp_header_size
79+
strings_data_start = sp_offset + sp_strings_start
80+
81+
strings = []
82+
for i in range(sp_string_count):
83+
rel_offset = struct.unpack_from('<I', data, offsets_start + i * 4)[0]
84+
s = read_string_from_pool(data, strings_data_start, rel_offset, is_utf8)
85+
strings.append(s)
86+
return strings, sp_chunk_size
87+
88+
def get_attr_string_value(strings, attr_raw_value, typed_type, typed_data):
89+
if attr_raw_value >= 0 and attr_raw_value < len(strings):
90+
return strings[attr_raw_value]
91+
if typed_type == TYPE_STRING and typed_data >= 0 and typed_data < len(strings):
92+
return strings[typed_data]
93+
return None
94+
95+
def patch_channel(manifest_path, target_channel_key, new_channel_id):
96+
with open(manifest_path, 'rb') as f:
97+
data = bytearray(f.read())
98+
99+
xml_type, xml_header_size = struct.unpack_from('<HH', data, 0)
100+
sp_offset = xml_header_size
101+
strings, sp_chunk_size = parse_string_pool(data, sp_offset)
102+
103+
# 1. 在字符串池中寻找目标属性
104+
channel_name_str_idx = None
105+
name_attr_str_idx = None
106+
value_attr_str_idx = None
107+
108+
for i, s in enumerate(strings):
109+
if s == target_channel_key:
110+
channel_name_str_idx = i
111+
elif s == "name":
112+
name_attr_str_idx = i
113+
elif s == "value":
114+
value_attr_str_idx = i
115+
116+
if channel_name_str_idx is None:
117+
print("未在文件中找到目标渠道键值")
118+
sys.exit(1)
119+
120+
# 2. 遍历标签,定位属性值并直接修改二进制
121+
pos = sp_offset + sp_chunk_size
122+
while pos < len(data):
123+
chunk_type = struct.unpack_from('<H', data, pos)[0]
124+
chunk_size = struct.unpack_from('<I', data, pos + 4)[0]
125+
126+
if chunk_type == CHUNK_START_TAG:
127+
attr_size = struct.unpack_from('<H', data, pos + 26)[0]
128+
attr_count = struct.unpack_from('<H', data, pos + 28)[0]
129+
attrs_base = pos + 36
130+
131+
has_channel_name = False
132+
value_attr_pos = None
133+
134+
for a in range(attr_count):
135+
attr_pos = attrs_base + a * attr_size
136+
attr_name_idx = struct.unpack_from('<i', data, attr_pos + 4)[0]
137+
attr_raw_value = struct.unpack_from('<i', data, attr_pos + 8)[0]
138+
typed_type = data[attr_pos + 15]
139+
typed_data = struct.unpack_from('<I', data, attr_pos + 16)[0]
140+
141+
if attr_name_idx == name_attr_str_idx:
142+
str_val = get_attr_string_value(strings, attr_raw_value, typed_type, typed_data)
143+
if str_val == target_channel_key:
144+
has_channel_name = True
145+
146+
if attr_name_idx == value_attr_str_idx:
147+
value_attr_pos = attr_pos
148+
149+
# 如果找到了我们的渠道标签,进行 4字节 原位覆盖
150+
if has_channel_name and value_attr_pos is not None:
151+
new_value_int = int(new_channel_id)
152+
# 强行将类型设置为 Int(0x10),并覆盖 4 字节的 Data
153+
data[value_attr_pos + 15] = TYPE_INT_DEC
154+
struct.pack_into('<I', data, value_attr_pos + 16, new_value_int)
155+
156+
# 将原始字符串引用置为 -1,避免和字符串池产生冲突
157+
attr_raw_value = struct.unpack_from('<i', data, value_attr_pos + 8)[0]
158+
if attr_raw_value >= 0:
159+
struct.pack_into('<i', data, value_attr_pos + 8, -1)
160+
161+
with open(manifest_path, 'wb') as f:
162+
f.write(data)
163+
print(f"✅ 成功将渠道号修改为: {new_channel_id}")
164+
return
165+
166+
pos += chunk_size
167+
168+
if __name__ == '__main__':
169+
# 命令行使用示例:python patch.py AndroidManifest.xml 84
170+
if len(sys.argv) == 3:
171+
patch_channel(sys.argv[1], "MY_APP_CHANNEL_ID", sys.argv[2])
172+
```
173+
174+
## 融入自动化脚本
175+
176+
有了这个核心脚本后,我们的多渠道打包流程可以精简为极速的四个步骤:
177+
1. **解包**: `unzip` 解压出待修改的 `AndroidManifest.xml`
178+
2. **改码**: 用上文的 Python 脚本一秒修改完毕。
179+
3. **重包**: 使用 `zip` 直接替换回原 APK 内(速度飞快)。
180+
4. **对齐&签名**: 调用 Android 原生的 `zipalign``apksigner`(或 `uber-apk-signer`)完成最后的加工。
181+
182+
## 结语
183+
184+
在处理多渠道打包这种强需求时,很多时候我们容易陷入使用繁重三方工具的怪圈。但回到问题本质,我们发现所需的只是一次对 4 字节内存的简单赋值。通过直接与底层 AXML 结构对话,我们不仅获得了极致的速度,还成功绕开了各种不靠谱的第三方工具 Bug,实现了自动化流程的最优解。

0 commit comments

Comments
 (0)