2020-09-15

Gradle的构建过程都不会?带你全面了解Android如何自定义Gradle 插件

目前 Android 工程的默认构建工具为 Gradle,我们在构建 APK 的时候往往会执行 ./gradlew assembleDebug 这样的命令。。

那么这个命令到底代表着什么含义呢?命令的执行究竟是在做什么事情呢?我们能不能在命令执行的过程中做一些自己的操作呢?接下来我们来具体的进行分析。

Gradle 的构建过程

Gradle Wrapper 是个啥

当我们在 Android Studio 中新建一个工程时,你会发现在工程的根目录下会创建以下几个文件:

在这里插入图片描述

实际上这几个文件是通过执行 $ Gradle Wrapper 生成的。Gradle Wrapper,顾名思义就是对 Gradle 构建工具的一层封装。

在和其他同事共同管理某个 Android 工程的时候,肯定会存在同事 A 电脑上的 Gradle 版本和同事 B 电脑上的 Gradle 版本不一样,那么这个不一样可能导致的问题是需要在 build.gradle 文件中添加不同的配置,甚至有的 Gradle 版本都无法成功跑通工程。

所以,Gradle 的工程师们将 Gradle 添加了一层简单的封装,Linux 用户可以通过执行 gradlew 来代替 gradle 命令,Windows 用户可以通过 gradlew.bat 来代替,实际上这俩文件就是个可执行脚本,我们可以直接打开这个脚本来看里面到底有什么。

# 只贴上主要代码 gradlew 文件...JAVACMD="java"...CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar...exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 

从里面的代码我们可以看到,实际上 gradlew 的脚本也只是执行了一下 Java 命令,assembleDebug 只是执行脚本的参数。我们修改一下脚本打印出来这个命令:

java -Xdock:name=Gradle -Xdock:icon=/media/gradle.icns -Dorg.gradle.appname=gradlew -classpath /xxx/food/gradle/wrapper/gradle-wrapper.jar org.gradle.wrapper.GradleWrapperMain assembleDebug -s 

从这里可以看到,脚本所做的事情就是通过 Java 命令来执行 /gradle/wrapper/ 下的 gradle-wrapper.jar 包。通过 JD-GUI 工具打开这个可执行 jar 包,找到那个 jar 包的入口类,也就是 org.gradle.wrapper.GradleWrapperMain,我们大致看一下这个 jar 包到底干了什么。

// org.gradle.wrapper.GradleWrapperMain文件public static void main(String[] args) throws Exception { File wrapperJar = wrapperJar(); // 获取这个可执行jar包的具体路径 File propertiesFile = wrapperProperties(wrapperJar); // 获取gradle-wrapper.properties的位置 File rootDir = rootDir(wrapperJar); // 获取工程的根目录 // 下面的一段代码用来解析命令行,我们暂时不去管它 CommandLineParser parser = new CommandLineParser(); ... WrapperExecutor wrapperExecutor = WrapperExecutor.forWrapperPropertiesFile(propertiesFile); // 下面这个方法便是解析gradle/wrapper/gradle-wrapper.properties文件里面的内容,然后根据里面的配置到相应的地址需下载另一个可执行jar包 wrapperExecutor.execute( args, new Install(logger, new Download(logger, "gradlew", wrapperVersion()), new PathAssembler(gradleUserHome)), new BootstrapMainStarter());} 

通过分析 execute() 方法的执行,我们会发现,这个方法执行了 gradle-wrapper.properties 文件的解析和下载工作,我们先看一下这个文件里面有什么:

// gradle/wrapper/gradle-wrapper.properties 文件配置distributionBase=GRADLE_USER_HOME // GRADLE_USER_HOME的地址默认在用户目录下的.gradle/文件夹里distributionPath=wrapper/distszipStoreBase=GRADLE_USER_HOMEzipStorePath=wrapper/distsdistributionUrl=https://services.gradle.org/distributions/gradle-4.4-all.zip 

如上所示,代码会找到 distributionUrl 这个路径,然后下载相应的文件。在这里,工程会指定固定的一个 Gradle 版本,然后所有的开发者都会在相同的路径下下载到相同的 ZIP 包,这样也就保证了在不同电脑上执行构建操作结果的一致性。下载文件的过程就不多述了,因为比较简单。

文件下载以后,当前就会去执行这个文件:

