当前位置:   article > 正文

Android数据库基础知识——SQLite数据库的使用_android自带的sqlite能联网吗

android自带的sqlite能联网吗

在移动客户端中,很大部分的数据都是来源于服务端的接口,移动端只是进行简单的逻辑处理并展示数据,移动开发人员更多的精力放在UI及用户交互体验的开发,但这并不意味着移动端开发人员就不需要去跟数据库打交道,在某些特别的功能下我们可以看到有对数据库的使用,比如手机通讯录数据的保存,短信会话的存储,以及很多应用中的离线功能等。因此,Android系统内置了一款轻量级关系型数据库SQLite,用于满足手机app对于关系型数据的存储。

一提到数据库操作,可能很多没有做过后端开发的移动开发人员会觉得头痛,但其实SQLite是很容易上手的,因为它不仅支持标准的SQL语法,而且还遵循ACID事物,操作起来与其它关系型数据库并无多大区别,甚至还更简单。Android正是把这个功能极为强大的数据库嵌入到了系统当中,使得本地持久化的功能有了一次质的飞跃。 下面我们就来看一看如何使用SQLite数据库吧。

创建数据库

在Android中,为了便于对数据库的管理,系统为我们提供了一个SQLiteOpenHelper帮助类,这个类有助于我对数据库的创建和升级。
我们要知道SQLiteOpenHelper是一个抽象类,类中有两个抽象方法,分别为onCreate()和onUpgrade()。这意味我们想要使用它,就需要通过继承,并重写其抽象方法,在onCreate()中执行创建数据库的SQL语句,在onUpgrade中处理升级数据库的逻辑。

在SQLiteOpenHelper中有两个很重要的方法:getReadableDatabase()和 getWritableDatabase()。这两个方法都可以创建或打开一个现有的数据库(如果数据库已存在则 直接打开,否则创建一个新的数据库),并返回一个可对数据库进行读写操作的对象。不同的是,当数据库不可写入的时候(如磁盘空间已满或没有权限),getReadableDatabase()方法返回的对象将以只读的方式去打开数据库,而getWritableDatabase()方法则将出现异常。SQLiteOpenHelper共有两个构造函数,最多接收5个参数,如下图:

SQLiteOpenHelper构造方法
参数说明:

  • 第一个参数:Context上下文对象。
  • 第二个参数:为数据库库名。
  • 第三个参数:允许我们在查询数据的时候返回一个自定义的 Cursor,一般传入null。
  • 第四各参数:当前数据库最新版本号,常用语更新数据库。
  • 第五个参数:这个对象用于处理数据库的异常,比较少使用。

下面我们来具体体会SQLiteOpenHelper的用法,在这里我们希望创建一个名为“BookStore.db”的数据库,然后在这个数据库中新建一张Book表,表中有 id(主键)、作者、价格、页数和书名等列。Book表的建表语句如下:

create table Book (id integer primary key autoincrement, 
author text, 
price real, 
pages integer, 
name text)
  • 1
  • 2
  • 3
  • 4
  • 5

SQLite 不像其他的数据库拥有众多繁杂的数据类型,它的数据类型很简单,integer表示整型,real表示浮点型,text表示文本类型,blob表示二进制类型。另外,上述建表语句中我们还使用了primary key将id列设为主键,并用autoincrement关键字表示id列是自增长的。

下面我们在代码中去执行这条SQl语句,新建一个MyDatabaseHelper类继承于SQLiteOpenHelper。代码如下:

public class MyDatabaseHelper extends SQLiteOpenHelper {
    public static final String CREATE_BOOK = "create table Book ("
            + "id integer primary key autoincrement, "
            + "author text, "
            + "price real, "
            + "pages integer, "
            + "name text)";

    private Context context;

    public MyDatabaseHelper(Context context, String name, SQLiteDatabase.CursorFactory factory, int version) {
        super(context, name, factory, version);
        this.context = context;
    }

    @Override
    public void onCreate(SQLiteDatabase sqLiteDatabase) {
        sqLiteDatabase.execSQL(CREATE_BOOK);
        Toast.makeText(context, "create succeeded", Toast.LENGTH_SHORT).show();
    }

