バックグラウンド
私たちの仕事ではサードパーティのライブラリをよく使用します。これらのライブラリにはさまざまな問題が発生することは避けられません。オープンソースライブラリの作成者に問題を発行する方法はありません。ただし、この効率は非常に低く、作成者は必ずしも変更する必要はないので、自分で変更するだけです。
解決
- プロジェクトの変更は可能ですが、効率が非常に低く、予測できない問題が発生する可能性もあり、開発コストが大幅に増加します。
- Gradleプラグインを開発し、Javassitを使用して、クラス内の既存のメソッドを動的に変更します。このメソッドは非常に効率的で、ソースコードに影響を与えません。
Javassit
javassistは、Javaバイトコードを動的に変更するオープンソースライブラリです。コンパイルされたクラスファイルにメソッドを追加/変更したり、コードを挿入したりできます。バイトコードを深く理解する必要はありません。同時に、完全に手動のメソッドを使用して新しいクラスオブジェクトを生成することもできます。javassitの特定の使用法を表示できます:Tutorial-1、Tutorial-2、Tutorial-3
Gradle
Android StudioプロジェクトはGradleを使用してビルドされます。ビルドツールGradleは、一連のタスクを含むスクリプトと見なすことができます。これらのタスクが順番に実行されると、プロジェクトは正常にパッケージ化されます。カスタムGradleプラグインの本質は、論理的に独立したコードを抽出してカプセル化することです。これにより、プラグインに依存することで関数をより効率的に再利用できます。Androidのgradleプラグインは、次の2つのカテゴリに分類されます。
- スクリプトプラグイン:通常のgradleスクリプト作成フォームと同じように、build.gradleファイルに直接書き込むことも、新しいgradleスクリプトファイルを自分で作成することもできます。
- オブジェクトプラグイン:プラグインのフルパスクラス名またはIDで参照されます。主に、以下に示す3つの書き込み形式があります
。1)現在のビルドスクリプトで直接書き込みます。
2)buildSrcディレクトリに書き込みます。
3)完全に独立したプロジェクトで書かれています。
buildSrc
buildSrcのデフォルトのプラグインディレクトリを使用します。このディレクトリのコードは、ビルド中に自動的にコンパイルおよびパッケージ化され、buildScriptのクラスパスに追加されるため、追加の構成なしで他のモジュールで直接使用できます。Gradleスクリプト参照。注:Gradle 6.0以降では、settings.gradleファイルで構成しないでください。構成しないと、次のように生成されます。'buildSrc' cannot be used as a project name as it is a reserved name
例外
- buildSrcの実行タイミングは、どのプロジェクト(build.gradle)よりも早いだけでなく、settings.gradleよりも早いです。
- ':buildSrc'がsettings.gradleで構成されている場合、buildSrcディレクトリは2回実行されるため、サブプロジェクトとして扱われます。したがって、 ':buildSrc'の構成はsettings.gradleから削除する必要があります。
ステップ
1.プロジェクトのルートディレクトリに新しいモジュールを作成し、固定名でbuildSrcという名前を付けます。
2. srcディレクトリの下にある空のメインディレクトリを削除し、メインディレクトリの下にgroovyディレクトリとresourcesディレクトリを作成します。
3. build.gradlesのすべての構成を削除し、次の構成を追加します
apply plugin: 'groovy'
dependencies {
implementation gradleApi()
implementation localGroovy()
implementation "com.android.tools.build:gradle:4.1.0"
implementation "commons-io:commons-io:2.4"
implementation "org.javassist:javassist:3.25.0-GA"
}
repositories {
google()
jcenter()
}
4. groovyディレクトリcom.gh.gamecenter.plugin
に独自のパッケージ名を作成してから、PluginとTransformをそれぞれ継承する2つのgroovyクラスを作成します。コードは次のとおりです。
class GhTransform extends Transform {
......
}
class GhPlugin implements Plugin<Project> {
void apply(Project project) {
project.android.registerTransform(new GhTransform(project))
}
}
5. resourcesディレクトリの下にMETA-INF.gradle-pluginsディレクトリを作成し、その中にcom.gh.gamecenter.plugin.properties
ファイルを作成します。プロパティの前のコンテンツはプラグインの名前です。次のコンテンツをファイルに追加します。
implementation-class=com.gh.gamecenter.plugin.GhPlugin
6.最後に、build.gradleにプラグインを導入して、基本的に構成します。
apply plugin: "com.gh.gamecenter.plugin"
android {
......
}
上記はカスタムプラグインの基本構成です。次のステップはプラグイン機能を実装することです
変換
GoogleはAndroidGradleV1.5.0の後にTransfromAPIを公式に提供し、サードパーティのプラグインがコンパイルプロセス中に.dexファイルにパッケージ化する前に.classファイルを操作できるようにします。必要なのは.classファイルをトラバースするようにTransformを実装することだけです。 getすべてのメソッドで、変更の完了後に元のファイルを置き換えます。
記事の背景で、プラグインを開発する目的は、サードパーティのオープンソースライブラリのクラスのメソッドを変更することであると述べました。具体的には、このメソッドの前に独自のコードを追加する必要があります。要件に応じてインターセプトを実装します。私のアプローチは次のとおりです。プロジェクトで静的メソッドを記述し、このメソッドの前に静的メソッドを呼び出して、インターセプト機能を実現できるようにします。具体的なコードは次のとおりです。
class GhTransform extends Transform {
private ClassPool classPool = ClassPool.getDefault()
Project project
GhTransform(Project project) {
this.project = project
}
@Override
String getName() {
return "GhTransform"
}
/**
* 需要处理的数据类型,目前 ContentType有六种枚举类型,通常我们使用比较频繁的有前两种:
* 1、CONTENT_CLASS:表示需要处理 java 的 class 文件。
* 2、CONTENT_JARS:表示需要处理 java 的 class 与 资源文件。
* 3、CONTENT_RESOURCES:表示需要处理 java 的资源文件。
* 4、CONTENT_NATIVE_LIBS:表示需要处理 native 库的代码。
* 5、CONTENT_DEX:表示需要处理 DEX 文件。
* 6、CONTENT_DEX_WITH_RESOURCES:表示需要处理 DEX 与 java 的资源文件。
*/
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
/**
* Transform 要操作的内容范围,目前 Scope 有五种基本类型:
* 1、PROJECT:只有项目内容
* 2、SUB_PROJECTS:只有子项目
* 3、EXTERNAL_LIBRARIES:只有外部库
* 4、TESTED_CODE:由当前变体(包括依赖项)所测试的代码
* 5、PROVIDED_ONLY:只提供本地或远程依赖项
* SCOPE_FULL_PROJECT 是一个Scope集合,包含Scope.PROJECT,Scope.SUB_PROJECTS,Scope.EXTERNAL_LIBRARIES 这三项,即当前Transform的作用域包括当前项目、子项目以及外部的依赖库
*/
@Override
Set<? super QualifiedContent.Scope> getScopes() {
//通常我们使用 SCOPE_FULL_PROJECT
return TransformManager.SCOPE_FULL_PROJECT
}
/**
* 是否需要增量编译
*/
@Override
boolean isIncremental() {
return false
}
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation)
//添加加android.jar目录
classPool.appendClassPath(project.android.bootClasspath[0].toString())
def outputProvider = transformInvocation.outputProvider
// 删除之前的输出
if (outputProvider != null) {
outputProvider.deleteAll()
}
transformInvocation.inputs.each {
input ->
input.directoryInputs.each {
dirInput ->
handleDirectory(dirInput.file)
def dest = outputProvider.getContentLocation(dirInput.name, dirInput.contentTypes, dirInput.scopes, Format.DIRECTORY)
// 将input的目录复制到output指定目录
FileUtils.copyDirectory(dirInput.file, dest)
}
}
transformInvocation.inputs.each {
input ->
input.jarInputs.each {
jarInput ->
if (jarInput.file.exists()) {
def srcFile = handleJar(jarInput.file)
//必须给jar重新命名,否则会冲突
def jarName = jarInput.name
def md5 = DigestUtils.md5Hex(jarInput.file.absolutePath)
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4)
}
def dest = outputProvider.getContentLocation(md5 + jarName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
FileUtils.copyFile(srcFile, dest)
}
}
}
}
void handleDirectory(File dir) {
//将类路径添加到classPool中
classPool.insertClassPath(dir.absolutePath)
if (dir.isDirectory()) {
dir.eachFileRecurse {
file ->
def filePath = file.absolutePath
classPool.insertClassPath(filePath)
if (shouldModify(filePath)) {
def inputStream = new FileInputStream(file)
CtClass ctClass = modifyClass(inputStream)
ctClass.writeFile()
//调用detach方法释放内存
ctClass.detach()
}
}
}
}
/**
* 主要步骤:
* 1.遍历所有jar文件
* 2.解压jar然后遍历所有的class
* 3.读取class的输入流并使用javassit修改,然后保存到新的jar文件中
*/
File handleJar(File jarFile) {
classPool.appendClassPath(jarFile.absolutePath)
def inputJarFile = new JarFile(jarFile)
def entries = inputJarFile.entries()
//创建一个新的文件
def outputJarFile = new File(jarFile.parentFile, "temp_" + jarFile.name)
if (outputJarFile.exists()) outputJarFile.delete()
def jarOutputStream = new JarOutputStream(new BufferedOutputStream(new FileOutputStream(outputJarFile)))
while (entries.hasMoreElements()) {
def jarInputEntry = entries.nextElement()
def jarInputEntryName = jarInputEntry.name
def outputJarEntry = new JarEntry(jarInputEntryName)
jarOutputStream.putNextEntry(outputJarEntry)
def inputStream = inputJarFile.getInputStream(jarInputEntry)
if (!shouldModify(jarInputEntryName)) {
jarOutputStream.write(IOUtils.toByteArray(inputStream))
inputStream.close()
continue
}
def ctClass = modifyClass(inputStream)
def byteCode = ctClass.toBytecode()
ctClass.detach()
inputStream.close()
jarOutputStream.write(byteCode)
jarOutputStream.flush()
}
inputJarFile.close()
jarOutputStream.closeEntry()
jarOutputStream.flush()
jarOutputStream.close()
return outputJarFile
}
static boolean shouldModify(String filePath) {
return filePath.endsWith(".class") &&
!filePath.contains("R.class") &&
!filePath.contains('$') &&
!filePath.contains('R$') &&
!filePath.contains("BuildConfig.class") &&
filePath.contains("ExoSourceManager")
}
CtClass modifyClass(InputStream is) {
def classFile = new ClassFile(new DataInputStream(new BufferedInputStream(is)))
def ctClass = classPool.get(classFile.name)
//判断是否需要解冻
if (ctClass.isFrozen()) {
ctClass.defrost()
}
def method = ctClass.getDeclaredMethod("release")
//必须使用全类名,否则编译会找不到类
def body = '''
int size = com.gh.gamecenter.video.detail.CustomManager.getVideoManagerSize();
if (size > 1) {
android.util.Log.e(\"gh_tag\",\"拦截成功\");
return;
}
'''
method.insertBefore(body)
return ctClass
}
}
これまでのところ、プラグインの開発が完了しました。次に、コードが正常に挿入されたことを確認する必要があります。組み込みのAndroid Studio Build / Analyze APKツールを使用して、apkファイル
をコンパイルして変更されたクラスを見つけることができます。右クリックShow Bytecode
してバイトコードを表示し、変更したメソッドを検索します。上
の赤いボックスは追加されたコードです。コードにログ行を挿入し、コードを実行してlogcatの印刷を確認します。
最後に、プラグインの開発が完了しました。 -で、その有効性を検証しましたが、少し問題があるようですが、将来同様の問題が発生した場合は、上記のコードに基づいて問題を解決することもできます。
注意が必要ないくつかの問題
- オープンソースライブラリと自分で作成した挿入コードを混同しないように注意してください
- buildSrcのbuild.gradleのAGPバージョンは、アプリモジュールと同じである必要があります
- 挿入されたコードによって参照されるクラスのフルパスを使用します
- コードで使用されるクラスを挿入し、クラスパスをclassPoolに追加する必要があります。そうしないと、コンパイルされません。
- settings.gradleでbuildSrcを構成しないでください
- jarを変更するかどうかに関係なく、jarをターゲットパスにコピーする必要があります