初探ndk的世界(一)

初探ndk的世界(一),第1张

初探ndk的世界(一)

更多博文,请看音视频系统学习的浪漫马车之总目录

ndk相关:
初探ndk的世界(一)

上一篇文章升级构建工具,从Makefile到CMake , 我们已经学习了现代化构建项目的脚本CMake,打了半年基础,不容易,终于可以开始摸到正题的影子了,今天开始讲ndk开发。

ndk介绍

ndk,即Native Development Kit,官方的描述是:

原生开发套件 (NDK) 是一套工具,使您能够在 Android 应用中使用 C 和 C++ 代码,并提供众多平台库,您可使用这些平台库管理原生 Activity 和访问实体设备组件,例如传感器和触摸输入。NDK 可能不适合大多数 Android 编程初学者,这些初学者只需使用 Java 代码和框架 API 开发应用。然而,如果您需要实现以下一个或多个目标,那么 NDK 就能派上用场:
1.进一步提升设备性能,以降低延迟或运行游戏或物理模拟等计算密集型应用。
2.重复使用您自己或其他开发者的 C 或 C++ 库。

从这里我们可以简单总结为,ndk是一套可以Android应用到C 和 C++ 代码的工具,一般用于需要提升性能的计算密集型应用或复用已存在的C 和 C++ 代码库的时候使用。

ndk具体作用可以用一句话概括:快速开发C、 C++的动态库,并自动将so和应用一起打包成 APK

这里的关键点就是Android的Java代码如何调用C 和 C++ 代码,这里又牵涉到一个很重要的概念——JNI。

JNI介绍

JNI,即Java Native Interface,按照习惯的开局方式,官方描述是:

The JNI is a native programming interface. It allows Java code that runs inside a Java Virtual Machine (VM) to interoperate with applications and libraries written in other programming languages, such as C, C++, and assembly.

一句话:JNI 是一个编程接口,它允许在 Java代码 里调用 C、C++、汇编等语言的代码,或 C、C++、汇编代码调用 Java 代码。

注意:JNI是Java本身的特性,和Android无关。(ndk是Android的工具,和Java无关)

JNI就像在Java和Native代码中间的一座桥梁,可谓是“一桥架南北,Java和Native变通途”。


前面说ndk是一套可以让Android应用到C 和 C++ 代码的工具,那ndk正是使用了Java的JNI来实现对C 和 C++ 代码的调用。

JNI基本流程

JNI整体步骤:

    定义一个Java类A在类A中定义一个native修饰的方法比如:public native String b();创建一个C(C++)源文件,定义一个方法,方法名为Java_ + 类A全名 +方法b拼接而成将3中的C(C++)源文件打包为一个动态链接库类A中通过static代码块中用System.loadLibrary加载4中生成的动态链接库

经过上面几个步骤,就可以在类A中通过方法b调用C源文件中的对应方法了。

ndk Hello World级别工程浅析

上面的JNI整体步骤罗列出来约等于白说,作为安卓开发,还是直接用ndk例子来说明整个JNI过程:

打开熟悉的Android Studio,创建新工程,选择:

点击next,填好工程基本信息和C++标准之后,工程建立完毕,呈现在眼前的是一个大部分安卓程序员十分熟悉但却有些陌生的目录结构:

其实陌生的地方就是多了个cpp的文件夹,顾名思义,就是存放c++代码的文件夹。

可以看到Android studio已经贴心地创建了一个c++源文件native-lib.cpp以及上一讲上一讲升级构建工具,从Makefile到CMake说到的CMakeList.txt文件了,Android studio2.2之后默认构建工具为CMake(另外还有ndk自带构建工具是ndk build)。瞄一下CMakeList.txt文件的内容:

# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

# Sets the minimum version of CMake required to build the native library.

cmake_minimum_required(VERSION 3.10.2)

# Declares and names the project.

