背景
在系统应用开发过程中,经常用到frameweok中的非公开api。而在Android studio 编译时,通常无法访问这些api。其中常见的是获取系统属性,但是在Android studio 却无法编译通过
常见解决方式
1. 反射
通过反射调用这些api是最常见的方式,Google在公开的sdk中隐藏了这些api中,但实际上这些类还是正常加载的, 仍然存在于/system/lib/framework.jar 可通过反射调用。
但是官方为了提升稳定性在反射调用方向越来越严格,高版本存在许多限制。但仍然有大佬通过一些方式绕过了这些限制,来实现反射调用,比如这些:
不过由于和版本相关,因此会存在不稳定的因素,并且反射效率是比较低的。
2.绕过编译器
既然类都是已经加载的,那么实际上只要告诉编译器,这个类是存在的,让他通过编译就行了。
在gradle中通过compileOnly 可以引入一个包,就能达到这个效果,实际上官方的sdk就是通过这种方式实现的,只不过只需指定compileSdkVersion,然后AGP就会自动添加AndroidSdk/platforms/android-<ver>/android.jar 到编译环境,这样就能在工程中访问android的那些类。
我的一个项目ve3344/Logger,也是利用这种方式,使得在普通的java库中也可以访问android的Log库,来实现在java项目中使用System.out输出,在android项目用Log输出,并且不用反射,没有性能影响。
那么直接通过compileOnly 直接引入framework.jar是否可行?
在java项目中可以的,但是在android项目中,AGP已经添加了一个sdk了,再指定一个会有冲突,涉及一个优先级问题。在这篇文章中有介绍如何解决这个问题 高版本 Android Studio 集成 framework.jar
但是也存在一些问题,配置繁琐,并且需要打开Android studio 里面的Generate *.iml files for modules imported from Gradle,才能成功调整sdk优先级。并且偶尔会识别错误,提示没有sdk。
然后是另外一些大佬的方案,替换sdk里面的android.jar:
从原理上来说,我认为这个是最完美的方案了,因此本文也主要讲使用这种方式进行导入。
3.字节码修改
利用Gradle 插件使用字节码工具直接生成调用的字节码,相当于不经过编译器了。这种方式几乎没有人使用,理论上是可行的。目前没人研究,先不展开讲。
替换android.jar问题和解决
导入时aosp-android-jar 的android.jar,但实际上遇到了一些问题,在替换android.jar之后Sync项目,在执行MockableJarTransform 时报错了,字节码版本太高了不支持。
Could not resolve all files for configuration ':app:androidApis'.
Failed to transform android.jar to match attributes {artifactType=android-mockable-jar, org.gradle.libraryelements=jar, org.gradle.usage=java-runtime, returnDefaultValues=false}.
Execution failed for MockableJarTransform: C:\Env\AndroidSdk\platforms\android-34\android.jar.
Unsupported class file major version 61
因为 android.jar 是从官方下载的,没有字节码版本问题,而替换后却存在字节码版本问题。
查看官方的字节码版本是java8的,而aosp-android-jar 的是java17的。
这里顺便讲下aosp-android-jar里的android.jar的制作方式。来源Reginer 大佬的 AndroidStudio引用framework.jar
- 先在AS或其他工具下载官方的sdk,下载完成后sdk多出一个目录如
AndroidSdk/platforms/android-34 - 解压这个目录下的android.jar 到一个目录如android_sdk
- 编译AOSP 的framewrok 部分得到out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes-header.jar
- 将classes-header.jar 解压,并替代android_sdk 里面的文件
- 重新将android_sdk 目录内容打包成android.jar
也就是实际上相当于将官方的android.jar 里面的.class 替换成AOSP编译出来的。
而AOSP目前默认java版本是17,所以编译出来的字节码也是java17的。
接下来是需要解决这个字节码版本问题。
插播一个知识: 字节码的版本是对应jvm虚拟机的,而不是java源码的。也就是即使使用的java17语法的源码,也是可以编译出java8的虚拟机对应的字节码的。 平时在Gradle中这样指定就行
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
但是AOSP的编译工具貌似不支持指定java版本(有大佬知道指定也可以告知下),已经编译出了java17 的class_header.jar
字节码版本是在.class 的第4~8字节中指定的,可以看到00 00 00 3D 对应的值是61,直接更改不确定会不会出现问题。
在此利用ASM包修改,将jar包中的.class 文件修改字节码版本到java11,其他文件不变,然后再生成新的jar。
当然,不管哪种方式,降级一般是很难保证字节码的兼容性的。不过还好我们改的jar只会在编译期处理,会真正加载到虚拟机执行,因此问题不大。
这里贴出关键代码。 引入asm包,注意版本要引入高版本的,不然确实可能处理不了java17版本的字节码
api "org.ow2.asm:asm:9.2"
api "org.ow2.asm:asm-util:9.2"
class SdkProcessor {
var verbose = false
fun process(input: File, output: File, targetLevel: Int = Opcodes.V11) {
JarOutputStream(BufferedOutputStream(FileOutputStream(output))).use { outputStream ->
JarFile(input).use { androidJar ->
for (entry in Collections.list<JarEntry>(androidJar.entries())) {
val inputStream: InputStream = androidJar.getInputStream(entry)
if (entry.name.endsWith(".class")) {
processClass(entry, inputStream, outputStream, targetLevel)
} else {
val zipEntry = ZipEntry(entry.name)
zipEntry.comment = entry.comment
outputStream.putNextEntry(zipEntry)
inputStream.copyTo(outputStream)
}
inputStream.close()
}
}
}
}
private fun processClass(
entry: JarEntry,
inputStream: InputStream,
outputStream: JarOutputStream,
targetLevel: Int
) {
val classReader = ClassReader(inputStream)
val classWriter = ClassWriter(ClassWriter.COMPUTE_MAXS or ClassWriter.COMPUTE_FRAMES)
val visitor: ClassVisitor = object : ClassVisitor(Opcodes.ASM7, classWriter) {
override fun visit(
version: Int,
access: Int,
name: String?,
signature: String?,
superName: String?,
interfaces: Array<out String>?
) {
if (version > targetLevel) {
if (verbose){
println("Update [${entry.name}] $version -> $targetLevel")
}
super.visit(Opcodes.V11, access, name, signature, superName, interfaces)
} else {
if (verbose){
println("Ignore [${entry.name}] $version ")
}
super.visit(version, access, name, signature, superName, interfaces)
}
}
}
classReader.accept(visitor, 0)
outputStream.putNextEntry(ZipEntry(entry.name))
outputStream.write(classWriter.toByteArray())
}
}
将处理后新生成的android.jar 替换sdk下的android.jar 然后重新同步下。
同步之后,没有报错了,并且编译可以通过,运行时成功调用了系统api获取了属性。
顺便一提MockableJarTransform 错误原因
MockableJarTransform 是用于单元测试时将android.jar 里面的Stub代码抛异常部分替换为默认返回值。 我们可以看到默认android.jar 里面的方法实现都是 throw RumtimeException("Stub!"),在android设备上运行,因为是加载的framework.jar所以实际是不会抛异常。而行host类型的单元测试时是在电脑上运行的,如果调用了这些api,则会抛出异常,影响测试。(我猜的)
Reginer_Y 大佬在这个地方介绍了其中一种解决方式,替换AGP里面的一些包 Execution failed for MockableJarTransform_execution failed for mockablejartransform:-CSDN博客
当然由于每个人使用的AGP版本不一样,因此需要根据AGP版本重新编译一下还是挺麻烦的。
我们在工程上依赖加上这个依赖导入AGP的库分析下报错原因
implementation "com.android.tools.build:gradle:3.1.4"
在com.android.builder.testing.MockableJarGenerator 查看报错位置
MockableJarGenerator 也是利用ASM来处理的。在低版本的AGP中,引入的ASM版本也是比较低的。 在ASM5.1 中,是直接检测字节码版本超过java8就直接抛出IllegalArgumentException 表示不支持。
因此这也是为啥直接替换android-hidden-api 里面的android.jar 不行的原因。
因此升级AGP版本(本质是升级里面的附带的ASM版本)是可以解决这个MockableJarTransform 问题的。
当然,也可以直接指定asm 版本到一个高版本,确实没有这个问题了。
buildscript {
repositories {
google()
mavenCentral()
maven { url "https://jitpack.io" }
}
dependencies {
classpath "com.android.tools.build:gradle:4.1.3"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.30"
classpath "org.ow2.asm:asm:9.2"
}
}
不过这样如果工程中的java版本比较低仍然会有编译问题。因为工程用低版本,却引入了高版本的android.jar。
这种情况要么升级java高版本,要么换kotlin(kotlin编译器兼容性更好)
因此直接对android.jar 字节码进行处理来说比较省事。
风险和具体应用
既然google有意隐藏这些api,就是不想要普通开发者去调用。
普通应用调用的android.jar里面的api都是公开的。即使变更,google也会保证一定具有一定兼容性。
但是系统应用开发一般会随系统版本升级,只是想要在AS中通过编译,因此也无需考虑系统兼容性,这也是本文的主要目的。
实际上访问隐藏的api还具有其他应用场景,广泛用于许多”黑科技”中。比如以下这些著名的开源项目。
- 免Root xposed 框架 android-hacker/VirtualXposed
- 投屏工具 Genymobile/scrcpy
总结
- 通过反射可以调用隐藏api,不过高版本存在许多限制,需要通过一定手段绕过,存在一定稳定性和效率问题,不适用系统应用开发。
- 通过替换sdk里面的android.jar 可以比较完美解决调用系统隐藏api,遇到同步问题时需要将android.jar 里的字节码进行降级。或者升级AGP版本。
- 通过显式指定asm版本,也可以解决MockableJarTransform 问题,在编译时如果遇到引入高版字节码版本问题。可以将需要调用到高版本jar内容的源码使用kotlin调用。