前言
很早之前写了一套分包脚本,但是对smali文件的处理基本上是写死的,大概就是指定哪几个文件目录挪动到smali_classes2里面,以此类推,导致后续有些包更新SDK之后,smali文件数量大增,导致回编译的时候报方法数超65k,觉得每次更新SDK之后都有可能会导致类似的问题,就决定优化下这块的算法。
脚本核心
- 整合多来源 Smali 文件(游戏母包、渠道包、aar依赖)到统一目录
- 按方法数阈值(默认 55000,当然你也可以自定义,建议不超过60000即可)拆分 Smali 文件为多个 dex目录(smali为主dex,smali_classes2/smali_classes3等为子 dex);
- 避免单个 dex方法数超过 Android 硬限制(65535),防止打包失败
- 保证主 dex包含核心包路径,子 dex 按 "最优适配" 算法分配,确保 APK运行时依赖正确性
一些依赖说明
| 术语 | 定义 |
|---|---|
| Smali 文件 | APK 反编译后的字节码文件,包含 Java 代码对应的指令集,是分 DEX 的处理对象 |
| dex 文件 | Android 虚拟机可执行文件,单个 DEX 最多支持 65535 个方法(硬限制) |
| 主 dex | 命名为smali的目录,包含 APK 运行必需的核心包路径(部分SDK启动类必须在这里面) |
| 子 dex | 命名为smali_classesN(N≥2)的目录,存储非核心包的 Smali 文件 |
| 方法数统计 | 统计单个 Smali 文件 / 目录下所有方法的数量(含定义方法和引用方法,去重后) |
| BFD 算法 | 最佳适配递减算法(Best Fit Decreasing),用于优化子 DEX 的方法数分配 |
这里要说下方法数统计这个问题,你去单独计算目录com/a,com/b得出来的方法数相加可能会比你直接计算com目录里面的要多,这是因为有些方法引用会涉及到重复,这边我自己参考网上的文章写了个java文件,转成methodsHelper.jar,后续直接用脚本执行这个jar包来统计方法
1. 整合smali文件
反编译游戏母包(game)还有渠道包(channel)会得到两份smali文件,先把游戏母包samli_classesN下的所有文件复制到smali文件夹里面,复制完之后删掉原有的路径,然后再把渠道包的所有smali文件复制到游戏工程的smali文件夹里面,直接覆盖,整理好一份总的smali 文件,后续方便装箱,is_game_temp代表的是游戏工程,复制完会自动删除对应的目录
python
# 把所有的smail文件合并在游戏工程的smail目录下,先合并游戏的,后合并渠道的(重复的直接覆盖)
merge_smali_dirs(temp_path,temp_smali_path,is_game_temp=True)
merge_smali_dirs(channel_path,temp_smali_path,is_game_temp=False)
def merge_smali_dirs(source_root_dir, target_root_dir,is_game_temp):
if not os.path.exists(source_root_dir):
log.info(f"源目录不存在: {source_root_dir}")
return
for folder_name in os.listdir(source_root_dir):
source_path = os.path.join(source_root_dir, folder_name)
# 1. 检查是否是文件夹
# 2. 检查名称是否以 'smali' 开头 (兼容smali,smali_classes2, smali_classes3 等)
# 3. 防止万一源目录和目标目录在同一个层级,导致死循环拷贝
if (os.path.isdir(source_path) and folder_name.startswith("smali") and
os.path.abspath(source_path) != os.path.abspath(target_root_dir)):
try:
# ========================================================
# 核心合并代码 (Python 3.8+)
# dirs_exist_ok=True 表示允许目标目录已存在,直接把内容拷进去
# ========================================================
shutil.copytree(source_path, target_root_dir, dirs_exist_ok=True)
if is_game_temp:
log.info(f" 合并成功! 删除游戏源目录: {source_path}")
# 合并成功后,删除源分包目录
shutil.rmtree(source_path)
except Exception as e:
log.error(f" Error: 处理 {folder_name} 失败: {e}")
2. 扫描 src_root(汇总后的smali目录) 下的所有 smali 文件
python
all_files = []
for dirpath, _, filenames in os.walk(temp_smali_path):
for name in filenames:
if name.endswith(".smali"):
full_path = os.path.join(dirpath, name)
all_files.append(full_path)
3. 分类主包文件 / 其他文件
MAIN_PREFIXES 里面指定的文件路径会被默认打包到主dex里面,其余的一律默认分配到其他分包里面,即smali_classes2...
python
MAIN_PREFIXES = ["android","org","okhttp3","okio"]
main_dex_files = []
other_files = []
for f in all_files:
rel_path = os.path.relpath(f, temp_smali_path).replace("\\", "/")
# 判断是否属于主包
if any(prefix in rel_path for prefix in MAIN_PREFIXES):
main_dex_files.append(f)
else:
other_files.append(f)
4. 按目录聚合并统计每个目录方法数
这里要说下怎么按目录来划分,写过分包脚本的同学应该都知道反编译之后会生成android、androidx、com、com/alipay、com/bun等相关的目录,分别来自各个不同的SDK,划分规矩如下(只是大笔比方)
| Smali 文件路径 | 分组键(group key) | 说明 |
|---|---|---|
| smali/Test.smali | [root] | 主目录下直接的 Smali 文件 |
| smali/com/Test.smali | com/[root] | com 目录下直接的 Smali 文件 |
| smali/com/google/gson/... | com/google | com 的一级子目录(google)下所有文件 |
| smali/org/apache/... | org | 非 com 的顶层目录(org)下所有文件 |
大致是可以分为4个组,smali下的所有smali文件、smali下除com目录的每个目录、com下的所有smali文件、com下的每个目录这四个组,下面代码是分组及统计每个组的方法数
python
def get_group_key(file_path, src_root):
"""
根据文件路径返回分组键
规则:
- smali/*.smali → "[root]"
- smali/com/*.smali → "com/[root]"
- smali/com/google/... → "com/google"
- smali/org/... → "org"
"""
rel_path = os.path.relpath(file_path, src_root).replace("\\", "/")
parts = rel_path.split("/")
# 情况 1: smali 根目录下的文件
if len(parts) == 1:
return "[root]"
# 情况 2: 非 com 的顶层目录(如 org, androidx)
if parts[0] != "com":
return parts[0]
# 情况 3: com 目录相关
if parts[0] == "com":
# com 目录下直接的文件
if len(parts) == 2:
return "com/[root]"
# com 的一级子目录(如 com/tencent)
else:
return f"com/{parts[1]}"
# 默认返回第一层目录
return parts[0]
# 按分组键归类
grouped = defaultdict(list)
for f in other_files:
# 返回每个分组 com/[root]
key = get_group_key(f, temp_smali_path)
grouped[key].append(f)
# 统计每组的文件数和方法总数
# log.info("\n📊 分组统计结果:")
# log.info(f"{'分组名称':<40s} {'文件数':>8s} {'方法数':>10s}")
# log.info("=" * 60)
group_info = []
total_files = 0
total_methods = 0
# 统计方法脚本的jar
methodsHelperTool = os.path.join(tool_path, "methodsHelper.jar")
for key, files in grouped.items():
if not files:
continue
try:
if key == "[root]":
# ✅ smali 根目录下直接的 .smali 文件
# 只统计这些文件本身的方法数
method_count = sum(get_method_count_by_jar(f, methodsHelperTool) for f in files) or 0
elif key == "com/[root]":
# ✅ smali/com 根目录下的文件,只统计这些文件
method_count = sum(get_method_count_by_jar(f, methodsHelperTool) for f in files) or 0
else:
# ✅ 普通分组:com/tencent、org、androidx等
group_dir = os.path.join(temp_smali_path, key.replace("/", os.sep))
if not os.path.isdir(group_dir):
log.warning(f"⚠️ 目录不存在: {group_dir},跳过。")
continue
# 一次性统计整个目录
method_count = get_method_count_by_jar(group_dir, methodsHelperTool) or 0
except Exception as e:
log.error(f"统计方法数失败 ({key}): {e}")
method_count = 0
group_info.append((key, files, method_count))
if method_count is not None:
total_files += len(files)
total_methods += method_count
# log.info(f"📊 {key:<40s} 文件数: {len(files):>6d} 方法数: {method_count:>8d}")
group_info.sort(key=lambda x: x[2], reverse=True)
# log.info("=" * 60)
# log.info(f"📦 总文件数: {total_files}, 总方法数: {total_methods}")
group_info 的结构大致如下
python
group_info = [
("androidx", [...], 52000), # 方法数最大
("org", [...], 45000),
("com/google", [...], 28000),
("com/tencent", [...], 15000),
("[root]", [...], 3000) # 方法数最小
]
5. 装箱和复制
大致的逻辑就是从smali_classes2开始装箱,先定义箱子的容量MAX_METHOD_COUNT = 55000,每次取一个分组往一个箱子(一个箱子代表一个dex文件)里面塞,直到塞满这个箱子(即MAX_METHOD_COUNT = 60000),塞满之后就开下一个箱子,直至所有分组都塞完,重点是他并不是按顺序塞,每一个分组都会找到最合适的箱子去塞,使每个箱子尽可能的塞满
,下面举个例子场景:
- 当前物品大小:15000 方法
- 箱子容量上限:60000 方法
现有箱子状态:
| 箱子 | 已用 | 剩余 | 能装下? |
|---|---|---|---|
| smali_classes2 | 50000 | 10000 | 不够 |
| smali_classes3 | 30000 | 30000 | 可以 |
| smali_classes4 | 42000 | 18000 | 可以 |
算法执行下来最后物品会被装进smali_classes4 里面,尽可能减少碎片化,也避免了最后dex文件太多的问题
python
copied_groups = 0
copied_files = 0
deleted_dirs = set()
group_to_files = {key: files for key, files in grouped.items()}
def copy_group_to_dex(gkey, dex_name, temp_smali_path, temp_path, group_to_files,
copied_groups, copied_files, deleted_dirs, log):
"""复制分组到对应 dex 目录,并删除源文件"""
target_dir = os.path.join(temp_path, dex_name)
os.makedirs(target_dir, exist_ok=True)
if "[root]" in gkey:
# 文件级分组
# log.info(f"📄 [实时复制] 文件级分组: {gkey}")
files = group_to_files.get(gkey, [])
for src_file in files:
rel_path = os.path.relpath(src_file, temp_smali_path)
dst_file = os.path.join(target_dir, rel_path)
os.makedirs(os.path.dirname(dst_file), exist_ok=True)
try:
shutil.copy2(src_file, dst_file)
os.remove(src_file)
copied_files += 1
except Exception as e:
log.error(f"❌ 复制文件失败: {src_file} -> {dst_file}, 错误: {e}")
copied_groups += 1
else:
# 目录级分组
src_dir = os.path.join(temp_smali_path, gkey)
dst_dir = os.path.join(target_dir, gkey)
if not os.path.exists(src_dir):
log.warning(f"⚠️ 源组目录不存在: {src_dir}")
return copied_groups, copied_files
if src_dir in deleted_dirs:
log.debug(f"跳过已删除目录: {src_dir}")
return copied_groups, copied_files
if os.path.exists(dst_dir):
shutil.rmtree(dst_dir)
try:
shutil.copytree(src_dir, dst_dir)
shutil.rmtree(src_dir)
deleted_dirs.add(src_dir)
copied_groups += 1
# log.info(f"📦 [实时复制] 复制组目录: {gkey} -> {dex_name}")
except Exception as e:
log.error(f"❌ 复制目录失败: {src_dir} -> {dst_dir}, 错误: {e}")
return copied_groups, copied_files
# ✅ 改为存储 groups 而不是 files
dex_bins = defaultdict(lambda: {"groups": [], "count": 0})
# ========== BFD 核心:Best Fit Decreasing ==========
# 1️⃣ 已经在 step3 里排序了,这里确保再按方法数从大到小排序
sorted_groups = sorted(group_info, key=lambda x: x[2], reverse=True)
# 2️⃣ 箱子索引从 2 开始(主 dex 为 smali)
next_dex_index = 2
for group in sorted_groups:
gkey = group[0] # 组名,如 "com/kldlz"
gfiles = group[1] # 该组的文件列表(暂不存,只记 key)
gcount = group[2] # 该组的方法总数
best_bin_name = None
min_remaining = MAX_METHOD_COUNT + 1 # 记录最优箱的剩余空间
# 在所有现有箱子中找能容纳它且最合适的那个,值为 {"smali_classes4": {"groups": ["kotlin"],"count": 12345}}
for dex_name, dex_data in dex_bins.items():
# 当前箱子remaining 剩余容量
remaining = MAX_METHOD_COUNT - dex_data["count"]
# remaining < min_remaining 是所有能容纳的箱子中,选择剩余空间最小的
if gcount <= remaining and remaining < min_remaining:
best_bin_name = dex_name
min_remaining = remaining
# 没找到合适箱子,开新箱
if best_bin_name is None:
for dex_name, dex_data in dex_bins.items():
# 进行一次真实方法数统计
dex_path = os.path.join(temp_path, dex_name)
real_count = get_method_count_by_jar(dex_path, methodsHelperTool) or 0
# log.info(f"更新 {dex_name} 的真实方法数为:{real_count}, 原有的方法数是:{dex_data['count']}")
dex_data["count"] = real_count
best_bin_name = f"smali_classes{next_dex_index}"
next_dex_index += 1
dex_bins[best_bin_name] = {"groups": [], "count": 0}
# ✅ 将组名添加到对应的 dex
dex_bins[best_bin_name]["groups"].append(gkey)
dex_bins[best_bin_name]["count"] += gcount
# ✅ 实时执行复制
copied_groups, copied_files = copy_group_to_dex(
gkey, best_bin_name, temp_smali_path, temp_path,
group_to_files, copied_groups, copied_files, deleted_dirs, log
)
# ✅ 更新当前 dex 的真实 method 数
dex_path = os.path.join(temp_path, best_bin_name)
real_count = get_method_count_by_jar(dex_path, methodsHelperTool) or 0
# log.info(f"更新 {best_bin_name} 的真实方法数为:{real_count}, 原有的虚拟方法数为:{dex_bins[best_bin_name]['count']}")
dex_bins[best_bin_name]["count"] = real_count
# 主包统计(仅统计,不移动)
main_dex_count = get_method_count_by_jar(temp_smali_path,methodsHelperTool)
if main_dex_count > 65535:
log.warning(f"⚠️ 主包方法数 ({main_dex_count}) 已超过65535,可能导致打包失败!")
# ========== 打印最终装箱方案 ==========
log.info("✅ 装箱方案完成 (BFD 算法结果):")
log.info(f"{'Dex 名称':<20s} {'分组数':>10s} {'方法数':>10s}")
log.info("-" * 45)
log.info(f"{'smali (主包)':<20s} {'-':>10s} {main_dex_count:>10d}")
for name in sorted(dex_bins.keys(), key=lambda x: int(x.replace("smali_classes", ""))):
data = dex_bins[name]
log.info(f"{name:<20s} {len(data['groups']):>10d} {data['count']:>10d}")
log.info("-" * 45)
输出结果如下
python
✅ 装箱方案完成 (BFD 算法结果):
Dex 名称 分组数 方法数
---------------------------------------------
smali (主包) - 36501
smali_classes2 12 54982
smali_classes3 12 54810
smali_classes4 14 54963
smali_classes5 5 7019
---------------------------------------------
这样即使sdk再多,smali文件再多,也可以靠脚本自己优化了,需要在主包里面启动的smali文件也可以支持配置,完美,如有什么问题可指出 欢迎交流