从 30MB 到 3MB:移动端 AI 落地ONNX Runtime 算子裁剪与 NDK 版本适配

碎碎念

记录一次吐血的 ONNX Runtime Android 剪裁编译:从 120MB 压到 30MB 的踩坑全指南。

最近在做 AnyVault(一个跨平台的本地存储应用)的语义搜索功能。为了保证离线的体验,在云端的基础上,希望在端侧跑本地的 BGE Embedding 模型(bge_pure_quant.onnx)。

但是官方提供的通用版 onnxruntime-androidAAR 包太大了, 编译 arm64, 32, x86, x86_64 四个架构的版本总共要占 120MB。塞进 App 里实在太臃肿。

为了控制应用的体积,必须走“定制化编译”这条路,我决定对其进行算子级裁剪(Minimal Build),只保留模型实际用到的算子。

一开始为了省事,我选了 Colab 跑编译,但是跑了3小时....就想着在本地跑。本以为和colab一样轻松,但是国内网络环境+window环境,结果硬是踩遍了交叉编译的各种深坑。这篇文章整理了所有排坑记录,希望能帮后来者少走弯路。


环境准备

前提建议: 本文主要面向国内和 Windows 场景。如果你网络顺畅且能忍受 Colab 的编译速度,建议直接用 Colab;如果是 Linux 环境,直接看 ONNX Runtime 官方文档 即可。

定制化编译涉及复杂的交叉编译工具链,环境对不上,后面全是报错。
我是编译的1.24.4版本(onnxruntime-android-1.24.4)

  1. 操作系统:强烈建议在 Linux (Ubuntu) 或 Colab 下跑。如果在 Windows 下跑,请务必安装 Visual Studio 2022 (v17),且安装时勾选“使用 C++ 的桌面开发”。(坑点:脚本写死了只认 VS2022,更新的版本它装瞎)。

  2. 网络代理:国内编译必须配置系统全局代理,否则 Gradle 同步和第三方库下载必挂。

  3. Android NDK:推荐使用 NDK r26(推荐 26.1.10909125)。(致命坑点:别追新用 r27)。

  4. 构建工具:CMake 3.26+、Git、Ninja。

  5. Python 环境:建议 Python 3.10+,并安装torch transformers "onnx<1.19.0" onnxruntime onnxruntime-extensions onnxscript ninja。

还有就是相关环境记得配置环境变量,我是额外配置了ANDROID_HOME(xxxxxxxxxxxxx\android-sdk),配置了记得重启终端/电脑

部分环境可以使用下面的脚本配置
python环境

pip install torch transformers "onnx<1.19.0" onnxruntime onnxruntime-extensions -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install  onnxscript
pip install ninja

Android NDK

import os
import sys
import zipfile
import urllib.request
import subprocess

BASE_DIR = r"你的位置"
SDK_ROOT = os.path.join(BASE_DIR, "android-sdk")
# Windows 命令行工具下载地址 (保持不变)
SDK_TOOLS_URL = "https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip"

