Android技術棧(六)JNI中的奇技淫巧

1 爲何會有這篇文章

嗯...其實這篇文章並非JNI的教程,而是由於網上關於JNI的好教程已經不少了,我在把別人寫過的東西再總結一遍其實也沒什麼意義,本身想寫點不同的東西。java

PS:本篇文章主要包含JNI和C++中的一些使用技巧,須要讀者對C++有必定的掌握。android

2 在JNI/C/C++/中檢查空懸指針

2.1 Windows下的作法

在Windows下,得益於強大的WinAPI,幹這事情很方便。在winbase.h中提供了兩個函數原型:git

2.1.1 IsBadReadPtr

IsBadReadPtr用於判斷指針所指區域的內存是否可讀。github

MSDN👉IsBadReadPtr(按照M$的尿性不必定能打得開,緣由你懂的)。面試

BOOL IsBadReadPtr(
  const VOID *lp, //指定起始內存地址
  UINT_PTR   ucb //內存塊長度
);
複製代碼

2.1.2 IsBadWritePtr

IsBadWritePtr用於判斷指針所指區域的內存是否可寫。編程

MSDM👉IsBadWritePtrwindows

BOOL IsBadWritePtr(
  LPVOID   lp, //指定起始內存地址
  UINT_PTR ucb //內存塊長度
);
複製代碼

2.2 Android/Linux下的作法

2.2.1 Linux信號機制

衆所周知,Linux在訪問空懸指針時會拋出SIGSEGV信號,若是你沒有使用signal設置信號處理函數,那這個程序立刻就崩了。要想不崩,咱們得使用signal配合sigsetjmp(長跳轉):api

#include <signal.h>
#include <setjmp.h>
#include <stdarg.h>
#include <stdlib.h>
#include <stdio.h>
jmp_buf env;
//信號處理函數
void signal_handler(int sig) {
    siglongjmp(env,1);
}

int main(int argc,char** argv) {
    //使用sigsetjmp保存一下上下文 
    int r = sigsetjmp(env,1);
    if(r == 0)
    {
        //註冊一個處理函數 
        signal(SIGSEGV, &signal_handler);
        //幹壞事的人的微博找到了@帶帶大師兄
        char* s = nullptr;
        (*s) = "nm$l";
    }
    eles
    {
        printf("👀到沒?👴回來了\n");
    }
    return 0;
}
複製代碼

可是這種方式,看着實在是太Low了,並且在Linux中,一個進程的信號處理函數是惟一的,有時候咱們不能佔用這個signal也不是爲多線程環境所準備的(由於它是C API)。那麼有沒有什麼標準化的辦法能夠繞開它呢?bash

2.2.2 使用write/dev/random寫數據(親測有用)

這個方法最初來源於StackOverflow上關於《IsBadReadPtr analogue on Unix》的討論。多線程

在Android/Linux下有一個虛擬設備/dev/random,它被用來描述系統的混亂程度,是系統隨機數設備,用系統的混亂程度來作隨機數種子。

當你使用write調用往這個設備裏寫值時,若所寫區域所指的內存有效,那write調用會返回大於0的值,反之就會返回負值或0。

這個操做並不會對正在運行的系統產生什麼負面影響,只是正常利用了《操做系統》課上所講的進程不知道這塊內存是否有效,而操做系統必須明確知道的這一點,屬常規操做的一種,使用這個技巧能夠幫助咱們在JNI編程中防止被同事重創。

3 直接緩衝區

3.1 我知道你面試也被問了

相信你們在面試中必定被問過nio的直接緩衝區,也必定在面試題庫中看見到過nio的直接緩衝區。一般來講題庫中的解釋都很籠統,會告你JVM在指定的內存區域分配了一塊獨立的緩衝區用於跟本機代碼交互,而且這塊內存是收JVM管理的。可是至因而怎麼交互的,怎麼管理的,怎麼就高效率了,每每避而不談,這裏我結合JNI給你們講講個人理解。

3.2 直接緩衝區的本質是什麼?

當咱們在Java中使用ByteBuffer.allocateDirect時,返回一個DirectByteBuffer,DirectByteBuffer在不一樣平臺上的實現差別很大,可是總的行爲能夠理解爲,Java在一個DirectByteBuffer對象上保存了一塊C++的byte[]的指針,而後你能夠經過Java API去訪問這個C++的byte[]。

