Android JNI学习手册

一、JNI基础学习-JNI调用java原生方法


class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        sample_text.setOnClickListener {

            callMethod("lilei", 18)
        }
    }

    external fun callMethod(name: String, age: Int)

    companion object {

        // Used to load the 'native-lib' library on application startup.
        init {
            System.loadLibrary("native-lib")
        }
    }
}

package com.microtechmd.jnidemo;

public class Student {

    private String name;
    private int age;

    public Student() {
    }

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "Student{name='" + name + '\'' +", age=" + age +'}';
    }
}

package com.microtechmd.jnidemo;

public class Person {

    private void setStudent(Student student){
        Log.d("dsh", "setStudent: "+student.toString());
    }

    public static String logcat(){

        Log.d("dsh", "log : ");
    }
}


extern "C" JNIEXPORT void JNICALL
Java_com_microtechmd_jnidemo_MainActivity_callMethod(
        JNIEnv *env,
        jobject jo /* this */, jstring name, jint age) {

    //创建Student对象
    const char *student_class_str = "com/microtechmd/jnidemo/Student";

    //获取student class
    jclass student_class = env->FindClass(student_class_str); 
    //根据class获取student对象
    jobject student_obj = env->AllocObject(student_class);
    //获取setName 方法iD
    jmethodID setName_ID = env->GetMethodID(student_class, "setName", "(Ljava/lang/String;)V");
    //执行setName方法
    env->CallVoidMethod(student_obj, setName_ID, name);

    jmethodID setAge_ID = env->GetMethodID(student_class, "setAge", "(I)V");
    env->CallVoidMethod(student_obj, setAge_ID, age);

    const char *person_class_str = "com/microtechmd/jnidemo/Person";
    jclass person_class = env->FindClass(person_class_str);
    jobject person_object = env->AllocObject(person_class);
    jmethodID setStudent_ID = env->GetMethodID(person_class, "setStudent",
                                               ("(Lcom/microtechmd/jnidemo/Student;)V"));

      /执行普通方法 需要对象和方法id、参数。总结类比java静态方法和普通方法的调用
    env->CallVoidMethod(person_object, setStudent_ID, student_obj);

   //获取静态方法 ,不需要person对象
    jmethodID log_ID = env->GetStaticMethodID(person_class, "logcat",
                                               ("()V"));
    //执行静态方法                                            
    jstring string_obj = static_cast<jstring> ( env->CallStaticVoidMethod(person_class,log_ID));
    const char *stringChar = env->GetStringUTFChars(string_obj,0);
    env->ReleaseStringUTFChars(string_obj,stringChar); //回收对象

    //JIN调用接口,有点类比Java发射
}

JNI调用java原生方法有四个重要的东西

一、class 类信息

二、method 方法信息