def setup_android_sdk():
    if not os.path.exists(SDK_ROOT):
        os.makedirs(SDK_ROOT)

    zip_path = os.path.join(BASE_DIR, "cmdline-tools.zip")

    # 1. 下载工具包
    if not os.path.exists(zip_path):
        print(f"🚀 正在下载 Android 命令行工具...")
        urllib.request.urlretrieve(SDK_TOOLS_URL, zip_path)
        print("✅ 下载完成!")

    # 2. 解压并整理结构
    latest_path = os.path.join(SDK_ROOT, "cmdline-tools", "latest")
    if not os.path.exists(latest_path):
        print("📦 正在解压并整理目录结构...")
        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
            zip_ref.extractall(os.path.join(SDK_ROOT, "temp_tools"))

        # 移动文件到正确的 'latest' 目录
        os.makedirs(os.path.dirname(latest_path), exist_ok=True)
        os.rename(os.path.join(SDK_ROOT, "temp_tools", "cmdline-tools"), latest_path)
        print("✅ 目录整理完毕!")

    # 3. 使用 sdkmanager 安装组件
    # 调整为你需要的 API 34, Build-Tools 30.0.3,以及稳定的 NDK r26
    print("⏳ 正在安装 Android 14 (API 34), Build-Tools 30.0.3 与 NDK r26...")
    sdk_manager = os.path.join(latest_path, "bin", "sdkmanager.bat")

    # 👇 核心修改区域:替换为你需要的指定版本
    packages = [
        "platforms;android-34",
        "build-tools;30.0.3",
        "platform-tools",
        "ndk;26.1.10909125"  # 强制使用兼容 ONNX Runtime 编译的 NDK 26
    ]

    # 自动接受协议并安装
	############################这里似乎无效,需要你手动接受协议##################################################################
    cmd = f'echo y | "{sdk_manager}" --sdk_root="{SDK_ROOT}" ' + " ".join([f'"{p}"' for p in packages])

    try:
        subprocess.run(cmd, shell=True, check=True)
        print("-" * 60)
        print("\n✨ [所有指定组件安装成功] ✨")
        print(f"SDK 路径: {SDK_ROOT}")
        print(f"NDK 路径: {os.path.join(SDK_ROOT, 'ndk', '26.1.10909125')}")
        print("-" * 60)
    except subprocess.CalledProcessError as e:
        print("-" * 60)
        print(f"❌ 安装失败: {e}")

if __name__ == "__main__":
    setup_android_sdk()

手动接受协议并安装

核心思路与模型转换

官方 AAR 大是因为内置了所有的算子(Operator)。我们的瘦身逻辑分两步:

  1. 分析模型: 使用脚本分析模型bge.onnx/bge.ort到底用了哪些算子,生成一个 .config 清单。
  2. 定制编译: 编译 Android AAR 时带上这个清单,让编译器只把需要的算子编进动态库。

下载库和模型

克隆onnxruntime仓库

git clone --depth 1 --recursive -b rel-1.24.4 https://github.com/microsoft/onnxruntime.git

下载模型及进行压缩
如果不需要INT8 动态量化压缩体积,删除相关代码即可。
这里使用的是jupyter,没有测试.py

import torch
import onnx
import os
from transformers import AutoTokenizer, AutoModel
from onnxruntime.quantization import quantize_dynamic, QuantType

# 1. 加载模型与配置
model_name = "BAAI/bge-small-zh-v1.5"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name)

# 🌟 新增:提取并保存 vocab.txt 给 KMP 端使用
vocab_path = "vocab.txt"
# HuggingFace 的 vocab 通常是无序 dict,必须按 ID 排序后导出
sorted_vocab = sorted(tokenizer.vocab.items(), key=lambda item: item[1])
with open(vocab_path, "w", encoding="utf-8") as f:
    for token, _ in sorted_vocab:
        f.write(f"{token}\n")
print("✅ vocab.txt 导出成功!请把它放进项目的 composeResources/files/ 目录下。")

# 2. 定义仅包含推理与池化的核心模型结构
class BgeCore(torch.nn.Module):
    def __init__(self, model):
        super().__init__()
        self.model = model

    def forward(self, input_ids, attention_mask, token_type_ids=None):
        # 传递给原始模型
        outputs = self.model(input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids)
        # 取 [CLS] token 的输出作为句子向量
        sentence_embeddings = outputs.last_hidden_state[:, 0]
        # L2 归一化 (极速计算余弦相似度的核心)
        return torch.nn.functional.normalize(sentence_embeddings, p=2, dim=1)

core_model = BgeCore(model).eval()

# 3. 导出纯净版 ONNX 模型
print("\n正在导出纯净版核心模型 (FP32)...")
fp32_onnx = "bge_pure_fp32.onnx"
# 使用 dummy_input 追踪计算图
dummy_input = tokenizer("测试文本", return_tensors="pt")