3.3 直接緩衝區怎麼就能被GC自動釋放了?

DirectByteBuffer管理的這塊C++的byte[]是受間接GC管理的,可是DirectByteBuffer沒有重寫Object的finalize。在咱們new一個DirectByteBuffer時,同時也會建立一個sun.misc.Cleaner(跟sun.misc.Unsafe同樣屬於JDK內部API),Cleaner繼承自java.lang.ref.PhantomReference(虛引用)。

虛引用不能用來訪問對象,他的get()方法會返回null,可是記住虛引用在JVM中它仍然是一個Reference的子類,JVM會觀察全部Reference所指向的對象。

當一個對象只被PhantomReference引用時,它會被Reference.ReferenceHandler這個線程處理,若是這個PhantomReference還剛好是一個sun.misc.Cleaner的話,就會調用Cleaner上的clean()方法,來清除那塊C++的byte[],下面是內存清除的關鍵代碼。

java.lang.ref.Reference.java中:

static boolean tryHandlePending(boolean var0) {
        Reference var1;
        Cleaner var2;
        try {
            synchronized(lock) {
                if (pending == null) {
                    if (var0) {
                        lock.wait();
                    }

                    return var0;
                }

                var1 = pending;
                var2 = var1 instanceof Cleaner ? (Cleaner)var1 : null;
                pending = var1.discovered;
                var1.discovered = null;
            }
        } catch (OutOfMemoryError var6) {
            Thread.yield();
            return true;
        } catch (InterruptedException var7) {
            return true;
        }

        if (var2 != null) {
            var2.clean();
            return true;
        } else {
            ReferenceQueue var3 = var1.queue;
            if (var3 != ReferenceQueue.NULL) {
                var3.enqueue(var1);
            }

            return true;
        }
    }
    
    private static class ReferenceHandler extends Thread {
        private static void ensureClassInitialized(Class<?> var0) {
            try {
                Class.forName(var0.getName(), true, var0.getClassLoader());
            } catch (ClassNotFoundException var2) {
                throw (Error)(new NoClassDefFoundError(var2.getMessage())).initCause(var2);
            }
        }

        ReferenceHandler(ThreadGroup var1, String var2) {
            super(var1, var2);
        }

        public void run() {
            while(true) {
                Reference.tryHandlePending(true);
            }
        }

        static {
            ensureClassInitialized(InterruptedException.class);
            ensureClassInitialized(Cleaner.class);
        }
    }
複製代碼

3.4 直接緩衝區怎麼就高效了呢?

先把結論告訴你,若是你在純Java環境中使用ByteBuffer.allocateDirect其實效率還不如ByteBuffer.allocate呢,由於DirectByteBuffer每次訪問那塊C++的byte[]都是一次JNI調用,每次JNI調用JVM都要作不少準備,好比切換線程狀態之類的,因此是有損耗的。

那爲何還說直接緩衝區高效呢?這得分應用場景,直接緩衝區與JNI一塊兒使用,那他就高效。下面我將比較使用JNI中會遇到的兩種狀況。

假設Java中的buffer內是咱們要與C++交互的數據。

  1. 使用ByteBuffer.allocateDirect
//java層
    @Test
    public void test() {
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
        transferToNative(buffer);
    }

    public native void transferToNative(ByteBuffer buffer);
    
    //C++層
    extern "C" JNIEXPORT void JNICALL
    Java_org_kexie_test_Test_transferToNative(
        JNIEnv *env,
        jobject thiz,
        jobject buffer) {
        void *nativeBuffer = env->GetDirectBufferAddress(buffer);
    }
複製代碼
  1. 使用Java原始的byte[]
//Java層
    @Test
    public void test() {
        byte[] buffer = new byte[1024];
        transferToNative(buffer);
    }

    public native void transferToNative(byte[] buffer);
    
    //C++
    extern "C" JNIEXPORT void JNICALL
    Java_org_kexie_test_Test_transferToNative(
        JNIEnv *env,
        jobject thiz,
        jbyteArray buffer) {
    void *nativeBuffer = env->GetByteArrayElements(buffer, nullptr);
    }
複製代碼

