Android字節碼插樁採坑筆記

1.寫在前面

俗話說「任何技術都是脫離了業務都將是空中樓閣」。最開始有研究字節碼插樁技術衝動的是咱們接入了一款統計類的SDK(這裏我就不具體說是哪款了)。他們的套路是第三方開發者須要接入他們的插件(Gradle Plugin),而後即可以實現無埋點進行客戶端的全量數據統計(全量的意思是包括頁面打開速度、方法耗時、各類點擊事件等)。當時因爲需求排期比較急,一直沒有時間研究他們的實現方式。春節假期,我實在難以控制體內的求知慾,經過查資料以及反編譯他們的代碼終於找到了技術的本源——字節碼插樁。正好公司這段時間要繼續搞一套統計系統,爲了避免侵入原有的項目架構,我也打算使用字節碼插樁技術來實現。so寫這篇文章的目的是將預研期的坑share一下,避免更多小夥伴入坑~html

先簡要描述一下接下來咱們要幹什麼

簡單來說,咱們要實現無埋點對客戶端的全量統計。這裏的統計歸納的範圍比較普遍,常見的場景有:java

  • 頁面(Activity、Fragment)的打開事件
  • 各類點擊事件的統計,包括但不限於Click LongClick TouchEvent
  • Debug期須要統計各個方法的耗時。注意這裏的方法包括接入的第三方SDK的方法。
  • 待補充

要實現這些功能須要擁有哪些技術點呢?

  • 面向切面編程思想(AOP)
  • Android打包流程
  • 自定義Gradle插件
  • 字節碼編織
  • 結合本身的業務實現統計代碼
  • 沒了。。。

2.開始惡補技術點

2.1 技術點——什麼是AOP

AOP(Aspect Oriented Program的首字母縮寫)是一種面向切面編程的思想。這種編程思想是相對於OOP(ObjectOriented Programming即面向對象編程)來講的。說破大天,我們要實現的功能仍是統計嘛,大規模的重複統計行爲是典型的AOP使用場景。因此搞懂什麼是AOP以及爲何要用AOP變得很重要android

先來講一下你們熟悉的面向對象編程:面向對象的特色是繼承、多態和封裝。而封裝就要求將功能分散到不一樣的對象中去,這在軟件設計中每每稱爲職責分配。實際上也就是說,讓不一樣的類設計不一樣的方法。這樣代碼就分散到一個個的類中去了。這樣作的好處是下降了代碼的複雜程度,使類可重用。git

But面向對象的編程天生有個缺點就是分散代碼的同時,也增長了代碼的重複性。好比我但願在項目裏面全部的模塊都增長日誌統計模塊,按照OOP的思想,咱們須要在各個模塊裏面都添加統計代碼,可是若是按照AOP的思想,能夠將統計的地方抽象成切面,只須要在切面裏面添加統計代碼就OK了。github

切面圖
其實在服務端的領域AOP已經被各路大佬玩的風生水起,例如Spring這類跨時代的框架。我第一次接觸AOP就是在自學Spring框架的的時候。最多見實現AOP的方式就是代理。

2.2 技術點——Android打包流程

既然想用字節碼插樁來實現無埋點,對Android的打包流程老是要了解一下的。否則我們怎麼系統何時會把Class文件生成出來供咱們插樁呢?官網的打包流程不是那麼的直觀。因此一塊兒來看一下更直觀的構建流程吧。web

Android打包流程
一圖頂千言,通過「Java Compiler步驟」,系統便生成了.class文件。這些class文件通過dex步驟再次轉化成Android識別的.dex文件。既然咱們要作字節碼插樁,就必須hook打包流程,在dex步驟以前對class字節碼進行掃描與從新編織,而後將編織好的class文件交給dex過程。這樣就實現了所謂的無埋點。那麼問題來了,咱們怎麼知道系統已經完成了「Java Compiler」步驟呢?這就引出下一個技術點——自定義Gradle插件。

2.3 技術點——自定義Gradle插件