project("ndkdemo")

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library( # Sets the name of the library.
             native-lib

             # Sets the library as a shared library.
             SHARED

             # Provides a relative path to your source file(s).
             native-lib.cpp )

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library( # Sets the name of the path variable.
              log-lib

              # Specifies the name of the NDK library that
              # you want CMake to locate.
              log )

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
                       native-lib

                       # links the target library to the log library
                       # included in the NDK.
                       ${log-lib} )

真的很贴心,连注释都写得这么详细~

如果有读过上一讲升级构建工具,从Makefile到CMake,结合注释那基本看得懂,就是从native-lib.cpp生成动态链接库libnative-lib.so,然后将系统的liblog.so库链接到libnative-lib.so。

唯一可能看不懂的就是find_library,也很简单,就是找到已有的库并赋值给一个变量,这里因为系统库默认在ndk的cmake的搜索路径中,所以无需指定路径,指定库名即可。

这里ndk会到对应目录寻找lib前缀+find_library中第二个参数名称对应的库,如图:
看看MainActivity写了啥:

package com.example.ndkdemo;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.TextView;

import com.example.ndkdemo.databinding.ActivityMainBinding;

public class MainActivity extends AppCompatActivity {

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

    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        // Example of a call to a native method
        TextView tv = binding.sampleText;
        tv.setText(stringFromJNI());
    }

    
    public native String stringFromJNI();
}

我们可以看到已经有Native方法stringFromJNI了,现在已经有了JNI整体步骤中的1创建一个类以及2创建一个native方法,接下来就是3创建一个C(C++)文件并定义一个与2中方法关联的方法,那我们看下AS帮我们创建好的native-lib.cpp中有没有这个方法:

#include 
#include 

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_ndkdemo_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject ) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

非常开心地看到,这里唯一的方法就是我们3所说的方法,因为它的名字是由Java_和MainActivity的全名(Java_com_example_ndkdemo_MainActivity)和Native方法的名称(stringFromJNI)拼接而成。

AS中可以直接点击这个标记跳转到Java对应的Native方法:


先不详细解释这里的代码,先运行一下工程:


手机成功显示出“Hello from C++”,说明已经成功读到Native代码的返回值了~

查看生成的apk,已经看到动态库被打包在里面了:

首先先总体过一下ndk是如何将Native代码打包到apk中的。我们知道Android的代码是使用Gradle进行构建的,所以猜测Native代码打包也是Gradle中处理,所以看下app模块的Gradle脚本,果然还是捕捉到了“可疑”的味道,在android块中发现:

是的,cmake命令是集成在Gradle中的,这里的externalNativeBuild块主要是用来配置cmake的路径和版本以及cmake产出结果输出路径的的(官方文档有较详细的说明:CmakeOptions)。当Gradle执行到externalNativeBuild时,就会执行里面的cmake命令,生成对应的Makfile,然后再生成动态库文件,然后再打包到apk中。

这里指定了2个参数,path指定了CMakeLists.txt文件路径,version指定了使用cmake版本,这里version必须大于CMakeLists.txt中指定的cmake_minimum_required,不然就会出错。

不过又发现在defaultConfig块中也有externalNativeBuild块:

这个又是什么用处呢?

这里是配置cmake具体的构建参数用的,比如:

externalNativeBuild {
  cmake {
  //cppFlags为C/C++预处理阶段的选项 这里配置C++支持rtti和异常 C++标准使用14
    cppFlags "-frtti -fexceptions -std=c++14"
    //指定C++标准库类型是动态库
    arguments '-DANDROID_STL=c++_shared'
  }
}

sync下gradle,然后打开在.CXX下对应的ABI(后面会细说)目录,可以看到这些就是之前讲的cmake中cmake执行所产生的的临时文件,打开其中的build_command.txt文件:

可以看到这里就是cmake配置的参数,其中有很多是默认配置的参数,另外就是上面配置添加的2个参数,在执行gradle打包任务的时候,就会执行这些参数去生成对应的库。

cppFlags 用于配置c++编译参数,而arguments其他的一些参数,比如上面配置的“-DANDROID_STL=c++_shared”就是指定C++标准库通过动态库方式打包。关于C++标准库可以参考libc++:

