课程 2: 在 Android 应用中使用数据库

这节课是 Android 开发(入门)课程 的第四部分《数据与数据库》的第二节课,导师依然是 Jessica Lin 和 Katherine Kuan。这节课在掌握 SQLite 数据库基本操作的前提下,在 Pets App 的最小可行性应用 (MVP, Minimum Viable Product) 中使用 SQLite 数据库,主要参考 这篇 Android Developers 文档,内容包括

  • 使用 Contract 类确定数据库架构。
  • 使用 SQLiteOpenHelper 类创建、连接、管理数据库。
  • 插入数据时使用的 ContentValues 类。
  • 读取数据时使用的 Cursor 对象。

关键词:Contract Class、SQLiteOpenHelper、SQLiteDatabase 、ContentValues、Cursor、Floating Action Button、Spinner

Contract Class

在 Android 应用中使用 SQLite 数据库,首先需要使用 Contract 类确定数据库架构 (schema)。所谓数据库架构,可以理解为第一节课中提到的表格结构,主要是每一列的属性及其对应的存储类和限制信息等。Android 提供了 Contract 类来保存数据库架构,即保存表格的名称、每一列的属性名等。这样做的好处是,将数据库架构及其相关的常量统一放在一个类内,容易访问和管理;同时在利用数据库架构的常量生成 SQL 指令时,杜绝了错别字的可能性。

例如在 Pets App 中,通过内部类 PetEntry 定义了表格的名称为字符串常量 pets,列 _id 作为数据库表格的 ID,宠物名字的列为字符串常量 name 等。

In java/com.example.android.pets/data/PetContract.java

/**
 * Inner class that defines constant values for the pets database table.
 * Each entry in the table represents a single pet.
 */
public static final class PetEntry implements BaseColumns {

    /** Name of database table for pets */
    public final static String TABLE_NAME = "pets";

    /**
     * Unique ID number for the pet (only for use in the database table).
     *
     * Type: INTEGER
     */
    public final static String _ID = BaseColumns._ID;

    /**
     * Name of the pet.
     *
     * Type: TEXT
     */
    public final static String COLUMN_PET_NAME ="name";

    /**
     * Possible values for the gender of the pet.
     */
    public static final int GENDER_UNKNOWN = 0;
    public static final int GENDER_MALE = 1;
    public static final int GENDER_FEMALE = 2;   
}
  1. 注意 PetContract 文件放在应用包名 (com.example.android.pets) 下单独的 data 包内,与 Activity 文件区分开。这是因为 Android 是通过包 (package) 来组织代码的,一类包存放一类特定功能的代码,例如 data 包下就存放数据相关的代码。

  2. 在调用 PetContract 中 PetEntry 的常量时,需要通过 PetContract.PetEntry 指定,例如

     PetContract.PetEntry.GENDER_MALE
    

    这是因为调用处导入的包名默认为外部类 PetContract

     import com.example.android.pets.data.PetContract;
    

    为了精简代码,可以将包名指向 PetContract 的内部类

     import com.example.android.pets.data.PetContract.PetEntry;
    

    这样在调用其常量时,就可以省略 PetContract

     PetEntry.GENDER_MALE
    

    这种方法适合在 Contract 类只有一个表格的情况下使用。

  3. PetContract 类要定义为 final 使之不能被扩展 (extends),因为它只对外提供常量,不实现任何功能。

  4. 对于用 INETGER 来存储固定选项的数据,其对应的规则常量也在此定义。例如上面的宠物性别信息,0 表示未知,1 表示雄性,2 表示雌性。

SQLiteOpenHelper

在 Contract 类确定数据库架构后,使用 Android 提供的 SQLiteOpenHelper 类来创建、连接、管理数据库。SQLiteOpenHelper 可以看成是应用与数据库之间的桥梁。

SQLiteOpenHelper 是一个抽象类,实现时需要 override onCreateonUpgrade method。例如在 Pets App 中,创建一个 PetDbHelper 类,扩展自 SQLiteOpenHelper。注意文件路径与 PetContract 的相同,表示 PetDbHelper 类属于数据功能的代码。

In java/com.example.android.pets/data/PetDbHelper.java

public class PetDbHelper extends SQLiteOpenHelper {
    private static final String DATABASE_NAME = "shelter.db";
    private static final int DATABASE_VERSION = 1;