接着2.2小節的問題,咱們怎麼知道打包系統已經完成「Java Compiler」步驟?即便知道打包系統生成了class字節碼文件又怎麼Hook掉該流程在完成自定義字節碼編織後再進行「dex」過程呢?原來,對於Android Gradle Plugin 版本在1.5.0及以上的狀況,Google官方提供了transformapi用做字節碼插樁的入口。說的直白一點經過自定義Gradle插件,重寫裏面transform方法就能夠在「Java Compiler」過程結束以後 「dex」過程開始以前得到回調。這正是字節碼從新編織的絕佳時機。編程

關於怎樣定義Gradle插件值得參考的資源

由於本文重點講字節碼插樁的技術流程,強調從面上覆蓋這套技術所涉及到的技術點,因此關於自定義插件的內容不展開講解了。按照上面推薦的資源本身基本能夠跑通自定義Gradle插件的流程。若是你們自定義插件的詳細內容請聯繫我,若是有必要我能夠出一篇自定義Gradle插件的教程。文末會給出郵箱。api

關於transform值得參考的資源:

  • 官方文檔
  • 滴滴插件化項目VirtualApk,該項目中的virtualapk-gradle-plugin就是利用這個插樁入口將插件的資源與宿主的資源進行剝離,防止宿主apk與插件apk資源衝突。詳見該項目裏面StripClassAndResTransform類。

2.4 技術點——字節碼編織

字節碼的相關知識是本文的核心技術點數組

2.4.1 什麼是字節碼

Java 字節碼(英語:Java bytecode)是Java虛擬機執行的一種指令格式。通俗來說字節碼就是通過javac命令編譯以後生成的Class文件。Class文件包含了Java虛擬機指令集和符號表以及若干其餘的輔助信息。Class文件是一組以8位字節爲基礎單位的二進制流,哥哥數據項目嚴格按照順序緊湊的排列在Class文件之中,中間沒有任何分隔符,這使得整個Class文件中存儲的內容幾乎全是程序運行時的必要數據。android-studio

由於Java虛擬機的提供商有不少,其具體的虛擬機實現邏輯都不相同,可是這些虛擬機的提供商都嚴格遵照《Java虛擬機規範》的限制。因此一份正確的字節碼文件是能夠被不一樣的虛擬機提供商正確的執行的。借用《深刻理解Java虛擬機》一書的話就是「代碼編譯的結果從本地機器碼轉變成字節碼,是存儲格式發展的一小步,確實編程語言發展的一大步」。

2.4.2 字節碼的內容

字節碼內容

這張圖是一張java字節碼的總覽圖。一共含有10部分,包含魔數,版本號,常量池,字段表集合等等。一樣本篇文章不展開介紹具體內容請參考這篇博文,有條件的同窗請閱讀《深刻理解Java虛擬機》一書。我如今讀了兩遍,每次讀都有新的感悟。推薦你們也讀一下,對本身的成長很是有好處。

關於字節碼幾個重要的內容:

全限定名

Class文件中使用全限定名來表示一個類的引用,全限定名很容易理解,即把類名全部「.」換成了「/」

例如

android.widget.TextView
複製代碼

的全限定名爲

android/widget/TextView
複製代碼

描述符

描述符的做用是描述字段的數據類型、方法的參數列表(包括數量、類型以及順序)和返回值。根據描述符的規則,基本數據類型(byte char double float int long short boolean)以及表明無返回值的void類型都用一個大寫字符來表示,對象類型則用字符「L」加對象的全限定名來表示,通常對象類型末尾都會加一個「;」來表示全限定名的結束。以下表

標誌字符 含義
B 基本類型byte
C 基本類型char
D 基本類型double
F 基本類型float
I 基本類型int
J 基本類型long
S 基本類型short
Z 基本類型boolean
V 特殊類型void
L 對象類型,例如Ljava/lang/Object