LLVM 的 libc++ 是 C++ 标准库,自 Lollipop 以来 Android *** 作系统便一直使用该库,并且从 NDK r18 开始成为 NDK 中唯一可用的 STL。
CMake 默认设置为 C++ Clang 默认设置的版本(目前为 C++14),因此您需要将 CMakeLists.txt 中的标准 CMAKE_CXX_STANDARD 设置为适当的值,才能使用 C++17 或更高版本的功能。如需了解详情,请参阅 CMake 中介绍 CMAKE_CXX_STANDARD 的文档。

ndk-build 默认情况下仍将此决定留给 Clang,因此 ndk-build 用户应使用 APP_CPPFLAGS 来添加 -std=c++17 或任何所需内容。

libc++ 的共享库为 libc++_shared.so,静态库为libc++_static.a。通常情况下,构建系统将根据用户需要对这些库的使用和打包进行处理。如果是非典型情况或在您实现自己的构建系统时,请参阅构建系统维护者指南或使用其他构建系统的指南。

在配置“arguments ‘-DANDROID_STL=c++_shared’”下运行下工程生成apk:

可以看到C++标准库是以动态库形式在apk中的。

上面提到了ABI这个东西,那究竟是个什么东西呢?看下官方文档一目了然(Android ABI):

不同的 Android 设备使用不同的 CPU,而不同的 CPU 支持不同的指令集。CPU 与指令集的每种组合都有专属的应用二进制接口 (ABI)。ABI 包含以下信息:

可使用的 CPU 指令集(和扩展指令集)。 运行时内存存储和加载的字节顺序。Android 始终是 little-endian。
在应用和系统之间传递数据的规范(包括对齐限制),以及系统调用函数时如何使用堆栈和寄存器。
可执行二进制文件(例如程序和共享库)的格式,以及它们支持的内容类型。Android 始终使用 ELF。如需了解详情,请参阅 ELF
System V 应用二进制接口。 如何重整 C++ 名称。如需了解详情,请参阅 Generic/Itanium C++ ABI。

通俗来说就是不同的cpu支持不同的指令集,每种指令集有不同的规范,这个规范就是ABI,一个cpu只能读取符合它的规范的机器代码(假如一个cpu对于加法的指令编码为0x0001,但是代码编译成另外一种指令集的机器码加法对应的指令编码为0x0002,那就运行在那个cpu就会出错),所以对于不同的cpu,同一套代码需要编译成不同的机器码。

以下是指令集和ABI的对应关系:

比如一个手机的指令集是armeabi,则运行的时候会去armeabi-v7a目录寻找动态库文件。如果手机的指令集是AArch64,则会先去arm64-v8a目录寻找动态库文件,如果找不到,则会去armeabi-v7a目录寻找动态库文件,这就是ABI兼容的体现,通过这个特点,可以节省包体积,但是性能会有损失

具体兼容关系可以看下图:

左边为cpu架构,右边是对应支持的ABI。

如果没有指定ABI,则默认会生成4个最常用的,每个目录有一个ABI对应的动态库:

但是一般没必要每个ABI都生成,可以通过配置以下块指定只生成什么ABI:

生成的apk如下:

根据ABI的兼容性,一般如果对性能不敏感的话,为了尽量减少包体积,现在一般只需要生成armeabi-v7a的动态库文件即可。

和ndk构建相关的就先介绍到这里,再回到具体的C++代码:

接下来就以此几行代码作为切入点,一点点解析JNI。

首先第1行的

#include 

jni.h是实现jni的基本头文件,大量的jni方法和变量都定义在里面,所以是必须引入的头文件。

再看下第4行:

extern "C" JNIEXPORT jstring JNICALL

“extern “C””想必看过之前初尝C++的世界 中“C++与C的混编”一小节的就知道是什么意思了,这里作用类似。因为我们写的是一个C++程序,C++支持方法重载,为了能够区分重载的方法,C++程序在编译的时候会将参数信息和方法名结合起来形成一个新的方法名。

