應用於Android無埋點的Gradle外掛解析
自定義外掛涉及到幾個知識點,比如Gradle構建工具、Groovy語法、Gradle外掛開發流程等等。這些知識我就預設大家都知道了。想學習或溫習的可以參考:
這個外掛用來做什麼?
試想一下我們程式碼埋點的過程:首先定位到事件響應函式,例如Button的onClick函式,然後在該事件響應函式中呼叫SDK資料蒐集介面。
Button btnLogin = (Button) findViewById(R.id.btn_login);
btnLogin.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
...
NPTracker.getInstance().trackEvent(Constant.EVENT_LOGIN_METHOD);
}
});
下面,我們介紹使用gradle外掛自動在目標響應函式中插入SDK資料蒐集程式碼,達到自動埋點的目的。本文對此的實戰即通過位元組碼插樁,在class檔案編譯成dex之前(同時也是proguard操作之前),遍歷所有要編譯的class檔案並對其中符合條件的方法進行修改,注入我們要呼叫的SDK資料蒐集程式碼,從而實現自動埋點的目的。
外掛是如何與應用關聯起來的?
為了方便開發,新建一個Android專案,然後開發只針對當前專案的Gradle外掛。針對當前專案開發Gradle外掛相對較簡單,但必須注意的是:
新建的Module名稱必須為BuildSrc
目錄結構示意如下:
然後自定義Gradle外掛類:
package com.codeless.plugin
import com.android.build.gradle.BaseExtension
import com.codeless.plugin.utils.DataHelper
import com.codeless.plugin.utils.Log
import org.gradle.api.Plugin
import org.gradle.api.Project
class InjectPluginImpl implements Plugin<Project> {
@Override
void apply(Project project) {
project.extensions.create('codelessConfig', InjectPluginParams)
registerTransform(project)
...
}
...
}
然後在app module的build.gradle
中引入自定義外掛:
apply plugin: com.codeless.plugin.InjectPluginImpl
codelessConfig {
//this will determine the name of this plugin transform, no practical use.
pluginName = 'myPluginTest'
//turn this on to make it print help content, default value is true
showHelp = true
//this flag will decide whether the log of the modifying process be printed or not, default value is false
keepQuiet = false
//this is a kit feature of the plugin, set it true to see the time consume of this build
watchTimeConsume = false
//this is the most important part, 3rd party JAR packages that want our plugin to inject;
//our plugin will inject package defined in 'AndroidManifest.xml' and 'butterknife.internal.butterknife.internal.DebouncingOnClickListener' by default.
//structure is like ['butterknife.internal','com.a.c'], type is HashSet<String>.
//You can also specify the name of the class;
//example: ['com.xxx.xxx.BaseFragment']
targetPackages = ['okhttp3']
}
可能有人對apply plugin: com.codeless.plugin.InjectPluginImpl
不是很理解,沒關係,那下面這個呢:
apply plugin: 'com.android.library' <==如果是編譯 Library,則載入此外掛
apply plugin: 'com.android.application' <==如果是編譯 Android APP,則載入此外掛
Project 的 API 請戳 Project。apply 其實是 Project 實現的 PluginAware 介面定義的:
apply函式接收多種引數,上述用法呼叫的其實是void apply(Map<String,?> options)
:
plugin 作為Map Key,表示載入指定id的外掛。
回到apply plugin: com.codeless.plugin.InjectPluginImpl
,它表示載入id為com.codeless.plugin.InjectPluginImpl
的外掛,也就是我們的自定義外掛。
apply plugin: com.codeless.plugin.InjectPluginImpl
這一行會導致直接執行com.codeless.plugin.InjectPluginImpl
外掛的void apply(Project project)
方法,傳入app module的build.gradle
對應的 Project 物件。
知識擴充:
每一個
build.gradle
檔案都會轉換成一個 Project 物件。通過 Project 物件可以訪問到build.gradle
中的各種配置。
拿到了app module的build.gradle
對應的 Project 物件,自然就能通過 project.codelessConfig
訪問傳入的一些配置項嘍。例如:
project.afterEvaluate {
Log.setQuiet(project.codelessConfig.keepQuiet);
Log.setShowHelp(project.codelessConfig.showHelp);
Log.logHelp();
if (project.codelessConfig.watchTimeConsume) {
Log.info "watchTimeConsume enabled"
project.gradle.addListener(new TimeListener())
} else {
Log.info "watchTimeConsume disabled"
}
}
講到這裡,相信大家已經清楚,我們的自定義外掛是如何應用到app module的了吧。
Transform程式碼注入原理
從1.5.0-beta1
開始,android的gradle外掛引入了com.android.build.api.transform.Transform
(transform-api),可以用於在android 打包、class轉換成dex過程中,加入開發者自定義的處理邏輯。
初識Transform
Transform的工作流程
Transform每次都是將一個輸入進行處理,然後將處理結果輸出,而輸出的結果將會作為另一個Transform的輸入。
理解Transform需要先了解一些概念:
概念 | 描述 |
---|---|
TransformInput | 輸入檔案:DirectoryInput集合與JarInput集合 |
DirectoryInput | 以原始碼方式參與專案編譯的所有目錄結構及其目錄下的原始碼檔案 |
JarInput | 以jar包方式參與專案編譯的所有本地jar包或遠端jar包 |
TransformOutputProvider | Transform的輸出 |
Scope | 作用域:PROJECT、SUB_PROJECTS、EXTERNAL_LIBRARIES等 |
ContentType | 檔案的型別:CLASSES、RESOURCES、DEX、NATIVE_LIBS等 |
知識擴充:
通過 Scope 和 ContentType 可以組成一個資源流。例如,PROJECT 和 CLASSES,表示了主專案中java 編譯成的 class 組成的一個資源流。再如,SUB_PROJECTS 和 CLASSES ,表示的是本地子專案中的 java 編譯成的 class 組成的一個資源流。Transform 用來處理和轉換這些流。
Transform是一個抽象類,我們需要自定義一個Transform,並實現必要的幾個方法:
public class InjectTransform extends Transform {
@Override
public String getName() {
return "xxxxxx";
}
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
// 配置 Transform 的輸入型別為 Class
return TransformManager.CONTENT_CLASS;
}
@Override
public Set<QualifiedContent.Scope> getScopes() {
// 配置 Transform 的作用域為全工程
return TransformManager.SCOPE_FULL_PROJECT;
}
@Override
public boolean isIncremental() {
return false;
}
@Override
public void transform(
@NonNull Context context,
@NonNull Collection<TransformInput> inputs,
@NonNull Collection<TransformInput> referencedInputs,
@Nullable TransformOutputProvider outputProvider,
boolean isIncremental) throws IOException, TransformException, InterruptedException {
// ...
}
}
下面一一解釋這幾個方法:
-
getName
指明本Transform的名字,隨意
-
getInputTypes
指明Transform的輸入型別,例如,返回 TransformManager.CONTENT_CLASS 表示配置 Transform 的輸入型別為 Class。
-
getScopes
指明Transform的作用域,例如,返回 TransformManager.SCOPE_FULL_PROJECT 表示配置 Transform 的作用域為全工程。
-
isIncremental
指明是否是增量構建
-
transform
用於處理具體的輸入輸出,核心操作都在這裡。上例中,配置 Transform 的輸入型別為 Class, 作用域為全工程,因此在
transform
方法中,inputs 會傳入工程內所有的 class 檔案。
定義好 Transform 後,接下來要在自定義的Plugin中註冊該Transform,從而新增到android編譯流程中。
class InjectPluginImpl implements Plugin<Project> {
@Override
void apply(Project project) {
...
registerTransform(project)
}
def static registerTransform(Project project) {
BaseExtension android = project.extensions.getByType(BaseExtension)
InjectTransform transform = new InjectTransform(project)
android.registerTransform(transform)
}
}
Transform的工作原理
Gradle 包中有一個 TransformManager 的類,用來管理所有的 Transform,其中,TransformManager 包含addTransform
方法:
public <T extends Transform> AndroidTask<TransformTask> addTransform(
@NonNull TaskFactory taskFactory,
@NonNull TransformVariantScope scope,
@NonNull T transform,
@Nullable TransformTask.ConfigActionCallback<T> callback) {
...
transforms.add(transform);
// create the task...
AndroidTask<TransformTask> task = taskRegistry.create(
taskFactory,
new TransformTask.ConfigAction<>(
scope.getFullVariantName(),
taskName,
transform,
inputStreams,
referencedStreams,
outputStream,
callback));
...
return task;
}
}
}
顯然,addTransform
方法在執行的過程中,會將 T (T extends Transform) 包裝成一個 TransformTask 物件,並進一步包裝成AndroidTask物件。所以可以理解為一個 Transform 就是一個 Task。該 Task 執行時,gradle引擎會去呼叫含有@TaskAction註解的方法,TransformTask類擁有Transfrom型別欄位,其transform
方法被標記為@TaskAction,且TransformTask的transform
方法最終呼叫了Transfrom的transform
方法。
/**
* A task running a transform.
*/
@ParallelizableTask
public class TransformTask extends StreamBasedTask implements Context {
private Transform transform;
Collection<SecondaryFile> secondaryFiles = null;
public Transform getTransform() {
return transform;
}
...
@TaskAction
void transform(final IncrementalTaskInputs incrementalTaskInputs)
throws IOException, TransformException, InterruptedException {
...
ThreadRecorder.get().record(ExecutionType.TASK_TRANSFORM, executionInfo,
getProject().getPath(), getVariantName(), new Recorder.Block<Void>() {
@Override
public Void call() throws Exception {
transform.transform(new TransformInvocationBuilder(TransformTask.this)
.addInputs(consumedInputs.getValue())
.addReferencedInputs(referencedInputs.getValue())
.addSecondaryInputs(changedSecondaryInputs.getValue())
.addOutputProvider(outputStream != null
? outputStream.asOutput()
: null)
.setIncrementalMode(isIncremental.getValue())
.build());
return null;
}
});
}
}
Gradle 的包中有一個 TaskManager 類,管理所有的 Task 執行。 其中有一個createPostCompilationTasks
方法:
public void createPostCompilationTasks(
@NonNull TaskFactory tasks,
@NonNull final VariantScope variantScope) {
...
// ----- External Transforms -----
// 新增自定義的 Transform
List<Transform> customTransforms = extension.getTransforms();
List<List<Object>> customTransformsDependencies = extension.getTransformsDependencies();
for (int i = 0, count = customTransforms.size() ; i < count ; i++) {
Transform transform = customTransforms.get(i);
AndroidTask<TransformTask> task = transformManager
.addTransform(tasks, variantScope, transform);
...
}
...
// ----- Minify next -----
// minifyEnabled 為 true 表示開啟混淆
// 新增 Proguard Transform
if (isMinifyEnabled) {
boolean outputToJarFile = isMultiDexEnabled && isLegacyMultiDexMode;
createMinifyTransform(tasks, variantScope, outputToJarFile);
}
...
// non Library test are running as native multi-dex
if (isMultiDexEnabled && isLegacyMultiDexMode) {
...
// 新增 JarMergeTransform
// create a transform to jar the inputs into a single jar.
if (!isMinifyEnabled) {
// merge the classes only, no need to package the resources since they are
// not used during the computation.
JarMergingTransform jarMergingTransform = new JarMergingTransform(
TransformManager.SCOPE_FULL_PROJECT);
variantScope.addColdSwapBuildTask(
transformManager.addTransform(tasks, variantScope, jarMergingTransform));
}
// 新增 MultiDex Transform
// create the transform that's going to take the code and the proguard keep list
// from above and compute the main class list.
MultiDexTransform multiDexTransform = new MultiDexTransform(
variantScope,
extension.getDexOptions(),
null);
multiDexClassListTask = transformManager.addTransform(
tasks, variantScope, multiDexTransform);
multiDexClassListTask.optionalDependsOn(tasks, manifestKeepListTask);
variantScope.addColdSwapBuildTask(multiDexClassListTask);
}
...
// 新增 Dex Transform
// create dex transform
DefaultDexOptions dexOptions = DefaultDexOptions.copyOf(extension.getDexOptions());
...
}
該方法在 javaCompile 之後呼叫, 會遍歷所有的 Transform,然後一一新增進 TransformManager。 新增完自定義的 Transform 之後,再新增 Proguard, JarMergeTransform, MultiDex, Dex 等 Transform。所以,現在應該清楚為什麼Transform可以在編譯之後、class轉換成dex之前,加入開發者自定義的處理邏輯了吧。
原理篇就講這麼多了,欲知更多,可以參考:
注入程式碼的時機
我們可以先看一個transform
的例子,它將不做任何處理,只是將輸入原樣輸出:
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
// 配置 Transform 的輸入型別為 Class
return TransformManager.CONTENT_CLASS;
}
@Override
public Set<QualifiedContent.Scope> getScopes() {
// 配置 Transform 的作用域為全工程
return TransformManager.SCOPE_FULL_PROJECT;
}
@Override
void transform(Context context, Collection<TransformInput> inputs,
Collection<TransformInput> referencedInputs,
TransformOutputProvider outputProvider, boolean isIncremental)
throws IOException, TransformException, InterruptedException {
// Transform的inputs有兩種型別,一種是目錄,一種是jar包,要分開遍歷
inputs.each { TransformInput input ->
//對型別為“資料夾”的input進行遍歷
input.directoryInputs.each { DirectoryInput directoryInput ->
//資料夾裡面包含的是我們手寫的類以及R.class、BuildConfig.class以及R$XXX.class等
// 獲取output目錄
def dest = outputProvider.getContentLocation(directoryInput.name,
directoryInput.contentTypes, directoryInput.scopes,
Format.DIRECTORY)
// 將input的目錄複製到output指定目錄
FileUtils.copyDirectory(directoryInput.file, dest)
}
//對型別為jar檔案的input進行遍歷
input.jarInputs.each { JarInput jarInput ->
//jar檔案一般是第三方依賴庫jar檔案
// 重新命名輸出檔案(同目錄copyFile會衝突)
def jarName = jarInput.name
def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4)
}
//生成輸出路徑
def dest = outputProvider.getContentLocation(jarName + md5Name,
jarInput.contentTypes, jarInput.scopes, Format.JAR)
//將輸入內容複製到輸出
FileUtils.copyFile(jarInput.file, dest)
}
}
}
在這個例子中,由於配置了 Transform 的輸入型別為 Class, 作用域為全工程,因此在transform
方法中,inputs 會傳入工程內所有的 class 檔案。inputs 為Collection<TransformInput> 集合物件,集合元素 TransformInput 定義如下:
public interface TransformInput {
/**
* Returns a collection of {@link JarInput}.
*/
@NonNull
Collection<JarInput> getJarInputs();
/**
* Returns a collection of {@link DirectoryInput}.
*/
@NonNull
Collection<DirectoryInput> getDirectoryInputs();
}
看介面方法可知,inputs 包含了 jar 包和目錄。也就是說,transform
方法可以遍歷到原始碼目錄中java類對應的class檔案,也可以遍歷到第三方jar包內的class檔案。辛辛苦苦摸索到這裡,想修改的class檔案就在眼前了,此時不注入程式碼更待何時呢?如下圖所示,我們可以分別遍歷原始碼目錄&jar包,對滿足修改條件的class檔案或jar包進行修改,將修改後的檔案輸出:
ASM實現無埋點
通過前面的分析,我們已經知道在哪裡修改class檔案了,然後發現又走不下去了,class檔案怎麼修改呢?難道我們要自己手動編寫位元組碼指令修改二進位制檔案?好在有ASM這個庫,至於ASM語法等知識並非本文的重點,所以,本文只討論無埋點外掛中涉及的部分。需要學習或溫習的請猛戳系列博文:
- 深入位元組碼 -- 使用 ASM 實現 AOP
- 深入位元組碼 -- 玩轉 ASM-Bytecode
- 深入位元組碼 -- ASM 關鍵介面 ClassVisitor
- 深入位元組碼 -- ASM 關鍵介面 MethodVisitor
ASM關鍵知識點
ASM被稱為是類的掃描器,它可以掃描到組成一個類的各個結構:
- 描述類訪問控制許可權,類名,父類,介面和註解
- 每個被宣告的成員變數,同樣包括訪問控制許可權、名稱、型別、註解
- 方法及建構函式,包括訪問控制許可權,名稱、名稱、返回值型別、引數型別、註解等;同時還包含方法體
ASM對class的生成和轉換是基於ClassVisitor抽象類的,該類的每個方法都對應class的一個結構,它的完整介面如下:
下面重點介紹我們需要用到的幾個方法:
-
void visit(int version, int access, String name,
String signature, String superName, String[] interfaces)該方法是當掃描類時第一個訪問的方法。各引數代表的含義是:類版本、修飾符、類名、泛型資訊、繼承的父類、實現的介面。我們只需關心繼承的父類和實現的介面,當執行到 visit 方法時,可以通過全域性變數儲存繼承的父類和實現的介面資訊。程式碼示例如下:
static class MethodFilterClassVisitor extends ClassVisitor { private String superName private String[] interfaces private ClassVisitor classVisitor public MethodFilterClassVisitor( final ClassVisitor cv) { super(Opcodes.ASM5, cv); this.classVisitor = cv } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { Log.logEach('* visit *', Log.accCode2String(access), name, signature, superName, interfaces); this.superName = superName // 記錄該類實現了哪些介面 this.interfaces = interfaces super.visit(version, access, name, signature, superName, interfaces); } }
-
MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)
當掃描器掃描到類的方法時呼叫該方法。各引數代表的含義是:修飾符、方法名、方法簽名、泛型資訊、丟擲的異常。其中,方法簽名的格式如下:(引數列表)返回值型別;例如
void onClick(View v)
的方法簽名為(Landroid/view/View;)V
。 -
visitEnd()
當掃描器完成類掃描時才會呼叫該方法。
當ASM的ClassReader讀取到Method時就轉入MethodVisitor介面處理。方法的定義,以及方法中指令的定義都會通過MethodVisitor介面通知給程式。MethodVisitor的具體實現由ClassVisitor的visitMethod返回值指定。下面是MethodVisitor介面的所有方法定義:
由於我們的無埋點目前只支援在方法體頭部或尾部插入埋點程式碼,因此我們只需關心下面兩個方法:
-
visitCode()
表示ASM開始掃描這個方法。
-
visitEnd()
表示方法輸出完畢。
ASM-Bytecode工具
由於 JVM 對位元組碼十分敏感,修改過程中稍微有一絲錯誤都會導致虛擬機器錯誤,而想要排查錯誤卻是一件比較困難的事情。因此不建議大家手動編寫ASM 程式碼,而是藉助 ASM-Bytecode 工具。
ASM-Bytecode 是一個Eclipse外掛,外掛的地址請戳ASM-Bytecode。
也可通過Eclipse Marketplace安裝(推薦)
外掛效果如下:
ASM無埋點例項
比如我們希望向View的onClick方法裡插入自定義的程式碼:
com.codeless.tracker.PluginAgent.onClick(v);
假設插入前是這樣子的:
public class MainActivity extends AppCompatActivity implements
View.OnClickListener{
@Override
public void onClick(View v) {
}
}
插入後的期望程式碼:
package com.codeless.demo;
public class MainActivity extends AppCompatActivity implements
View.OnClickListener{
@Override
public void onClick(View v) {
PluginAgent.onClick(var1);
}
}
最便捷的方法是,使用 ASM-Bytecode 工具將插入後的期望程式碼翻譯成 ASM 程式碼:
mv.visitVarInsn(ALOAD, 1);
mv.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 2);
mv.visitEnd();
從而得到
com.codeless.tracker.PluginAgent.onClick(v);
對應的 ASM 程式碼語句如下
mv.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);
然後,使用 ClassVisitor 掃描原始碼class檔案(當然也包含MainActivity.class啦),將該語句插到void onClick(View v)
方法體頭部。具體操作請留意程式碼註釋:
public class MethodFilterClassVisitor extends ClassVisitor {
private String superName
private String[] interfaces
private ClassVisitor classVisitor
public MethodFilterClassVisitor(
final ClassVisitor cv) {
super(Opcodes.ASM5, cv);
this.classVisitor = cv
}
@Override
public void visit(int version, int access, String name,
String signature, String superName, String[] interfaces) {
Log.logEach('* visit *', Log.accCode2String(access), name, signature, superName, interfaces);
this.superName = superName
// 記錄該類實現了哪些介面
this.interfaces = interfaces
super.visit(version, access, name, signature, superName, interfaces);
}
@Override
public MethodVisitor visitMethod(int access, String name,
String desc, String signature, String[] exceptions) {
MethodVisitor myMv = null;
if (interfaces != null && interfaces.length > 0) {
// 根據方法名+方法描述判斷是否需要修改方法體;
// 例如,當前遍歷到View的onClick方法時,name是onClick,desc是(Landroid/view/View;)V;
// 則滿足修改條件onClick(Landroid/view/View;)V
// 當第一個條件滿足後,還需進一步判斷當前類是否實現了View$OnClickListener介面
if ('onClick(Landroid/view/View;)V' == (name + desc) && interfaces.contains('android/view/View$OnClickListener')) {
try {
MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
myMv = new MethodLogVisitor(methodVisitor) {
@Override
void visitCode() {
super.visitCode();
// 在方法體開頭插入自定義埋點程式碼
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);
}
}
} catch (Exception e) {
e.printStackTrace();
myMv = null
}
}
}
if (myMv != null) {
return myMv;
} else {
return cv.visitMethod(access, name, desc, signature, exceptions);
}
}
}
總結一下上面的例子,就是當一個Activity
或Fragment
實現了View$OnClickListener
介面,使用外掛遍歷到該Activity
或Fragment
位元組碼中的onClick(View v)
時,向該方法中插入com.codeless.tracker.PluginAgent.onClick(v)
。com.codeless.tracker.PluginAgent中的onClick(View v)
方法即是您想要注入到點選事件響應onClick中的程式碼。
上面只是簡化版的例子,完整專案程式碼要比這個複雜和豐富一些,有興趣歡迎forkLazierTracker檢視完整程式碼。
分析到這裡,不知道大家清楚了沒,如果還有疑問或者有好的建議,可以在LazierTracker下面提issue,就醬。
相關文章
- 使用新 Android Gradle 外掛加速您的應用構建AndroidGradle
- Android Gradle外掛AndroidGradle
- SOFATracer 外掛埋點機制詳解
- Android Studio gradle外掛版本和gradle版本對應關係AndroidGradle
- Android與Gradle(一):Gradle外掛開發AndroidGradle
- Android Studio之Gradle和Gradle外掛的區別AndroidGradle
- iptables深入解析:應用層外掛篇
- 得到Android團隊無埋點方案Android
- Android開發中Gradle外掛,Gradle版本與JDK版本之間的對應關係 AndroidAndroidGradleJDK
- Gradle系列之Gradle外掛Gradle
- Gradle系列(四) Gradle外掛Gradle
- gradle自定義外掛Gradle
- js無侵入埋點方案JS
- Android Studio NDK :一、基礎入門(基於gradle-experimental外掛)AndroidGradle
- Android外掛化原理解析——概要Android
- 編寫最基本的Gradle外掛Gradle
- 不得不學之「 Gradle」 ⑤ Gradle 外掛Gradle
- 關於vim的實用外掛
- Android無埋點資料收集SDK關鍵技術Android
- fullpage外掛基本應用
- artDialog外掛應用
- uploadify外掛的功能應用
- Android多渠道打包工具Gradle外掛使用詳解AndroidGradle
- 擁抱 Android Studio 之五:Gradle 外掛開發AndroidGradle
- 應用於網站導航中的 15 個 jQuery 外掛網站jQuery
- Android 外掛化原理解析(1):概要Android
- 美團無埋點方案 - Gradle Plugin 的方式,在編譯期間修改 class | 掘金技術徵文GradlePlugin編譯
- 自定義Gradle-Plugin 外掛GradlePlugin
- 使用 Java 開發 Gradle 外掛JavaGradle
- Gradle自定義外掛詳解Gradle
- 通俗易懂的Gradle外掛講解Gradle
- 在Gradle中使用jaxb的xjc外掛Gradle
- 適用於開發者的最佳火狐外掛
- bigSlide 外掛應用IDE
- react 的pdf預覽外掛應用React
- android 基於dex的外掛化開發Android
- 無埋點統計SDK實踐
- 8個超實用的jQuery外掛應用jQuery