Android组件化开发实践(九):自定义Gradle插件

简介: 本文紧接着前一章Android组件化开发实践(八):组件生命周期如何实现自动注册管理,主要讲解怎么通过自定义插件来实现组件生命周期的自动注册管理。1. 采用groovy创建插件新建一个Java Library module,命名为lifecycle...

本文紧接着前一章Android组件化开发实践(八):组件生命周期如何实现自动注册管理,主要讲解怎么通过自定义插件来实现组件生命周期的自动注册管理。

1. 采用groovy创建插件

新建一个Java Library module,命名为lifecycle-plugin,删除 src->main 下面的java目录,新建一个groovy目录,在groovy目录下创建类似java的package,在 src->main 下面创建一个 resources 目录,在resources目录下依次创建 META-INF/gradle-plugins 目录,最后在该目录下创建一个名为 com.hm.plugin.lifecycle.properties的文本文件,文件名是你要定义的插件名,按需自定义即可。最后的工程结构如图所示:

img_64f473af1bfe989a463463218cdb3925.png

修改module的build.gradle文件,引入groovy插件等:

apply plugin: 'java-library'
apply plugin: 'groovy'
apply plugin: 'maven'

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    compile gradleApi()
    compile localGroovy()
    compile 'com.android.tools.build:transform-api:1.5.0'
    compile 'com.android.tools.build:gradle:3.0.1'
}

sourceCompatibility = "1.7"
targetCompatibility = "1.7"

//通过maven将插件发布到本地的脚本配置,根据自己的要求来修改
uploadArchives {
    repositories.mavenDeployer {
        pom.version = '1.0.0'
        pom.artifactId = 'hmlifecyclepluginlocal'
        pom.groupId = 'com.heima.iou'
        repository(url: "file:///Users/hjy/.m2/repository/")
    }
}

这里有几点需要说明的是:

  1. 通常都是采用groovy语言来创建gradle plugin的,groovy是兼容java的,你完全可以采用java来编写插件。关于groovy语言,了解一些基础语法就足够支撑我们去编写插件了。
  2. src/main/resources/META-INF/gradle-plugins目录下定义插件声明,*.properties文件的文件名就是插件名称。

2. 实现Plugin接口

要编写一个插件是很简单的,只需实现Plugin接口即可。

package com.hm.iou.lifecycle.plugin

import com.android.build.gradle.AppExtension
import org.gradle.api.Plugin
import org.gradle.api.Project

class LifeCyclePlugin implements Plugin<Project>{
    @Override
    void apply(Project project) {
        println "------LifeCycle plugin entrance-------"
    }
}

接着在com.hm.plugin.lifecycle.properties文件里增加配置:

implementation-class=com.hm.iou.lifecycle.plugin.LifeCyclePlugin

其中implementation-class的值为Plugin接口的实现类的全限定类名,至此为止一个最简单的插件编写好了,它的功能很简单,仅仅是在控制台打印一句文本而已。

我们通过maven将该插件发布到本地的maven仓库里,发布成功后,我们在app module里引入该插件,修改app module目录下的build.gradle文件,增加如下配置:

apply plugin: 'com.android.application'
//引入自定义插件,插件名与前面的*.properties文件的文件名是一致的
apply plugin: 'com.hm.plugin.lifecycle'
buildscript {
    repositories {
        google()
        jcenter()
        //自定义插件maven地址,替换成你自己的maven地址
        maven { url 'file:///Users/hjy/.m2/repository/' }
    }
    dependencies {
        //通过maven加载自定义插件
        classpath 'com.heima.iou:hmlifecyclepluginlocal:1.0.0'
    }
}

我们build一下工程,在Gradle Console里会打印出"------LifeCycle plugin entrance-------"来,这说明我们的自定义插件成功了。

讲到这里可以看到,按这个步骤实现一个gradle插件是很简单的,它并没有我们想象中那么高深莫测,你也可以自豪地说我会制作gradle插件了。

3. Gradle Transform