    public PetDbHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        // Create a String that contains the SQL statement to create the pets table
        String SQL_CREATE_PETS_TABLE =  "CREATE TABLE " + PetEntry.TABLE_NAME + " ("
                + PetEntry._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
                + PetEntry.COLUMN_PET_NAME + " TEXT NOT NULL, "
                + PetEntry.COLUMN_PET_BREED + " TEXT, "
                + PetEntry.COLUMN_PET_GENDER + " INTEGER NOT NULL, "
                + PetEntry.COLUMN_PET_WEIGHT + " INTEGER NOT NULL DEFAULT 0);";

        // Execute the SQL statement
        db.execSQL(SQL_CREATE_PETS_TABLE);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // The database is still at version 1, so there's nothing to do be done here.
    }
}
  1. 首先定义两个常量,分别表示数据库的名称,不要忘记后缀 .db;以及数据库版本,初始值默认为 1。
  2. 构造函数调用超级类,输入参数分别为
    (1)Context,应用环境,执行构造函数时传入;
    (2)数据库名称,传入在外部类定义的常量;
    (3)CursorFactory,设为 null 以使用默认值;
    (4)数据库版本,传入在外部类定义的常量。
  3. override onCreate method 添加创建数据库时的指令,其中调用了 SQLiteDatabase 的 execSQL method 执行上面生成的 CREATE TABLE SQL 指令(存为字符串),实现创建数据库的操作。值得注意的是,由于 execSQL 无返回值,所以它不能用来执行如 SELECT 等带有返回值的 SQL 指令。
  4. override onUpgrade method 添加数据库版本发生变化时的指令,例如 SQLite 数据库表格增加了一列,数据库版本变更,可以在 onUpgrade 使用 execSQL 执行 DROP TABLE SQL 指令,删除旧表格后再调用 onCreate 创建新数据库。不过在这里,目前数据库版本保持不变,所以 onUpgrade 暂时留空,这部分内容将在后续课程修改。

实现 SQLiteOpenHelper 这个抽象类后,就可以通过它来创建、连接、管理 SQLiteDatabase 数据库了。例如在 Pets App 中,首先创建 SQLiteOpenHelper 实例。

In CatalogActivity.java

PetDbHelper mDbHelper = new PetDbHelper(this);

调用 PetDbHelper 的构造函数,传入 this 即当前 Activity 的环境。获得 PetDbHelper 实例后,调用 getReadableDatabase() 创建或连接 SQLiteDatabase 数据库。

SQLiteDatabase db = mDbHelper.getReadableDatabase();

这条指令相当于 sqlite3 的 .open 指令,用于打开或创建一个数据库文件。具体来说,应用在调用 getReadableDatabase() 时,PetDbHelper 会查询当前是否已存在数据库,若无,PetDbHelper 会执行其 onCreate method 创建数据库,返回一个 SQLiteDatabase 对象;若有则不执行 onCreate method,直接创建一个 SQLiteDatabase 对象,与已有的数据库连接。因此,无论应用是否已存在数据库,调用 getReadableDatabase() 的结果都是获得一个连接数据库的 SQLiteDatabase 对象,通过它来操作数据库。

不过事实上,通过 getReadableDatabase() 获得的 SQLiteDatabase 对象,只能对数据库进行读取 (Read) 操作,如果想要 CRUD 的其余操作,需要调用 getWritableDatabase() 获取 SQLiteDatabase 对象。

ContentValues

在获得一个连接数据库的 SQLiteDatabase 对象后,就可以通过它对数据库进行 CRUD 操作,其中经常用到 ContentValues 构造数据。ContentValues 是一个具象类,用于存储大量的键/值对,对于数据库而言,键是对象的属性,值是对应的属性值。例如在 Pets App 中,创建一个 ContentValues 对象,并通过 put method 添加键/值对。

String nameString = mNameEditText.getText().toString().trim();
String breedString = mBreedEditText.getText().toString().trim();
String weightString = mWeightEditText.getText().toString().trim();
int weight = Integer.parseInt(weightString);

ContentValues values = new ContentValues();
values.put(PetEntry.COLUMN_PET_NAME, nameString);
values.put(PetEntry.COLUMN_PET_BREED, breedString);
values.put(PetEntry.COLUMN_PET_GENDER, mGender);
values.put(PetEntry.COLUMN_PET_WEIGHT, weight);