    @Override
    public void onUpgrade(SQLiteDatabase sqLiteDatabase, int i, int i1) {}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

从上面的代码可以看到,我们把建表语句定义成了一个字符串常量,然后在onCreate()方法中又调用了SQLiteDatabase的execSQL()方法去执行这条建表语句,并弹出一个Toast 提示创建成功,这样 就可以保证在数据库创建完成的同时还能成功创建Book表。下面是Activity中部分代码:

private SQLiteOpenHelper dbHelper;
...
@Override
protected void onCreate(Bundle savedInstanceState) {
    dbHelper = new MyDatabaseHelper(this, "BookStore.db", null, 1);
    findViewById(R.id.btn_create_database).setOnClickListener(...
        dbHelper.getReadableDatabase();
...
  });
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

这里我们在onCreate()方法中构建了一个MyDatabaseHelper对象,并且通过构造函数的参数将数据库名指定为BookStore.db,版本号指定为1,然后在Create database按钮的点击事件 里调用了getWritableDatabase()方法。这样当第一次点击Create database按钮时,就会检测到当前程序中并没有BookStore.db这个数据库,于是会创建该数据库并调用 MyDatabaseHelper中的onCreate()方法,这样Book表也就得到了创建,然后会弹出一个Toast 提示创建成功。再次点击Create database按钮时,会发现此时已经存在BookStore.db数据库了,因此不会再创建一次。

完成了创建之后,我们该如何去验证呢?以下提供两种方式的简述:
1. adb是Android SDK中自带的调试工具,通过工具连接手机,使用cd命令进入到/data/data/应用包名/databases/目录下,键入相应命令进行查看。
2. 如果手机已经root,使用Root Explorer也是可以直接在手机上查看/data/data/应用包名/databases/目录下的所有文件。

升级数据库

在创建数据库过程中,我们发现在onUpgrade()方法中,并没有做任何的实现。这个方法在数据管理中至关重要,是用于对数据库进行升级的。

现在我们来对刚刚创建的数据库进行升级,添加一张Category表用于记录图书分类。表中中有 id(主键)、分类名和分类等这几个列。建表语句如下:

create table Category (id integer primary key autoincrement, 
category_name text, 
category_code integer)
  • 1
  • 2
  • 3

接下来将这条建表语句添加到MyDatabaseHelper中。代码如下:

public class MyDatabaseHelper extends SQLiteOpenHelper {
    public static final String CREATE_BOOK = "create table Book ("
            + "id integer primary key autoincrement, "
            + "author text, "
            + "price real, "
            + "pages integer, "
            + "name text)";

    public static final String CREATE_CATEGORY = "create table Category ("
            + "id integer primary key autoincrement, "
            + "category_name text, "
            + "category_code integer)";

    private Context context;

    public MyDatabaseHelper(Context context, String name, SQLiteDatabase.CursorFactory factory, int version) {
        super(context, name, factory, version);
        this.context = context;
    }

    @Override
    public void onCreate(SQLiteDatabase sqLiteDatabase) {
        sqLiteDatabase.execSQL(CREATE_BOOK);
        sqLiteDatabase.execSQL(CREATE_CATEGORY);
        Toast.makeText(context, "create succeeded", Toast.LENGTH_SHORT).show();
    }

