|
| 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