然而前面这个插件并没有什么卵用,它仅仅只是在编译时,在控制台打印一句话而已。那么怎么通过插件在打包前去扫描所有的class文件呢,幸运的是官方给我们提供了 Gradle Transform技术,简单来说就是能够让开发者在项目构建阶段即由class到dex转换期间修改class文件,Transform阶段会扫描所有的class文件和资源文件,具体技术我这里不详细展开,下面通过伪代码部分说下我的思路。

//只需要继承Transform类即可
class LifeCycleTransform extends Transform {

    Project project

    LifeCycleTransform(Project project) {
        this.project = project
    }

    //该Transform的名称,自定义即可,只是一个标识
    @Override
    String getName() {
        return "LifeCycleTransform"
    }

    //该Transform支持扫描的文件类型,分为class文件和资源文件,我们这里只处理class文件的扫描
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    //Transfrom的扫描范围,我这里扫描整个工程,包括当前module以及其他jar包、aar文件等所有的class
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    //是否增量扫描
    @Override
    boolean isIncremental() {
        return true
    }

    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs,
                   TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        println "\nstart to transform-------------->>>>>>>"

        def appLikeProxyClassList = []
        //inputs就是所有扫描到的class文件或者是jar包,一共2种类型
        inputs.each { TransformInput input ->
            //1.遍历所有的class文件目录
            input.directoryInputs.each { DirectoryInput directoryInput ->
                //递归扫描该目录下所有的class文件
                if (directoryInput.file.isDirectory()) {
                    directoryInput.file.eachFileRecurse {File file ->
                        //形如 Heima$$****$$Proxy.class 的类,是我们要找的目标class,直接通过class的名称来判断,也可以再加上包名的判断,会更严谨点
                        if (ScanUtil.isTargetProxyClass(file)) {
                            //如果是我们自己生产的代理类,保存该类的类名
                            appLikeProxyClassList.add(file.name)
                        }
                    }
                }

                //Transform扫描的class文件是输入文件(input),有输入必然会有输出(output),处理完成后需要将输入文件拷贝到一个输出目录下去,
                //后面打包将class文件转换成dex文件时,直接采用的就是输出目录下的class文件了。
                //必须这样获取输出路径的目录名称
                def dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
                FileUtils.copyDirectory(directoryInput.file, dest)
            }

            //2.遍历查找所有的jar包
            input.jarInputs.each { JarInput jarInput ->
                println "\njarInput = ${jarInput}"

                //与处理class文件一样,处理jar包也是一样,最后要将inputs转换为outputs
                def jarName = jarInput.name
                def md5 = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }
                //获取输出路径下的jar包名称,必须这样获取,得到的输出路径名不能重复,否则会被覆盖
                def dest = outputProvider.getContentLocation(jarName + md5, jarInput.contentTypes, jarInput.scopes, Format.JAR)
                if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
                    File src = jarInput.file
                    //先简单过滤掉 support-v4 之类的jar包,只处理有我们业务逻辑的jar包
                    if (ScanUtil.shouldProcessPreDexJar(src.absolutePath)) {
                        //扫描jar包的核心代码在这里,主要做2件事情:
                        //1.扫描该jar包里有没有实现IAppLike接口的代理类;
                        //2.扫描AppLifeCycleManager这个类在哪个jar包里,并记录下来,后面需要在该类里动态注入字节码;
                        List<String> list = ScanUtil.scanJar(src, dest)
                        if (list != null) {
                            appLikeProxyClassList.addAll(list)
                        }
                    }
                }
                //将输入文件拷贝到输出目录下
                FileUtils.copyFile(jarInput.file, dest)
            }
        }

        println ""
        appLikeProxyClassList.forEach({fileName ->
            println "file name = " + fileName
        })
        println "\n包含AppLifeCycleManager类的jar文件"
        println ScanUtil.FILE_CONTAINS_INIT_CLASS.getAbsolutePath()
        println "开始自动注册"

        //1.通过前面的步骤,我们已经扫描到所有实现了 IAppLike接口的代理类;
        //2.后面需要在 AppLifeCycleManager 这个类的初始化方法里,动态注入字节码;
        //3.将所有 IAppLike 接口的代理类,通过类名进行反射调用实例化
        //这样最终生成的apk包里,AppLifeCycleManager调用init()方法时,已经可以加载所有组件的生命周期类了
        new AppLikeCodeInjector(appLikeProxyClassList).execute()

        println "transform finish----------------<<<<<<<\n"
    }
}