經過上面的總結相信你們心中已經有答案了,直接緩衝區本質上就是一塊C++內存(byte[]),因此傳過來傳過去不會有任何效率損失,到了C++層只須要跟JVM取出DirectByteBuffer所管理的指針就完了,可是若是使用java原始的byte[]的話,因爲JVM不信任任何C++代碼,GetByteArrayElements這個函數大機率是會將原始byte[]拷貝一次的,若是這個java的byte[]很大(好比圖片),你想一想那效率有多酸爽,還有可能致使OOM。

當初我在面阿里的時候就被問了直接緩衝區,最尷尬的是還沒答出來,雲裏霧裏的,後來回去一搜,我擦,這玩意不就是ByteBuffer.allocateDirect嗎,和JNI交互用的那個。因爲我學Java SE的時候都是直接擼JDK源碼的,面試官當時一說中文都給👴整懵了。

4 用JNI來「幹壞事」

4.1 建立類的對象而不調用其的構造函數

有時候咱們想用一個對象,可是不想它調用本身的構造函數去發生一些咱們不想讓它出現的事情,者時候咱們就須要越過該類的構造函數去建立對象,在JNI中有一個方法AllocObject,它可以不調用該類的任何構造函數來建立該類的對象。

extern "C" JNIEXPORT jobject JNICALL
Java_org_kexie_test_Test_allObject(
        JNIEnv *env,
        jclass clazz) {
    return env->AllocObject(clazz);
}
複製代碼

固然,一樣效果sun.misc.Unsafe#allocateInstance也能實現,JNI能夠做爲一種補充。

4.2 非虛調用

4.2.1 虛調用

虛調用是什麼?若是你只學過Java你可能會對這個概念比較陌生,由於在Java中全部的函數都是虛函數,全部的調用也都是虛調用。

那麼什麼是虛調用呢?簡單來講就是方法重寫,若是熟悉C++和C#等語言的同窗應該知道,若是方法前面不加virtual修飾符的話子類是不可以重寫父類的同簽名方法的,而就像我剛纔說的,因爲Java中全部函數都是虛函數,因此默認全部函數都能被重寫,只有一個例外,那就是你將這個方法的修飾符加上了final的時候。

4.2.2 能幹什麼壞事?

那麼這玩意它有什麼用呢?這裏我舉一個我用它的具體例子,是美團的熱修復框架Robust中攻克的一個技術難點。

一般咱們調用父類方法時直接使用super.xxxx()調用就能夠了,在編譯器處理後super會變成invokesuper指令(invokespecial的一種)。

Robust爲了模擬實現JVM的invokesuper指令,須要爲每一個補丁類再生成一個繼承了被修復類父類的助手類,而且在助手類的static函數中橋接invokesuper指令。

這麼作的緣由有如下兩點:

  1. 當須要修復某一個類的某個方法,但又要父類方法獲得調用時(如Activity的onCreate),常規編碼手段是行不通的。
  2. 若是正常編譯的話,invokesuper在運行的時候不會出任何問題的,可是若是在你要用字節碼處理框架生成用invokesuper調用某一個類的super方法的時候它就會出問題了。若是invokesuper的調用處不是,目標方法的子類,你只會獲得一個java.lang.NoSuchMethodError。

在我本身的私人分支中,我用JNI改善了這個實現。經過JNI中的CallNonvirtualObjectMethod直接調用某個類的super方法,免去static方法的橋接,而且還能避免爲每個類去生成一個繼承了被修復類父類的助手類。

固然,這裏說的使用場景仍是比較狹窄的,其實使用該方法還能完成不少在Java層不能實現的Hacker操做,好比某些Framework方法中重寫時加上了檢查操做,咱們就能夠經過這個辦法越過檢查幹一些壞事。

這裏我也將這部分源碼開源出來留給有興趣的同窗自行研究:

Java層 ReflectEngine.java

package org.kexie.android.hotfix.internal;

import java.lang.reflect.Method;
import java.lang.reflect.Modifier;

import androidx.annotation.Keep;

/** * JVM反射執行引擎 * 因爲Java API的限制,因此invokesuper使用JNI實現 * 可是徹底沒有平臺依賴,只使用C++11,是可移植並對Rom無要求的 */
@Keep
final class ReflectEngine {

    ReflectEngine() {
    }
    