long newRowId = db.insert(PetEntry.TABLE_NAME, null, values);
  1. 添加到 ContentValues 对象的键为 Contract 类中定义的架构列常量,对应的值为从 EditText 获取的用户输入值。
  2. toString 后调用 trim 去除字符串开头和结尾的多余空格。
  3. 调用 Integer.parseInt() 使字符串转换为整数。

构造好往数据库添加的数据后,调用 SQLiteDatabase 的 insert method 向数据库添加数据。输入参数分别为

  1. table: 要添加数据的表格名称。
  2. nullColumnHack: 可选参数,可设为 null,仅在往数据库添加空行时用到。
  3. values: 要往数据库添加的数据,数据类型为 ContentValues 对象。

SQLiteDatabase 的 insert method 返回值为新添加的行 ID,发生错误时返回 -1。

Tips:
1. 调用 finish() method 可以关闭当前 Activity,使屏幕界面回到原先 Activity。
2. 根据 Activity 的生命周期,在 onStart 中添加的代码,可以在用户通过 Activity 切换回来时执行。

Cursor

与往数据库添加数据类似,SQLiteDatabase 也提供了读取数据的方法,例如通过 rawQuery 传入 SQL 指令读取数据,不过还有更规范的 query method,可以避免直接操作 SQL 指令导致的语法错误。SQLiteDatabase 提供了很多不同的 query method,其中较简单的为

Cursor query (String table, 
              String[] columns, 
              String selection, 
              String[] selectionArgs, 
              String groupBy, 
              String having, 
              String orderBy)

有七个输入参数,分别为

  1. table
    想要读取的表格名称。
  2. columns
    想要读取的列数据,传入 null 表示读取所有列,但是不建议这么做,尤其是数据库很庞大时会消耗大量系统资源。
  3. selection
    读取数据的筛选条件,相当于 SQL 指令的 WHERE 条件,传入 null 表示无筛选条件,返回所有数据行。
  4. selectionArgs
    与 selection 配合使用,当 selection 包含 "?" 时,selectionArgs 数组的字符串会填入相应的位置,作为读取数据的筛选条件。这种模式属于防止 SQL 注入的安全措施。
  5. groupBy
    读取的数据行的分组条件,相当于 SQL 指令的 GROUP BY 条件,传入 null 表示数据行不分组。
  6. having
    在读取的数据行通过 groupBy 分组的情况下,指定哪一组包含 Cursor 内,相当于 SQL 指令的 HAVING 条件,传入 null 表示所有组包含 Cursor 内。另外,当 groupBy 为 null,having 也要传入 null。
  7. orderBy
    读取的数据行的排序方法,相当于 SQL 指令的 ORDER BY 条件,传入 null 表示数据行保持默认排序。

例如 这篇 Android Developers 文档 中的例子,它读取的数据是 ID、标题、副标题三列,标题为 "My Title",按照副标题降序排列的,不分组的。其中 selection 中的 "= ?" 可以是一个等号,也可以是两个等号。

SQLiteDatabase db = mDbHelper.getReadableDatabase();

// Define a projection that specifies which columns from the database
// you will actually use after this query.
String[] projection = {
    FeedEntry._ID,
    FeedEntry.COLUMN_NAME_TITLE,
    FeedEntry.COLUMN_NAME_SUBTITLE
    };

// Filter results WHERE "title" = 'My Title'
String selection = FeedEntry.COLUMN_NAME_TITLE + " = ?";
String[] selectionArgs = { "My Title" };

// How you want the results sorted in the resulting Cursor
String sortOrder = FeedEntry.COLUMN_NAME_SUBTITLE + " DESC";

Cursor cursor = db.query(
    FeedEntry.TABLE_NAME,      // The table to query
    projection,                // The columns to return
    selection,                 // The columns for the WHERE clause
    selectionArgs,             // The values for the WHERE clause
    null,                      // don't group the rows
    null,                      // don't filter by row groups
    sortOrder                  // The sort order
    );

SQLiteDatabase 的所有 query method 的返回值都是一个 Cursor 对象,它保存了读取到的数据库的多行内容。在读取数据时,Cursor 可以看成是应用与 SQLiteDatabase 之间的桥梁。