“extern “C””就是告诉编译器,该方法是C语言的方法,让编译器不用将参数信息和方法名结合起来形成一个新的方法名,使用原来的方法即可。所以如果没有添加“extern “C””,则该方法就会按照C++的方式将参数信息和方法名结合起来形成一个新的方法名,这样在运行过程中,当Java调用Native方法的时候,会按照Java_ + 类全名 +Native方法的方式去搜索对应的C++方法,这样子就会找不到而报错。

--------------------------------------------实例:

再看下JNIEXPORT,点击去看,发现原来是jni.h内部的一个宏定义:

__attribute__表示一个属性,这里的属性指的就是visibility,属性值为default,什么意思呢?就是代表一个方法是否对外可见,这里default是可见的。参考when to use JNIEXPORT and JNICALL in Android NDK?所说的,这里就是确定一个方法是否放入动态链接库的一个动态方法表里,该方法表可以看做是给jni提供可以让Java层调用的方法,为什么这么做呢,因为这样可以控制Native方法的暴露,增加反编译难度。

因为是宏定义,实际上会在预编译阶段替换为具体的宏,所以我们可以直接手动替换,然后将visibility属性改为hidden:

#include 
#include 
//注意到visibility 改为"hidden"
extern "C" __attribute__ ((visibility ("hidden"))) jstring JNICALL
Java_com_example_ndkdemo_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject ) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

程序可以编译并打包安装到手机上,但是运行报错:

java.lang.UnsatisfiedlinkError: No implementation found for java.lang.String com.example.ndkdemo.MainActivity.stringFromJNI() (tried Java_com_example_ndkdemo_MainActivity_stringFromJNI and Java_com_example_ndkdemo_MainActivity_stringFromJNI__)

就是找不到方法,说明修改的visibility属性生效了。

接下来看看

extern "C" JNIEXPORT jstring JNICALL

之后的jstring ,这个是方法的返回类型,因为Java的数据类型不能直接在Native代码中使用,所以Java的数据类型在Native中会有对应的类型,在后面会详细介绍,这里jstring 就是String的对应类型。

后面的JNICALL表示什么呢?点进去看:

额, 是一个空的宏定义,那有啥用?

有句话叫做空白是一种美,当然这里的空白不是为了美,JNICALL这个是一种说明标识该方法可以应用于jni的标识,不过在android中,这个标识为空,但是在其他平台,比如Windows中,JNICALL的的值为:“__stdcall”。

接下来看下函数的env参数:

JNIEnv* env在jn是一个核心的参数,我们说jni是Java和C/C++的桥梁,那么支撑这个桥梁的钢筋混泥土。

JNIEnv是一个线程相关的结构体, 该结构体代表了 Java 在本线程的运行环境。通过JNIEnv可以调用到一系列JNI系统函数。
每个线程中都有一个 JNIEnv 指针。JNIEnv只在其所在线程有效, 它不能在线程之间进行传递。

JNIEnv实际上就是结构体_JNIEnv,里面包含着所有Java和C/C++交互的方法:

可以看到其实是对JNINativeInterface的代理:

而这些函数,就是今天文章的主角。

最后一个参数是jobject类型,后面会细讲这个类型是什么,如果一个Native函数在Java中声明为成员方法,则一定会带上这个参数,他代表的是调用该方法的Java对象,为Native访问Java对象提供了通道。

JNI数据类型

上面例子的C++代码中的Java_com_example_ndkdemo_MainActivity_stringFromJNI方法,提到了返回值为jstring类型,参数有个jobject类型参数,它们是什么呢?

可以把Java和Native代码想象为2个平行宇宙,那么一个在Java宇宙的类型为String的东西,在Native宇宙中它就不叫做String了,它变成了jstring的东西,同理,在Java宇宙的类型为Object的东西,在Native宇宙中就变成了jobject。除了这两个以外,当然还有许多的这样的映射关系(以下图表来源Oracle官方文档 :添加链接描述):

基本类型:

引用类型:

除了Class、String、Throwable和基本数据类型的数组外,其余所有Java对象的数据类型在JNI中都用jobject表示。

简单验证下:

在MainActivity中加上一个Native方法,参数为任选的几个基本类型和引用类型:

借助Android studio的力量,直接“ALT+Enter”键一键在C++代码中生成对应的Native方法:

可以看到类型和上面的表格是对应的(其他类型大家可以验证下)。

Native和Java层交互: Java向Native层传参和获取返回值

再回到一开始的代码:

#include 
#include 
//注意到visibility 改为"hidden"
extern "C" __attribute__ ((visibility ("hidden"))) jstring JNICALL
Java_com_example_ndkdemo_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject ) {
    //创建一个C++字符串
    std::string hello = "Hello from C++";
    //将C++字符串转为jstring 再返回给Java层。hello.c_str将string转为char*类型。
    return env->NewStringUTF(hello.c_str());
}

首先创建一个C++字符串hello ,因为Java的和Native是2个平行宇宙,所以string 是不能直接给Java的,所以需要通过NewStringUTF转为jstring才能安心交给Java层处理。

可以看到,NewStringUTF方法就是由JNIEnv类型的env调用的,之前说JNIEnv是为Java和Native提供交互的桥梁,在这里就可以看出具体就是通过JNIEnv可以将Native层的数据类型转为jni类型,再交给Java层。

如果是接收Java层的字符串呢?同样也是jstring,只是这次是作为参数:

MainActivity:

public native String stringForJNI(String s);

C++中:

extern "C"
JNIEXPORT jstring JNICALL
Java_com_example_ndkdemo_MainActivity_stringForJNI(JNIEnv *env, jobject thiz, jstring s) {
    jboolean isCopy = JNI_FALSE;
    //isCopy是JVM返回的标志位(还记得通过指针传参在函数内部修改外面变量吧)。返回JNI_TRUE表示原字符串的拷贝。返回JNI_FALSE表示返回原字符串的指针,即指向原来从Java的字符串
    const char* c = env->GetStringUTFChars(s, &isCopy);
    //这里为了简单就直接原封不动返回给Java
    return env->NewStringUTF(c);
}

通过Java传入的字符串会转化为jstring形式传给Native,这里C++必须通过GetStringUTFChars方法转化为C++中的字符串,才能被C++处理。

这里要注意的是,一般情况下,Java层传入的jstring如果确定在C++层不再使用了,需要通过ReleaseStringUTFChars方法释放对Java层对象的持有防止内存泄漏:

const char* c = env->GetStringUTFChars(s, &isCopy);
//对c的使用。。。
env->ReleaseStringUTFChars(s,c);
//不再使用c。。。

比如刚才的例子可以这样写:

extern "C"
JNIEXPORT jstring JNICALL
Java_com_example_ndkdemo_MainActivity_stringForJNI(JNIEnv *env, jobject thiz, jstring s) {
    jboolean isCopy = JNI_FALSE;
    //返回JNI_TRUE表示原字符串的拷贝。返回JNI_FALSE表示返回原字符串的指针,即指向原来从Java的字符串
    const char* c = env->GetStringUTFChars(s, &isCopy);
    jstring result = env->NewStringUTF(c);
    //字符串c不再使用到则释放对Java层字符串的引用
    env->ReleaseStringUTFChars(s,c);
    return result;
}

对于基本类型来说,我们不需要手动转为jint或者jboolean,直接返回或者传参即可,比如:

MainActivity:

public native int intFromJNI();

public native double DoubleFromJNI();

public native boolean booleanFromJNI();
//基本类型传参
public native String intForJNI(int i);

C++:

extern "C"
JNIEXPORT jint JNICALL
Java_com_example_ndkdemo_MainActivity_intFromJNI(JNIEnv *env, jobject thiz) {
    // TODO: implement intFromJNI()
    return 1;
}