對於數組類型,每個維度將使用「[」字符來表示 例如咱們須要定義一個String類型的二維數組

java.lang.String[][]
將會被表示成
[[java/lang/String;

int[]
將會被表示成
[I;
複製代碼

用描述符來描述方法時,按照先參數列表後返回值的順序進行描述。參數列表按照參數的順序放到一組小括號「()」以內。舉幾個栗子:

void init()
會被描述成
()V

void setText(String s)
會被描述成
(Ljava/lang/String)V;

java.lang.String toString()
會被描述成
()Ljava/lang/String;
複製代碼

2.4.3 虛擬機字節碼執行引擎知識

執行引擎是虛擬機最核心的組成部分之一。本篇仍然控制版面,避免長篇大論的討論具體內容而忽略須要解決的問題的本質。下面咱們重點討論一下Java的運行時內存佈局:

虛擬機的內存能夠分爲堆內存與棧內存。堆內存是全部線程共享的,棧內存則是線程私有的。下圖爲虛擬機運行時數據區

運行時數據區
這裏重點解釋一下棧內存。Java虛擬機棧是線程私有的,它描述的是Java方法執行的內存模型:每一個方法在執行的同時會建立一個棧幀用於存局部變量表、操做數棧、動態連接、方法返回地址等信息。每個方法從調用到執行完畢的過程,就對應着一個棧幀在虛擬機棧中從入棧到出棧的過程。每個棧幀都包含了局部變量表、操做數棧、動態連接、方法返回地址和一些額外的附加信息。在編譯成class文件後,棧幀中須要多大的局部變量表和多深的操做數棧已經保存在字節碼文件(class文件)的code屬性中,所以一個棧幀須要分配多少內存,不會受到程序運行的影響,只會根據虛擬機的具體實現不一樣。一個線程中的方法調用鏈可能會很長,即有不少棧幀。對於一個當前活動的線程中,只有位於線程棧頂的棧幀纔是有效的,稱爲當前棧幀(current stack Frame),這個棧幀關聯的方法稱爲當前方法(current method),棧幀的概念圖以下:
解釋一下上圖相關概念:

  • 局部變量表:局部變量表是一組變量存儲空間,用於存儲方法參數(就是方法入參)和方法內部定義局部變量。局部變量表的容量以容量槽爲最小單位(slot)。虛擬機經過索引的定位方式使用局部變量表,索引值的範圍爲0到局部變量的最大slot值,在static方法中,0表明的是「 this」,即當前調用該方法的引用(主調方),其他參數從1開始分配,當參數列表中的參數分配完後,就開始給方法內的局部變量分配。用Android的click方法舉個栗子:
public void onClick(View v) {
                
            }
複製代碼

這個方法的局部變量表的容量槽爲:

Slot Number value
0 this
1 View v
  • 操做數棧:操做數棧又被稱爲操做棧,它是一個後入先出的棧結構。當一個方法剛開始執行時,操做數棧裏是空的,在方法的執行過程當中,會有各類字節碼指令向操做數棧中寫入和提取內容,也就是出棧和入棧的過程。例如,在執行字節碼指令iadd(兩個int類型整數相加)時要求操做數棧中最接近棧頂的兩個元素已經存入兩個int類型的值,而後執行相加時,會將這兩個int值相加,而後將相加的結果入棧。具體的字節碼操做指令能夠參考維基百科,也能夠參考國內巴掌的文章

2.4.4 字節碼編織之ASM簡介

惡補完前面的知識點,終於到了最後的一步。怎樣對字節碼進行編織呢?這裏我選了一個強大的開源庫ASM。

什麼是ASM?

ASM 是一個 Java 字節碼操控框架。它能被用來動態生成類或者加強既有類的功能。ASM 能夠直接產生二進制 class 文件,也能夠在類被加載入 Java 虛擬機以前動態改變類行爲。Java class 被存儲在嚴格格式定義的 .class 文件裏,這些類文件擁有足夠的元數據來解析類中的全部元素:類名稱、方法、屬性以及 Java 字節碼(指令)。ASM 從類文件中讀入信息後,可以改變類行爲,分析類信息,甚至可以根據用戶要求生成新類。

爲何選擇ASM來進行字節碼編織?

由於有了前人作的實驗,我沒有對字節碼編織的庫進行效率測試。參考網易樂得團隊的實驗結果:

Framework First time Later times
Javassist 257 5.2
BCEL 473 5.5
ASM 62.4 1.1

經過上表可見,ASM的效率更高。不過效率高的前提是該庫的語法更接近字節碼層面。因此上面的虛擬機相關知識顯得更加劇要。

這個庫也沒什麼可展開描述的,值得參考的資源:

爲了快速上手ASM,安利一個插件[ASM Bytecode Outline]。這裏須要感謝巴掌的文章。ASM的內容就介紹到這裏,具體怎麼使用你們參考項目代碼或者本身研究一波文檔就行了。

3.項目實戰

咱們以Activity的開啓爲切面,對客戶端內全部Activity的onCreate onDestroy進行插樁。建議先clone一份demo項目

3.1 新建Gradle插件

按照2.3小節的內容,聰明的你必定能很快新建一個Gradle插件並能跑通流程吧。若是你的流程沒跑通能夠參考項目源碼。

須要注意的點:

注意點1:

項目中須要將Compile的地址換成你的本機地址,不然編譯會失敗。須要改動的文件有traceplugin/gradle.properties中的LOCAL_REPO_URL屬性。

以及跟項目下的build.gradle文件中的maven地址

3.2 完善自定義插件,添加掃描與修改邏輯

例如demo項目中的TracePlugin.groovy就是掃描的入口,經過重寫transform方法,咱們能夠得到插樁入口,將對Class文件的處理轉化成ASM處理。

public class TracePlugin extends Transform implements Plugin<Project> {
    void apply(Project project) {
        def android = project.extensions.getByType(AppExtension);
        //對插件進行註冊,添加插樁入口
        android.registerTransform(this)
    }


    @Override
    public String getName() {
        return "TracePlugin";
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    @Override
    public Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    @Override
    public boolean isIncremental() {
        return false;
    }
    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs,
                   TransformOutputProvider outputProvider, boolean isIncremental) 
            throws IOException, TransformException, InterruptedException {
        println '//===============TracePlugin visit start===============//'
        //刪除以前的輸出
        if (outputProvider != null)
            outputProvider.deleteAll()
        //遍歷inputs裏的TransformInput
        inputs.each { TransformInput input ->
            //遍歷input裏邊的DirectoryInput
            input.directoryInputs.each {
                DirectoryInput directoryInput ->
                    //是不是目錄
                    if (directoryInput.file.isDirectory()) {
                        //遍歷目錄
                        directoryInput.file.eachFileRecurse {
                            File file ->
                                def filename = file.name;
                                def name = file.name
                                //這裏進行咱們的處理 TODO
                                if (name.endsWith(".class") && !name.startsWith("R\$") &&
                                        !"R.class".equals(name) && !"BuildConfig.class".equals(name)) {
                                    ClassReader classReader = new ClassReader(file.bytes)
                                    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                                    def className = name.split(".class")[0]
                                    ClassVisitor cv = new TraceVisitor(className, classWriter)
                                    classReader.accept(cv, EXPAND_FRAMES)
                                    byte[] code = classWriter.toByteArray()
                                    FileOutputStream fos = new FileOutputStream(
                                            file.parentFile.absolutePath + File.separator + name)
                                    fos.write(code)
                                    fos.close()

                                }
                        }
                    }
                    //處理完輸入文件以後,要把輸出給下一個任務
                    def dest = outputProvider.getContentLocation(directoryInput.name,
                            directoryInput.contentTypes, directoryInput.scopes,
                            Format.DIRECTORY)
                    FileUtils.copyDirectory(directoryInput.file, dest)
            }


            input.jarInputs.each { JarInput jarInput ->
                /**
                 * 重名名輸出文件,由於可能同名,會覆蓋
                 */
                def jarName = jarInput.name
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }

                File tmpFile = null;
                if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
                    JarFile jarFile = new JarFile(jarInput.file);
                    Enumeration enumeration = jarFile.entries();
                    tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_trace.jar");
                    //避免上次的緩存被重複插入
                    if (tmpFile.exists()) {
                        tmpFile.delete();
                    }
                    JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile));
                    //用於保存
                    ArrayList<String> processorList = new ArrayList<>();
                    while (enumeration.hasMoreElements()) {
                        JarEntry jarEntry = (JarEntry) enumeration.nextElement();
                        String entryName = jarEntry.getName();
                        ZipEntry zipEntry = new ZipEntry(entryName);
                        //println "MeetyouCost entryName :" + entryName
                        InputStream inputStream = jarFile.getInputStream(jarEntry);
                        //若是是inject文件就跳過

                        //重點:插樁class
                        if (entryName.endsWith(".class") && !entryName.contains("R\$") &&
                                !entryName.contains("R.class") && !entryName.contains("BuildConfig.class")) {
                            //class文件處理
                            jarOutputStream.putNextEntry(zipEntry);
                            ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
                            ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                            def className = entryName.split(".class")[0]
                            ClassVisitor cv = new TraceVisitor(className, classWriter)
                            classReader.accept(cv, EXPAND_FRAMES)
                            byte[] code = classWriter.toByteArray()
                            jarOutputStream.write(code)

                        } else if (entryName.contains("META-INF/services/javax.annotation.processing.Processor")) {
                            if (!processorList.contains(entryName)) {
                                processorList.add(entryName)
                                jarOutputStream.putNextEntry(zipEntry);
                                jarOutputStream.write(IOUtils.toByteArray(inputStream));
                            } else {
                                println "duplicate entry:" + entryName
                            }
                        } else {

                            jarOutputStream.putNextEntry(zipEntry);
                            jarOutputStream.write(IOUtils.toByteArray(inputStream));
                        }

                        jarOutputStream.closeEntry();
                    }
                    //寫入inject註解

                    //結束
                    jarOutputStream.close();
                    jarFile.close();
                }

                //處理jar進行字節碼注入處理 TODO

                def dest = outputProvider.getContentLocation(jarName + md5Name,
                        jarInput.contentTypes, jarInput.scopes, Format.JAR)
                if (tmpFile == null) {
                    FileUtils.copyFile(jarInput.file, dest)
                } else {
                    FileUtils.copyFile(tmpFile, dest)
                    tmpFile.delete()
                }
            }
        }
        println '//===============TracePlugin visit end===============//'

    }
複製代碼

上述TracePlugin.groovy文件完成了字節碼與ASM的結合,那具體怎麼修改字節碼呢?新建繼承自ClassVisitor的Visitor類

  • 重寫裏面的visit方法以便篩選哪些類須要插樁,例如篩選全部繼承自Activity的類才插樁。
  • 重寫visitMethod方法以便篩選當前類哪些方法須要插樁。例如篩選全部onCreate方法才插樁。 具體註釋見代碼:
/**
 * 對繼承自AppCompatActivity的Activity進行插樁
 */

public class TraceVisitor extends ClassVisitor {

    /**
     * 類名
     */
    private String className;

    /**
     * 父類名
     */
    private String superName;

    /**
     * 該類實現的接口
     */
    private String[] interfaces;

    public TraceVisitor(String className, ClassVisitor classVisitor) {
        super(Opcodes.ASM5, classVisitor);
    }

    /**
     * ASM進入到類的方法時進行回調
     *
     * @param access
     * @param name       方法名
     * @param desc
     * @param signature
     * @param exceptions
     * @return
     */
    @Override
    public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature,
                                     String[] exceptions) {
        MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
        methodVisitor = new AdviceAdapter(Opcodes.ASM5, methodVisitor, access, name, desc) {

            private boolean isInject() {
                //若是父類名是AppCompatActivity則攔截這個方法,實際應用中能夠換成本身的父類例如BaseActivity
                if (superName.contains("AppCompatActivity")) {
                    return true;
                }
                return false;
            }

            @Override
            public void visitCode() {
                super.visitCode();

            }

            @Override
            public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
                return super.visitAnnotation(desc, visible);
            }

            @Override
            public void visitFieldInsn(int opcode, String owner, String name, String desc) {
                super.visitFieldInsn(opcode, owner, name, desc);
            }


            /**
             * 方法開始以前回調
             */
            @Override
            protected void onMethodEnter() {
                if (isInject()) {
                    if ("onCreate".equals(name)) {
                        mv.visitVarInsn(ALOAD, 0);
                        mv.visitMethodInsn(INVOKESTATIC,
                                "will/github/com/androidaop/traceutils/TraceUtil",
                                "onActivityCreate", "(Landroid/app/Activity;)V",
                                false);
                    } else if ("onDestroy".equals(name)) {
                        mv.visitVarInsn(ALOAD, 0);
                        mv.visitMethodInsn(INVOKESTATIC, "will/github/com/androidaop/traceutils/TraceUtil"
                                , "onActivityDestroy", "(Landroid/app/Activity;)V", false);
                    }
                }
            }

            /**
             * 方法結束時回調
             * @param i
             */
            @Override
            protected void onMethodExit(int i) {
                super.onMethodExit(i);
            }
        };
        return methodVisitor;

    }

    /**
     * 當ASM進入類時回調
     *
     * @param version
     * @param access
     * @param name       類名
     * @param signature
     * @param superName  父類名
     * @param interfaces 實現的接口名
     */
    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        this.className = name;
        this.superName = superName;
        this.interfaces = interfaces;
    }
}
複製代碼