    @Override
    public void onUpgrade(SQLiteDatabase sqLiteDatabase, int i, int i1) {
        sqLiteDatabase.execSQL("drop table if exists Book");
        sqLiteDatabase.execSQL("drop table if exists Book");
        onCreate(sqLiteDatabase);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

Activity中的代码只需要修改如下一行,将MyDatabaseHelper构造方法中第四个参数的值加1即可(1–>2)。代码如下:

dbHelper = new MyDatabaseHelper(this, "BookStore.db", null, 2);
  • 1

如果只在onCreate()添加Category的建表语句,而onUpgrade()中不加任何操作,我们会发现Category表根本不会被创建,这是因为BookStore.db数据库已经存在了,之后不管你多少次去调用getWritableDatabase()方法,都不会再执行onCreate()。那么怎样才能进行数据库更新呢?上面我们提到MyDatabaseHelper构造方法的第四个参数,当传入的数值大于当前数据库版本号,onUpgrade()就会被调用,这样我们就可以在该方法中进行数据库升级操作。在上面的代码中,有对Book表进行删除操作,这是因为不允许再次创建一张已存在的表,如果不删除,那么执行到onCreate()时,就会报异常。在实际的项目开发中通常不会这么简单粗暴的处理,而且久的表肯定是会有一些数据的,不可能随便删除。做完以上工作,重新运行程序,点击Create database按钮,在/data/data/应用包名/databases/目录下,我们就能看到新增的Category表。

添加数据

我们现在已经知道创建和升级数据库的方法,接下来就需要了解如何操作表中的数据了。其实我们可以对数据进行的操作无非有4种,即CRUD。其中C代表添加(Create),R代 表查询(Retrieve),U代表更新(Update),D代表删除(Delete)。每一种操作又各自对应了一 种SQL命令,如果你比较熟悉SQL语言的话,一定会知道添加数据时使用insert,查询数据时使用select,更新数据时使用update,删除数据时使用delete。不过对于大部分的移动开发者来说都是谈SQL变色的,因此Android也提供了一系列的辅助工具,使得在Android中即使不去编写SQL语句,也能轻松完成所有的CRUD操作。

前面我们已经知道,SQLiteOpenHelper的getReadableDatabase()或getWriteDatabasek()方法是可以用于数据库的创建和升级操作的,我们注意到,这两个方法都会返回一个SQLiteDatabase对象,借助这个对象就可以对表中数据进行CRUD操作了。

在进入添加数据之前,我们先来了解一下ContentValues,这个辅助类会在之后的CRUD操作中用到,其实现了Parcelable接口,类中维护的是一个初始化长度为8的HashMap成员变量,Key是String类型,values只能存储基本类型的数据,不能存储对象,相应的提供了一系列put()重载方法,用于向ContentValues中添加数据,在数据库操作中Key对应的是列名,value就是对应的值。

下面我们看看如何向数据库的表中添加数据吧。SQLiteDatabase提供了一个insert()方法,这个方法就是用来专门添加数据的。此方法接收3个参数,第一个参数是表名,我们希望向哪张表里添加数据,这里就传入该表的名字;第二个参数用于在未指定添加数据的情况下给某些可为空的列自动赋值NULL,一般我们用不到这个,直接传入null即可;第三个参数就是我们上面提到的ContentValues对象;这个方法的返回类型为long,其值是最后插入行的ID,如果插入出现错误则返回-1,在开发中极少需要去接收返回值。下面我们向Book表中插入两条数据:

findViewById(R.id.btn_add_data).setOnClickListener(...
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        ContentValues values = new ContentValues();
        // 组装第一条数据
        values.put("name", "The First line Of Code");
        values.put("author", "Guo Lin");
        values.put("pages", 454);
        values.put("price", 39.99);
        db.insert("Book", null, values);// 插入第一条数据
        values.clear();
        // 组装第二条数据
        values.put("name", "The Last line Of Code");
        values.put("author", "Guo Lin2");
        values.put("pages", 500);
        values.put("price", 49.99);
        db.insert("Book", null, values);// 插入第二条数据
        Toast.makeText(MainActivity.this, "Insert Succeeded", Toast.LENGTH_SHORT).show();
    ...
);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

从上面的代码,我们看到两条数据的组装过程中都没有对id进行赋值,这是因为在建表时,我们就将id列设置为自增长,它的值会在入库时自动生成。完成上述的代码之后,重新运行程序,就可以在表中插入两条数据。大家可以通过adb连接手机,使用cd命令进行查看验证。

更新数据

就像添加数据一样,对于更新数据SQLiteDatabase给我们提供了一个update()方法,这个方法接收了4个参数,第一个参数和insere()方法一样,也是数据库名;第二个参数是ContentValues对象,将要更新的数据组装进去;第三和第四个参数是约束条件,用于指定要更新的行列数据,默认更新所有数据;方法返回值为受影响的行数。

下面我们把书名为《The First line Of Code》的价格更新为20.99,代码如下:

findViewById(R.id.btn_update_data).setOnClickListener(...
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        ContentValues values = new ContentValues();
        values.put("price", 20.99);
        db.update("Book", values, "name = ?", new String[]{"The First line Of Code"});
        Toast.makeText(MainActivity.this, "Update succeeded", Toast.LENGTH_SHORT).show();
    ...
);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

这里我们在点击事件中先创建一个ContentValues对象,指定一组数据,并将price字段的值设为20.99,然后调用SQLiteDatabase的update()方法,第三个参数为“name = ?”字符串,问号是占位符,多个约束条件,用英文逗号间隔,如“name1 = ? , name2 = ?”,第四个参数是字符串数组,数组的长度等于第三个参数中的占位符数量,对应着第三个参数中占位符相应的内容。

删除数据

添加和更新的功能都非常简单,代码不多,很容易理解。在弄懂添加和更新之后,我们再来看删除功能则更为简单。同样的,为了便于删除数据,SQLiteDatabase也为我们提供了一个delete()方法,这个方法接收了三个参数,第一个为表名,第二和第三个为约束条件,同update()方法的第三和第四个;方法返回值为受影响的行数。代码如下:

findViewById(R.id.btn_delete_data).setOnClickListener(...
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        db.delete("Book", "pages > ?", new String[]{"500"});
        Toast.makeText(MainActivity.this, "Delete succeeded", Toast.LENGTH_SHORT).show();
    ...
);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

因为很简单,这里就不做过多解释了,重新运行应用程序,并到数据库中验证就好。

查询数据

相比于增删改,查询就要复杂一些了,SQLiteDatabase提供了一个query()方法,但是这个方法的参数很多,从下图我们可以看到参数最少的也有7个。

query()方法的参数
我们来看一下共有的7个参数的含义吧。第一个为表名;第二个为要查询的列,默认查询所有;第三和第四个为约束条件,同增删改;第五参数用于对group by之后的数据进行进一步的过滤;第六个参数为指定需要进行group by的列,为空则不进行group by;第七个参数为指定查询结果的排序方式,不指定则按默认排序。

虽然query()方法的参数非常多,但是并非所有参数都必须要传入,比如查询Book表中的所有数据,我们就只需传入表名,其它参数均传入null。调用query()方法后会返回一个Cursor对象,查询到的所有数据都封装在里面,我们只需从里面取出即可。查询示例如下:

findViewById(R.id.btn_query_data).setOnClickListener(...
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        // 查询Book表中所有的数据
        Cursor cursor = db.query("Book", null, null, null, null, null, null);
        StringBuilder sb;
        if (cursor.moveToFirst()) {
            do {
                sb = new StringBuilder();
                // 遍历Cursor对象,取出数据
                String name = cursor.getString(cursor.getColumnIndex("name"));
                sb.append(name);
                sb.append("_");
                String author = cursor.getString(cursor.getColumnIndex("author"));
                sb.append(author);
                sb.append("_");
                int pages = cursor.getInt(cursor.getColumnIndex("pages"));
                sb.append(pages);
                sb.append("_");
                double price = cursor.getDouble(cursor.getColumnIndex("price"));
                sb.append(price);
                Log.i("MainActivity", sb.toString());
            } while (cursor.moveToNext());
        }
cursor.close();
    ...
);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

接着我们调用Cursor的 moveToFirst()方法将数据的指针移 动到第一行的位置,然后进入了一个循环当中,去遍历查询到的每一行数据。在这个循环中可以 通过 Cursor 的getColumnIndex()方法获取到某一列在表中对应的位置索引,然后将这个索引传入到相应的取值方法中,就可以得到从数据库中读取到的数据了,并将数据进行拼接。接着使用 Log的方式将取出的数据打印出来。最后调用close()方法来关闭Cursor。

直接使用sql语句操作数据库

对于数据库的操作,Android和第三方开源都提供了很多辅助工具,但是可能有人觉得使用辅助工具逼格不够高,想要直接使用SQL语句来操作数据库。针对这些大牛,Android也提供了一系列的方法,使得可以直接使用SQL来操作数据库,如下面两图。
execSQL系列方法
rawQuery系列方法
除了查询使用的是SQLiteDatabase的rawQuery()方法,其它三种操作都调用execSQL()方法,下面来解释一下这两个方法中的参数。

  • execSQL(String sql, Object[] bindArgs)方法的第一个参数为SQL语句,第二个参数为SQL语句中占位符参数的值,参数值在数组中的顺序要和占位符的位置对应。
  • rawQuery()方法的第一个参数为select语句;第二个参数为select语句中占位符参数的值,如果select语句没有使用占位符,该参数可以设置为null。
// 调用execSQL()方法添加数据
db.execSQL("insert into Book (name, author, pages, price) values(?, ?, ?, ?)", 
        new String[]{"The First line Of Code", "Guo Lin", "454", "39.99"});
db.execSQL("insert into Book (name, author, pages, price) values(?, ?, ?, ?)", 
        new String[]{"The Last line Of Code", "Guo Lin2", "500", "49.99"});
// 调用execSQL()方法更新数据
db.execSQL("updata Book set price = ? where name = ?", new String[]{"20.99", 
        "The First line Of Code"});
// 调用execSQL()方法删除数据
db.execSQL("delete from Book where pages > ?", new String[]{"500"});
//调用rawQuery()方法查询数据
db.rawQuery("select * from Book",null);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

使用SQL语句执行结果和之前我们讲到的操作方式,其得到的结果是完全一致的。

扩展内容

在前面讲解的内容中,很多地方能看到getWritableDatabase()身影,与getWritableDatabase()相似的方法getReadableDatabase(),我们并没有用到,这两个方法内部都调用了getDatabaseLocked()方法,只是一个传入true,一个传入false。那么getDatabaseLocked()方法内部都做了哪些工作呢,让我们一起走进源码:

private SQLiteDatabase getDatabaseLocked(boolean writable) {
    if (mDatabase != null) {
        if (!mDatabase.isOpen()) {
            // Darn!  The user closed the database by calling mDatabase.close().
            mDatabase = null;
        } else if (!writable || !mDatabase.isReadOnly()) {
            // The database is already open for business.
            return mDatabase;
        }
    }

    if (mIsInitializing) {
        throw new IllegalStateException("getDatabase called recursively");
    }

    SQLiteDatabase db = mDatabase;
    try {
        mIsInitializing = true;

        if (db != null) {
            if (writable && db.isReadOnly()) {
                db.reopenReadWrite();
            }
        } else if (mName == null) {
            db = SQLiteDatabase.create(null);
        } else {
            try {
                if (DEBUG_STRICT_READONLY && !writable) {
                    final String path = mContext.getDatabasePath(mName).getPath();
                    db = SQLiteDatabase.openDatabase(path, mFactory,
                            SQLiteDatabase.OPEN_READONLY, mErrorHandler);
                } else {
                    db = mContext.openOrCreateDatabase(mName, mEnableWriteAheadLogging ?
                            Context.MODE_ENABLE_WRITE_AHEAD_LOGGING : 0,
                            mFactory, mErrorHandler);
                }
            } catch (SQLiteException ex) {
                if (writable) {
                    throw ex;
                }
                Log.e(TAG, "Couldn't open " + mName
                        + " for writing (will try read-only):", ex);
                final String path = mContext.getDatabasePath(mName).getPath();
                db = SQLiteDatabase.openDatabase(path, mFactory,
                        SQLiteDatabase.OPEN_READONLY, mErrorHandler);
            }
        }

        onConfigure(db);

        final int version = db.getVersion();
        if (version != mNewVersion) {
            if (db.isReadOnly()) {
                throw new SQLiteException("Can't upgrade read-only database from version " +
                        db.getVersion() + " to " + mNewVersion + ": " + mName);
            }

            db.beginTransaction();
            try {
                if (version == 0) {
                    onCreate(db);
                } else {
                    if (version > mNewVersion) {
                        onDowngrade(db, version, mNewVersion);
                    } else {
                        onUpgrade(db, version, mNewVersion);
                    }
                }
                db.setVersion(mNewVersion);
                db.setTransactionSuccessful();
            } finally {
                db.endTransaction();
            }
        }

        onOpen(db);

        if (db.isReadOnly()) {
            Log.w(TAG, "Opened " + mName + " in read-only mode");
        }

        mDatabase = db;
        return db;
    } finally {
        mIsInitializing = false;
        if (db != null && db != mDatabase) {
            db.close();
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90

下面对源码进行简单的解析:
1. getDatabaseLocked接收的是一个boolean类型的参数,标识是否请求必写操作。在数据库已经初始化完成且版本正常的情况下,如果传入false,那一定会返回一个SQLiteDatabase对象;如果传入true,在只能读不能写的情况下,将会抛出异常。
2. 从源码中,我们看到SQLiteOpenHelper中只持有一个SQLiteDatabase对象(即,mDatabase成员变量)。
3. 首先数据库对象处在,数据库为打开状态,且传入的必写标识为false或数据库为非只读,那么直接返回SQLiteDatabase对象;如果数据库为关闭状态,那么会将数据库对象置空。
4. 如果本次请求属于递归调用,那么会抛出异常。
5. 如果数据库对象存在,指定了必读标识且数据库为只读状态,那么会以读写的方式重新打开数据库。
6. 如果当前不存在数据库对象,并且初始化时没有指定数据库名称,也会创建一个默认数据库。
7. 如果数据库对象不存在,但是指定了数据库的名称,那么系统会先判断数据库是否为只读状态且不请求写操作,那么将尝试打开数据库,否则调用Context的openOrCreateDatabase方法创建/打开数据库。如果这一过程出现异常,那么要是请求写操作会直接抛出异常,要是请求只读操作,则会继续尝试打开数据库。
8. 数据库或降级升级时,会根据版本号进行判断,然后把升级和降级的过程作为一个事务进行操作,确保数据安全。
9. 创建数据库对象的默认版本号为0。

实例代码
Ps:本来不想要下载积分的,但是现在csdn对于上传的资源,必须要设置下载所需积分,至少一分 >>_<<。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/羊村懒王/article/detail/267232
推荐阅读
相关标签
  

闽ICP备14008679号