我们来看看ScanUtil类里的代码逻辑:

class ScanUtil {

    static final PROXY_CLASS_PREFIX = "Heima\$\$"
    static final PROXY_CLASS_SUFFIX = "\$\$Proxy.class"
    //注意class文件名中的包名是以“/”分隔开,而不是“.”分隔的,这个包名是我们通过APT生成的所有 IAppLike 代理类的包名
    static final PROXY_CLASS_PACKAGE_NAME = "com/hm/iou/lifecycle/apt/proxy"
    //AppLifeCycleManager是应用生命周期框架初始化方法调用类
    static final REGISTER_CLASS_FILE_NAME = "com/hm/lifecycle/api/AppLifeCycleManager.class"

    //包含生命周期管理初始化类的文件,即包含 com.hm.lifecycle.api.AppLifeCycleManager 类的class文件或者jar文件
    static File FILE_CONTAINS_INIT_CLASS

    /**
     * 判断该class是否是我们的目标类
     *
     * @param file
     * @return
     */
    static boolean isTargetProxyClass(File file) {
        if (file.name.endsWith(PROXY_CLASS_SUFFIX) && file.name.startsWith(PROXY_CLASS_PREFIX)) {
            return true
        }
        return false
    }

    /**
     * 扫描jar包里的所有class文件:
     * 1.通过包名识别所有需要注入的类名
     * 2.找到AppLifeCycleManager类所在的jar包,后面我们会在该jar包里进行代码注入
     *
     * @param jarFile
     * @param destFile
     * @return
     */
    static List<String> scanJar(File jarFile, File destFile) {
        def file = new JarFile(jarFile)
        Enumeration<JarEntry> enumeration = file.entries()
        List<String> list = null
        while (enumeration.hasMoreElements()) {
            //遍历这个jar包里的所有class文件项
            JarEntry jarEntry = enumeration.nextElement()
            //class文件的名称,这里是全路径类名,包名之间以"/"分隔
            String entryName = jarEntry.getName()
            if (entryName == REGISTER_CLASS_FILE_NAME) {
                //标记这个jar包包含 AppLifeCycleManager.class
                //扫描结束后,我们会生成注册代码到这个文件里
                FILE_CONTAINS_INIT_CLASS = destFile
            } else {
                //通过包名来判断,严谨点还可以加上类名前缀、后缀判断
                //通过APT生成的类,都有统一的前缀、后缀
                if (entryName.startsWith(PROXY_CLASS_PACKAGE_NAME)) {
                    if (list == null) {
                        list = new ArrayList<>()
                    }
                    list.addAll(entryName.substring(entryName.lastIndexOf("/") + 1))
                }
            }
        }
        return list
    }

    static boolean shouldProcessPreDexJar(String path) {
        return !path.contains("com.android.support") && !path.contains("/android/m2repository")
    }

}

修改Plugin接口实现类,在插件中注册该Transfrom:

class LifeCyclePlugin implements Plugin<Project>{
    @Override
    void apply(Project project) {
        println "------LifeCycle plugin entrance-------"
        def android = project.extensions.getByType(AppExtension)
        android.registerTransform(new LifeCycleTransform(project))
    }
}

前面的代码里,先注释掉LifeCycleTransform类里的AppLikeCodeInjector相关代码,这块我们后面再讲。我们再新建一个Android Library module,在该module里创建 ModuleCAppLike、ModuleDAppLike,同样都实现IAppLike接口并采用@AppLifeCycle作为注解。最后采用最新的插件重新build一下工程,看看Gradle Console里的输出信息。

file name = Heima$$ModuleCAppLike$$Proxy.class
file name = Heima$$ModuleDAppLike$$Proxy.class
file name = Heima$$ModuleAAppLike$$Proxy.class
file name = Heima$$ModuleBAppLike$$Proxy.class