extern "C"
JNIEXPORT jdouble JNICALL
Java_com_example_ndkdemo_MainActivity_DoubleFromJNI(JNIEnv *env, jobject thiz) {
    // TODO: implement DoubleFromJNI()
    return 2.1;
}

extern "C"
JNIEXPORT jboolean JNICALL
Java_com_example_ndkdemo_MainActivity_booleanFromJNI(JNIEnv *env, jobject thiz) {
    return false;
}

//基本类型传参
extern "C"
JNIEXPORT jstring JNICALL
Java_com_example_ndkdemo_MainActivity_intForJNI(JNIEnv *env, jobject thiz, jint i) {
    // TODO: implement intForJNI()
    //可以直接使用
    int a = i;
}
Native访问Java层属性和方法

上面完成了Java调用C++方法,那么C++访问Java方法或者属性当然同样是ok的。

访问Java层属性

MainActivity增加一个属性和Native方法setStringForText:

private String text = "";
.....
setStringForText();
// Example of a call to a native method
TextView tv = binding.sampleText;
tv.setText(text);

.....
//定义Native方法给text赋值
public native void setStringForText();

对应的Native方法:

extern "C"
JNIEXPORT void JNICALL
Java_com_example_ndkdemo_MainActivity_setStringForText(JNIEnv *env, jobject thiz) {
    // TODO: implement setStringForText()
    jclass activityClass=env->GetObjectClass(thiz);
//    获取java层的text属性
    jfieldID idText = env->GetFieldID(activityClass, "text", "Ljava/lang/String;");
    //给text属性赋值
    jstring text1 = env->NewStringUTF("native 层修改");
    env->SetObjectField(thiz, idText, text1);
}

这里参数jobject上面有所提及,就是如果一个Native函数在Java中声明为成员方法,则一定会带上这个参数,他代表的是调用该方法的Java对象,那么在这里,它就是MainActivity在Native的映射,这里 *** 作分为3步:

    通过jobject参数获取MainActivity对应的class对象在Native层的映射jclass 对象。通过jclass 对象得到text属性的id通过text属性id去修改text的值

其实这就是一个反射的 *** 作,这里要注意的一点就是第2步得到text属性的id的方法GetFieldID第最后一个参数,它代表访问的属性的类型描述符。

在JVM虚拟机中,存储数据类型的名称时,是使用指定的描述符来存储,而不是我们习惯的 int,float 等:


这里我们访问的是String类型属性,所以类型描述符就是L+String全类名即“Ljava/lang/String”。

运行下:

很稳没有翻车~

访问Java层方法

在MainActivity中添加成员方法callBack和Native方法callJavaCallBack:

//修改tv的值,准备给Native层调用,让Native修改tv显示的字符串
    private void callBack(String s){
        tv.setText(s);
    }

public native void callJavaCallBack();
...............
	 // Example of a call to a native method
        tv = binding.sampleText;
        tv.setText(text);
		//onCreate中调用callJavaCallBack方法
        callJavaCallBack();

对应的C++:

extern "C"
JNIEXPORT void JNICALL
Java_com_example_ndkdemo_MainActivity_callJavaCallBack(JNIEnv *env, jobject thiz) {
    // TODO: implement callJavaCallBack()
    //获取jclass对象
    jclass activityClass=env->GetObjectClass(thiz);
    //获取Java方法的jmethodID
    jmethodID callBackMethod = env->GetMethodID(activityClass, "callBack", "(Ljava/lang/String;)V");
    std::string hello = "I am CallBack";
    jstring s = env->NewStringUTF(hello.c_str());
    //调用Java方法,传参必须是jni类型
    env->CallVoidMethod(thiz, callBackMethod, s);
}

和修改属性套路非常相似,也是类似反射的方式。

运行下(省略图),没翻车~

总结

今天主要介绍了ndk和jni相关的基础内容,并且通过例子说明了Native层和Java一些基本交互 *** 作,下一篇将介绍一些更复杂的 *** 作。

如果觉得本文有帮助,别忘了点赞关注哦~

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5718949.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-18

发表评论

登录后才能评论

评论列表(0条)

保存