Smali 文件生成dex装箱算法整合

前言

很早之前写了一套分包脚本,但是对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文件也可以支持配置,完美,如有什么问题可指出 欢迎交流

相关推荐
电饭叔28 分钟前
《python语言程序设计》2018版--第8章14题利用字符串输入作为一个信用卡号之一(Luhn算法解释)
android·java·python
LDG_AGI33 分钟前
【推荐系统】深度学习训练框架(十三):模型输入——《特征索引》与《特征向量》的边界
人工智能·pytorch·分布式·深度学习·算法·机器学习
CoovallyAIHub35 分钟前
如何让SAM3在医学图像上比专用模型还强?一个轻量Adapter如何让它“秒变”专家?
深度学习·算法·计算机视觉
suoge22340 分钟前
热传导控制方程有限元弱形式推导-有限元编程入门
算法
希望有朝一日能如愿以偿40 分钟前
力扣每日一题:统计梯形的数目
算法·leetcode·职场和发展
小女孩真可爱42 分钟前
大模型学习记录(八)---------RAG评估
linux·人工智能·python
姓刘的哦1 小时前
RK3568开发板运行Qt
开发语言·qt
刘晓倩1 小时前
Python3的Sequence
开发语言·python
ZhengEnCi1 小时前
一次多线程同步问题的排查:从 thread_count 到 thread.join() 的踩坑之旅
python·网络协议·tcp/ip