torch.onnx.export(
    core_model,
    (dummy_input['input_ids'], dummy_input['attention_mask'], dummy_input['token_type_ids']),
    fp32_onnx,
    input_names=['input_ids', 'attention_mask', 'token_type_ids'],  # 明确的三个输入
    output_names=['sentence_embedding'],
    dynamic_axes={
        'input_ids': {0: 'batch_size', 1: 'sequence_length'},
        'attention_mask': {0: 'batch_size', 1: 'sequence_length'},
        'token_type_ids': {0: 'batch_size', 1: 'sequence_length'},
        'sentence_embedding': {0: 'batch_size'}
    },
    opset_version=18,
    do_constant_folding=True
)
print("✅ FP32 模型导出成功!")

# 4. 动态量化 (INT8)
print("\n正在进行 INT8 动态量化以压缩体积...")
final_model_path = "bge_pure_quant.onnx"
quantize_dynamic(
    fp32_onnx,
    final_model_path,
    weight_type=QuantType.QInt8
)

# 清理临时文件
if os.path.exists(fp32_onnx):
    os.remove(fp32_onnx)

print(f"\n✨ 导出圆满成功!")
print(f"👉 请下载这 2 个文件用于客户端开发:\n 1. {final_model_path} (核心模型)\n 2. {vocab_path} (词表)")

致命细节:必须转为 .ort 格式

在移动端千万不要直接跑 .onnx。因为当你开启极简构建(--minimal_build)时,ORT 为了减小体积,会精准切除标准的 ONNX 解析器。如果你不转格式直接跑 native 层直接崩给你看(闪退)。转成 .ort(FlatBuffers)不仅能避免闪退,还能去除 Protobuf 依赖,进一步减小内存占用。

核心文件夹目录树

我的ONNX Runtime 编译 Android AAR 的核心文件夹目录树,可以参考

│

├──onnxwin.py 				<-- 📄执行的python文件(建议放在根目录)
├──bge_pure_quant.ort 						<-- 📄 你的模型(建议放在根目录)
├── bge_pure_quant.required_operators.config  <-- 📄 你提取生成的“算子图纸” (建议放在根目录)
├── android_build_settings.json               <-- 📄 极简编译配置 JSON (建议放在根目录,后面的代码会自动生成)
onnxruntime/                                  <-- 官方克隆下来的源码根目录
├── tools/
│   └── ci_build/
│       └── github/
│           └── android/
│               └── build_aar_package.py      <-- 🎯 核心打包脚本 (你要执行的文件)
│
├── build/                                    <-- 编译时自动生成的目录 (也是清缓存的重灾区)
│   └── android_aar/
│       ├── intermediates/                    <-- 编译中间产物 
│       │   └── arm64-v8a/
│       │       └── MinSizeRel/
│       │           ├── CMakeCache.txt        <-- ⚠️ 增量编译修改算子后,需删除此文件
│       │           └── libonnxruntime.so     <-- ⚠️ 增量编译修改算子后,需删除旧的 .so
│       │
│       └── outputs/
│           └── aar////很多个子文件夹
│               └── onnxruntime-mobile.aar    <-- 🎉 最终生成的瘦身 AAR 包位置
└──

一键提取算子清单

只要一行命令,就能完成格式转换并提取“算子图纸”:

# 转换格式并生成 required_operators.config
python3 -m onnxruntime.tools.convert_onnx_models_to_ort bge_pure_quant.onnx

执行后,终端会输出以下日志:

Converting models with optimization style 'Fixed' and level 'all'
Converting optimized ONNX model /content/bge_pure_quant.onnx to ORT format model /content/bge_pure_quant.ort
Converted 1/1 models successfully.
Generating config file from ORT format models with optimization style 'Fixed' and level 'all'
2026-04-19 12:28:27,320 ort_format_model.utils [INFO] - Created config in /content/bge_pure_quant.required_operators.config

此时你得到了能在移动端直接加载的 bge_pure_quant.ort,以及这份至关重要的配置图纸 bge_pure_quant.required_operators.config。

我生成的bge_pure_quant.required_operators.config内容如下:

# Generated from model/s:
# - /content/bge_pure_quant.ort
ai.onnx;7;And
ai.onnx;11;DynamicQuantizeLinear,Range
ai.onnx;13;Cast,Clip,Concat,DequantizeLinear,Erf,Expand,Gather,GatherND,IsNaN,MatMul,Max,Slice,Softmax,Squeeze,Transpose,Unsqueeze
ai.onnx;14;Add,Div,Mul,Reshape
ai.onnx;15;Shape
ai.onnx;16;GreaterOrEqual,Where
ai.onnx;17;LayerNormalization
ai.onnx;18;ReduceL2
com.microsoft;1;DynamicQuantizeMatMul,FusedMatMul,MatMulIntegerToFloat,SkipLayerNormalization

管理员编译 AAR

注意,这个python脚本需要在有管理员权限的cmd进行编译

import os
import subprocess
import sys
import shutil
import json

print("Step 3: 🔥 真正开始编译 AAR (物理文件级 SDK 注入版)...")

# ==========================================
# 📂 路径定义
# ==========================================
BASE_DIR = r"你的目录"

ORT_ROOT = rf"{BASE_DIR}\onnxruntime"
CONFIG_PATH = rf"{BASE_DIR}\bge_pure_quant.required_operators.config"
BUILD_SETTINGS = rf"{ORT_ROOT}\custom_mobile_build_settings.json"
SDK_PATH = rf"{BASE_DIR}\android-sdk"
NDK_PATH = rf"{SDK_PATH}\ndk\26.1.10909125"
BUILD_DIR = rf"{ORT_ROOT}\build\android_aar"

# 👈 新增:Java 目录路径
JAVA_DIR = rf"{ORT_ROOT}\java"
LOCAL_PROPS_PATH = os.path.join(JAVA_DIR, "local.properties")

# ==========================================
# 🚀 物理绝杀:直接写入 local.properties 堵住 Gradle 的嘴
# ==========================================
print("🪄 正在生成 local.properties 强行注入 SDK 路径...")
os.makedirs(JAVA_DIR, exist_ok=True)
with open(LOCAL_PROPS_PATH, "w", encoding="utf-8") as f:
    # Android/Gradle 配置文件必须用正斜杠
    sdk_path_forward = SDK_PATH.replace("\\", "/")
    f.write(f"sdk.dir={sdk_path_forward}\n")
print(f"✅ local.properties 写入成功!({LOCAL_PROPS_PATH})")
# ==========================================
# 🛡️ 基础环境注入
# ==========================================
os.environ["CMAKE_GENERATOR"] = "Ninja"
os.environ["VCPKG_MAX_CONCURRENCY"] = "8"
os.environ["CMAKE_BUILD_PARALLEL_LEVEL"] = "8"

# 双保险,顺便也留着环境变量
os.environ["ANDROID_HOME"] = SDK_PATH
os.environ["ANDROID_SDK_ROOT"] = SDK_PATH

import glob # 别忘了在文件开头 import glob

# ==========================================
# 🧹 深度外科手术:猎杀空壳 .so 与 Gradle 缓存
# ==========================================
print("🧹 正在执行深度清理:抹除所有残留的伪造库和打包缓存...")

# 1. 猎杀所有的 .so 文件,逼迫 Ninja 重新链接
for so_file in glob.glob(rf"{BUILD_DIR}\**\*.so", recursive=True):
    try:
        os.remove(so_file)
        print(f"🔫 已击杀残留库: {os.path.basename(so_file)}")
    except Exception as e:
        pass

# 2. 清理 Gradle 的打包缓存,防止 13 秒 UP-TO-DATE 惨案
JAVA_BUILD_DIR = rf"{JAVA_DIR}\build"
if os.path.exists(JAVA_BUILD_DIR):
    try:
        shutil.rmtree(JAVA_BUILD_DIR)
        print("✅ 已清理 Gradle 打包缓存")
    except Exception:
        pass
# ==========================================
# 🚀 自动配置 Gradle 阿里云全局镜像
# ==========================================
print("🪄 正在配置 Gradle 阿里云镜像...")
gradle_dir = os.path.expanduser("~/.gradle")
os.makedirs(gradle_dir, exist_ok=True)
init_gradle_path = os.path.join(gradle_dir, "init.gradle")

