DEX 加载与 app_process 启动链路
从 DEX 文件、类加载器到设备端命令,分清标准 API 与平台实验边界。
DEX 加载与 app_process 启动链路
目标:理解 DEX 从编译产物到运行时类的完整链路,并在自己的调试设备上完成一次可观察的
app_process实验。
先区分两条路径
第一条是应用内动态加载。
它使用 Android SDK 的 DexClassLoader。
它运行在应用自己的进程和沙箱中。
它适合插件、脚本和受控扩展。
第二条是设备端运行时实验。
它通过 adb shell 调用系统提供的运行时入口。
它通常会涉及 app_process 和 CLASSPATH。
它依赖设备镜像、Android 版本、shell 身份和运行时实现。
它不是普通应用可以稳定调用的 SDK API。
本文把两条路径放在一起比较。
DEX 是什么
Java 或 Kotlin 源码先编译成 JVM 字节码。
Android 工具链再把字节码转换成 DEX。
DEX 面向 Android Runtime。
一个 APK 可以包含 classes.dex。
一个应用也可以包含多个 DEX。
构建工具会根据方法数拆分文件。
运行时需要知道类属于哪个 ClassLoader。
类的可见性不是全局共享的。
最小项目结构
dex-runner/
├── settings.gradle.kts
├── build.gradle.kts
├── runner/
│ ├── build.gradle.kts
│ └── src/main/java/com/koko/runner/Main.java
├── host/
│ └── run.sh
└── device/
├── push.sh
└── inspect.sh
runner 生成 DEX。
host 负责构建和命令编排。
device 负责推送和检查。
Gradle 配置
使用 Android Gradle Plugin 建立一个最小 Java 模块。
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "dex-runner"
include(":runner")
根项目不需要 AndroidManifest。
这里的目标是生成可观察的 DEX 文件。
模块脚本
plugins {
id("com.android.application") version "8.6.1" apply false
}
模块脚本如下。
plugins {
id("com.android.application")
}
android {
namespace = "com.koko.runner"
compileSdk = 35
defaultConfig {
applicationId = "com.koko.runner"
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "0.1"
}
}
如果只是生成 DEX,不需要安装 APK。
构建工具仍会生成 APK 相关中间产物。
Main 类
app_process 需要一个带 main 方法的入口类。
package com.koko.runner;
public final class Main {
private Main() {
}
public static void main(String[] args) {
System.out.println("runner-start");
System.out.println("argument-count=" + args.length);
for (int index = 0; index < args.length; index++) {
System.out.println("arg[" + index + "]= " + args[index]);
}
System.out.println("runner-stop");
}
}
这个入口不访问 Android Context。
它不创建 Activity。
它不依赖应用资源。
它只验证类是否成功加载。
先走标准应用内路径
Android 官方 DexClassLoader 可以加载 APK 或 JAR 中的类。
应用应该把外部 DEX 放入自己的私有目录。
不要从可被其他应用写入的公共目录加载不可信 DEX。
不要把任意网络下载内容直接交给 ClassLoader。
应用内加载示例
class PluginLoader(
private val context: Context,
) {
fun load(dexFile: File): Class<*> {
val optimized = File(context.codeCacheDir, "dex")
if (!optimized.exists()) optimized.mkdirs()
val loader = DexClassLoader(
dexFile.absolutePath,
optimized.absolutePath,
null,
context.classLoader,
)
return loader.loadClass("com.koko.runner.Main")
}
}
API 26 以后 optimizedDirectory 参数没有实际作用。
保留参数只是为了兼容构造函数签名。
真实代码仍应传入有效路径。
反射调用 main
fun invokeMain(entry: Class<*>, args: Array<String>) {
val method = entry.getMethod("main", Array<String>::class.java)
method.invoke(null, args)
}
入口方法必须是 public static。
Kotlin 顶层函数会生成不同的类名。
为了减少歧义,示例使用 Java 类。
文件校验
加载之前计算 SHA-256。
fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(8192)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
校验值应该来自可信来源。
哈希只能确认内容一致。
哈希不能证明内容安全。
构建 DEX
在项目根目录执行 Debug 构建。
./gradlew :runner:assembleDebug
APK 通常位于以下路径。
runner/build/outputs/apk/debug/runner-debug.apk
查看 APK 内容。
unzip -l runner/build/outputs/apk/debug/runner-debug.apk | grep classes
预期能看到 classes.dex。
从 APK 提取 classes.dex
rm -rf build/extracted
mkdir -p build/extracted
unzip -p runner/build/outputs/apk/debug/runner-debug.apk classes.dex > build/extracted/runner.dex
提取出的文件不是普通文本。
它是运行时字节码容器。
检查 DEX
Android SDK 提供 apkanalyzer。
apkanalyzer dex packages build/extracted/runner.dex
列出文件中的类。
apkanalyzer dex packages --defined-only build/extracted/runner.dex
不同 Build-Tools 版本的参数可能略有变化。
以本机 apkanalyzer --help 为准。
推送到设备
设备端实验只使用自己的调试设备。
adb wait-for-device
adb shell rm -f /data/local/tmp/runner.dex
adb push build/extracted/runner.dex /data/local/tmp/runner.dex
adb shell chmod 600 /data/local/tmp/runner.dex
DEX 文件不需要执行权限。
它由运行时读取。
给 DEX 设置执行位没有意义。
先检查设备身份
adb shell id
adb shell getprop ro.build.version.sdk
adb shell getprop ro.product.cpu.abilist
adb shell getprop ro.build.version.release
app_process 的行为和设备系统版本有关。
把这些信息写进实验记录。
不要只记录“能运行”三个字。
app_process 的基本形态
常见实验形态如下。
adb shell 'CLASSPATH=/data/local/tmp/runner.dex app_process /data/local/tmp com.koko.runner.Main hello world'
这里的 /data/local/tmp 是类路径工作目录参数。
它不是 Java 包名。
最后一个参数是主类名。
后面的参数进入 main(String[] args)。
具体解析由设备上的 app_process 实现决定。
为什么要强调实验边界
app_process 不是普通应用 SDK 的稳定入口。
厂商可能修改运行时启动参数。
SELinux 可能阻止访问 DEX 文件。
shell 身份可能缺少系统服务权限。
Android 版本可能改变类路径处理。
设备可能没有你预期的命令行为。
因此文章只把它当作调试设备上的运行时实验。
观察输出
最小成功输出应该类似下面内容。
runner-start
argument-count=2
arg[0]= hello
arg[1]= world
runner-stop
没有输出不代表没有执行。
检查命令退出码。
adb shell 'CLASSPATH=/data/local/tmp/runner.dex app_process /data/local/tmp com.koko.runner.Main hello; echo exit:$?'
退出码为零只是入口正常返回。
它不代表权限调用成功。
让入口打印环境
为了理解运行环境,可以打印系统属性。
package com.koko.runner;
public final class EnvironmentDump {
private EnvironmentDump() {
}
public static void main(String[] args) {
System.out.println("java.version=" + System.getProperty("java.version"));
System.out.println("java.home=" + System.getProperty("java.home"));
System.out.println("java.class.path=" + System.getProperty("java.class.path"));
System.out.println("path.separator=" + System.getProperty("path.separator"));
System.out.println("user.name=" + System.getProperty("user.name"));
System.out.println("user.dir=" + System.getProperty("user.dir"));
}
}
不要把系统属性当作权限证明。
它们只是诊断信息。
类路径问题
入口类找不到时,先确认 DEX 路径。
adb shell ls -l /data/local/tmp/runner.dex
确认包名和类名完全一致。
com.koko.runner.Main
不要写成文件路径。
不要添加 .class 后缀。
不要把斜杠写进类名。
依赖类问题
如果 Main 引用了第三方类,单个 DEX 可能不够。
构建工具会把依赖合并进 APK。
但手动提取时仍需要确认类是否存在。
apkanalyzer dex packages --defined-only runner.dex | grep com.koko
多个 DEX 需要完整类路径。
adb shell 'CLASSPATH=/data/local/tmp/classes.dex:/data/local/tmp/classes2.dex app_process /data/local/tmp com.koko.runner.Main'
路径分隔符在 Android Linux 上通常是冒号。
以设备运行时实际行为为准。
DEX 与资源的区别
DEX 只包含类和字节码。
它不自动携带 APK 资源表。
它不自动提供 Context。
它不自动拥有应用权限。
它不自动加载 native 库。
如果入口依赖资源,单独 DEX 通常不够。
如果入口依赖 JNI,需要额外处理库搜索路径。
不要假设 Context 存在
app_process 入口不是 Activity。
下面的代码可能失败。
Context context = (Context) getApplicationContext();
普通 Java main 不会自动获得应用 Context。
要访问 Android 服务,需要明确的运行时上下文。
即使拿到了上下文,也不代表拥有对应权限。
访问系统服务的边界
系统服务通过 Binder 提供接口。
接口调用会检查调用者 UID。
系统服务也可能检查包名和权限。
shell UID 不是 system UID。
root UID 也不等于可以绕过所有策略。
不要把能启动 DEX 写成能调用所有系统服务。
运行参数解析
入口应该显式解析参数。
public final class Args {
private Args() {
}
public static void main(String[] args) {
if (args.length == 0) {
System.err.println("usage: Main <command>");
System.exit(64);
}
String command = args[0];
if ("version".equals(command)) {
System.out.println("0.1.0");
return;
}
System.err.println("unknown command: " + command);
System.exit(64);
}
}
退出码 64 代表参数错误。
不要把异常堆栈当作用户界面。
启动脚本
#!/usr/bin/env bash
set -euo pipefail
DEX="/data/local/tmp/runner.dex"
ENTRY="com.koko.runner.Main"
adb shell "test -r '$DEX'"
adb shell "CLASSPATH='$DEX' app_process /data/local/tmp '$ENTRY' version"
脚本先检查文件可读。
再执行入口。
不要把任意用户输入直接拼接进 shell 命令。
更安全的参数传递
参数来自固定数组时风险较低。
args=(version)
adb shell CLASSPATH="$DEX" app_process /data/local/tmp "$ENTRY" "${args[@]}"
Windows 环境需要使用 PowerShell 重新组织命令。
不同 adb 版本对参数转义的行为也可能不同。
复杂参数应该先写入文件再读取。
日志与标准输出
System.out 可能映射到 logcat。
也可能被启动包装器重定向。
先观察标准输出。
再检查 logcat。
adb logcat -d | grep -E "runner-start|runner-stop"
不要让调试输出包含令牌和个人数据。
反编译和验证
可以用 jadx 或 baksmali 做本地验证。
这些工具只用于检查自己的构建产物。
jadx -d build/jadx runner-debug.apk
查看 DEX 中的方法数量。
apkanalyzer dex references build/extracted/runner.dex | head
工具输出受版本影响。
优先阅读本机工具帮助。
R8 与混淆
Debug 构建通常关闭压缩。
Release 构建可能改变类名。
手动启动入口需要保持类名。
可以使用 keep 规则。
-keep public class com.koko.runner.Main {
public static void main(java.lang.String[]);
}
只对自己的构建产物使用规则。
不要为了调试关闭全部安全优化。
多 DEX
当方法数超过单 DEX 限制时,构建会生成多个 DEX。
unzip -l runner-debug.apk | grep 'classes.*dex'
如果手动抽取了 classes.dex,依赖类可能缺失。
抽取全部文件。
unzip -j runner-debug.apk 'classes*.dex' -d build/all-dex
根据设备运行时的类路径规则组合。
不要假设排序在所有工具中一致。
DexClassLoader 的类隔离
不同 ClassLoader 加载同名类时,类型可能不兼容。
val first = loaderA.loadClass("com.koko.runner.Model")
val second = loaderB.loadClass("com.koko.runner.Model")
println(first == second)
通常会输出 false。
不要跨加载器直接强制转换对象。
可以通过稳定接口或序列化数据通信。
资源和 native 库
DexClassLoader 的 librarySearchPath 用于 native 库目录。
val loader = DexClassLoader(
dexPath,
optimizedDirectory,
context.applicationInfo.nativeLibraryDir,
context.classLoader,
)
目录必须真实存在。
ABI 必须匹配设备。
ELF 依赖也必须可解析。
设备差异记录
每次实验至少记录以下字段。
ro.build.version.sdk
ro.build.version.release
ro.product.manufacturer
ro.product.model
ro.product.cpu.abilist
uid
app_process path
runtime path
不同设备的二进制路径可能不同。
不要硬编码 /system/bin/app_process 为唯一位置。
先通过 command -v 检查。
adb shell command -v app_process
adb shell command -v app_process64
常见失败一:ClassNotFoundException
确认 DEX 文件确实包含入口类。
apkanalyzer dex packages --defined-only runner.dex | grep 'com.koko.runner.Main'
确认类名拼写。
确认 CLASSPATH 没有隐藏空格。
确认命令中的路径在设备端存在。
不要先修改权限。
先证明类确实在文件中。
常见失败二:找不到 app_process
检查命令路径。
adb shell 'command -v app_process || command -v app_process64'
某些设备只提供 64 位入口。
某些环境会限制 shell 的 PATH。
使用绝对路径前先确认它存在。
不要把主机上的 app_process 推到设备。
常见失败三:Permission denied
检查 DEX 可读权限。
adb shell ls -l /data/local/tmp/runner.dex
adb shell id
检查 SELinux 上下文。
adb shell ls -Z /data/local/tmp/runner.dex
不要关闭 SELinux 来掩盖问题。
换到自己的调试设备验证策略差异。
常见失败四:NoClassDefFoundError
入口依赖的类没有放入类路径。
检查所有 DEX 文件。
检查依赖是否只存在于 APK 资源中。
检查混淆是否改了类名。
将入口缩减到 JDK 基础类,再逐步加依赖。
每次只改变一个变量。
常见失败五:UnsupportedClassVersionError
主机编译出的字节码版本过高。
检查 Java 编译目标。
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
Android 工具链会进一步转换字节码。
不要把桌面 JVM 的 class 文件直接当作 DEX。
常见失败六:参数被 shell 吞掉
空格、引号和美元符号会经过多层解析。
主机 shell 先解析一次。
adb 再传递一次。
设备 shell 可能继续解析。
先用固定参数测试。
再逐步加入特殊字符。
日志打印最终收到的参数。
运行时实验的最小闭环
编译入口。
提取 DEX。
检查入口类。
等待设备在线。
推送 DEX。
检查 DEX 可读。
确认 app_process 路径。
执行固定参数。
记录输出。
记录退出码。
保存设备版本。
标准 API 与平台实验对照
DexClassLoader 适合应用内受控加载。
它拥有应用进程的权限边界。
它可以和应用生命周期绑定。
app_process 适合开发设备上的运行时观察。
它的入口参数和类路径依赖系统实现。
它不等于获得系统权限。
两者都不能替代安全审计。
一个可重复的检查脚本
#!/usr/bin/env bash
set -euo pipefail
readonly dex="/data/local/tmp/runner.dex"
readonly entry="com.koko.runner.Main"
echo "sdk=$(adb shell getprop ro.build.version.sdk | tr -d '\r')"
echo "abi=$(adb shell getprop ro.product.cpu.abilist | tr -d '\r')"
echo "uid=$(adb shell id | tr -d '\r')"
echo "app_process=$(adb shell 'command -v app_process || command -v app_process64' | tr -d '\r')"
adb shell "test -r '$dex'"
adb shell "CLASSPATH='$dex' app_process /data/local/tmp '$entry' version"
脚本输出环境摘要。
它不修改系统设置。
它不修改 SELinux。
它不需要 root。
结束前的检查清单
入口类来自自己的构建产物。
DEX 哈希已经记录。
设备是自己的测试设备。
ADB 连接状态正常。
设备版本已记录。
类路径可读。
主类名完全匹配。
参数没有被意外转义。
输出没有敏感信息。
退出码已经记录。
失败时保留完整命令和环境。
结语
DEX 不是神秘的黑盒。
它是编译产物。
ClassLoader 决定类从哪里来。
进程身份决定能做什么。
系统服务决定哪些调用可以继续。
把这几层分开,实验就会变得可解释。
先用标准 API 建立应用内链路。
再在自己的设备上观察 app_process。
不要把一次成功运行扩展成所有设备的承诺。