三、sign 方法签名 ,里面包括了方法的参数类型信息 和返回信息,如(Ljava/lang/String;)V 代表的就是 void xxx(String)方法;其中构造方法用 ,多个参数的方法这样表示 (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; 代表 String xxxx(String , String )

四、实例对象

二、JNI基础学习-String的处理

java传给c一个string,javah生成了方法名后,

发现传递来的是一个jstring(因为在c里,是没有string的),

public class Jni {
    static {
        System.loadLibrary("native-lib");
    }
    public native String study_string(String str);
}

//生成的头文件
JNIEXPORT jstring JNICALL Java_jni_study_com_cvmars_Jni_study_1string
  (JNIEnv *, jobject, jstring);

传递来的是一个jstring(因为在c里,是没有string的), jstring其实是void*(任意类型)

我们需要调用一个方法,把jstring转为C语言的char*类型,先看下这个工具方法:

#include <stdlib.h>

/**
 * 把一个jstring转换成一个c语言的char* 类型.
 */
char* _JString2CStr(JNIEnv* env, jstring jstr) {
    char* rtn = NULL;
    jclass clsstring = (*env)->FindClass(env, "java/lang/String");
    jstring strencode = (*env)->NewStringUTF(env,"GB2312");
    jmethodID mid = (*env)->GetMethodID(env, clsstring, "getBytes", "(Ljava/lang/String;)[B");
    jbyteArray barr = (jbyteArray)(*env)->CallObjectMethod(env, jstr, mid, strencode); // String .getByte("GB2312");
    jsize alen = (*env)->GetArrayLength(env, barr);
    jbyte* ba = (*env)->GetByteArrayElements(env, barr, JNI_FALSE);
    if(alen > 0) {
        rtn = (char*)malloc(alen+1); //"\0"
        memcpy(rtn, ba, alen);
        rtn[alen]=0;
    }
    (*env)->ReleaseByteArrayElements(env, barr, ba,0);
    return rtn;
}

实现native方法

JNIEXPORT jstring JNICALL Java_jni_study_com_jnibsetpractice_Jni_transe_1string
        (JNIEnv *env, jobject instance, jstring jstr) {

    //把一个jstring转换成一个c语言的char* 类型
    char *cStr = _JString2CStr(env, jstr);
    //c语言拼接字符串
    char *cNewStr = strcat(cStr, "简单加密一下哈哈哈!!!");
    // 把c语言里的char* 字符串转成java认识的字符串
    return (*env)->NewStringUTF(env, cNewStr);
}

三、JNI基础学习-在C里输出log的办法

  1. 在C 里输入
image
  1. 在Android.mk里输入
image
  1. 使用log
image
LOGD("length = %d", length);

四、JNI基础学习- 数据类型和签名机制

由于Java语言与C/C++语言数据类型的不匹配,需要单独定义一系列的数据类型转换关系来完成两者之间的对等(或者说是映射)。下面给出jni与Java数据类型对应表(jni类型均被定义在jni.h头文件中),如下表1和表2,在jni函数中,需要使用以下jni类型来等价与Java语言对应的类型。

1.基本类型对照表

<style> td {white-space:pre-wrap;border:1px solid #dee0e3;}</style> <byte-sheet-html-origin data-id="Oe3MXsb6ys-1611024151055" data-version="1" data-is-embed="true"><colgroup><col width="249"><col width="258"><col width="270"></colgroup>
| Java类型 | JNI类型 | 描述 |
| boolean | Jboolean | 无符号8位 |
| byte | Jbyte | 无符号8位 |
| char | Jchar | 无符号16位 |
| short | Jshort | 有符号16位 |
| int | Jint | 有符号32位 |
| long | Jlong | 有符号64位 |
| float | Jfloat | 有符号32位 |
| double | Jdouble | 有符号64位 |</byte-sheet-html-origin>

2.引用类型对照表

<style> td {white-space:pre-wrap;border:1px solid #dee0e3;}</style> <byte-sheet-html-origin data-id="qhTSRvgq9c-1611024151061" data-version="1" data-is-embed="true"><colgroup><col width="341"><col width="440"></colgroup>
| Java引用类型 | JNI类型 |
| boolean[] | jbooleanArray |
| byte[] | jbyteArray |
| char[] | jcharArray |
| short[] | jshortArray |
| int[] | jintArray |
| long[] | jlongArray |
| float[] | jfloatArray |
| double[] | jdoubleArray |
| All objects | jobject |
| java.lang.Class | jclass |
| java.lang.String | jstring |
| Object[] | jobjectArray |
| java.lang.Throwable | jthrowable |</byte-sheet-html-origin>

1 深入理解JNIEnv

上面列出了JNI自定义类型,而为了操作这些类型,尤其是引用类型,就需要JNIEnv来协助完成。那么,什么是JNIEnv呢?实际上,JNIEnv的实体是一个名为JNINativeInterface的结构体,而这个结构体又是什么呢?JNINativeInterface结构体定义在头文件jni.h中,是一个复杂的函数指针集合,每一个函数指针又会指向一个本地实现函数,来完成特定的功能。诸如常见的New StringUTF,FindClass都定义在其中,如下列出了部分内容:

/*  jni.h */#if defined(__cplusplus)typedef _JNIEnv JNIEnv;     // C++typedef _JavaVM JavaVM;#elsetypedef const struct JNINativeInterface* JNIEnv;     // Ctypedef const struct JNIInvokeInterface* JavaVM;#endifstruct JNINativeInterface {
    …
    jclass      (*FindClass)(JNIEnv*, const char*);
    …
    jstring     (*NewString)(JNIEnv*, const jchar*, jsize);
   …
   void        (*SetCharArrayRegion)(JNIEnv*, jcharArray,
                        jsize, jsize, const jchar*);
  …
    jint    (*RegisterNatives)(JNIEnv*, jclass, const JNINativeMethod*,
                        jint);
  …
    jint        (*GetJavaVM)(JNIEnv*, JavaVM**);
  …./* added in JNI 1.6 */// … 表示省略了部分内容
 };

下图来帮助理解这个复杂的指向关系:

image

有了JNIEnv*指针,就可以使用函数指针调用特定的实现函数,来完成特定需求的功能。需要注意的是,env变量是线程线程相关的,不可从一个线程传递env变量到另外一个线程。

那么又是如何使线程获得这个JNIEnv结构体指针的呢?这里涉及到一个重要的函数JNI_OnLoad(JavaVM* vm,void* reserved),当通过System. loadLibrary()方法来加载我们指定的动态库(如.so库)时,Java虚拟机会检测库中是否实现了JNI_OnLoad函数,如果实现了则这个函数就会被调用,并且一个代表JVM的对象vm被作为参数传递进来,这个对象一个进程只有一份,可以通过它的AttachCurrentThread方法来获得JNIEnv*对象,当我们的线程完成特定任务退出之前,应该调用vm的DetachCurrentThread来释放资源。

上述方法均被定义在jni.h,如下:

/* jni.h */#if defined(__cplusplus)
typedef _JNIEnv JNIEnv;
typedef _JavaVM JavaVM;#else
typedef const struct JNINativeInterface* JNIEnv;
typedef const struct JNIInvokeInterface* JavaVM;#endif/*
 * JNI invocation interface.
 */struct JNIInvokeInterface {  // C// ....
    jint        (*DestroyJavaVM)(JavaVM*);
    jint        (*AttachCurrentThread)(JavaVM*, JNIEnv**, void*);
    jint        (*DetachCurrentThread)(JavaVM*);
    jint        (*GetEnv)(JavaVM*, void**, jint);
    jint        (*AttachCurrentThreadAsDaemon)(JavaVM*, JNIEnv**, void*);
};
/*
 * C++ version.
 */struct _JavaVM {    // C++const struct JNIInvokeInterface* functions;
#if defined(__cplusplus)
    jint DestroyJavaVM()
    { return functions->DestroyJavaVM(this); }
    jint AttachCurrentThread(JNIEnv** p_env, void* thr_args)
    { return functions->AttachCurrentThread(this, p_env, thr_args); }
    jint DetachCurrentThread()
    { return functions->DetachCurrentThread(this); }
    jint GetEnv(void** env, jint version)
    { return functions->GetEnv(this, env, version); }
    jint AttachCurrentThreadAsDaemon(JNIEnv** p_env, void* thr_args)
    { return functions->AttachCurrentThreadAsDaemon(this, p_env, thr_args); }#endif /*__cplusplus*/
};
/*
 * Prototypes for functions exported by loadable shared libs.  These are
 * called by JNI, not provided by JNI.
 */
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved);
JNIEXPORT void JNICALL JNI_OnUnload(JavaVM* vm, void* reserved);// ....

在JNI_OnLoad()函数中,也可以通过vm->GetEnv((void*)&env来获得JNIEnv指针。JNI_OnLoad()函数基本功能是确定并返回Java虚拟机支持的JNI版本,我们还可以用作其他用途(诸如做一些初始化工作),一个重要的用途是实现JNI函数的动态注册。

与JNI_OnLoad()函数正好相反,当共享库被卸载时,会调用JNI_OnUnload()函数,我们可以做一些收尾的工作。

2 JNI函数的注册过程

在前面讲解了JNI函数,并没有深入探究Java层函数与jni函数的对应关系的建立,那么这种关联是怎样建立的,或者说当发起java native方法的调用时,是如何找到与之对应的jni函数的呢?这个过程可以分别用静态注册和动态注册的方式来完成。其实前面已经讲过了静态注册的原理。没错!就是命名规范,按照前面说的来命名jni函数,就可以实现,这里就不再赘述了。接下来,介绍JNI函数的动态注册过程。

何为动态注册呢?说的直白点就是手动的参与它的注册过程,让JNI函数在一加载完.so动态库后就完成它的注册过程(使之与对应java native函数关联起来),而不是等到调用时再来进行注册,以提高调用效率,并且我们也不用遵守前面的命名规范了,可以给jni函数取自己认为合适的名字。

要完成这个动态注册过程,就需要使用在上面提到过的JNI_OnLoad函数,它是在.so动态库加载后就会被调用的,而这又早于JNI函数的调用时机,因此在这个函数里实现注册过程是很合理的。

要完成动态注册,方法一可以选择使用AndroidRuntime类的registerNativeMethods方法来完成注册,这个方法原型如下:

/*
 * Register native methods using JNI.
 */
static int AndroidRuntime::registerNativeMethods(JNIEnv* env,
    const char* className, const JNINativeMethod* gMethods, int numMethods)
{
    return jniRegisterNativeMethods(env, className, gMethods, numMethods);
}

使用这个函数需要提供包含进行注册的jni函数的类的全路径(如项目中的OkayOps 类,全路径为com/yu/ops/OkayOps),要进行注册的方法信息结构体数组(JNINativeMethod)及方法个数。JNINativeMethod是一个C结构体,用于存储Java native方法与JNI函数的一一对应关系,包含的信息有native方法名、函数签名、函数指针。它的定义如下所示:

typedef struct {
const char* name; // Java层声明的native函数的名字,不需要带路径  。
const char* signature; // Java层声明的native函数签名信息,用字符串表示
void*  fnPtr;   //JNI 层对应函数的函数指针,它的类型void*
} JNINativeMethod;

上面涉及一个新概念函数签名,现在只需知道它是用来标识匹配哪个java的native方法即可,为了分析注册过程的条理清晰,将在下一节详细介绍。在registerNativeMethods方法的最后又调用了jniRegisterNativeMethods方法来完成注册,这个函数是在JNIHelp.h中声明(Android提供的帮助类来方便使用jni,路径android/libnativehelper/include/nativehelper/JNIHelp.h,实现在JNIHelp.cpp),可以先来看看这个方法:

/* JNIHelp.cpp */
extern "C" int jniRegisterNativeMethods(C_JNIEnv* env, const char* className,
    const JNINativeMethod* gMethods, int numMethods)
{
    JNIEnv* e = reinterpret_cast<JNIEnv*>(env);

    ALOGV("Registering %s's %d native methods...", className, numMethods);
    // 获取指定类名的Class对象,并存储在局部引用中
    scoped_local_ref<jclass> c(env, findClass(env, className));
    if (c.get() == NULL) {  // 获取class对象为NULL
        char* tmp;
        const char* msg;
        if (asprintf(&tmp,
                     "Native registration unable to find class '%s'; aborting...",
                     className) == -1) {
            // Allocation failed, print default warning.
            msg = "Native registration unable to find class; aborting...";
        } else {
            msg = tmp;
        }
        e->FatalError(msg);
    }
     // 调用JNIEnv的RegisterNatives来完成注册
    if ((*env)->RegisterNatives(e, c.get(), gMethods, numMethods) < 0) {
        char* tmp;
        const char* msg;
        if (asprintf(&tmp, "RegisterNatives failed for '%s'; aborting...", className) == -1) {
            // Allocation failed, print default warning.
            msg = "RegisterNatives failed; aborting...";
        } else {
            msg = tmp;
        }
        e->FatalError(msg);
    }

    return 0;
}

可以发现,jniRegisterNativeMethods函数并不是具体实现,最终它会调用JNIEnv的RegisterNatives函数来完成JNI函数的注册。到此注册过程分析完成,终究是回到JNIEnv上。下面看看RegisterNatives函数原型:

jint (*RegisterNatives) (JNIEnv* env, jclass clazz, const JNINativeMethod* gMethods , jint numMethods);

可以发现,它和AndroidRuntime::registerNativeMethods函数的参数较为类似,除了第二个参数不同以外,其他均相同。而第二个参数正是要进行动态注册的类的Class运行时类,可以使用JNIEnv的FindClass函数来获取。

第二种进行动态注册的方式就是基于上面的分析,即:第一步,使用JNIEnv的FindClass函数来拿到需要进行动态注册的类的运行时Class类;第二步,直接使用JNIEnv的RegisterNatives函数来完成JNI函数的注册。

到此,我们分析了两种方案来完成JNI函数动态注册的目标。第一种,分析了使用AndroidRuntime::registerNativeMethods函数来完成动态注册的流程,使用该函数总体上来说使用方便,但流程较为复杂,第二种,使用JNIEnv的RegisterNatives函数完成动态注册,这种方法流程简单,但需要自个获取运行时Class类,稍显得烦琐点。

本文实现注册的代码如下:

// 需要注册的方法信息表
static JNINativeMethod method_table[] = {
        {"NativeReadOkayData", "([B)I", (void*)Java_android_com_read_yu_data},
    {"NativeWriteOkayData", "([BI)I", (void*) Java_android_com_write_yu_data}, 
};

// 包含本地方法的类的全路径
static const char* classPathName="com/yu/ops/OkayOps";

// 使用AndroidRuntime的registerNativeMethods方法来完成注册
static int register_com_yu_signature_ops(JNIEnv *env)
{
    LOGI("register_com_yu_ops_OkayOps");

    return AndroidRuntime::registerNativeMethods(env,classPathName,method_table,NELEM(method_table));
}
// 加载动态库的时候被回调
jint JNI_OnLoad(JavaVM* vm,void* reserved)
{
 LOGI("JNI_OnLoad");
 JNIEnv* env = NULL;
 jint result = -1;
 if(vm->GetEnv((void**)&env,JNI_VERSION_1_6) != JNI_OK)  
{
    goto bail;
 }

 LOGI("register method");

 if(register_com_yu_signature_ops(env) < 0)  // 注册
 {
    goto bail;
 }
 init();  // 做一些初始化工作
 return JNI_VERSION_1_6;
bail:
 return result;
}

3 签名机制

在上面动态注册小节提到一个函数签名(signature)的概念,这是用来干什么的呢?了解java语言的都知道它有一种方法重载机制,因此,为了能够调用正确的java层native方法,光凭方法名称是不够的,还需要知道它的具体参数与返回值。函数签名就是函数的参数与返回值的结合体,用来进行精准匹配。

函数签名由字符串组成,第一部分是包含在圆括号()里的,用来说明参数类型,第二部分则跟的是返回值类型。比如”([Ljava/lang/Object;)Z”就是参数为Object[],返回值是boolean的函数的签名。下表列出类型与签名标识的对应关系:

| Java类型 | 类型标识 |
| boolean | Z |
| byte | B |
| char | C |
| short | S |
| int | I |
| long | J |
| float | F |
| double | D |
| String | L/java/lang/String; |
| int[] | [I |
| Object[] | [L/java/lang/Object; |

int[]的标识是[I,其他基本数据类型的标识基本类似,用[+类型标识组合。需要注意的是,除了基本数据类型的数组以外,引用类型的标识后都需要跟上一个分号。一般,人为的写签名字符串难免会出错,而且类型签名标识又难以记忆,所幸的是java提供了相关命令来快速生成签名信息。到要生成签名的项目的bin目录下,使用javap命令加 –s选项来快速生成签名信息,如下:

D:\code\yu_jar\bin>javap -s com.yu.ops.OkayOps
Compiled from "OkayOps.java"
public class com.yu.ops.OkayOps {
  public java.lang.String SERVICE;
    descriptor: Ljava/lang/String;
  static {};
    descriptor: ()V
  public com.yu.ops.OkayOps();
    descriptor: ()V
  public final int yu_read(byte[]);
    descriptor: ([B)I
  public final void yu_write(byte[]);
    descriptor: ([B)V
  public native int NativeReadOkayData(byte[]);
    descriptor: ([B)I
  public native int NativeWriteOkayData(byte[], int);
    descriptor: ([BI)I
}

在方法下面的descriptor的内容即是所需要的签名信息。签名信息比较有用,在JNI函数的调用中,经常会需要以签名作为参数。

在jni.h头文件我们可以看到基本类型方法签名定义,如下:

typedef union jvalue {
    jboolean    z;
    jbyte       b;
    jchar       c;
    jshort      s;
    jint        i;
    jlong       j;
    jfloat      f;
    jdouble     d;
    jobject     l;
} jvalue;
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容