    /** * 跳轉到JNI * 使用JNIEnv->CallNonvirtual[TYPE]Method實現 * 主要是爲了實現invoke-super指令 * 容許拋出異常,在native捕獲以後拋出到java層 */
    private static native Object invokeNonVirtual(Class type, Method method, Class[] pramTypes, Class returnType, Object object, Object[] prams ) throws Throwable;
}
複製代碼

C++層 jni-reflect.cpp

//
// Created by Luke on 2019/5/29.
//

#include <jni.h>
#include <unordered_map>
#include <functional>

#define VERSION JNI_VERSION_1_4

using namespace std;

static JavaVM *javaVM = nullptr;
static jmethodID hashCode = nullptr;

using UnBoxer = function<void(JNIEnv *, jobject, jvalue *)>;
using Invoker = function<jobject(JNIEnv *, jclass, jmethodID, jobject, jvalue *)>;

struct HashCode {
    size_t operator()(const jclass &k) const noexcept {
        JNIEnv *env = nullptr;
        javaVM->GetEnv((void **) (&env), VERSION);
        return (size_t) env->CallIntMethod(k, hashCode);
    }
};

struct Equals {
    bool operator()(const jclass &k1, const jclass &k2) const noexcept{
        JNIEnv *env = nullptr;
        javaVM->GetEnv((void **) (&env), VERSION);
        return env->IsSameObject(k1, k2);
    }
};

static unordered_map<jclass, Invoker, HashCode, Equals> invokeMapping;
static unordered_map<jclass, UnBoxer, HashCode, Equals> unBoxMapping;

static jclass javaLangObjectClass = nullptr;