包含AppLifeCycleManager类的jar文件
/Users/hjy/Desktop/heima/code/gitlab/HM-AppLifeCycleMgr/app/build/intermediates/transforms/LifeCycleTransform/debug/17.jar

可以看到,在Transform过程中,我们找到了ModuleAAppLike、ModuleBAppLike、ModuleCAppLike、ModuleDAppLike这4个类的代理类,以及AppLifeCycleManager这个class文件所在的jar包。

img_38356e1aac39826cdbeb47f8198ce622.png

在app->build->intermediates->transforms中,可以看到所有的Transform,包括我们刚才自定义的Transform。从上图中可以看到,这里的0.jar、1.jar、2.jar等等,都是通过outputProvider.getContentLocation()方法来生成的,这个Transform目录下的class文件、jar包等,会当做下一个Transform的inputs传递过去。

4. 通过ASM动态修改字节码

到现在,我们只剩下最后一步了,那就是如何注入代码了。ASM 是一个 Java 字节码操控框架,它能被用来动态生成类或者增强既有类的功能。我这里对ASM不做详细介绍了,主要是介绍使用ASM动态注入代码的思路。

首先,我们修改一下AppLifeCycleManager类,增加动态注入字节码的入口方法:

    /**
     * 通过插件加载 IAppLike 类
     */
    private static void loadAppLike() {
    }

    //通过反射去加载 IAppLike 类的实例
    private static void registerAppLike(String className) {
        if (TextUtils.isEmpty(className))
            return;
        try {
            Object obj = Class.forName(className).getConstructor().newInstance();
            if (obj instanceof IAppLike) {
                APP_LIKE_LIST.add((IAppLike) obj);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 初始化
     *
     * @param context
     */
    public static void init(Context context) {
        //通过插件加载 IAppLike 类
        loadAppLike();
        Collections.sort(APP_LIKE_LIST, new AppLikeComparator());
        for (IAppLike appLike : APP_LIKE_LIST) {
            appLike.onCreate(context);
        }
    }

相比之前,这里增加了一个loadAppLike()方法,在init()方法调用时会先执行。通过前面Transform步骤之后,我们现在的目标是把代码动态插入到loadAppLike()方法里,下面这段代码是我们期望插入后的结果:

private static void loadAppLike() {
  registerAppLike("com.hm.iou.lifecycle.apt.proxy.Heima$$ModuleAAppLike$$Proxy");
  registerAppLike("com.hm.iou.lifecycle.apt.proxy.Heima$$ModuleBAppLike$$Proxy");
  registerAppLike("com.hm.iou.lifecycle.apt.proxy.Heima$$ModuleCAppLike$$Proxy");
  registerAppLike("com.hm.iou.lifecycle.apt.proxy.Heima$$ModuleDAppLike$$Proxy");
}

这样在初始化时,就已经知道要加载哪些生命周期类,来看看具体实现方法,关于ASM不了解的地方,需要先搞清楚其使用方法再来阅读:

class AppLikeCodeInjector {

    //扫描出来的所有 IAppLike 类
    List<String> proxyAppLikeClassList

    AppLikeCodeInjector(List<String> list) {
        proxyAppLikeClassList = list
    }

    void execute() {
        println("开始执行ASM方法======>>>>>>>>")

        File srcFile = ScanUtil.FILE_CONTAINS_INIT_CLASS
        //创建一个临时jar文件,要修改注入的字节码会先写入该文件里
        def optJar = new File(srcFile.getParent(), srcFile.name + ".opt")
        if (optJar.exists())
            optJar.delete()
        def file = new JarFile(srcFile)
        Enumeration<JarEntry> enumeration = file.entries()
        JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar))
        while (enumeration.hasMoreElements()) {
            JarEntry jarEntry = enumeration.nextElement()
            String entryName = jarEntry.getName()
            ZipEntry zipEntry = new ZipEntry(entryName)
            InputStream inputStream = file.getInputStream(jarEntry)
            jarOutputStream.putNextEntry(zipEntry)

            //找到需要插入代码的class,通过ASM动态注入字节码
            if (ScanUtil.REGISTER_CLASS_FILE_NAME == entryName) {
                println "insert register code to class >> " + entryName

                ClassReader classReader = new ClassReader(inputStream)
                // 构建一个ClassWriter对象,并设置让系统自动计算栈和本地变量大小
                ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)
                ClassVisitor classVisitor = new AppLikeClassVisitor(classWriter)
                //开始扫描class文件
                classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)

                byte[] bytes = classWriter.toByteArray()
                //将注入过字节码的class,写入临时jar文件里
                jarOutputStream.write(bytes)
            } else {
                //不需要修改的class,原样写入临时jar文件里
                jarOutputStream.write(IOUtils.toByteArray(inputStream))
            }
            inputStream.close()
            jarOutputStream.closeEntry()
        }

        jarOutputStream.close()
        file.close()

        //删除原来的jar文件
        if (srcFile.exists()) {
            srcFile.delete()
        }
        //重新命名临时jar文件,新的jar包里已经包含了我们注入的字节码了
        optJar.renameTo(srcFile)
    }

    //插入字节码的逻辑,都在这个类里面
    class AppLikeClassVisitor extends ClassVisitor {
        AppLikeClassVisitor(ClassVisitor classVisitor) {
            super(Opcodes.ASM5, classVisitor)
        }

        @Override
        MethodVisitor visitMethod(int access, String name,
                                  String desc, String signature,
                                  String[] exception) {
            println "visit method: " + name
            MethodVisitor mv = super.visitMethod(access, name, desc, signature, exception)
            //找到 AppLifeCycleManager里的loadAppLike()方法,我们在这个方法里插入字节码
            if ("loadAppLike" == name) {
                mv = new LoadAppLikeMethodAdapter(mv, access, name, desc)
            }
            return mv
        }
    }

    class LoadAppLikeMethodAdapter extends AdviceAdapter {

        LoadAppLikeMethodAdapter(MethodVisitor mv, int access, String name, String desc) {
            super(Opcodes.ASM5, mv, access, name, desc)
        }

        @Override
        protected void onMethodEnter() {
            super.onMethodEnter()
            println "-------onMethodEnter------"
            //遍历插入字节码,其实就是在 loadAppLike() 方法里插入类似registerAppLike("");的字节码
            proxyAppLikeClassList.forEach({proxyClassName ->
                println "开始注入代码:${proxyClassName}"
                def fullName = ScanUtil.PROXY_CLASS_PACKAGE_NAME.replace("/", ".") + "." + proxyClassName.substring(0, proxyClassName.length() - 6)
                println "full classname = ${fullName}"
                mv.visitLdcInsn(fullName)
                mv.visitMethodInsn(INVOKESTATIC, "com/hm/lifecycle/api/AppLifeCycleManager", "registerAppLike", "(Ljava/lang/String;)V", false);
            })
        }

        @Override
        protected void onMethodExit(int opcode) {
            super.onMethodExit(opcode)
            println "-------onMethodEnter------"
        }
    }

}