Cursor 的一个重要概念是当前行的位置。在 Cursor 没有数据时,它的默认位置为无效的 -1,所以与 Array 类似,Cursor 的首个可用位置为 0,随后逐步递增。Cursor 提供了 move method 用于移动当前行的位置,以获取指定行的数据,常用的有

Method Description
moveToFirst () 使 Cursor 当前行的位置移至首行
moveToLast () 使 Cursor 当前行的位置移至末行
moveToNext () 使 Cursor 当前行的位置下移一行
moveToPosition (int position) 使 Cursor 当前行的位置移至指定行,-1 <= position <= count
moveToPrevious () 使 Cursor 当前行的位置返回到原来那一行

Cursor 的 move method 的返回值类型都是布尔类型,移动成功为 true,失败则 false。例如 Cursor 当前行为最后一行时调用 moveToNext 的返回值即 false。

Cursor 还提供很多 getter method 用于获取不同类型的数据,例如 getInt()getStringgetType 等,它们的输入参数都是 columnIndex 列索引,这是由 projection 决定的,通过 getColumnIndex 获得。

使用完 Cursor 后,一定要调用 close method 关闭 Cursor,防止内存泄漏。

以 Pets App 为例

try {
    displayView.setText("The pets table contains " + cursor.getCount() + " pets.\n\n");
    displayView.append(PetEntry._ID + " - " +
            PetEntry.COLUMN_PET_NAME + " - " +
            PetEntry.COLUMN_PET_BREED + " - " +
            PetEntry.COLUMN_PET_GENDER + " - " +
            PetEntry.COLUMN_PET_WEIGHT + "\n");

    int idColumnIndex = cursor.getColumnIndex(PetEntry._ID);
    int nameColumnIndex = cursor.getColumnIndex(PetEntry.COLUMN_PET_NAME);
    int breedColumnIndex = cursor.getColumnIndex(PetEntry.COLUMN_PET_BREED);
    int genderColumnIndex = cursor.getColumnIndex(PetEntry.COLUMN_PET_GENDER);
    int weightColumnIndex = cursor.getColumnIndex(PetEntry.COLUMN_PET_WEIGHT);

    while (cursor.moveToNext()) {
        int currentID = cursor.getInt(idColumnIndex);
        String currentName = cursor.getString(nameColumnIndex);
        String currentBreed = cursor.getString(breedColumnIndex);
        int currentGender = cursor.getInt(genderColumnIndex);
        int currentWeight = cursor.getInt(weightColumnIndex);

        displayView.append(("\n" + currentID + " - " +
                currentName + " - " +
                currentBreed + " - " +
                currentGender + " - " +
                currentWeight));
    }
} finally {
    cursor.close();
}
  1. 使用 append 为 TextView 添加内容。
  2. 使用 getColumnIndex 获取每一列的索引。
  3. moveToNext 放入 while 循环语句中达到遍历所有数据行的效果。这是因为正常情况下,moveToNext 的返回值为 true 即进入循环;直到 Cursor 在最后一行时调用 moveToNext 的返回值为 false 即跳出循环。
  4. 通过不同的 getter method 获取不同类型的数据。
  5. 将上述代码放入 try/finally 区块内,即使应用崩溃,也能保证执行 close method 关闭 Cursor。

如何提取应用的数据库文件

在 Android Studio 界面右上角搜索 Device File Explorer 打开模拟器的文件浏览器,打开目录 data > data > com.example.android.pets > databases 即可查看应用内的数据库文件。右键选择 "Save As..." 将数据库文件提取到电脑中,即可通过终端访问。

Note:
1. 此方法仅适用于模拟器或具有最高权限 (rooted) 的物理设备。
2. 清除应用的缓存 (cache) 和数据 (data) 会删除应用的数据库文件。

Floating Action Button

Pets App 使用了 FloatingActionButton 连接两个 Activity,它是一个悬浮在 UI 之上的圆形按钮,有独特的显示和交互效果。

In activity_catalog.xml

<android.support.design.widget.FloatingActionButton
    android:id="@+id/fab"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_alignParentBottom="true"
    android:layout_alignParentRight="true"
    android:layout_margin="@dimen/fab_margin"
    android:src="@drawable/ic_add_pet"/>

