Android studio 导入framework.jar 开发系统应用

3,717 阅读8分钟

背景

在系统应用开发过程中,经常用到frameweok中的非公开api。而在Android studio 编译时,通常无法访问这些api。其中常见的是获取系统属性,但是在Android studio 却无法编译通过

image.png

常见解决方式

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 是从官方下载的,没有字节码版本问题,而替换后却存在字节码版本问题。

image.png

查看官方的字节码版本是java8的,而aosp-android-jar 的是java17的。

这里顺便讲下aosp-android-jar里的android.jar的制作方式。来源Reginer 大佬的 AndroidStudio引用framework.jar

  1. 先在AS或其他工具下载官方的sdk,下载完成后sdk多出一个目录如AndroidSdk/platforms/android-34
  2. 解压这个目录下的android.jar 到一个目录如android_sdk
  3. 编译AOSP 的framewrok 部分得到out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes-header.jar
  4. 将classes-header.jar 解压,并替代android_sdk 里面的文件
  5. 重新将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

image.png

字节码版本是在.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 然后重新同步下。

image.png

同步之后,没有报错了,并且编译可以通过,运行时成功调用了系统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 查看报错位置

image.png

image.png

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"
    }
}

image.png

不过这样如果工程中的java版本比较低仍然会有编译问题。因为工程用低版本,却引入了高版本的android.jar。

这种情况要么升级java高版本,要么换kotlin(kotlin编译器兼容性更好)

因此直接对android.jar 字节码进行处理来说比较省事。

风险和具体应用

既然google有意隐藏这些api,就是不想要普通开发者去调用。

普通应用调用的android.jar里面的api都是公开的。即使变更,google也会保证一定具有一定兼容性。

但是系统应用开发一般会随系统版本升级,只是想要在AS中通过编译,因此也无需考虑系统兼容性,这也是本文的主要目的。

实际上访问隐藏的api还具有其他应用场景,广泛用于许多”黑科技”中。比如以下这些著名的开源项目。

  1. 免Root xposed 框架 android-hacker/VirtualXposed
  2. 投屏工具 Genymobile/scrcpy

总结

  1. 通过反射可以调用隐藏api,不过高版本存在许多限制,需要通过一定手段绕过,存在一定稳定性和效率问题,不适用系统应用开发。
  2. 通过替换sdk里面的android.jar 可以比较完美解决调用系统隐藏api,遇到同步问题时需要将android.jar 里的字节码进行降级。或者升级AGP版本。
  3. 通过显式指定asm版本,也可以解决MockableJarTransform 问题,在编译时如果遇到引入高版字节码版本问题。可以将需要调用到高版本jar内容的源码使用kotlin调用。