最后重新编译插件再运行,验证结果。

这里有个比较困难的地方,就是需要使用ASM编写class字节码。我这里推荐一个比较好用的方法:

  1. 将要注入的java源码先写出来;
  2. 通过javac编译出class文件;
  3. 通过asm-all.jar反编译该class文件,可得到所需的ASM注入代码;
    执行命令如下:
java -classpath "asm-all.jar" org.objectweb.asm.util.ASMifier com/hm/lifecycle/api/AppLifeCycleManager.class

从中找到loadAppLike()方法字节码处,这样通过ASM注入代码就比较简单了:

{
mv = cw.visitMethod(ACC_PRIVATE + ACC_STATIC, "loadAppLike", "()V", null, null);
mv.visitCode();
mv.visitLdcInsn("com.hm.iou.lifecycle.apt.proxy.Heima$$ModuleAAppLike$$Proxy");
mv.visitMethodInsn(INVOKESTATIC, "com/hm/lifecycle/api/AppLifeCycleManager", "registerAppLike", "(Ljava/lang/String;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 0);
mv.visitEnd();
}

5. 小结

结合前一章我们基本上实现了自动注册加载组件的生命周期管理类,做到了无侵入式的服务注册,离我们的彻底组件化解耦更近一步了。本文有些地方借鉴了阿里的路由框架ARouter,其基本思路是一致的,弄懂了这些也基本上就弄懂了ARouter的实现原理 ,原理弄清楚了之后,在此基础上咱们写出自己的框架也不是什么难事了。

源码地址:https://github.com/houjinyun/Android-AppLifecycleMgr
源码已经托管到github上了, 有兴趣的可以跟我留言,互相交流学习进步。

系列文章
Android组件化开发实践(一):为什么要进行组件化开发?
Android组件化开发实践(二):组件化架构设计
Android组件化开发实践(三):组件开发规范
Android组件化开发实践(四):组件间通信问题
Android组件化开发实践(五):组件生命周期管理
Android组件化开发实践(六):老项目实施组件化
Android组件化开发实践(七):开发常见问题及解决方案
Android组件化开发实践(八):组件生命周期如何实现自动注册管理
Android组件化开发实践(九):自定义Gradle插件

目录
相关文章
|
3天前
|
开发框架 前端开发 Android开发
安卓与iOS开发中的跨平台策略
在移动应用开发的战场上,安卓和iOS两大阵营各据一方。随着技术的演进,跨平台开发框架成为开发者的新宠,旨在实现一次编码、多平台部署的梦想。本文将探讨跨平台开发的优势与挑战,并分享实用的开发技巧,帮助开发者在安卓和iOS的世界中游刃有余。
|
8天前
|
搜索推荐 Android开发 开发者
探索安卓开发中的自定义视图:打造个性化UI组件
【10月更文挑战第39天】在安卓开发的世界中,自定义视图是实现独特界面设计的关键。本文将引导你理解自定义视图的概念、创建流程,以及如何通过它们增强应用的用户体验。我们将从基础出发,逐步深入,最终让你能够自信地设计和实现专属的UI组件。
|
10天前
|
Android开发 Swift iOS开发
探索安卓与iOS开发的差异和挑战
【10月更文挑战第37天】在移动应用开发的广阔舞台上,安卓和iOS这两大操作系统扮演着主角。它们各自拥有独特的特性、优势以及面临的开发挑战。本文将深入探讨这两个平台在开发过程中的主要差异,从编程语言到用户界面设计,再到市场分布的不同影响,旨在为开发者提供一个全面的视角,帮助他们更好地理解并应对在不同平台上进行应用开发时可能遇到的难题和机遇。
|
10天前
|
存储 API 开发工具
探索安卓开发:从基础到进阶
【10月更文挑战第37天】在这篇文章中,我们将一起探索安卓开发的奥秘。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的信息和建议。我们将从安卓开发的基础开始,逐步深入到更复杂的主题,如自定义组件、性能优化等。最后,我们将通过一个代码示例来展示如何实现一个简单的安卓应用。让我们一起开始吧!
|
人工智能 移动开发 Java
Android Studio插件版本与Gradle 版本对应关系
Android Studio插件版本与Gradle 版本对应关系
2371 0
Android Studio插件版本与Gradle 版本对应关系
|
存储 Java Android开发
Android 开发 - 充分利用Gradle
Android 开发 - 充分利用Gradle
172 2
|
1月前
|
Android开发
Android基于gradle task检查各个module之间资源文件冲突情况
Android基于gradle task检查各个module之间资源文件冲突情况
Android基于gradle task检查各个module之间资源文件冲突情况
|
1月前
|
Android开发
Android gradle task任务检查各个module之间资源文件冲突.md
Android gradle task任务检查各个module之间资源文件冲突.md
Android gradle task任务检查各个module之间资源文件冲突.md
|
5月前
|
C# Android开发 开发者
Android gradle编译时字节码处理
Android gradle编译时字节码处理
72 1
|
5月前
|
Android开发
Android Gradle开发—脚本实现自动打包后复制一份APK文件,并修改APK名称,到指定目录作备份
Android Gradle开发—脚本实现自动打包后复制一份APK文件,并修改APK名称,到指定目录作备份
284 0