将 FloatingActionButton 的 ID 设置为 fab,圆形按钮的位置放在屏幕的右下角,距离边缘 16dp。由于 FloatingActionButton 是 ImageView 的子类,所以其显示图标可以通过 android:src 属性设置;其余特性与 Button 类似,例如在 Pets App 中,设置其 OnClickListener 动作为打开 EditorActivity。

In CatalogActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_catalog);

    // Setup FAB to open EditorActivity
    FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
    fab.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View view) {
            Intent intent = new Intent(CatalogActivity.this, EditorActivity.class);
            startActivity(intent);
        }
    });

    ...
}
Spinner

Pets App 使用了 Spinner 作为输入宠物性别的选项,它是一个单选菜单,默认状态下会显示默认值和一个向下箭头的图标。

In activity_editor.xml

<Spinner
    android:id="@+id/spinner_gender"
    android:layout_height="48dp"
    android:layout_width="wrap_content"
    android:paddingRight="16dp"
    android:spinnerMode="dropdown"/>

将 Spinner 的 ID 设置为 spinner_gender,通过 android:spinnerMode 属性设置用户在点击 Spinner 时的展开方式是默认的下拉菜单 (dropdown) 还是弹出对话框 (dialog)。

In EditorActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_editor);

    ...

    mGenderSpinner = (Spinner) findViewById(R.id.spinner_gender);
    setupSpinner();
}

/**
 * Setup the dropdown spinner that allows the user to select the gender of the pet.
 */
private void setupSpinner() {
    // Create adapter for spinner. The list options are from the String array it will use
    // the spinner will use the default layout
    ArrayAdapter genderSpinnerAdapter = ArrayAdapter.createFromResource(this,
            R.array.array_gender_options, android.R.layout.simple_spinner_item);

    // Specify dropdown layout style - simple list view with 1 item per line
    genderSpinnerAdapter.setDropDownViewResource(android.R.layout.simple_dropdown_item_1line);

    // Apply the adapter to the spinner
    mGenderSpinner.setAdapter(genderSpinnerAdapter);

    // Set the integer mSelected to the constant values
    mGenderSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
        @Override
        public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
            String selection = (String) parent.getItemAtPosition(position);
            if (!TextUtils.isEmpty(selection)) {
                if (selection.equals(getString(R.string.gender_male))) {
                    mGender = PetEntry.GENDER_MALE;
                } else if (selection.equals(getString(R.string.gender_female))) {
                    mGender = PetEntry.GENDER_FEMALE;
                } else {
                    mGender = PetEntry.GENDER_UNKNOWN;
                }
            }
        }

        // Because AdapterView is an abstract class, onNothingSelected must be defined
        @Override
        public void onNothingSelected(AdapterView<?> parent) {
            mGender = PetEntry.GENDER_UNKNOWN;
        }
    });
}
  1. onCreate 通过 findViewById 找到 Spinner 对象,并通过辅助方法 setupSpinner() 设置 Spinner 适配器和监听器。
  2. Spinner 适配器设置为一个 ArrayAdapter,通过其静态方法 createFromResource 创建,输入参数分别为应用环境 (Context)、Array 资源 ID、布局资源 ID。其中,布局资源 ID 使用 Android 提供的默认布局 simple_spinner_item;Array 资源 ID 则是在应用内定义的资源。

In res/values/arrays.xml

<resources>
    <!-- These are the options displayed in the gender drop-down Spinner -->
    <string-array name="array_gender_options">
        <item>@string/gender_unknown</item>
        <item>@string/gender_male</item>
        <item>@string/gender_female</item>
    </string-array>
</resources>
  1. 创建 Spinner 的 ArrayAdapter 适配器后,调用 setDropDownViewResource 设置菜单的布局,其中 Android 提供的布局 simple_dropdown_item_1line 为每行显示一个项目的 ListView。

  2. Spinner 的 AdapterView.OnItemSelectedListener 需要 override 两个方法,通过 onItemSelected 设置用户选择不同项目时的对应指令,其中应用了 TextUtils.isEmpty 来判断当前选项是否为空,增强代码的鲁棒性;另外,通过 onNothingSelected 设置没有项目选中时的指令。

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

推荐阅读更多精彩内容