ASM 是什么葡缰?
AOP(面向切面編程),是一種編程思想忱反,但是它的實(shí)現(xiàn)方式有很多泛释,比如:APT、AspectJ温算、JavaAssist怜校、ASM 等。
ASM 和 Javassist類似注竿,也是一個(gè) Java 字節(jié)碼操控框架茄茁。它能被用來動(dòng)態(tài)生成類或者增強(qiáng)既有類的功能。ASM 可以直接產(chǎn)生二進(jìn)制 class 文件巩割,也可以在類被加載入 Java 虛擬機(jī)之前動(dòng)態(tài)改變類行為裙顽。Java class 被存儲(chǔ)在嚴(yán)格格式定義的 .class 文件里,這些類文件擁有足夠的元數(shù)據(jù)來解析類中的所有元素:類名稱宣谈、方法愈犹、屬性以及 Java 字節(jié)碼(指令)。ASM 從類文件中讀入信息后蒲祈,能夠改變類行為甘萧,分析類信息萝嘁,甚至能夠根據(jù)用戶要求生成新類梆掸。
簡單點(diǎn)說,通過 javac 將 .java 文件編譯成 .class 文件牙言,.class 文件中的內(nèi)容雖然不同酸钦,但是它們都具有相同的格式,ASM 通過使用訪問者(visitor)模式咱枉,按照 .class 文件特有的格式從頭到尾掃描一遍 .class 文件中的內(nèi)容卑硫,在掃描的過程中徒恋,就可以對(duì) .class 文件做一些操作了,有點(diǎn)黑科技的感覺
所以ASM 就是一個(gè)字節(jié)碼操作庫欢伏,可以大大降低我們操作字節(jié)碼的難度
Android 的打包過程
如圖所示是Android打包流程入挣,.java文件->.class文件->.dex文件,只要在紅圈處攔截住硝拧,拿到所有方法進(jìn)行修改完再放行就可以了径筏,而做到這一步也不難,Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API, 允許第三方 Plugin 在打包 dex 文件之前的編譯過程中操作 .class 文件障陶,我們做的就是實(shí)現(xiàn)Transform進(jìn)行.class文件遍歷拿到所有方法滋恬,修改完成對(duì)原文件進(jìn)行替換。
原理概述
我們可以自定義一個(gè)Gradle Plugin抱究,然后注冊(cè)一個(gè)Transform對(duì)象恢氯,在tranform方法里,可以分別遍歷目錄和jar包鼓寺,然后我們就可以遍歷當(dāng)前應(yīng)用程序的所有.class文件勋拟,然后在利用ASM框架的相關(guān)API,去加載響應(yīng)的.class 文件侄刽,并解析指黎,就可以找到滿足特定條件的.class文件和相關(guān)方法,最后去修改相應(yīng)的方法以動(dòng)態(tài)插入埋點(diǎn)字節(jié)碼州丹,從而達(dá)到自動(dòng)埋點(diǎn)的效果醋安。
DEMO
本范例嘗試對(duì)點(diǎn)擊android中的普通點(diǎn)擊事件進(jìn)行一個(gè)攔截,并在其中插入代碼墓毒。
1吓揪、創(chuàng)建android工程,只寫一個(gè)簡單點(diǎn)擊事件即可(
代碼..略
2所计、創(chuàng)建plugin lib module
1柠辞、修改plugin的gradle
apply plugin: 'groovy'
apply plugin: 'maven'
dependencies {
compile gradleApi()
compile localGroovy()
compile 'org.ow2.asm:asm:6.0'
compile 'org.ow2.asm:asm-commons:6.0'
compile 'org.ow2.asm:asm-analysis:6.0'
compile 'org.ow2.asm:asm-util:6.0'
compile 'org.ow2.asm:asm-tree:6.0'
compileOnly 'com.android.tools.build:gradle:3.2.1', {//這里注意需要保持版本一致,否則會(huì)報(bào)錯(cuò)
exclude group:'org.ow2.asm'
}
}
repositories {
jcenter()
}
//調(diào)試模式下在本地生成倉庫(也可推入自己已有的maven倉庫)
uploadArchives {
repositories.mavenDeployer {
//本地倉庫路徑主胧,以放到項(xiàng)目根目錄下的 repo 的文件夾為例
repository(url: uri('../repo'))
//groupId 叭首,自行定義
pom.groupId = 'com.canzhang.android'
//artifactId
pom.artifactId = 'bury-point-com.canzhang.plugin'
//插件版本號(hào)
pom.version = '1.0.0-SNAPSHOT'
}
}
2、在main目錄下新建groovy包
groovy 是一種語言踪栋,和java語法比較類似
3焙格、創(chuàng)建transform類
這個(gè)類的作用就是在被編譯成dex之前能夠攔截到.class文件,然后找到匹配我們需求的夷都,進(jìn)行修改調(diào)整眷唉。
/**
* Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API,
* 允許第三方 Plugin 在打包 dex 文件之前的編譯過程中操作 .class 文件,
* 我們做的就是實(shí)現(xiàn)Transform進(jìn)行.class文件遍歷拿到所有方法,修改完成對(duì)原文件進(jìn)行替換冬阳。
*/
class AnalyticsTransform extends Transform {
private static Project project
private AnalyticsExtension analyticsExtension
AnalyticsTransform(Project project, AnalyticsExtension analyticsExtension) {
this.project = project
this.analyticsExtension = analyticsExtension
}
/**
* /返回該transform對(duì)應(yīng)的task名稱(編譯后會(huì)出現(xiàn)在build/intermediates/transform下生成對(duì)應(yīng)的文件夾)
* @return
*/
@Override
String getName() {
return AnalyticsSetting.PLUGIN_NAME
}
/**
* 需要處理的數(shù)據(jù)類型蛤虐,有兩種枚舉類型
* CLASSES 代表處理的 java 的 class 文件,RESOURCES 代表要處理 java 的資源
* @return
*/
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
/**
* 指 Transform 要操作內(nèi)容的范圍肝陪,官方文檔 Scope 有 7 種類型:
* 1. EXTERNAL_LIBRARIES 只有外部庫
* 2. PROJECT 只有項(xiàng)目內(nèi)容
* 3. PROJECT_LOCAL_DEPS 只有項(xiàng)目的本地依賴(本地jar)
* 4. PROVIDED_ONLY 只提供本地或遠(yuǎn)程依賴項(xiàng)
* 5. SUB_PROJECTS 只有子項(xiàng)目驳庭。
* 6. SUB_PROJECTS_LOCAL_DEPS 只有子項(xiàng)目的本地依賴項(xiàng)(本地jar)。
* 7. TESTED_CODE 由當(dāng)前變量(包括依賴項(xiàng))測(cè)試的代碼
* @return
*/
@Override
Set<QualifiedContent.Scope> getScopes() {
//點(diǎn)進(jìn)去可以看到這個(gè)包含(項(xiàng)目氯窍、項(xiàng)目依賴嚷掠、外部庫)
//Scope.PROJECT,
//Scope.SUB_PROJECTS,
//Scope.EXTERNAL_LIBRARIES
return TransformManager.SCOPE_FULL_PROJECT
// return Sets.immutableEnumSet(
// QualifiedContent.Scope.PROJECT,
// QualifiedContent.Scope.SUB_PROJECTS)
}
@Override
boolean isIncremental() {//是否增量構(gòu)建
return false
}
//這里需要注意,就算什么都不做荞驴,也需要把所有的輸入文件拷貝到目標(biāo)目錄下不皆,否則下一個(gè)Task就沒有TransformInput了,
// 如果是此方法空實(shí)現(xiàn),最后會(huì)導(dǎo)致打包的APK缺少.class文件
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
_transform(transformInvocation.context, transformInvocation.inputs, transformInvocation.outputProvider, transformInvocation.incremental)
}
void _transform(Context context, Collection<TransformInput> inputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
if (!incremental) {
outputProvider.deleteAll()
}
/**Transform 的 inputs 有兩種類型熊楼,一種是目錄霹娄,一種是 jar 包,要分開遍歷 */
inputs.each { TransformInput input ->
/**遍歷目錄*/
input.directoryInputs.each { DirectoryInput directoryInput ->
/**當(dāng)前這個(gè) Transform 輸出目錄*/
File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
File dir = directoryInput.file
if (dir) {
HashMap<String, File> modifyMap = new HashMap<>()
/**遍歷以某一擴(kuò)展名結(jié)尾的文件*/
dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
File classFile ->
if (AnalyticsClassModifier.isShouldModify(classFile.name, analyticsExtension)) {
File modified = AnalyticsClassModifier.modifyClassFile(dir, classFile, context.getTemporaryDir())
if (modified != null) {
/**key 為包名 + 類名鲫骗,如:/cn/data/autotrack/android/app/MainActivity.class*/
String ke = classFile.absolutePath.replace(dir.absolutePath, "")
modifyMap.put(ke, modified)//修改過后的放到一個(gè)map中然后在寫回源目錄犬耻,覆蓋原來的文件
}
}
}
FileUtils.copyDirectory(directoryInput.file, dest)
modifyMap.entrySet().each {
Map.Entry<String, File> en ->
File target = new File(dest.absolutePath + en.getKey())
if (target.exists()) {
target.delete()
}
FileUtils.copyFile(en.getValue(), target)
en.getValue().delete()
}
}
}
/**遍歷 jar*/
input.jarInputs.each { JarInput jarInput ->
String destName = jarInput.file.name
/**截取文件路徑的 md5 值重命名輸出文件,因?yàn)榭赡芡?會(huì)覆蓋*/
def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath).substring(0, 8)
/** 獲取 jar 名字*/
if (destName.endsWith(".jar")) {
destName = destName.substring(0, destName.length() - 4)
}
/** 獲得輸出文件*/
File dest = outputProvider.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
def modifiedJar = AnalyticsClassModifier.modifyJar(jarInput.file, context.getTemporaryDir(), true, analyticsExtension)
if (modifiedJar == null) {
modifiedJar = jarInput.file
}
FileUtils.copyFile(modifiedJar, dest)
}
}
}
}
3、創(chuàng)建插件類
/**
* 可以通過配置主工程目錄中的gradle.properties 中的
* canPlugin.disablePlugin字段來控制是否開啟此插件
*/
class AnalyticsPlugin implements Plugin<Project> {
void apply(Project project) {
//這個(gè)AnalyticsExtension 以及canPlugin名稱执泰,可以提供我們?cè)谕鈱优渲靡恍﹨?shù)枕磁,從而支持外層擴(kuò)展
AnalyticsExtension extension = project.extensions.create("canPlugin", AnalyticsExtension)
//這個(gè)可以讀取工程的gradle.properties 里面的can.disablePlugin 字段,控住是否注冊(cè)此插件
boolean disableAnalyticsPlugin = false
Properties properties = new Properties()
if (project.rootProject.file('gradle.properties').exists()) {
properties.load(project.rootProject.file('gradle.properties').newDataInputStream())
disableAnalyticsPlugin = Boolean.parseBoolean(properties.getProperty("disablePlugin", "false"))
}
if (!disableAnalyticsPlugin) {
println("------------您開啟了全埋點(diǎn)插樁插件--------------")
AppExtension appExtension = project.extensions.findByType(AppExtension.class)
//注冊(cè)我們的transform類
appExtension.registerTransform(new com.canzhang.plugin.AnalyticsTransform(project, extension))
} else {
println("------------您已關(guān)閉了全埋點(diǎn)插樁插件--------------")
}
}
}
到這里插件和gradle的tranform類我們都創(chuàng)建好了术吝,下面需要看該怎么修改我們想修改的類了计济。
4、ASM中的ClassVisitor
ClassVisitor:主要負(fù)責(zé)遍歷類的信息排苍,包括類上的注解沦寂、構(gòu)造方法、字段等等淘衙。
所以我們可以在這個(gè)類中篩選出符合我們條件的類或者方法传藏,然后去修改,實(shí)現(xiàn)我們的目的彤守。
比如我們本例子就是為了找到實(shí)現(xiàn)了View$OnClickListener
接口的類毯侦,然后遍歷這個(gè)類,并找到重寫后的onClick(View v)
方法具垫。
這里就細(xì)節(jié)貼代碼了侈离,不懂得地方可以看注釋
/**
* 使用ASM的ClassReader類讀取.class的字節(jié)數(shù)據(jù),并加載類做修,
* 然后用自定義的ClassVisitor霍狰,進(jìn)行修改符合特定條件的方法,
* 最后返回修改后的字節(jié)數(shù)組
*/
class AnalyticsClassVisitor extends ClassVisitor implements Opcodes {
//插入的外部類具體路徑
private String[] mInterfaces
private ClassVisitor classVisitor
private String mCurrentClassName
AnalyticsClassVisitor(final ClassVisitor classVisitor) {
super(Opcodes.ASM6, classVisitor)
this.classVisitor = classVisitor
}
private
static void visitMethodWithLoadedParams(MethodVisitor methodVisitor, int opcode, String owner, String methodName, String methodDesc, int start, int count, List<Integer> paramOpcodes) {
for (int i = start; i < start + count; i++) {
methodVisitor.visitVarInsn(paramOpcodes[i - start], i)
}
methodVisitor.visitMethodInsn(opcode, owner, methodName, methodDesc, false)
}
/**
* 這里可以拿到關(guān)于.class的所有信息饰及,比如當(dāng)前類所實(shí)現(xiàn)的接口類表等
* @param version 表示jdk的版本
* @param access 當(dāng)前類的修飾符 (這個(gè)和ASM 和 java有些差異蔗坯,比如public 在這里就是ACC_PUBLIC)
* @param name 當(dāng)前類名
* @param signature 泛型信息
* @param superName 當(dāng)前類的父類
* @param interfaces 當(dāng)前類實(shí)現(xiàn)的接口列表
*/
@Override
void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces)
mInterfaces = interfaces
mCurrentClassName = name
AnalyticsUtils.logD("當(dāng)前的類是:" + name)
AnalyticsUtils.logD("當(dāng)前類實(shí)現(xiàn)的接口有:" + mInterfaces)
}
/**
* 這里可以拿到關(guān)于method的所有信息,比如方法名燎含,方法的參數(shù)描述等
* @param access 方法的修飾符
* @param name 方法名
* @param desc 方法簽名(就是(參數(shù)列表)返回值類型拼接)
* @param signature 泛型相關(guān)信息
* @param exceptions 方法拋出的異常信息
* @return
*/
@Override
MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)
String nameDesc = name + desc
methodVisitor = new AnalyticsDefaultMethodVisitor(methodVisitor, access, name, desc) {
@Override
void visitEnd() {
super.visitEnd()
}
@Override
void visitInvokeDynamicInsn(String name1, String desc1, Handle bsm, Object... bsmArgs) {
super.visitInvokeDynamicInsn(name1, desc1, bsm, bsmArgs)
}
@Override
protected void onMethodExit(int opcode) {//方法退出節(jié)點(diǎn)
super.onMethodExit(opcode)
}
@Override
protected void onMethodEnter() {//方法進(jìn)入節(jié)點(diǎn)
super.onMethodEnter()
if ((mInterfaces != null && mInterfaces.length > 0)) {
//如果當(dāng)前類實(shí)現(xiàn)的接口有View$OnClickListener宾濒,并且當(dāng)前進(jìn)入的方法是onClick(Landroid/view/View;)V
//這里如果不知道怎么寫,可以寫個(gè)demo打印一下屏箍,就很快知道了绘梦,這里涉及一些ASM和Java中不同的寫法。
if ((mInterfaces.contains('android/view/View$OnClickListener') && nameDesc == 'onClick(Landroid/view/View;)V')) {
AnalyticsUtils.logD("插樁:OnClickListener nameDesc:" + nameDesc + " currentClassName:" + mCurrentClassName)
//這里就是插代碼邏輯了
methodVisitor.visitVarInsn(ALOAD, 1)
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/canzhang/asmdemo/sdk/MySdk", "onViewClick", "(Landroid/view/View;)V", false)
}
}
}
@Override
AnnotationVisitor visitAnnotation(String s, boolean b) {
return super.visitAnnotation(s, b)
}
}
return methodVisitor
}
}
要插入的代碼
public class MySdk {
/**
* 常規(guī)view 被點(diǎn)擊赴魁,自動(dòng)埋點(diǎn)
*
* @param view View
*/
@Keep
public static void onViewClick(View view) {
Log.e("Test","成功插入 666666:"+view);
}
}
核心代碼分析
@Override
protected void onMethodEnter() {//方法進(jìn)入節(jié)點(diǎn)
super.onMethodEnter()
if ((mInterfaces != null && mInterfaces.length > 0)) {
//如果當(dāng)前類實(shí)現(xiàn)的接口有View$OnClickListener卸奉,并且當(dāng)前進(jìn)入的方法是onClick(Landroid/view/View;)V
//這里如果不知道怎么寫,可以寫個(gè)demo打印一下颖御,就很快知道了榄棵,這里涉及一些ASM和Java中不同的寫法。
if ((mInterfaces.contains('android/view/View$OnClickListener') && nameDesc == 'onClick(Landroid/view/View;)V')) {
AnalyticsUtils.logD("插樁:OnClickListener nameDesc:" + nameDesc + " currentClassName:" + mCurrentClassName)
//這里就是插代碼邏輯了
methodVisitor.visitVarInsn(ALOAD, 1)
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/canzhang/asmdemo/sdk/MySdk", "onViewClick", "(Landroid/view/View;)V", false)
}
}
}
當(dāng)方法進(jìn)入的時(shí)候潘拱,如果判斷符合我們的條件疹鳄,則進(jìn)行方法插入。
- 問題1:
nameDesc
為啥這么寫芦岂。
nameDesc == 'onClick(Landroid/view/View;)V'
為什么是這樣寫的瘪弓,后面的V是個(gè)什么東東。
首先grovvy中是可以使用==號(hào)來判斷字符串是否相等的禽最,其次方法名是和java有一些差異腺怯,這個(gè)我們可以深入去了解這些差異學(xué)習(xí),就可以理解為何這么寫川无。還有一種簡單的方法瓢喉,可以直接打印日志的方式來快速知道我們需要的方法應(yīng)該怎么寫。
入?yún)?duì)應(yīng)關(guān)系表
image.png
例子
- 問題2: 這插入的是什么鬼舀透,怎么有點(diǎn)看不懂栓票,如何知道怎么插。
ASM就是幫助我們操作字節(jié)碼的愕够,封裝了一些api可供我們調(diào)用走贪,這個(gè)轉(zhuǎn)換可以使用一個(gè)插件 ASM Bytecode outline ,android studio 可以下載此插件(參考教程
)。
5惑芭、創(chuàng)建配置文件
按照如圖所示創(chuàng)建對(duì)應(yīng)路徑和配置文件com.canzhang.plugin.properties
坠狡,這里需要注意
- 配置文件的名字:
com.canzhang.plugin
就是插件的名稱,就是稍后我們生成插件后遂跟,引用此插件的module需要聲明的那個(gè):apply plugin: 'com.canzhang.plugin' - 配置內(nèi)容就是我們插件的的包名和類名
# 此文件名為插件引用名逃沿,下面這行則是對(duì)應(yīng)的插件路徑
implementation-class=com.canzhang.plugin.AnalyticsPlugin
6婴渡、然后我們就可以運(yùn)行構(gòu)建plugin了
構(gòu)建好之后我們就可以在本地看到這樣一個(gè)文件夾
這里如果想開放此插件給到其他工程使用,則可以提交repo到githup,然后按照下方配置流程進(jìn)行配置(步驟7)凯亮,另外需要額外配置倉庫地址
maven { url "https://raw.githubusercontent.com/gudujiucheng/ASMDemo/master/repo" }
其中:https://raw.githubusercontent.com/
為固定路徑边臼,gudujiucheng
為Github用戶名,ASMDemo
為項(xiàng)目名假消,master/repo
為倉庫相對(duì)路徑柠并。
7、使用插件
- 項(xiàng)目gradle配置(配置本地倉庫富拗、并引入插件)
buildscript {
repositories {
google()
jcenter()
//本地調(diào)試倉庫
maven {
url uri('repo')
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.0'
//引用插件
classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
- 主module gradle 配置
apply plugin: 'com.canzhang.plugin'
然后運(yùn)行編譯之后臼予,就可以看到我們插樁的代碼了。
插樁前的代碼:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
findViewById(R.id.tv_test).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Toast.makeText(MainActivity.this, "普通點(diǎn)擊事件", Toast.LENGTH_SHORT).show();
}
});
}
}
如下圖所示啃沪,可以看到具體插樁后的字節(jié)碼粘拾,可以點(diǎn)擊查看(注意如果插樁的class是jar包內(nèi)的,則需要自行反編譯jar進(jìn)行查看(推薦一個(gè)簡單易用的反編譯工具:https://github.com/linchaolong/ApkToolPlus)创千,或者調(diào)整插件半哟,使輸出一份class到指定文件夾查看)。
更多細(xì)節(jié)待續(xù)....
- Demo:https://github.com/gudujiucheng/ASMDemo.git
- 敏感函數(shù)調(diào)用分析見:http://www.reibang.com/p/70e14250cccc
注意事項(xiàng):
- 沒有生成插件之前签餐,要把依賴去掉寓涨,不然跑不起來
主module屏蔽
apply plugin: 'com.canzhang.plugin'
主工程的gradle屏蔽
classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'
屏蔽之后先build項(xiàng)目成功后,在觸發(fā)生成插件氯檐,然后在放開屏蔽的兩項(xiàng)戒良,就可以了
關(guān)于混淆:關(guān)于混淆可以不用擔(dān)心」谏悖混淆其實(shí)是個(gè)ProguardTransform糯崎,在自定義的Transform之后執(zhí)行。
插件插入不存在的代碼也是不會(huì)報(bào)錯(cuò)的河泳,因?yàn)槭窃诰幾g后插入的沃呢,直到運(yùn)行的時(shí)候才會(huì)報(bào)錯(cuò),所以要注意插入代碼的正確性拆挥。
-
出現(xiàn)莫名其妙的錯(cuò)誤薄霜,如
RuntimeException
這里asm不同版本的api,有時(shí)候會(huì)做api版本限制纸兔,要檢查下惰瓜,自己的api版本是否錯(cuò)誤:(發(fā)生這些錯(cuò)誤的原因,主要是因?yàn)槲覀儗懰赖陌姹竞嚎螅晚?xiàng)目實(shí)際應(yīng)用的asm版本不相同導(dǎo)致的)
我們的代碼
比如下面這些版本限制觸發(fā)的異常:(這里只是拋出了異常崎坊,并沒有很細(xì)致的提示,所以需要留意看錯(cuò)誤日志)
其他細(xì)節(jié)
- 上文是用
groovy
來寫的(groovy的編譯錯(cuò)誤提示不是很好洲拇,建議用其他語言寫)奈揍,也可以使用java
或者kotlin
來寫曲尸,可以選擇自己熟悉的語法,這幾種語言最后都會(huì)轉(zhuǎn)換成字節(jié)碼男翰,通過jvm來執(zhí)行另患。 - 如果用于項(xiàng)目,可以考慮參考其他框架進(jìn)行一些增量編譯和多線程并發(fā)處理文件等方面的優(yōu)化奏篙,提高編譯速度,可參考:https://github.com/Leaking/Hunter
參考文章:
本文主要是用于記錄迫淹,參考自神策全埋點(diǎn)教程
http://www.reibang.com/p/9039a3e46dbc
http://www.reibang.com/p/c2c1d350d245
http://www.reibang.com/p/16ed4d233fd1