破解Gradle(六) Gradle Plugin技术及玩转transform
如果你想在编译期间搞事情,如常用的有无痕埋点,方法耗时统计和组件通信中自动注入等等,就要来学习字节码插桩的技术。而所谓字节码插桩技术其实就是修改已经编译好的class文件,在里面添加自己的字节码,然后打出的包就是修改后的class文件。在动手开发之前,还需要了解如何自定义gradle插件
,以及如何自定义Transform
,下面我们来看看具体做法。
一、 Gradle插件
在Gradle官方文档里目前定义插件的方式有 三种:
脚本插件
:直接在构建脚本中直接写插件的代码,编译器会自动将插件编译并添加到构建脚本的classpath中。buildSrc project
:执行Gradle时 会把根目录下的buildSrc目录作为插件源码目录进行编译,编译后会把结果加入到构建脚本的classpath中,对于整个项目是可用的。Standalone project
:可以在独立项目中开发插件,然后将项目达成jar包,发布到本地或者mave服务器上。
实例代码可以参考 GradleTestDemo
1.1 直接在build.gradle文件中实现
//应用插件
apply plugin: CustomPluginA//自定义插件示例
class CustomPluginA implements Plugin<Project> {@Overridevoid apply(Project target) {println 'Hello gradle!'}
}
这种方式在构建脚本之外是不可以见的,所以只有在定义该插件的gradle脚本里才可以引用改插件。
1.2 在默认目录buildSrc中实现
buildSrc目录是gradle默认的目录之一,该目录会在构建时自动的进行编译打包,所以在这里面不需要任何额外的配置,就可以直接被其他模块中的gradle脚本所引用。
- 创建的目录结构
2. 将项目中的build.gradle中的所有配置去掉,并配置groovy、resources为源码目录以及相关依赖:
buildscript {ext {kotlin_version = '1.5.31'apg_Version = '3.4.0'booster_version = '4.0.0'}repositories {mavenCentral()google()jcenter()}dependencies {classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"classpath "com.android.tools.build:gradle:$apg_Version"}
}apply plugin: 'maven'
apply plugin: 'maven-publish'
apply plugin: 'java'
apply plugin: 'groovy'
apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'repositories {mavenCentral()google()jcenter()
}dependencies {implementation gradleApi()implementation localGroovy()//操作的工具类implementation "commons-io:commons-io:2.6"// Android DSL Android编译的大部分gradle源码implementation 'com.android.tools.build:gradle:3.4.0'//ASMimplementation 'org.ow2.asm:asm:7.1'implementation 'org.ow2.asm:asm-util:7.1'implementation 'org.ow2.asm:asm-commons:7.1'
}
gradle插件是可以使用java,groovy,kotlin编写 ,所以你可以根据自己的需要引入相关的依赖。
在main目录下新建resources/MATE-INF/gradle-plugins目录:
在里面新建“HelloPlugin.properties”文件,其中“HelloPlugin”是可以随意定义的名称,而这个名称也就是你的插件名称。然后在引用该插件时你可以通过
apply plugin: 'HelloPlugin'
的方式来引用。HelloPlugin.properties
文件中的内容就是:implementation-class=transform.hello.HelloTransformPlugin
另外定义在
buildSrc
下面的插件也可以直接用apply plugin: HelloPlugin
来引入,而HelloPlugin就是你定义的plugin的类名了。注意:格式一定要写成resources/MATE-INF/gradle-plugins这样的三级目录,有些同学操作的时候碰到自定义的plugin找不到就是因为在直接复制目录地址的时候,由于编译器缩写的关系,目录地址变成了resources/MATE-INF.gradle-plugins。
自定义gradle插件:
在
transform
目录下创建HelloTransformPlugin
类,并实现Plugin接口。class HelloTransformPlugin implements Plugin<Project> {@Overridevoid apply(Project project) {println "Hello TransformPlugin"//将Extension注册给Plugindef extension = project.extensions.create("custom", CustomExtension)//注册方式1 AppExtension就是application pluginAppExtension appExtension = project.extensions.getByType(AppExtension)appExtension.registerTransform(new HelloTransform())//注册之后会在TransformManager#addTransform中生成一个task.//注册方式2//project.android.registerTransform(new HelloTransform())} }
你会看到这里多了个
Transform类
也是接下来我们要说的。另外其中的 CustomExtension 是自定义属性类,可以通过主项目的 build.gradle 文件传值,这样就可以在脚本中去扩展属性:class CustomExtension {String extensionArgs = "" }
然后在主项目的
build.gradle
命名要与注册时保持一致:custom{extensionArgs = "我是参数" }
在
project.extensions.create
方法的内部其实质是 通过project.extensions.create()
方法来获取在custom
闭包中定义的内容并通过反射将闭包的内容转换成一个CustomExtension
对象。
1.3 在独立项目开发中实现
这种方式基本跟第二种相似,不过要引入这个插件的话要先把它发布到本地或者mave服务器上。
修改 build.gradle 的内容,增加上传到本地的代码,可以如下这样修改:
apply plugin: 'groovy' apply plugin: 'java' apply plugin: 'maven'repositories {jcenter() }uploadArchives {repositories.mavenDeployer {//指定maven的仓库url,IP+端口+目录 // repository(url: "http://localhost:8081/nexus/content/repositories/releases/") {// //填写你的Nexus的账号密码 // authentication(userName: "admin", password: "123456") // }// 配置本地仓库路径,这里是项目的根目录下的maven目录中repository(url: uri('../repo'))// 唯一标识 一般为模块包名 也可其他pom.groupId = "com.xiam.plugin"// 项目名称(一般为模块名称 也可其他pom.artifactId = "startplugin"// 发布的版本号pom.version = "1.0.0"} }dependencies {implementation gradleApi()implementation localGroovy()// Android DSL Android编译的大部分gradle源码implementation 'com.android.tools.build:gradle:3.4.0' }
修改相关的 build.gradle 文件,添加依赖,在根项目的 build.gradle 中添加:
最后是构建在 gradle task 里面,运行 uploadArchives 任务即可,或者通过
./gradlew uploadArchivers
来执行这个 task:
二、玩转Transform
Google官方在Android GradleV1.5.0
版本以后提供了Transform API,它允许第三方插件在编译后的类文件转换为dex文件之前做处理操作,我们需要做的就是实现Transform来对.class文件便遍历来拿到所有方法,修改完成后再对源文件进行替换就可以了。感兴趣可以去看看Transform版本历史。
2.1 Transform的使用
在前面我们已经看到了怎样对一个transform进行注册,也就是我们在我们自定义的plugin中,通过如下进行注册,这里我选择使用kotlin来实现:
class HelloPlugin: Plugin<Project> {override fun apply(target: Project) {target.extensions.findByType(AppExtension::class.java)?.run {registerTransform(HelloTransform(target))}}
}
自定义的Transform是要继承于com.android.build.api.transform.Transform
,可以看下Transform文档介绍,现在我们先来定义一个自定义的Transform(不支持增量
):
class HelloTransform: Transform() {/*** 返回对应的 Task 名称。*/override fun getName(): String = "HelloTransform"/*** 输入文件的类型* 可供我们去处理的有两种类型, 分别是编译后的java代码, 以及资源文件(非res下文件, 而是assests内的资源)*/override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> = TransformManager.CONTENT_CLASS/*** 是否支持增量* 如果支持增量执行, 则变化输入内容可能包含 修改/删除/添加 文件的列表*/override fun isIncremental(): Boolean = false/*** 指定插件的适用范围。*/override fun getScopes(): MutableSet<in QualifiedContent.Scope> = TransformManager.SCOPE_FULL_PROJECT/*** transform的执行主函数* 进行具体的转换过程*/override fun transform(transformInvocation: TransformInvocation?) {transformInvocation?.inputs?.forEach {// 输入源为文件夹类型 (本地 project 编译成的多个 class ⽂件存放的目录)it.directoryInputs.forEach {directoryInput->with(directoryInput){// 获取class文件输出路径val dest = transformInvocation.outputProvider.getContentLocation(name,contentTypes,scopes,Format.DIRECTORY)//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了 file.copyTo(dest)}}// 输入源为jar包类型(各个依赖所编译成的 jar 文件) it.jarInputs.forEach { jarInput->with(jarInput){// 获取jar包的输出路径val dest = transformInvocation.outputProvider.getContentLocation(name,contentTypes,scopes,Format.JAR)//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了 file.copyTo(dest)}}}}
}
2.1.1 getName()
这个方法返回的就是我们的Transform名称,也就是会在Build的流程里会出现的:
这名字最终是如何构成的? 可以在gradle源码里有个TransformManager
的类,这个类负责管理所有的Transform的子类,可以找到一个getTaskNamePrefix
方法。会以tansform开头,之后拼接contentType,这个也就是Transform的输入类型,有Classes
和Resources
两种类型,最后就是会跟上我们这个Transform
的Name
了。
#TransformManagerstatic String getTaskNamePrefix(@NonNull Transform transform) {StringBuilder sb = new StringBuilder(100);sb.append("transform");sb.append(transform.getInputTypes().stream().map(inputType ->CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.UPPER_CAMEL, inputType.name())).sorted() // Keep the order stable..collect(Collectors.joining("And")));sb.append("With");StringHelper.appendCapitalized(sb, transform.getName());sb.append("For");return sb.toString();}
2.1.2 getInputTypes()
这个则是用来限定transform处理文件的类型,在对class文件进行处理时,返回的是TransformManager.CONTENT_CLASS
,而在对资源文件处理时,返回的是TransformManager.CONTENT_RESOURCES
。
除了CLASSES和RESOURCES,还有一些我们开发过程无法使用的类型,比如DEX文件,这些隐藏类型在一个独立的枚举类ExtendedContentType中,这些类型只能给Android编译器使用。
2.1.3 getScopes()
这个是指定需要处理哪种文件,也就是用来表明作用域。可以看下有哪些选项:
/*** 表示 Transform 要操作的内容范围,目前 Scope 有五种基本类型:* 1、PROJECT 只有项目内容* 2、SUB_PROJECTS 只有子项目* 3、EXTERNAL_LIBRARIES 只有外部库* 4、TESTED_CODE 由当前变体(包括依赖项)所测试的代码* 5、PROVIDED_ONLY 只提供本地或远程依赖项* SCOPE_FULL_PROJECT 是一个 Scope 集合,包含 Scope.PROJECT,*/
enum Scope implements ScopeType {/** Only the project (module) content */PROJECT(0x01),/** Only the sub-projects (other modules) */SUB_PROJECTS(0x04),/** Only the external libraries */EXTERNAL_LIBRARIES(0x10),/** Code that is being tested by the current variant, including dependencies */TESTED_CODE(0x20),/** Local or remote dependencies that are provided-only */PROVIDED_ONLY(0x40),/*** Only the project's local dependencies (local jars)** @deprecated local dependencies are now processed as {@link #EXTERNAL_LIBRARIES}*/@DeprecatedPROJECT_LOCAL_DEPS(0x02),/*** Only the sub-projects's local dependencies (local jars).** @deprecated local dependencies are now processed as {@link #EXTERNAL_LIBRARIES}*/@DeprecatedSUB_PROJECTS_LOCAL_DEPS(0x08);......}
一般来说如果要处理所有class字节码的话,一般使用TransformManager.SCOPE_FULL_PROJECT
,也就是如下:
public static final Set<Scope> SCOPE_FULL_PROJECT =Sets.immutableEnumSet(Scope.PROJECT,Scope.SUB_PROJECTS,Scope.EXTERNAL_LIBRARIES);
2.1.4 inIncremental()
表示是否支持增量编译,关闭时就会进行全量编译,并且会删除上一次的输出内容。当我们开启增量编译的时候,input就包含了changed/removed/added/notchanged四种状态:
NOTCHANGED
: 当前文件没有改变,不需处理,甚至复制操作都不用ADDED、CHANGED
: 有修改文件,并输出给下一个任务REMOVED
: outputProvider获取路径对应的文件被移除
2.1.5 transform()
在这个方法中里我们将每个jar包和class文件赋值到dest路径下,这个dest路径也就是下一个Transform的输入数据,在复制的过程中,我们就可以对jar包和class文件的字节码进行修改(ASM在这里飘过~)
。
处理后的class/jar包可以到/build/intermediates/transforms/HelloTransform/
下查看,你会看到所有jar包都是123456递增着来的。可以看下获取输出路径的方法:
# IntermediateFolderUtilspublic synchronized File getContentLocation(String name, Set<ContentType> types, Set<? super Scope> scopes, Format format) {Preconditions.checkNotNull(name);Preconditions.checkNotNull(types);Preconditions.checkNotNull(scopes);Preconditions.checkNotNull(format);Preconditions.checkState(!name.isEmpty());Preconditions.checkState(!types.isEmpty());Preconditions.checkState(!scopes.isEmpty());Iterator var5 = this.subStreams.iterator();SubStream subStream;do {if (!var5.hasNext()) {//这里可以看到它是按位置递增SubStream newSubStream = new SubStream(name, this.nextIndex++, scopes, types, format, true);this.subStreams.add(newSubStream);return new File(this.rootFolder, newSubStream.getFilename());}subStream = (SubStream)var5.next();} while(!name.equals(subStream.getName()) || !types.equals(subStream.getTypes()) || !scopes.equals(subStream.getScopes()) || format != subStream.getFormat());return new File(this.rootFolder, subStream.getFilename());}
2.2 Transform的原理
介绍了如何使用Transfoem之后,我们再来看下它的原理(gradle插件7.0.2版本)。
首先我们来看下从Java源代码到apk的过程,如下图:
从这里我们可以清楚看到gradle的打包过程基本上是通过官方的Transform
来完成。而每个Transform
其实都是一个gradle task
,Android编译器中的TaskManager
将每个Transform
串连起来,第一个Transform
接收来自javac编译的结果,以及本地的第三方依赖还有asset目录下的resource
资源。然后这些编译的中间产物会在Transform
组成的链条上流动,每一个Tansform
会对class进行处理之后再传给下一个Transform
。
而我们自定的Transform
会插入到这个Tansform
链条的最前面,要优先于ProguardTransform
执行的,所以不会造成因为混淆而无法扫描到类信息。
2.2.1 TransformManager
在前面自定义plugin中调用registerTransform
对transform
进行注册时,实际上是放入了BaseExtension
类中的list数组里,然后是由TaskManager
调用了TransformManager
的addTransform
方法。这里TransformManager
管理了项目对应变体的所有Transform
对象。
我们来看下addTransform方法具体实现:
# TransformManagerpublic <T extends Transform> Optional<TaskProvider<TransformTask>> addTransform(@NonNull TaskFactory taskFactory,@NonNull TransformVariantScope scope,@NonNull T transform,@Nullable PreConfigAction preConfigAction,@Nullable TaskConfigAction<TransformTask> configAction,@Nullable TaskProviderCallback<TransformTask> providerCallback) {......List<TransformStream> inputStreams = Lists.newArrayList();//transform task的命名String taskName = scope.getTaskName(getTaskNamePrefix(transform));// 获取仅引用型流List<TransformStream> referencedStreams = grabReferencedStreams(transform);// 找到输入流, 并计算通过transform的输出流IntermediateStream outputStream = findTransformStreams(transform,scope,inputStreams,taskName,scope.getGlobalScope().getBuildDir());......transforms.add(transform);// transform task的创建return Optional.of(taskFactory.register(new TransformTask.CreationAction<>(scope.getFullVariantName(),taskName,transform,inputStreams,referencedStreams,outputStream,recorder),preConfigAction,configAction,providerCallback));}
在
getTaskNamePrefix
方法中会定义task的名字,前面也已经分析过了。然后在
grabReferencedStreams
方法中,对transform的数据输入,通过内部定义的引用型输入的Scope和ContentType两个维度进行过滤,可以看到grabReferencedStreams
方法里求取与streams
作用域和作用类型的交集来获取对应的流, 将其定义为我们需要的引用型流。private List<TransformStream> grabReferencedStreams(@NonNull Transform transform) {Set<? super Scope> requestedScopes = transform.getReferencedScopes();......List<TransformStream> streamMatches = Lists.newArrayListWithExpectedSize(streams.size());Set<ContentType> requestedTypes = transform.getInputTypes();for (TransformStream stream : streams) {Set<ContentType> availableTypes = stream.getContentTypes();Set<? super Scope> availableScopes = stream.getScopes();Set<ContentType> commonTypes = Sets.intersection(requestedTypes,availableTypes);Set<? super Scope> commonScopes = Sets.intersection(requestedScopes, availableScopes);if (!commonTypes.isEmpty() && !commonScopes.isEmpty()) {streamMatches.add(stream);}}return streamMatches;}
之后在
findTransformStreams
方法中,会根据定义的SCOPE和INPUT_TYPE,获取对应的消费型输入流,移除这一部分的消费性的输入流。为所有类型和范围创建单个组合输出流,并将其添加到下一次转换的可用流列表中。private IntermediateStream findTransformStreams(@NonNull Transform transform,@NonNull TransformVariantScope scope,@NonNull List<TransformStream> inputStreams,@NonNull String taskName,@NonNull File buildDir) {......Set<ContentType> requestedTypes = transform.getInputTypes();//在streams中移除对应的消费型输入流consumeStreams(requestedScopes, requestedTypes, inputStreams);// 创建输出流Set<ContentType> outputTypes = transform.getOutputTypes();File outRootFolder =FileUtils.join(buildDir,StringHelper.toStrings(AndroidProject.FD_INTERMEDIATES,FD_TRANSFORMS,transform.getName(),scope.getDirectorySegments()));// 输出流的创建IntermediateStream outputStream =IntermediateStream.builder(project,transform.getName() + "-" + scope.getFullVariantName(),taskName).addContentTypes(outputTypes).addScopes(requestedScopes).setRootLocation(outRootFolder).build();streams.add(outputStream);return outputStream;}
最后将新创建的
TransformTask
注册到TaskManager
中。
2.2.2 TransformTask
在这个类里我们看到最终Transform
的tansform方法
被调用,也就是在其对应的TaskAction方法中执行:
# TransformTask@TaskActionvoid transform(final IncrementalTaskInputs incrementalTaskInputs)throws IOException, TransformException, InterruptedException {final ReferenceHolder<List<TransformInput>> consumedInputs = ReferenceHolder.empty();final ReferenceHolder<List<TransformInput>> referencedInputs = ReferenceHolder.empty();final ReferenceHolder<Boolean> isIncremental = ReferenceHolder.empty();final ReferenceHolder<Collection<SecondaryInput>> changedSecondaryInputs =ReferenceHolder.empty();isIncremental.setValue(transform.isIncremental() && incrementalTaskInputs.isIncremental());//在增量模式下, 判断输入流(引用型和消费型)的变化......recorder.record(ExecutionType.TASK_TRANSFORM,executionInfo,getProject().getPath(),getVariantName(),new Recorder.Block<Void>() {@Overridepublic Void call() throws Exception {transform.transform(new TransformInvocationBuilder(TransformTask.this).addInputs(consumedInputs.getValue()).addReferencedInputs(referencedInputs.getValue()).addSecondaryInputs(changedSecondaryInputs.getValue()).addOutputProvider(outputStream != null? outputStream.asOutput(isIncremental.getValue()): null).setIncrementalMode(isIncremental.getValue()).build());if (outputStream != null) {outputStream.save();}return null;}});}
到这里我们已经知道了Transform的数据流动原理、输入的类型和过滤机制。
2.3 Transform的增量与并发
学习了上面之后我们可以轻松的定义出一个Transform。可是每次编译transform
方法都会执行,就会遍历所有的class文件,会解压所有jar文件,然后重新压缩成所有的jar文件,这样就会拖慢编译的时间。如何解决,这里我们就用到了增量编译。
但不是每次的编译都可以增量编译,毕竟第一次编译或clean后重新编译directory.changedFiles
为空,需要做好区分经测试。如果不是增量编译,则清空output目录,然后按照前面的方式,逐个class/jar处理。如果是增量编译,则要检查每个文件的Status,Status分为四种NOTCHANGED
/ADDED
/CHANGED
/REMOVED
,并且对四种文件的操作不尽相同。
可以来看下增量编译的代码实现,详细代码可以看下–>BaseTransform
//进行具体的转换过程。override fun transform(transformInvocation: TransformInvocation) {Log.log("transform start--------------->")onTransformStart()val outputProvider = transformInvocation.outputProviderval context = transformInvocation.contextval isIncremental = transformInvocation.isIncrementalval startTime = System.currentTimeMillis()//不是增量更新,删除之前输出if (!isIncremental){outputProvider.deleteAll()}transformInvocation?.inputs?.forEach{input ->//输入为文件夹类型 (本地 project 编译成的多个 class ⽂件存放的目录)input.directoryInputs.forEach{directoryInput ->submitTask {handleDirectory(directoryInput, outputProvider, context, isIncremental)}}//输入为jar包类型 (各个依赖所编译成的 jar 文件)input.jarInputs.forEach{jarInput ->submitTask {handleJar(jarInput, outputProvider, context, isIncremental)}}}val taskListFeature = executorService.invokeAll(taskList)taskListFeature.forEach{it.get()}onTransformEnd()Log.log("transform end--------------->" + "duration : " + (System.currentTimeMillis() - startTime) + " ms")}
对输入为jar包类型处理:
private fun handleJar(jarInput: JarInput, outputProvider: TransformOutputProvider, context: Context, isIncremental: Boolean) {//得到上一个Transform输入文件val inputJar = jarInput.file//得到当前Transform输出jar文件val outputJar = outputProvider.getContentLocation(jarInput.name, jarInput.contentTypes,jarInput.scopes, Format.JAR)//增量处理if (isIncremental){when(jarInput.status){//文件没有改变Status.NOTCHANGED -> {}//有修改文件Status.ADDED,Status.CHANGED -> {}//文件被移除Status.REMOVED -> {if (outputJar.exists()){FileUtils.forceDelete(outputJar)}return}else -> {return}}}if (outputJar.exists()){FileUtils.forceDelete(outputJar)}//修改后的文件val modifiedJar = if (ClassUtils.isLegalJar(jarInput.file)) {modifyJar(jarInput.file, context.temporaryDir)} else {Log.log("不处理: " + jarInput.file.absoluteFile)jarInput.file}FileUtils.copyFile(modifiedJar, outputJar)}
对输入为文件夹类型处理:
private fun handleDirectory(directoryInput: DirectoryInput, outputProvider: TransformOutputProvider, context: Context, isIncremental: Boolean) {//得到上一个Transform输入文件目录val inputDir = directoryInput.file//得到当前Transformval outputDir = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes,directoryInput.scopes, Format.DIRECTORY)val srcDirPath = inputDir.absolutePathval destDirPath = outputDir.absolutePath//写入临时文件的目录val temporaryDir = context.temporaryDir//创建目录FileUtils.forceMkdir(outputDir)if (isIncremental){directoryInput.changedFiles.entries.forEach { entry ->val inputFile = entry.key//最终文件应该存放的路径
// val destFilePath = inputFile.absolutePath.replace(srcDirPath, destDirPath)
// val destFile = File(destFilePath)when(entry.value){Status.ADDED, Status.CHANGED ->{//处理class文件modifyClassFile(inputFile, srcDirPath, destDirPath, temporaryDir)}Status.REMOVED -> {val destFilePath = inputFile.absolutePath.replace(srcDirPath, destDirPath)val destFile = File(destFilePath)if (destFile.exists()){destFile.delete()}}Status.NOTCHANGED -> {}}}} else {//过滤出是文件的,而不是目录directoryInput.file.walkTopDown().filter { it.isFile }.forEach { classFile ->modifyClassFile(classFile, srcDirPath, destDirPath, temporaryDir)}}}
根据这个就能为我们的编译插件提供增量的特性。
三、结语
通过本文,我们学习了如何自定义一个Gradle插件,如何定义一个Transform
以及Transform
的内部原理。学完这些还是不够的,跟之后要讲到的ASM结合起来,你就能利用字节码插桩技术为所欲为了。
参考:
深度探索 Gradle 自动化构建技术(四、自定义 Gradle 插件)
写个更牛逼的Transform | Plugin 进阶教程
Android Transform增量编译
Gradle+Transform+Asm自动化注入代码
Transform API
如何开发一款高性能的gradle transform
一起玩转Android项目中的字节码
深入理解Transform
Transform详解
破解Gradle(六) Gradle Plugin技术及玩转transform相关推荐
- [转]Android Studio系列教程六--Gradle多渠道打包
转自:http://www.stormzhang.com/devtools/2015/01/15/android-studio-tutorial6/ Android Studio系列教程六--Grad ...
- 彻底搞懂Gradle、Gradle Wrapper与Android Plugin for Gradle的区别和联系
欢迎和大家交流技术相关问题: 邮箱: jiangxinnju@163.com 博客园地址: http://www.cnblogs.com/jiangxinnju GitHub地址: https://g ...
- AS 中 Plugin for Gradle 和 Gradle 之间的版本对应关系
Plugin for Gradle 和 Gradle 之间的版本对应关系 来源:https://developer.android.com/studio/releases/gradle-plugin ...
- Android Studio之提示Gradle sync failed: Plugin with id ‘com.novoda.bintray-release‘ not found.
1 问题 导入别人的模块到Android Studio,错误提示如下 Gradle sync failed: Plugin with id 'com.novoda.bintray-release' n ...
- 彻底解决gradle与gradle plugin匹配关系以及gradle下载缓慢的问题
文章目录 问题引入 原因 解决方法 1. 检查gradle版本和gradle插件版本是否匹配 2. 解决gradle下载慢的问题 手动下载gradle包 替换依赖仓库 方法一:在build.gradl ...
- 命令构建gradle项目_【Android 修炼手册】Gradle 篇 -- Gradle 源码分析
预备知识 理解 gradle 的基本开发 了解 gradle task 和 plugin 使用及开发 了解 android gradle plugin 的使用 看完本文可以达到什么程度 了解 grad ...
- 【Android 修炼手册】Gradle 篇 -- Gradle 源码分析
预备知识 理解 gradle 的基本开发 了解 gradle task 和 plugin 使用及开发 了解 android gradle plugin 的使用 看完本文可以达到什么程度 了解 grad ...
- 【Android 修炼手册】Gradle 篇 -- Gradle 的基本使用
预备知识 基本的 android 开发知识 了解 Android Studio 基本使用 看完本文可以达到什么程度 掌握 gradle 的基本使用 了解 gradle 及 android gradle ...
- Android Gradle和Gradle插件区别
2019独角兽企业重金招聘Python工程师标准>>> 一.引言 1.什么是Gradle?什么是Gradle插件? build.gradle中依赖的classpath 'com.an ...
最新文章
- 为什么深度神经网络这么难训练?| 赠书
- 关于大型网站技术演进的思考(二十)--网站静态化处理—web前端优化—中(12)...
- JQuery ajax请求一直返回Error(parsererror)
- kd树的根节点_kd树总结
- 实验楼项目课学习笔记-jQuery翻转拼图游戏
- 计算一个二进制数中数字“1”的个数(位运算)
- BeautifulSoup库使用
- 对android上下文和窗口的理解
- Server object instance creation failed on all SOC machines
- dual mysql 获取序列_如何获取 MySQL 插入数据的自增 ID
- JS浏览器对象-Location对象
- 关于原理图库和封装库设计(三)
- 微信打开h5链接,缓存未清除解决办法
- 药物不良反应数据库信息的下载
- 如何快速辨别工业级POE交换机和普通交换机的不同?
- 测试经典名言100句
- OpenStack高级控制服务之使用编配服务(Heat)实现自动化部署云主机
- oracle的set函数,setex(oracle trunc函数)
- Windows Phone 游戏 Roll In The Hole 去除 XBL 服务
- 微博android升级7.000,华为 Android 7.0 升级计划曝光:G9 青春版 /Nova 也有份