如何在 Android 运行时不使用存储空间生成并执行 Java .class 字节码?
结论
如果“不使用存储空间”是指运行时不将生成的 .class、.dex 写入文件,OpenJDK 和 Android 8.0(API 26)及以上系统都可以在内存中生成并加载代码,但加载方式需要按平台实现:
- OpenJDK:直接通过
ClassLoader.defineClass()加载内存中的 JVM 字节码。 - Android API 26 及以上:先在内存中将 JVM 字节码转换为 DEX,再通过
InMemoryDexClassLoader加载。 - 旧版 Dalvik 或 API 26 以下的 Android:没有满足上述要求的通用公开类加载 API。
如果必须同时满足支持旧版 Dalvik、只使用公开 Java API、运行时不写文件这三个条件,就无法完整实现。若能将 Android 最低版本设为 API 26,则可以采用内存加载方案。Android 官方文档说明,InMemoryDexClassLoader 可以执行未写入本地文件系统的 DEX 代码。
这种方案仍然需要 RAM,也不能保证操作系统和虚拟机在整个运行期间完全没有磁盘活动。
为什么 Android 不能直接执行 .class
OpenJDK 使用 JVM class 文件格式,Android 的 Dalvik 和 ART 使用 DEX 格式。即使将 .class 内容放入 byte[],Android 也不会自动将其转换为 DEX。AOSP 运行时说明指出,ART 与 Dalvik 都执行 DEX 字节码。
从 Android 5.0 起,默认运行时是 ART。InMemoryDexClassLoader 虽然位于 dalvik.system 包下,但旧版 Dalvik 并没有这项功能。ART 兼容性说明
两个平台可以共用编译器,字节码转换和类加载则需要分别适配:
动态编译器 / Byte Buddy
|
v
内存中的 JVM class 字节码
|
+-- OpenJDK:defineClass()
|
+-- Android:内存转换为 DEX → InMemoryDexClassLoader
第一步:直接生成 class 字节数组
Byte Buddy 可以直接生成类的字节数组,无须调用保存文件的方法。下面的类包含一个 public static int answer() 方法,返回值为 42:
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.ClassFileVersion;
import net.bytebuddy.description.modifier.Ownership;
import net.bytebuddy.description.modifier.Visibility;
import net.bytebuddy.implementation.FixedValue;
// 放在方法体内执行
byte[] classBytes = new ByteBuddy(ClassFileVersion.JAVA_V8)
.subclass(Object.class)
.name("example.Generated")
.defineMethod(
"answer",
int.class,
Visibility.PUBLIC,
Ownership.STATIC)
.intercept(FixedValue.value(42))
.make()
.getBytes();
classBytes 中就是 class 文件格式的内容,不需要先创建 .class 文件。这里明确指定 Java 8 class 格式,避免生成器根据当前运行环境选择更高版本。
示例只生成一个类。如果生成过程中还产生了辅助类,也需要一并处理。
第二步:在 OpenJDK 上直接加载
OpenJDK 的 ClassLoader.defineClass() 接受 class 字节数组。可以通过一个简单的加载器暴露该方法:Java 8 API 文档
public final class MemoryClassLoader extends ClassLoader {
public MemoryClassLoader(ClassLoader parent) {
super(parent);
}
public Class<?> define(String name, byte[] bytes) {
return defineClass(name, bytes, 0, bytes.length);
}
}
在方法体内加载类并调用方法:
ClassLoader parent = MemoryClassLoader.class.getClassLoader();
Class<?> generated = new MemoryClassLoader(parent)
.define("example.Generated", classBytes);
Object result = generated.getMethod("answer").invoke(null);
System.out.println(result); // 42
这段代码用于 OpenJDK,Android 端需要使用 DEX 加载器。
第三步:在 Android 上通过 D8 完成内存转换
D8 提供命令行入口,也提供 Java API:
addClassProgramData()接收内存中的 class 字节数组。DexIndexedConsumer接收生成的 DEX 数据。D8.run()执行转换。
使用这些 API,就不必先保存 .class、运行命令,再读取 .dex。D8 输入 API 源码、D8 调用入口源码
下面的代码将单个 class 转换为单个 DEX,再从内存加载。应用需要先集成能在目标 Android 环境运行的 D8 库,因为 D8 不是 Android 系统内置的应用 API。
import android.annotation.TargetApi;
import com.android.tools.r8.ByteDataView;
import com.android.tools.r8.CompilationFailedException;
import com.android.tools.r8.D8;
import com.android.tools.r8.D8Command;
import com.android.tools.r8.DexIndexedConsumer;
import com.android.tools.r8.DiagnosticsHandler;
import com.android.tools.r8.origin.Origin;
import java.nio.ByteBuffer;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import dalvik.system.InMemoryDexClassLoader;
@TargetApi(26)
public final class AndroidMemoryLoader {
public static Class<?> load(
String className,
byte[] classBytes,
ClassLoader parent)
throws CompilationFailedException, ClassNotFoundException {
Map<Integer, byte[]> outputs = new ConcurrentHashMap<>();
DexIndexedConsumer consumer = new DexIndexedConsumer() {
@Override
public void accept(
int fileIndex,
ByteDataView data,
Set<String> descriptors,
DiagnosticsHandler handler) {
outputs.put(fileIndex, data.copyByteData());
}
@Override
public void finished(DiagnosticsHandler handler) {
}
};
D8Command command = D8Command.builder()
.addClassProgramData(classBytes, Origin.unknown())
.setMinApiLevel(26)
.setProgramConsumer(consumer)
.build();
D8.run(command);
if (outputs.size() != 1 || !outputs.containsKey(0)) {
throw new IllegalStateException(
"This example requires exactly one DEX output");
}
ByteBuffer dexBuffer = ByteBuffer.wrap(outputs.get(0));
ClassLoader loader = new InMemoryDexClassLoader(
dexBuffer, parent);
return loader.loadClass(className);
}
}
在 Android API 26 及以上环境中调用:
Class<?> generated = AndroidMemoryLoader.load(
"example.Generated",
classBytes,
AndroidMemoryLoader.class.getClassLoader());
Object result = generated.getMethod("answer").invoke(null);
System.out.println(result); // 42
ByteDataView 的数据只保证在 accept() 回调期间有效,因此需要调用 copyByteData() 保存副本。回调可能并发执行,所以示例使用 ConcurrentHashMap 接收结果。DexIndexedConsumer 接口说明
使用时需要注意什么
Java 8 语法不等于工具库兼容 Java 8
示例使用 Java 8 语法,但 Byte Buddy、D8 能否在 Java 8 或目标 Android 环境运行,取决于具体版本。选定依赖版本后,需要检查其运行时要求,并验证打包后的应用。
setMinApiLevel(26) 控制生成 DEX 的最低目标 API,不会让 D8 自身自动兼容 Android。
复杂输入可能需要补充编译依赖
示例只处理简单类。如果生成的代码涉及接口方法、lambda 或需要 desugaring 的功能,D8 可能还需要库和类路径信息。命令行文档也说明了部分 Java 8 特性对依赖输入的要求。D8 文档
如果转换阶段也不能读取文件,可以通过 addLibraryResourceProvider()、addClasspathResourceProvider() 提供内存中的 class 定义。即使 Android 运行时中存在某个类,D8 也不会自动获得该类的 JVM class 格式定义。
多类和多个 DEX 需要扩展示例
生成多个类时,应将所有待转换类的字节数组交给同一次 D8 编译,包括辅助类。示例只接受一个 DEX 输出;如果可能产生多个 DEX,API 27 起可以使用接收 ByteBuffer[] 的 InMemoryDexClassLoader 构造方法。构造方法文档
父加载器必须能找到生成代码依赖的应用类,同一个加载器中也不能反复定义同名类。
Byte Buddy 的现成 Android 加载策略仍可能写文件
Byte Buddy 文档中的 AndroidClassLoadingStrategy 使用临时目录完成 Android 类加载,无法直接满足“不落盘”的要求。可以保留 Byte Buddy 的字节码生成部分,再自行接入 D8 内存转换和 InMemoryDexClassLoader。Byte Buddy Android 支持说明
如果必须支持 API 26 以下设备,就需要放宽条件,例如允许将 DEX 保存到应用私有目录,或者改用 Java 实现解释器。解释器可以跨平台执行自定义指令,但这种方式与将动态生成的类交给宿主虚拟机加载不同。
备注:内容仅供参考。