aliyun_mirror = """
allprojects {
    buildscript {
        repositories {
            maven { url 'https://maven.aliyun.com/repository/google' }
            maven { url 'https://maven.aliyun.com/repository/public' }
            maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
        }
    }
    repositories {
        maven { url 'https://maven.aliyun.com/repository/google' }
        maven { url 'https://maven.aliyun.com/repository/public' }
    }
}
"""
with open(init_gradle_path, "w", encoding="utf-8") as f:
    f.write(aliyun_mirror)

# ==========================================
# 🪄 自动修复:生成 JSON 配置文件
# ==========================================
default_settings = {
    "format": "ort",
    "build_params": [
    "--build_shared_lib",  # 👈 核心修复 1:强迫生成 .so 动态库
        "--build_java",        # 👈 核心修复 2:强迫生成 JNI 桥接和 Java 接口
        "--android",                  # 👈 核心:强制开启 Android 编译模式
"--compile_no_warning_as_error",
        "--cmake_generator", "Ninja",  # 👈 核心:强制使用 Ninja (Windows 下交叉编译唯一解)
        "--minimal_build",  # 👈 新增:开启极简构建(配合 format: ort 必加)
        "--disable_ml_ops",  # 👈 新增:如果不需要传统机器学习算子(Tree/SVM等),强烈建议加上以减小体积
        "--skip_tests",
        "--parallel"
    ]
}
os.makedirs(os.path.dirname(BUILD_SETTINGS), exist_ok=True)
with open(BUILD_SETTINGS, "w", encoding="utf-8") as f:
    json.dump(default_settings, f, indent=4)


# ==========================================
# 🚀 构建编译命令
# ==========================================
build_cmd = [
    sys.executable,
    rf"{ORT_ROOT}\tools\ci_build\github\android\build_aar_package.py",
    "--build_dir", BUILD_DIR,
    "--config", "MinSizeRel",
    "--android_sdk_path", SDK_PATH,
    "--android_ndk_path", NDK_PATH,
    "--include_ops_by_config", CONFIG_PATH,
    BUILD_SETTINGS
]

print("-" * 60)
print("执行命令:", " ".join(build_cmd))
print("-" * 60)

# ==========================================
# ⚡ 开始执行
# ==========================================
try:
    result = subprocess.run(build_cmd, check=True, env=os.environ)
    print("-" * 60)
    print(f"\n🎉 恭喜!AAR 编译彻底成功!")
    print(f"📦 你的终极产物路径:{BUILD_DIR}\\outputs\\aar\\")

except subprocess.CalledProcessError as e:
    print("-" * 60)
    print(f"\n❌ 编译失败,退出码: {e.returncode}")
    sys.exit(1)

Android项目引入包

我是kmp项目,我只需要将编译生成的 .aar 放入 app 的 libs 文件夹,并在 build.gradle.kts 中添加依赖引入。
然后将导出的 vocab.txt和模ort模型放入项目的 composeResources/files/ 目录下即可。

二、 编译深坑大赏

接下来的重头戏是交叉编译。如果你在跑 build_aar_package.py 时发现产出的 AAR 只有几百 KB,或者运行直接崩溃,那你基本是掉进了以下这几个坑里。
提醒,我的版本是24.4

坑 1:Windows 环境下 VS 编译器“认死理”

现象: 脚本跑到了 Bootstrapping vcpkg 阶段直接暴毙,提示:error: in triplet x64-windows: Unable to find a valid Visual Studio instance。
原因: Windows 下编译时,ONNX 会先用 vcpkg 编译几个辅助工具(如 protoc.exe)。我本以为装最新的 Visual Studio 2026 Build Tools 能向下兼容,结果 ONNX 脚本源码里写死了只认 Visual Studio 2022 (v17),看到 2026 直接装瞎。
解法: 老老实实卸载新版,去微软官网下载 Visual Studio 2022 Build Tools。注意:安装时必须且只需勾选“使用 C++ 的桌面开发”。不用配任何环境,vcpkg 就能自动抓到编译器。

坑 2:看似成功的0kb (编译器“路径依赖”陷阱)