// org.gradle.wrapper.BootstrapMainStarterpublic class BootstrapMainStarter { public void start(String[] args, File gradleHome) throws Exception {  File gradleJar = findLauncherJar(gradleHome);  URLClassLoader contextClassLoader = new URLClassLoader(new URL[]{gradleJar.toURI().toURL()}, ClassLoader.getSystemClassLoader().getParent());  Thread.currentThread().setContextClassLoader(contextClassLoader);  // 通过反射找到org.gradle.launcher.GradleMain 进行具体的调用  Class<?> mainClass = contextClassLoader.loadClass("org.gradle.launcher.GradleMain");  Method mainMethod = mainClass.getMethod("main", String[].class);  mainMethod.invoke(null, new Object[]{args});  if (contextClassLoader instanceof Closeable) {   ((Closeable) contextClassLoader).close();  } }} 

如上所示,Java 代码找到下载的 jar 包,然后通过 ClassLoader 加载到内存里,加载以后通过反射调用里面的入口类。

到此,Gradle Wrapper 的整个调用过程结束了,它的功能就是保证多个电脑上能够以相同的 Gradle 版本构建 Android 的工程代码,后续的执行则是开启了 Gradle 的真正构建过程,我们接下来进行分析。

Gradle 的构建生命周期

Gradle 的整个构建过程共分为三个阶段:init 初始化阶段、config 配置阶段和 build 执行阶段。下面简单说一下这三个阶段分别做什么工作。

init 初始化阶段

初始化阶段主要是解析 settings.gradle 文件,查看该工程引入了多少个 module。如下所示,可以在 settings.gradle 文件下定义需要引入的 module 和其对应的目录:

include ':app'include ':library'project(':library').projectDir = new File('../library') 
config 阶段

在 config 阶段便是去解析每个 module 里的 build.gradle 文件,并逐行执行,完成对 project 的配置,并构造 Task 任务依赖关系图以便在执行阶段按照依赖关系执行 Task。

build 执行阶段

执行阶段便是根据 config 阶段生成的 Task 依赖关系图,来挨个地去执行各个 Task。每个 Task 可以看做是一个功能体,比如说,在构建过程中 Java 文件需要先转换为 class 文件,然后 class 文件要再次转换成 dex 文件,然后 dex 文件最终组合生成 APK,这个过程中每一步都是由一个 Task 来执行的。后续在介绍自定义 Gradle 插件的时候会讲到 Task 相关的东西。

Gradle 构建过程代码分析

刚才梳理了一下 Gradle 构建过程的生命周期,分为上面那三个阶段,那么,具体到代码是如何实现这三个声明周期的呢?我们具体进行一下分析。

在 Gradle Wrapper 的末尾,我们提到构建过程走到了通过反射找到 org.gradle.launcher.GradleMain 进行具体的调用。那么我们就继续跟着源码走。

// org.gradle.launcher.GradleMainpublic class GradleMain { public static void main(String[] args) throws Exception {  new ProcessBootstrap().run("org.gradle.launcher.Main", args); }} 

org.gradle.launcher.GradleMain 是真正执行构建过程的入口类,深入到 new ProcessBootstrap().run() 方法中继续执行。