static void LoadMapping(JNIEnv *env) {

    function<jclass(const char *)> findClass = [env](const char *name) {
        return (jclass) env->NewGlobalRef(env->FindClass(name));
    };

    javaLangObjectClass = findClass("java/lang/Object");
    hashCode = env->GetMethodID(javaLangObjectClass, "hashCode", "()I");

    jclass zWrapper = findClass("java/lang/Boolean");
    jclass iWrapper = findClass("java/lang/Integer");
    jclass jWrapper = findClass("java/lang/Long");
    jclass dWrapper = findClass("java/lang/Double");
    jclass fWrapper = findClass("java/lang/Float");
    jclass cWrapper = findClass("java/lang/Character");
    jclass sWrapper = findClass("java/lang/Short");
    jclass bWrapper = findClass("java/lang/Byte");

    jmethodID zBox = env->GetStaticMethodID(zWrapper, "valueOf", "(Z)Ljava/lang/Boolean;");
    jmethodID iBox = env->GetStaticMethodID(iWrapper, "valueOf", "(I)Ljava/lang/Integer;");
    jmethodID jBox = env->GetStaticMethodID(jWrapper, "valueOf", "(J)Ljava/lang/Long;");
    jmethodID dBox = env->GetStaticMethodID(dWrapper, "valueOf", "(D)Ljava/lang/Double;");
    jmethodID fBox = env->GetStaticMethodID(fWrapper, "valueOf", "(F)Ljava/lang/Float;");
    jmethodID cBox = env->GetStaticMethodID(cWrapper, "valueOf", "(C)Ljava/lang/Character;");
    jmethodID sBox = env->GetStaticMethodID(sWrapper, "valueOf", "(S)Ljava/lang/Short;");
    jmethodID bBox = env->GetStaticMethodID(bWrapper, "valueOf", "(B)Ljava/lang/Byte;");

    jmethodID zUnBox = env->GetMethodID(zWrapper, "booleanValue", "()Z");
    jmethodID iUnBox = env->GetMethodID(iWrapper, "intValue", "()I");
    jmethodID jUnBox = env->GetMethodID(jWrapper, "longValue", "()J");
    jmethodID dUnBox = env->GetMethodID(dWrapper, "doubleValue", "()D");
    jmethodID fUnBox = env->GetMethodID(fWrapper, "floatValue", "()F");
    jmethodID cUnBox = env->GetMethodID(cWrapper, "charValue", "()C");
    jmethodID sUnBox = env->GetMethodID(sWrapper, "shortValue", "()S");
    jmethodID bUnBox = env->GetMethodID(bWrapper, "byteValue", "()B");

    jmethodID returnType = env->GetMethodID(env->FindClass("java/lang/reflect/Method"),
                                            "getReturnType", "()Ljava/lang/Class;");

    function<jclass(jclass, jmethodID)> getRealType =
            [env, returnType](jclass clazz, jmethodID methodId) {
                jobject method = env->ToReflectedMethod(clazz, methodId, JNI_FALSE);
                jobject type = env->CallObjectMethod(method, returnType);
                return (jclass) env->NewGlobalRef(type);
            };

    jclass zClass = getRealType(zWrapper, zUnBox);
    jclass iClass = getRealType(iWrapper, iUnBox);
    jclass jClass = getRealType(jWrapper, jUnBox);
    jclass dClass = getRealType(dWrapper, dUnBox);
    jclass fClass = getRealType(fWrapper, fUnBox);
    jclass cClass = getRealType(cWrapper, cUnBox);
    jclass sClass = getRealType(sWrapper, sUnBox);
    jclass bClass = getRealType(bWrapper, bUnBox);

    unBoxMapping[zClass] = [zUnBox](JNIEnv *env, jobject obj, jvalue *value) {
        value->z = env->CallBooleanMethod(obj, zUnBox);
    };
    invokeMapping[zClass] = [zWrapper, zBox](JNIEnv *env, jclass type, jmethodID id, jobject obj,
                                             jvalue *values) {
        jboolean r = env->CallNonvirtualBooleanMethodA(obj, type, id, values);
        if (env->ExceptionCheck()) {
            return (jobject)nullptr;
        }
        return env->CallStaticObjectMethod(zWrapper, zBox, r);
    };

    unBoxMapping[iClass] = [iUnBox](JNIEnv *env, jobject obj, jvalue *value) {
        value->i = env->CallIntMethod(obj, iUnBox);
    };
    invokeMapping[iClass] = [iWrapper, iBox](JNIEnv *env, jclass type, jmethodID id, jobject obj,
                                             jvalue *values) {
        jint r = env->CallNonvirtualIntMethodA(obj, type, id, values);
        if (env->ExceptionCheck()) {
            return (jobject)nullptr;
        }
        return env->CallStaticObjectMethod(iWrapper, iBox, r);
    };

    unBoxMapping[jClass] = [jUnBox](JNIEnv *env, jobject obj, jvalue *value) {
        value->j = env->CallLongMethod(obj, jUnBox);
    };
    invokeMapping[jClass] = [jWrapper, jBox](JNIEnv *env, jclass type, jmethodID id, jobject obj,
                                             jvalue *values) {
        jlong r = env->CallNonvirtualLongMethodA(obj, type, id, values);
        if (env->ExceptionCheck()) {
            return (jobject)nullptr;
        }
        return env->CallStaticObjectMethod(jWrapper, jBox, r);
    };

    unBoxMapping[dClass] = [dUnBox](JNIEnv *env, jobject obj, jvalue *value) {
        value->d = env->CallDoubleMethod(obj, dUnBox);
    };
    invokeMapping[dClass] = [dWrapper, dBox](JNIEnv *env, jclass type, jmethodID id, jobject obj,
                                             jvalue *values) {
        jdouble r = env->CallNonvirtualDoubleMethodA(obj, type, id, values);
        if (env->ExceptionCheck()) {
            return (jobject)nullptr;
        }
        return env->CallStaticObjectMethod(dWrapper, dBox, r);
    };

    unBoxMapping[fClass] = [fUnBox](JNIEnv *env, jobject obj, jvalue *value) {
        value->f = env->CallFloatMethod(obj, fUnBox);
    };
    invokeMapping[fClass] = [fWrapper, fBox](JNIEnv *env, jclass type, jmethodID id, jobject obj,
                                             jvalue *values) {
        jfloat r = env->CallNonvirtualFloatMethodA(obj, type, id, values);
        if (env->ExceptionCheck()) {
            return (jobject)nullptr;
        }
        return env->CallStaticObjectMethod(fWrapper, fBox, r);
    };

    unBoxMapping[cClass] = [cUnBox](JNIEnv *env, jobject obj, jvalue *value) {
        value->c = env->CallCharMethod(obj, cUnBox);
    };
    invokeMapping[cClass] = [cWrapper, cBox](JNIEnv *env, jclass type, jmethodID id, jobject obj,
                                             jvalue *values) {
        jchar r = env->CallNonvirtualCharMethodA(obj, type, id, values);
        if (env->ExceptionCheck()) {
            return (jobject)nullptr;
        }
        return env->CallStaticObjectMethod(cWrapper, cBox, r);
    };

    unBoxMapping[sClass] = [sUnBox](JNIEnv *env, jobject obj, jvalue *value) {
        value->s = env->CallShortMethod(obj, sUnBox);
    };
    invokeMapping[sClass] = [sWrapper, sBox](JNIEnv *env, jclass type, jmethodID id, jobject obj,
                                             jvalue *values) {
        jshort r = env->CallNonvirtualShortMethodA(obj, type, id, values);
        if (env->ExceptionCheck()) {
            return (jobject)nullptr;
        }
        return env->CallStaticObjectMethod(sWrapper, sBox, r);
    };

    unBoxMapping[bClass] = [bUnBox](JNIEnv *env, jobject obj, jvalue *value) {
        value->b = env->CallByteMethod(obj, bUnBox);
    };
    invokeMapping[bClass] = [bWrapper, bBox](JNIEnv *env, jclass type, jmethodID id, jobject obj,
                                             jvalue *values) {
        jbyte r = env->CallNonvirtualByteMethodA(obj, type, id, values);
        if (env->ExceptionCheck()) {
            return (jobject)nullptr;
        }
        return env->CallStaticObjectMethod(bWrapper, bBox, r);
    };
}