注意:

若是你對ASM用的並非那麼熟練,別忘了ASM Bytecode Outline插件。上面TraceVisitor.java中的onMethodEnter方法內部代碼即是從ASM Bytecode Outline生成直接拷貝過來的。至於這個插件怎麼使用2.4.4小節已經介紹過了。

3.3 完善自定義統計工具,實現最終數據統計

demo項目中app/TraceUtil.java類是用來統計的代碼,項目中我只是在onCreate與onDestroy時彈出了一個Toast,你徹底能夠把這兩個函數執行的時間記錄下來,實現統計用戶在線時長等邏輯。TraceUtils.java代碼以下:

/**
 * Created by will on 2018/3/9.
 */

public class TraceUtil {
    private final String TAG = "TraceUtil";

    /**
     * 當Activity執行了onCreate時觸發
     *
     * @param activity
     */
    public static void onActivityCreate(Activity activity) {
        Toast.makeText(activity
                , activity.getClass().getName() + "call onCreate"
                , Toast.LENGTH_LONG).show();
    }


    /**
     * 當Activity執行了onDestroy時觸發
     *
     * @param activity
     */
    public static void onActivityDestroy(Activity activity) {
        Toast.makeText(activity
                , activity.getClass().getName() + "call onDestroy"
                , Toast.LENGTH_LONG).show();
    }
}
複製代碼