private void runNoExit(String mainClassName, String[] args) throws Exception { ClassPathRegistry classPathRegistry = new DefaultClassPathRegistry(new DefaultClassPathProvider(new DefaultModuleRegistry(CurrentGradleInstallation.get()))); ClassLoaderFactory classLoaderFactory = new DefaultClassLoaderFactory(); ClassPath antClasspath = classPathRegistry.getClassPath("ANT"); ClassPath runtimeClasspath = classPathRegistry.getClassPath("GRADLE_RUNTIME"); ClassLoader antClassLoader = classLoaderFactory.createIsolatedClassLoader(antClasspath); // 我们发现通过新建classLoader,在classLoader增加了新的依赖项,但这些依赖项不知道是什么。不过这不是我们关注的重点,我们继续代码的执行 ClassLoader runtimeClassLoader = new VisitableURLClassLoader(antClassLoader, runtimeClasspath); ClassLoader oldClassLoader = Thread.currentThread().getContextClassLoader(); Thread.currentThread().setContextClassLoader(runtimeClassLoader); try { // 在此处通过反射调用了org.gradle.launcher.Main这个类的run方法, Class<?> mainClass = runtimeClassLoader.loadClass(mainClassName); Object entryPoint = mainClass.newInstance(); Method mainMethod = mainClass.getMethod("run", String[].class); mainMethod.invoke(entryPoint, new Object[]{args}); } finally { ... }} 

主流程是通过反射调用了 org.gradle.launcher.Main 这个类的 run 方法,我们具体看一下这段代码。

// org.gradle.launcher.Mainpublic class Main extends EntryPoint { public static void main(String[] args) {  new Main().run(args); } protected void doAction(String[] args, ExecutionListener listener) {  UnsupportedJavaRuntimeException.assertUsingVersion("Gradle", JavaVersion.VERSION_1_7);  createActionFactory().convert(Arrays.asList(args)).execute(listener); } CommandLineActionFactory createActionFactory() {  return new CommandLineActionFactory(); }} 

org.gradle.launcher.Main 这个类实际上继承了 org.gradle.launcher.bootstrap.EntryPoint 这个类,而它的 run 方法实际也就是提供了对 Main 中 doAction 方法的回调。

// org.gradle.launcher.cli.CommandLineActionFactorypublic Action<ExecutionListener> convert(List<String> args) { ServiceRegistry loggingServices = createLoggingServices(); LoggingConfiguration loggingConfiguration = new DefaultLoggingConfiguration(); return new WithLogging(loggingServices,    buildLayoutFactory,    args,    loggingConfiguration,    new ExceptionReportingAction(    new ParseAndBuildAction(loggingServices, args),    new BuildExceptionReporter(loggingServices.get(StyledTextOutputFactory.class), loggingConfiguration, clientMetaData())));} 

CommandLineActionFactory.convert 返回 WithLogging 的实例,然后调用了 WithLogging 的 execute 方法。

然后经过各种回调等,最终调用的是 ParseAndBuildAction 的 execute 方法(中间的回调过程略过,有兴趣的同学可以自己查看一下,我们只分析主流程的代码):

// org.gradle.launcher.cli.CommandLineActionFactory#ParseAndBuildActionpublic void execute(ExecutionListener executionListener) { List<CommandLineAction> actions = new ArrayList<CommandLineAction>(); // BuildInActions 是处理help version 这些命令的 actions.add(new BuiltInActions()); // 如果不是上面的两条命令行参数,则执行BuildActionsFactory里的参数。 createActionFactories(loggingServices, actions); CommandLineParser parser = new CommandLineParser(); for (CommandLineAction action : actions) { action.configureCommandLineParser(parser); } Action<? super ExecutionListener> action; try { ParsedCommandLine commandLine = parser.parse(args); action = createAction(actions, parser, commandLine); } catch (CommandLineArgumentException e) { action = new CommandLineParseFailureAction(parser, e); } action.execute(executionListener);}private Action<? super ExecutionListener> createAction(Iterable<CommandLineAction> factories, CommandLineParser parser, ParsedCommandLine commandLine) { for (CommandLineAction factory : factories) { // 根据命令行参数选中相关的处理的Action,比如说在gradle参数后面跟着 help 或者version 参数,则选中BuiltInActions  Runnable action = factory.createAction(parser, commandLine); if (action != null) {  return Actions.toAction(action); } } throw new UnsupportedOperationException("No action factory for specified command-line arguments.");} 

如果在 gradlew 参数后面加 help 或者 version 时,将交给 BuiltInActions 进行处理,其它的则会交给 BuildActionsFactory 类处理。我们直接查看一下 BuildActionsFactory 的执行代码。

// org.gradle.launcher.cli.BuildActionsFactorypublic Runnable createAction(CommandLineParser parser, ParsedCommandLine commandLine) { Parameters parameters = parametersConverter.convert(commandLine, new Parameters()); parameters.getStartParameter().setInteractive(ConsoleStateUtil.isInteractive()); parameters.getDaemonParameters().applyDefaultsFor(jvmVersionDetector.getJavaVersion(parameters.getDaemonParameters().getEffectiveJvm())); // 下面是通过各种不同的参数 if (parameters.getDaemonParameters().isStop()) { / return stopAllDaemons(parameters.getDaemonParameters(), loggingServices); } if (parameters.getDaemonParameters().isStatus()) { return showDaemonStatus(parameters.getDaemonParameters(), loggingServices); } if (parameters.getDaemonParameters().isForeground()) { DaemonParameters daemonParameters = parameters.getDaemonParameters(); ForegroundDaemonConfiguration conf = new ForegroundDaemonConfiguration(  UUID.randomUUID().toString(), daemonParameters.getBaseDir(), daemonParameters.getIdleTimeout(), daemonParameters.getPeriodicCheckInterval()); return new ForegroundDaemonAction(loggingServices, conf); } if (parameters.getDaemonParameters().isEnabled()) { return runBuildWithDaemon(parameters.getStartParameter(), parameters.getDaemonParameters(), loggingServices); } if (canUseCurrentProcess(parameters.getDaemonParameters())) { return runBuildInProcess(parameters.getStartParameter(), parameters.getDaemonParameters(), loggingServices); } return runBuildInSingleUseDaemon(parameters.getStartParameter(), parameters.getDaemonParameters(), loggingServices);} 

后三个方法中都会调用到 runBuildAndCloseServices,这也是执行 Gradle 构建的方法。

最终代码会执行到 RunBuildAction 的 run 方法。

// org.gradle.launcher.cli.RunBuildActionpublic void run() { try { // 这个executer实际上是 InProcessBuildActionExecuter 的实例 executer.execute(  new ExecuteBuildAction(startParameter),  new DefaultBuildRequestContext(new DefaultBuildRequestMetaData(clientMetaData, startTime), new DefaultBuildCancellationToken(), new NoOpBuildEventConsumer()),  buildActionParameters,  sharedServices); } finally { if (stoppable != null) {  stoppable.stop(); } }} 

最终查看 InProcessBuildActionExecuter 执行的代码。

public Object execute(BuildAction action, BuildRequestContext buildRequestContext, BuildActionParameters actionParameters, ServiceRegistry contextServices) { // 最终通过调用获取到了GradleLauncher的实例,他的一个实现类DefaultGradleLauncher GradleLauncher gradleLauncher = gradleLauncherFactory.newInstance(action.getStartParameter(), buildRequestContext, contextServices); try { RootBuildLifecycleListener buildLifecycleListener = contextServices.get(ListenerManager.class).getBroadcaster(RootBuildLifecycleListener.class); buildLifecycleListener.afterStart(); try {  GradleBuildController buildController = new GradleBuildController(gradleLauncher);  buildActionRunner.run(action, buildController);  return buildController.getResult(); } finally {  buildLifecycleListener.beforeComplete(); } } finally { gradleLauncher.stop(); }} 

在 GradleLauncher 的实现类中,我们看到了熟悉的东西:

private enum Stage { Load, Configure, Build} 

然后代码便会调用到 DefaultGradleLauncher 的 run ()->doBuild () 方法

private BuildResult doBuild(final Stage upTo) { // TODO:pm Move this to RunAsBuildOperationBuildActionRunner when BuildOperationWorkerRegistry scope is changed final AtomicReference<BuildResult> buildResult = new AtomicReference<BuildResult>(); WorkerLeaseService workerLeaseService = buildServices.get(WorkerLeaseService.class); workerLeaseService.withLocks(workerLeaseService.getWorkerLease()).execute(new Runnable() { @Override public void run() {  Throwable failure = null;  try {  // 开始构建之前  buildListener.buildStarted(gradle);  // 开始构建  doBuildStages(upTo);  } catch (Throwable t) {  failure = exceptionAnalyser.transform(t);  }  buildResult.set(new BuildResult(upTo.name(), gradle, failure));  // 构建完成之后  buildListener.buildFinished(buildResult.get());  if (failure != null) {  throw new ReportedException(failure);  } } }); return buildResult.get();} 

然后我们看一下开始构建时的代码:

private void doBuildStages(Stage upTo) { if (stage == Stage.Build) {  throw new IllegalStateException("Cannot build with GradleLauncher multiple times"); } if (stage == null) {  // Evaluate init scripts  initScriptHandler.executeScripts(gradle);  // 初始化阶段,解析Settings.gradle文件夹  settings = settingsLoader.findAndLoadSettings(gradle);  stage = Stage.Load; } if (upTo == Stage.Load) {  return; } if (stage == Stage.Load) {  // 配置阶段  buildOperationExecutor.run(new ConfigureBuild());  stage = Stage.Configure; } if (upTo == Stage.Configure) {  return; } stage = Stage.Build; // 绘制task的依赖树 buildOperationExecutor.run(new CalculateTaskGraph()); // 执行task。 buildOperationExecutor.run(new ExecuteTasks()); } 

到最后一步,感觉所有的努力都没有白费,终于看到了熟悉的 Gradle 构建的三个阶段。原来 Gradle 的构建也是用代码写出来的,并没有想象的那么高深。

Gradle 插件

什么是 Gradle 插件

我们上面讲解过,Gradle 的构建过程实际上就是各个 Task 的执行过程,那么这些执行的 Task 从哪里来呢?答案就是从 Gradle 插件里来。

我们发现当我们新建一个 Android 工程时,在 App 这个 module 的 build.gradle 文件的第一行里会有以下代码:

apply plugin: 'com.android.application' 

这句代码的作用便是将构建 Android 应用的所有需要 Task 都加载进来了。所以我们看到,Gradle 生命周期的三个阶段仅仅是个壳子,如果想构建 Android 工程,那么就用 apply plugin: 'com.android.application' 引入所有的构建 Android 应用所需要的 Task;如果想要构建 Java 工程,那么只需要通过 apply plugin: 'java' 来引入 Java 工程所需要的 Task 便可。

那么知道了 Gradle 插件的强大功能,我们将如何按照自己的需要自定义 Gradle 插件呢?我们下面来进行讲解。

自定义 Gradle 插件

我们在自定义 Gradle 插件的时候,需要解决以下问题:

  • 问题一:如何自定义一个 Gradle Plugin?

  • 问题二:Gradle Plugin 怎么调试?

  • 问题三:Gradle Plugin 的 apply 方法是什么时候触发的?

下面以实际的例子来介绍如何自定义 Gradle 插件,并对 Gradle 插件进行调试。在这个实际的例子中,Gradle 插件的定义和使用分别在两个不同的工程中,这样定义出来的 plugin 能够供外部使用。

问题一:自定义一个插件

新建两个工程 CustomPlugin、GradleProject。前者是定义插件的地方,后者是使用插件的地方。我们先定义插件。

下面是完成插件自定义以后的目录结构,我们先来一个总览。

在这里插入图片描述

新建一个 module,删除里面的所有文件,然后新建成如上图所示的目录结构。其中 MyCustomPlugin 是定义的插件类,而 mycustomplugin.properties 是配置的插件属性。

首先在 build.gradle 文件中添加如下代码。

// 应用另外两个插件apply plugin:"groovy"apply plugin: "maven"dependencies { // 使用gradle的api compile gradleApi() // 使用groovy的api compile localGroovy()}repositories { // 下载api相关文件的仓库 mavenCentral()} 

添加以后点击 Sync Project with Gradle 按钮,就是这个:

在这里插入图片描述

然后 Android Studio 就会识别出 groovy 文件夹,groovy 文件夹就变成了蓝色。

然后在 MyCustomPlugin.groovy 添加如下代码,在 apply 方法中具体执行我们想要这个插件去做的事情。

class MyCustomPlugin implements Plugin<Project> { @Override void apply(Project project) {  println("start mycustomplugin")  println(project.name) }} 

最后一步,在 mycustomplugin.properties 文件中添加如下代码,用来指明插件的处理类:

implementation-class=com.dianping.myplugin.MyCustomPlugin 

其中 mycustomplugin.properties 中的 mycustomplugin,代表着这个插件在使用时的名称,例如,使用时就是 apply plugin:'mycustomplugin’。使用方通过名称找到这个插件的配置文件,然后根据配置文件找到这个插件具体执行的类。

到此,自定义一个插件的基本工作就完成了,下面就讲一讲如何使用。

如何使用

打包

在 myplugin 这个 module 中的 build.gradle 文件中添加一些代码,添加后整个代码结构如下。

apply plugin:"groovy"apply plugin: "maven"dependencies { compile gradleApi() compile localGroovy()}repositories { mavenCentral()}// 此处为新添加的代码// 定义组group='com.dianping.myplugin'//定义版本version='1.0.0'uploadArchives { repositories {  mavenDeployer {   // 定义插件打包后上传的位置,可以随意指定,但是在使用时需要指定同样的文件才能找到   repository(url: uri('../../repo'))  } }} 

添加完成后再次点击:

在这里插入图片描述

然后会在 Gradle project 面板中出现 uploadArchives 的 Task。

在这里插入图片描述

双击它,就会在 …/…/repo 目录下出现相关文件。

使用

在 GradleProject 的根级别的 build.gradle 中 buildscript 节点上添加代码,添加完后如下所示:

buildscript { repositories {  google()  jcenter()  // 添加的代码  maven {   url uri('../repo') // 指定路径,这个路径和上面的生成路径是一致的  } } dependencies {  classpath 'com.android.tools.build:gradle:3.0.0'  // 添加的代码。myplugin是上面定义的时候module的名称,com.dianping.myplugin是group。  classpath 'com.dianping.myplugin:myplugin:1.0.0' }} 

最后在 app 这个 module 中添加插件使用。

apply plugin: 'mycustomplugin' 

添加完后执行 ./gradlew :app:assembleDebug 就能看到打印结果:

start mycustompluginapp 

至此,使用上也讲完了。下面该讲一讲如何调试了。

问题二:如何调试

在刚才的 CustomPlugin 工程下在菜单栏中选择 Run >> Edit Configurations。然后点击 remote,新建远程调试,其他东西都不用改,直接点击 OK 就行。

在这里插入图片描述

新建完成以后再 GradleProject 中运行如下命令:

./gradlew :app:assembleDebug -Dorg.gradle.debug=true 

然后在 CustomPlugin 需要断点的地方打上断点,点击下面红框里的按钮,启动调试。断点处就会终止执行。

在这里插入图片描述

至此,插件的开发也能够调试了。

问题三:apply 方法什么时候执行

Gradle 构建的过程总共分为三个阶段:初始化阶段、配置阶段、运行阶段。初始化阶段是执行 settings.gradle 文件中的内容,看看这个 Project 需要构建哪几个 module。在配置阶段是从根 Project 依次遍历 module,并为每个 module 生成一个 Project 对象。配置阶段完成时就形成了一个完整的 Task 依赖图。然后就是执行阶段执行相关的 Task。

那么 apply 方法是什么时候执行的呢?是在配置阶段遇到 apply plugin:'mycustomplugin’ 就开始执行,我们可以在前后打 log 来验证。结果和预期一样。apply 方法中传入的 Project 对象就是某个使用该插件的 Project 的对象。

println 'before'apply plugin: 'mycustomplugin'println 'after' 

非独立工程定义和使用插件

如果想要在自己的工程里面使用 Gradle 插件,那么更加简单。

新建一个 Project,叫做 PluginDemo, 在 app 的 build.gradle 中写上如下代码:

class ApkDistPlugin implements Plugin<Project> { @Override void apply(Project project) {  project.task("apkdist") << {   println 'hello world'  } }}apply plugin: ApkDistPlugin命令行输入:./gradlew -q -p app/ apkdist// 输出结果为:hello world 

让插件是可以配置的

大多数插件都需要在 build script 中获取到一定的配置信息。其中一个方法就是通过 Extension 类来进行,Project 类中持有了 ExtensionContainer 对象,包含了对这个 Project 所有的配置。那么我们就可以通过它来添加我们自己的配置。下面是一个例子。

class ApkDistExtension { Closure nameMap = null String destDir = null}class GreetingPluginExtension { String message = null}class ApkDistPlugin implements Plugin<Project> { @Override void apply(Project project) {  project.extensions.create("apkdistconf",ApkDistExtension)  def extension = project.extensions.create("greet",GreetingPluginExtension)  project.task("apkdist") << {   def closure = project['apkdistconf'].nameMap   closure('hello world closure')   println 'hello world'   println project['apkdistconf'].destDir   println extension.message  } }}apply plugin: ApkDistPluginapkdistconf { nameMap { name ->  println "$name haha" } destDir 'heiheihei'}greet.message = "greet" 

下面是运行结果

// 执行的命令./gradlew -q -p app/ apkdist// 运行的结果hello world closure hahahello worldheiheiheigreet

现在都说互联网寒冬,其实只要自身技术能力够强,咱们就不怕!我这边专门针对Android开发工程师整理了一套【Android进阶学习视频】、【全套Android面试秘籍】、【Android知识点PDF】。如有需要获取资料文档的朋友,可以点击我GitHub免费获取!

 

Gradle的构建过程都不会?带你全面了解Android如何自定义Gradle 插件
玩具品类数据解读运营组长欧苏丹电商卖家大流量,高转化率的"隐形杀手"实用安全亚马逊运营推广软件亚马逊电商中国轨迹15年:10年定位迷失,5年奋发为时已晚亚马逊发声,严厉打击不正当竞争行为!别忘了官方渠道获取评价..亚马逊恶意评价对卖家有什么影响,该如何处理?店铺规则及工具的应用注册亚马逊卖家账号出口易继平台客服、官方微信后,Wish官方客服热线电话开通啦!怎样打造爆款,秘诀在这里!亚马逊Bed Pillows 行业分析报告_专业版案例分析:跨境电商驱动数字化转型!本月加拿大邮费上调将正式生效 / 印度将对美国加征报复性关税

No comments:

Post a Comment