现象: 跑了半小时,几千步全通过,终端输出 BUILD SUCCESSFUL。满心欢喜解压 AAR 一看,jni/arm64-v8a/libonnxruntime.so 文件大小赫然写着 0 kb。
原因: 这是 Windows 环境下最隐蔽的坑。CMake 在 Windows 上有极强的“路径依赖”,它会默认调用 Visual Studio (MSBuild) 去编译。
结果就是:系统虽然显示编译成功,但其实它是在为你当前的 Windows x64 系统编译 .dll 库。等到了打包 AAR 的最后阶段,脚本发现找不到安卓架构对应的 .so 二进制文件,于是只能尴尬地塞进几个 0KB 的占位符。

解法(双管齐下):

  1. 强制切换编译器:必须安装 Ninja (pip install ninja),并在编译参数里明确指定 --cmake_generator Ninja 和 --android。这能强迫 CMake 放弃 Visual Studio,转而使用 NDK 里的 clang。

  2. NDK 版本稳健性:虽然主因是编译器,但 NDK r27 确实太新,移除了一些旧版 ORT 脚本依赖的工具。建议降级到 NDK r26(如 26.1.10909125),这是目前公认最稳的版本。

坑 3:消失的 jni 目录 (参数配置遗漏)

现象: 解决了 0kb,编出来的 AAR 里面却只有 headers 头文件,连 jni 目录都没了,Android Studio 无法调用。
原因: 如果你使用了自定义 JSON 配置进行极简构建("format": "ort"),如果不显式声明,底层 CMake 默认只会给你编出 C++ 的静态库(.a),不会生成动态库和 JNI 桥接层。
解法: 在你的 JSON 配置 build_params 数组里,必须显式声明:--build_shared_lib 和 --build_java。

坑 4:32 位架构的报警

现象: 编 arm64-v8a 顺风顺水,一到编译 32 位的 armeabi-v7a 时,直接报 error: implicit conversion loses integer precision: 'int64_t' to 'size_type' 并中断。
原因: 64 位模型参数在 32 位系统转换时会有精度丢失警告。而 ORT 构建配置极度严格,默认开启了 Warning as Error(警告即错误)。
解法: 给编译器发免死金牌,增加参数 --compile_no_warning_as_error,让编译器别太死板。

坑 5:Gradle 网络背刺

现象: C++ 全编完了,到了最后打包 AAR 的 Gradle 阶段,报 SSLHandshakeException 或者死活无法下载 Kotlin 编译器。
原因: 纯粹的网络问题。即便你在命令行挂了代理,Gradle 所在的独立 JVM 进程经常无视系统环境变量,执意直连。
解法: 确保能连上 Maven 中央仓库,或者配置阿里云镜像等。

修改算子后如何快速迭代? (Ninja 增量缓存陷阱)

现象:如果中途更换了模型,重新生成了包含新算子的 required_operators.config,再次运行脚本发现瞬间就编译完了。但放入 App 运行直接崩溃报 No matching implementation found。

原因:Ninja 增量编译复用了旧算子生成的 .o 对象文件和旧的 .so。

极限微创清理法:
不要去全删 12GB 的 build 目录!只需要手动删除以下文件,就能保留大头缓存,迫使系统重编新算子:

  1. 删掉 java/build 目录。
  2. 删掉 build/android_aar/outputs/aar 结果目录。

再次运行,即可。
(这里有点忘记了,如果删掉后还是不行,那就只能删除整个build文件,重新编译了)

成果展示与总结

经过这番折腾,我最终的产物体积对比:

  • 原始 AAR: ~120MB (全架构)
  • 定制后 AAR: ~30MB (全架构,针对 BGE 模型精简)

对于端侧 AI 落地来说,这开销完全可以接受。定制化编译就像一场外科手术,路径、参数、环境变量错一点都会失败,但术后的包体积是真的香。

最后总结一句避坑真经: 如果能在 Linux 跑尽量避开 Windows。必须在 Windows 跑的话,认准 VS 2022、NDK 降级 r26、代理直接写死,多用 --parallel 开多核编译。

posted @ 2026-04-19 21:45  kingwzun  阅读(266)  评论(0)    收藏  举报