看到這裏有人會有疑問,這個TraceUtil的onActivityCreate與onActivityDestroy是何時被執行的?固然是經過TraceVisitor的visitMethod方法插樁插進去的呀。

3.4 本身運行一下Demo & Enjoy

項目代碼

看下項目的效果,統計代碼已經被成功注入。

項目效果

4. 其餘的小Tips

  • 字節碼插樁是面向整個應用的插樁,若是咱們只想插某一個函數的樁應該怎麼辦呢?例如我只想插MainActivity的onCreate函數,而不想插其餘Activity的onCreate。這時候可使用自定義註解來解決。方案是自定義一個註解,在想統計的方法上打上這個註解,在ASM的ClassVisitor類中重寫visitAnnotation方法來肯定要不要插樁。怎樣自定義註解能夠看個人這篇博文
  • 若是想插不一樣的樁該怎麼辦呢?例如我既想統計Activity的生命週期函數又想統計View的Click事件。講道理這塊個人經驗不夠豐富,個人方案比較low,我是經過在ClassVisitor中判斷當前類的名字、當前類的父類名字、當前類實現了哪些接口、以及當前類方法的名字來判斷的,比較臃腫。小夥伴們有什麼好的想法能夠留言或聯繫我

寫在最後

因爲這篇博文所涉及到的知識點比較多,不少地方我可能沒有展開寫的比較糙。若是寫的有什麼問題但願你們及時提出來,一塊兒學習,一塊兒進步。

參考資源


About Me

contact way value
mail weixinjie1993@gmail.com
wechat W2006292
github https://github.com/weixinjie
blog https://juejin.im/user/57673c83207703006bb92bf6
相關文章
相關標籤/搜索