static jobject invokeNonVirtual( JNIEnv *env, jclass type, jmethodID methodId, jclass returnType, jobject object, jvalue *values) {
    auto it = invokeMapping.find(returnType);
    if (it != invokeMapping.end()) {
        return it->second(env, type, methodId, object, values);
    } else if (env->IsAssignableFrom(returnType, javaLangObjectClass)) {
        return env->CallNonvirtualObjectMethodA(object, type, methodId, values);
    } else {
        env->CallNonvirtualVoidMethodA(object, type, methodId, values);
        return nullptr;
    }
}

static void CheckUnBox(JNIEnv *env, jclass clazz, jobject obj, jvalue *out) {
    auto it = unBoxMapping.find(clazz);
    if (it != unBoxMapping.end()) {
        it->second(env, obj, out);
    } else {
        out->l = obj;
    }
}

static jvalue *GetNativeParameter( JNIEnv *env, jobjectArray pramTypes, jobjectArray prams) {
    jvalue *values = nullptr;
    if (pramTypes != nullptr) {
        auto length = env->GetArrayLength(pramTypes);
        if (length > 0) {
            values = new jvalue[length];
            for (int i = 0; i < length; ++i) {
                auto clazz = (jclass) env->GetObjectArrayElement(pramTypes, i);
                jobject obj = env->GetObjectArrayElement(prams, i);
                CheckUnBox(env, clazz, obj, &values[i]);
            }
        }
    }
    return values;
}

extern "C"
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
    javaVM = vm;
    JNIEnv *env = nullptr;
    javaVM->GetEnv((void **) (&env), VERSION);
    LoadMapping(env);
    return VERSION;
}

extern "C"
JNIEXPORT jobject JNICALL Java_org_kexie_android_hotfix_internal_ReflectEngine_invokeNonVirtual( JNIEnv *env, jclass _, jclass type, jobject method, jobjectArray pramTypes, jclass returnType, jobject object, jobjectArray prams) {
    jmethodID methodId = env->FromReflectedMethod(method);
    jvalue *values = GetNativeParameter(env, pramTypes, prams);
    jobject result = invokeNonVirtual(env, type, methodId, returnType, object, values);
    delete values;
    return result;
}
複製代碼

5 結語&JNI(可能的)代替品

JNI曾是與Java於C++的惟一手段,當咱們須要複用一些祖傳的C++基本庫時不得不選擇它,這其實對Java編寫人員提出了必定的C++要求,我知道有不少同窗都是以爲C++賊麻煩纔開始學Java的,其實我也是,因此sun爲廣大Java開發者提供了另一個方案JNA。不說取代JNI吧,但至少是咱不用寫C++了,效率上確定仍是不如C++的。

因此技術沒有好與壞,只有適合與不適合。我是 晨曦 一個爲本身興趣編程的開源愛好者,喜歡個人文章還請同窗幫我點個👍吧。歡迎白嫖,可是碼字不易,👍多了纔有動力分享更多內容給你們。

相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息