转载

Android技术栈(六)JNI中的奇技淫巧

嗯...其实这篇文章并不是JNI的教程,而是因为网上关于JNI的好教程已经很多了,我在把别人写过的东西再总结一遍其实也没什么意义,自己想写点不一样的东西。

PS:本篇文章主要包含JNI和C++中的一些使用技巧,需要读者对C++有一定的掌握。

2 在JNI/C/C++/中检查空悬指针

2.1 Windows下的做法

在Windows下,得益于强大的WinAPI,干这事情很方便。在 winbase.h 中提供了两个函数原型:

2.1.1 IsBadReadPtr

IsBadReadPtr用于判断指针所指区域的内存是否可读。

MSDN:point_right: IsBadReadPtr (按照M$的尿性不一定能打得开,原因你懂的)。

BOOL IsBadReadPtr(
  const VOID *lp, //指定起始内存地址
  UINT_PTR   ucb //内存块长度
);
复制代码

2.1.2 IsBadWritePtr

IsBadWritePtr用于判断指针所指区域的内存是否可写。

MSDM:point_right: IsBadWritePtr 。

BOOL IsBadWritePtr(
  LPVOID   lp, //指定起始内存地址
  UINT_PTR ucb //内存块长度
);
复制代码

2.2 Android/Linux下的做法

2.2.1 Linux信号机制

众所周知,Linux在访问空悬指针时会抛出 SIGSEGV 信号,如果你没有使用 signal 设置信号处理函数,那这个程序马上就崩了。要想不崩,我们得使用 signal 配合 sigsetjmp (长跳转):

#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(":eyes:到没?:older_man:回来了/n");
    }
    return 0;
}
复制代码

但是这种方式,看着实在是太 Low 了,而且在Linux中,一个进程的信号处理函数是唯一的,有时候我们不能占用这个 signal 也不是为多线程环境所准备的(因为它是 C API )。那么有没有什么 标准化 的办法可以绕开它呢?

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源码的,面试官当时一说中文都给:older_man:整懵了。

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++的。

所以技术没有好与坏,只有适合与不适合。我是 晨曦 一个为自己兴趣编程的开源爱好者,喜欢我的文章还请同学帮我点个:+1:吧。欢迎白嫖,但是码字不易,:+1:多了才有动力分享更多内容给大家。

原文  https://juejin.im/post/5cb42255f265da037c7cd7c8
正文到此结束
Loading...