【翻譯】安卓架構組件(6)-Room持久化類庫

相關文章:

Room為SQLite提供了一個抽象層,使得可以流暢使用SQLite的所有功能。

處理大量結構化數據的app可以從本地數據持久化中獲取巨大利益。最常見的用例是緩存相關的數據。在這種情況下,當設備無法訪問網絡的時候,用戶仍然可以在離線時瀏覽內容。任何用戶原始數據的變化都會在連接網絡后同步。

核心框架提供了原生SQL的支持。盡管這些API很強大,但是比較底層并且需要花費大量的時間和努力去使用:

  • 沒有原生SQL查詢語句的編譯時驗證。當你的數據結構變化時,你需要手動更新受影響的SQL。這個過程會花費大量的時間并且很容易錯誤頻出。

Room考慮到了這些,提供了SQLite的抽象層。

Room有三個主要的組件:

  • 數據庫(Database):你可以使用該組件創建數據庫的持有者。該注解定義了實體列表,該類的內容定義了數據庫中的DAO列表。這也是訪問底層連接的主要入口點。注解類應該是抽象的并且擴展自RoomDatabase。在運行時,你可以通過調用Room.databaseBuilder()或者Room.inMemoryDatabaseBuilder()獲取實例。
  • 實體(Entity):這個組件代表了持有數據庫表記錄的類。對每種實體來說,創建了一個數據庫表來持有所有項。你必須通過Database中的entities數組來引用實體類。實體的每個成員變量都被持久化在數據庫中,除非你注解其為@Ignore

實體類可以擁有無參數構造函數(如果DAO類可以訪問每個持久化成員變量)或者擁有和實體類成員變量匹配參數的構造函數。Room也可以使用全部或者部分構造函數,例如只接收部分成員變量的構造函數。

  • 數據訪問對象(DAO):這個組件代表了作為DAO的類或者接口。DAO是Room的主要組件,負責定義訪問數據庫的方法。被注解@Database的類必須包含一個無參數的抽象方法并返回被@Dao注解的類型。當編譯時生成代碼時,Room會創建該類的實現。

通過使用DAO類訪問數據庫而不是查詢構建器或直接查詢,你可以將數據庫架構的不同組件分離。此外,DAO允許你在測試時很容易地模擬數據訪問。

這些組件和app的其他部分關系圖如下:

image

(img)

下面的代碼片段包含了簡單的數據庫配置,含有1個實體和一個DAO:

//User.java
@Entity
public class User {
    @PrimaryKey
    private int uid;

    @ColumnInfo(name = "first_name")
    private String firstName;

    @ColumnInfo(name = "last_name")
    private String lastName;

    // 省略Getters Setters(實際代碼中不可省略)
   }

//UserDao.java
@Dao
public interface UserDao {
    @Query("SELECT * FROM user")
    List<User> getAll();

    @Query("SELECT * FROM user WHERE uid IN (:userIds)")
    List<User> loadAllByIds(int[] userIds);

    @Query("SELECT * FROM user WHERE first_name LIKE :first AND "
           + "last_name LIKE :last LIMIT 1")
    User findByName(String first, String last);

    @Insert
    void insertAll(User... users);

    @Delete
    void delete(User user);
}

//AppDatabase.java
@Database(entities = {User.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

創建上面的文件以后,你可以使用以下代碼獲取已創建數據庫實例:

AppDatabase db = Room.databaseBuilder(getApplicationContext(),
        AppDatabase.class, "database-name").build();

當實例化AppDatabase對象時,你可以遵循單例設計模式,因為每個RoomDatabase實例代價是非常昂貴的,并且你幾乎不需要訪問多個實例。

實體

當一個類被@Entity注解,并被@Database注解的entities屬性引用時,Room為這個實體在數據庫中創建一個表。

默認情況,Room為實體類的每個成員變量創建一個列。如果一個實體類的某個成員變量不想被持久化,你可以使用Ignore注解標記,如:

@Entity
class User {
    @PrimaryKey
    public int id;

    public String firstName;
    public String lastName;

    @Ignore
    Bitmap picture;//不進行持久化
}

為了持久化成員變量,Room必須可以訪問它。你可以使成員變量是公共的,或者提供getter和setter方法。如果你使用getter/setter方法,請記住它們在Room中遵循Java Beans的概念。

主鍵

每個實體必須至少定義一個成員變量作為主鍵。甚至僅僅有一個成員變量,也要標記其為@PrimaryKey。同時,如果你想要Room指定ID自增,你可以設置@PrimaryautoGenerate屬性。如果實體的主鍵是綜合的,你可以使用@EntityprimaryKeys屬性,如:

@Entity(primaryKeys = {"firstName", "lastName"})
class User {
    public String firstName;
    public String lastName;

    @Ignore
    Bitmap picture;
}

默認情況下,Room使用類名作為數據庫表的表名。如果你想要數據庫表有一個其他的名字,設置@Entity注解的tableName屬性即可:

@Entity(tableName = "users")
class User {
    ...
}

注意:SQLite中的表名是大小寫敏感的。

tablename屬性相似,Room使用成員名作為列名,如果你想要改變類名,在成員上添加@ColumnInfo注解即可:

@Entity(tableName = "users")
class User {
    @PrimaryKey
    public int id;

    @ColumnInfo(name = "first_name")
    public String firstName;

    @ColumnInfo(name = "last_name")
    public String lastName;

    @Ignore
    Bitmap picture;
}

索引與唯一

取決于你如何訪問數據,你可能想要索引確切的字段以加速數據的查詢。為了向實體添加索引,在@Entity中添加indices屬性,列出你想要包括的字段名或者字段名組:

@Entity(indices = {@Index("name"), @Index("last_name", "address")})
class User {
    @PrimaryKey
    public int id;

    public String firstName;
    public String address;

    @ColumnInfo(name = "last_name")
    public String lastName;

    @Ignore
    Bitmap picture;
}

有些時候具體的成員或成員組必須是獨一無二的。你可以設置@Index的屬性uniquetrue

@Entity(indices = {@Index(value = {"first_name", "last_name"},
        unique = true)})
class User {
    @PrimaryKey
    public int id;

    @ColumnInfo(name = "first_name")
    public String firstName;

    @ColumnInfo(name = "last_name")
    public String lastName;

    @Ignore
    Bitmap picture;
}

關系

因為SQLite是一個關系型數據庫,你可以指定對象間的關系。即使大多數ORM類庫允許實體對象互相引用,Room則顯式地禁止了這一點。

即使你不能直接使用關系映射,Room仍然允許你去定義實體鍵的外鍵約束。

例如,有另一個叫做Book的實體,你可以通過使用@ForeignKey注解定義其和User實體的關系,如:

@Entity(foreignKeys = @ForeignKey(entity = User.class,
                                  parentColumns = "id",
                                  childColumns = "user_id"))
class Book {
    @PrimaryKey
    public int bookId;

    public String title;

    @ColumnInfo(name = "user_id")
    public int userId;
}

外鍵是非常強大的,因為它們允許你指定引用實體更新時會發生什么、例如,你可以告訴SQLite去刪除所有的書籍,如果這些書所對應的User被刪除并且指定了@ForeignKey的屬性onDelete=CASCADE

SQLite將@Insert(OnConfilct=REPLACE)處理為REMOVEREPLACE的集合而不僅僅是更新操作。這個替換沖突值的方法可能會對你的外鍵約束起作用。

內嵌對象

有些時候你想要一個實體類或POJO類作為數據庫邏輯的一部分。這種情況下,你可以使用@Embedded注解來。你可以查詢內嵌成員,就像你可能查詢其他字段一樣。

例如,我們的User類包含一個Address類型的成員,代表了streetcity、statepostCode。為了分別存儲這些字段,在User類中包含一個Address成員并標記為@Embedded,如:

class Address {
    public String street;
    public String state;
    public String city;

    @ColumnInfo(name = "post_code")
    public int postCode;
}

@Entity
class User {
    @PrimaryKey
    public int id;

    public String firstName;

    @Embedded
    public Address address;
}

這個表代表了一個User對象包含了以下字段:idfirstName,streetstate,citypost_code

內嵌成員也可以含有其他內嵌成員

如果一個實體含有多種相同類型的內嵌成員,你可以通過設置prefix屬性保持每個字段的唯一性。Room之后添加提供的值到每個內嵌對象的起始位置。

數據訪問對象(DAO)

Room的主要組件是Dao類。DAO以清晰的方式抽象除了訪問數據庫的行為。

Room不允許在主線程方位數據庫,除非你在Builder調用allowMainThreadQueries(),因為這可能會導致UI被鎖住。而異步查詢則不受此約束,因為異步調用在后臺線程運行查詢工作。

便捷方法

有很多可以使用DAO類的便捷查詢方法,例如:

Insert

當你創建一個DAO方法并標記其為@Insert,Room會生成在單一事務中將所有參數存入數據庫的實現:

@Dao
public interface MyDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    public void insertUsers(User... users);

    @Insert
    public void insertBothUsers(User user1, User user2);

    @Insert
    public void insertUsersAndFriends(User user, List<User> friends);
}

如果@Insert方法只接收一個參數,它會返回long,表示新插入項的row Id。如果參數是數組或集合,它會返回long[]或者List<Long>

Update

Update是更新一組實體的便捷方法。它查詢匹配主鍵的記錄然后更新。如:

@Dao
public interface MyDao {
    @Update
    public void updateUsers(User... users);
}

盡管通常并不需要如此,你可以讓該方法返回一個int值,表示更新至數據庫的行號。

Delete

Delete是刪除一組實體的便捷方法。它使用主鍵去尋找記錄并刪除:

@Dao
public interface MyDao {
    @Delete
    public void deleteUsers(User... users);
}

同上,你可以讓該方法返回一個int值表示被刪除的行號。

使用@Query

@Query是用于DAO類的主要注解。它允許你在數據庫上執行讀寫操作。每個Query方法都會在編譯時驗證,因此如果查詢語句有問題,那么編譯時就會報錯,而不是在運行時發生。

Room同樣驗證查詢的返回值,如果返回對象的成員名和字段名不一致,Room會以以下兩種方式警告:

  • 如果僅僅部分成員名相符,則發出警告
  • 如果沒有成員名相符,則發出錯誤

簡單查詢

@Dao
public interface MyDao {
    @Query("SELECT * FROM user")
    public User[] loadAllUsers();
}

這是一個加載所有用戶的簡單查詢。在編譯時,Room知道這是查詢用戶表的所有字段。如果查詢語句含有語法錯誤,或者用戶表在數據庫中并不存在,Room會顯示相應的錯誤。

查詢中傳遞參數

大多數時候,你需要在查詢中傳遞參數來執行過濾操作,例如僅僅顯示具體年齡的用戶。為了完成這個任務,在你的Room注解中使用方法參數,如:

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age > :minAge")
    public User[] loadAllUsersOlderThan(int minAge);
}

當編譯時處理這個查詢時,,Room將:minAgeminAge匹配在一起。Room使用參數名進行匹配,如果匹配不成功,會在編譯時報錯。

你也可以傳遞多個參數或引用多次,如:

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
    public User[] loadAllUsersBetweenAges(int minAge, int maxAge);

    @Query("SELECT * FROM user WHERE first_name LIKE :search "
           + "OR last_name LIKE :search")
    public List<User> findUserWithName(String search);
}

返回所有字段的子集

大多數時候,你可能需要一個實體的一部分成員變量,例如你的UI可能只顯示用戶的名和姓,而不是用戶的所有細節。通過僅僅獲取出現在你UI中的字段,你可以存儲很多資源,并且你的查詢完成地更快。

Room允許你從查詢中返回任何對象,只要結果字段集可以被映射到返回的對象上。例如,你可以創建下面的POJO類來獲取用戶的姓和名:

public class NameTuple {
    @ColumnInfo(name="first_name")
    public String firstName;

    @ColumnInfo(name="last_name")
    public String lastName;
}

現在你可以在你的查詢方法中這樣使用POJO類:

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user")
    public List<NameTuple> loadFullName();
}

Room理解這次返回first_namelast_name字段的查詢,并可以映射到NameTuple類。這樣,Room就能生成正確的代碼。如果查詢返回太多的字段,或者某個字段并不存在于NameTuple中,Room會顯示一個警告。

傳遞參數集合

你的某些查詢可能會傳遞大量的參數,而且直到運行時才知道具體的參數。例如,你可能會獲取關于用戶所屬區域的信息。當參數為集合時,Room能夠理解并自動根據當前提供的參數進行擴展:

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
    public List<NameTuple> loadUsersFromRegions(List<String> regions);
}

可觀察查詢

當運行查詢時,你通常想要在數據變化的時候你的app界面自動更新。為了做到這一點,在查詢方法中使用LiveData類型的返回值。Room會生成所有必要的代碼,當數據更新時,會自動更新LiveData。

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
    public LiveData<List<User>> loadUsersFromRegionsSync(List<String> regions);
}

RxJava

Room也可以從你定義的查詢中直接返回RxJava2的PublisherFlowable對象。為了使用這個功能,添加android.arch.persistence.room:rxjava2到你的Gradle構建依賴。你可以隨后返回RxJava2定義的類型,如:

@Dao
public interface MyDao {
    @Query("SELECT * from user where id = :id LIMIT 1")
    public Flowable<User> loadUserById(int id);
}

查詢多張表

你的一些查詢可能需要訪問多張表來計算結果。Room允許你寫任何的查詢,因此你可以使用連接表。另外,如果結果是可觀察數據類型,例如Flowable或者LiveData,Room會驗證所有SQL查詢語句。

下面的代碼片段展示了如何連接兩張表,一張表是包含用戶借書的信息,另一張包含當前借出的信息:

@Dao
public interface MyDao {
    @Query("SELECT * FROM book "
           + "INNER JOIN loan ON loan.book_id = book.id "
           + "INNER JOIN user ON user.id = loan.user_id "
           + "WHERE user.name LIKE :userName")
   public List<Book> findBooksBorrowedByNameSync(String userName);
}

你也可以從這些查詢中返回POJO類。例如你可以這樣寫一個用戶和其寵物姓名的查詢語句:

@Dao
public interface MyDao {
   @Query("SELECT user.name AS userName, pet.name AS petName "
          + "FROM user, pet "
          + "WHERE user.id = pet.user_id")
   public LiveData<List<UserPet>> loadUserAndPetNames();

   // 你也可以在單獨的文件中定義該類,只要你添加了public修飾符
     static class UserPet {
       public String userName;
       public String petName;
   }
}

使用類型轉換

Room提供內置工具用于基本類型和其封裝類型的裝換。但是有些時候你可能使用了使用了自定義數據類型,而想在數據庫表中始終單個字段。為了添加這類自定義類型支持,你需要提供一個TypeConverter,將自定義類轉換到Room已知可以持久化的類型。

例如,如果我們想要持久化Date實例,我們可以這樣寫:

public class Converters {
    @TypeConverter
    public static Date fromTimestamp(Long value) {
        return value == null ? null : new Date(value);
    }

    @TypeConverter
    public static Long dateToTimestamp(Date date) {
        return date == null ? null : date.getTime();
    }
}

上面的例子定義了兩個函數,一個將Date類型轉換為Long類型,另一個進行相反的轉換。

接下來,在AppDataBase添加@TypeConverters注解,使得Room可以使用你定義的轉換器:

@Database(entities = {User.java}, version = 1)
@TypeConverters({Converter.class})
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

使用了這些轉換器以后,你可以在其他查詢中使用你的自定義類型,就像基本類型一樣:

//User.java
@Entity
public class User {
    ...
    private Date birthday;
}

//UserDao.java
@Dao
public interface UserDao {
    ...
    @Query("SELECT * FROM user WHERE birthday BETWEEN :from AND :to")
    List<User> findUsersBornBetweenDates(Date from, Date to);
}

你可以限制@TypeConverters的作用范圍,包括單獨實體,DAO以及DAO方法。

數據庫遷移

當你在app添加以及修改功能時,你需要修改你的實體類以響應這些變化。當一個用戶更新到最新版本的app時,你不想讓他們丟掉所有已經存在的數據,特別是不能再從遠程服務器獲取的數據。

Room允許你編寫Migration類來保護用戶數據。每個Migration類指定一個startVersionendVersion。在運行時,Room運行每個Migration類的migrate()方法,使用正確的順序遷移至數據庫的更新版本。

如果你沒有提供必要的遷移,Room會重新構建數據庫,這意味著你將丟失所有數據庫中的數據。

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();

static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, "
                + "`name` TEXT, PRIMARY KEY(`id`))");
    }
};

static final Migration MIGRATION_2_3 = new Migration(2, 3) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("ALTER TABLE Book "
                + " ADD COLUMN pub_year INTEGER");
    }
};

為了保持你的遷移功能的正確性,使用完整的查詢語句而不是詢而不是引用表示查詢的常量。

在遷移過程完成后,Room驗證當前的表以確保遷移的正確性。如果Room找到問題,會拋出未匹配的異常信息。

測試遷移

遷移是很重要的事情,錯誤的編寫會導致你app的崩潰循環。為了保證app的穩定性,你應該測試你的遷移工作。Room提供了一個測試的Maven構件來幫助測試。但是,為了該構件可以工作,你需要導出你的數據庫表。

導出數據庫表

//build.gradle
android {
    ...
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = ["room.schemaLocation":
                             "$projectDir/schemas".toString()]
            }
        }
    }
}

Room會將你數據庫的表信息導出為一個json文件。你應該在版本控制系統中保存該文件,該文件代表了你的數據庫表歷史記錄,這樣允許Room創建舊版本的數據庫用于測試。

為了測試遷移,添加android.arch.persistence.room:testing到你的測試依賴,以及添加模式表的位置至asset文件夾,如:

//build.gradle
android {
    ...
    sourceSets {
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

測試包提供了一個MigrationTestHelper類,可以讀取這些模式表文件。

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
    private static final String TEST_DB = "migration-test";

    @Rule
    public MigrationTestHelper helper;

    public MigrationTest() {
        helper = new MigrationTestHelper(InstrumentationRegistry.getContext(),
                MigrationDb.class.getCanonicalName(),
                new FrameworkSQLiteOpenHelperFactory());
    }

    @Test
    public void migrate1To2() throws IOException {
        SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);

        // db 版本為1. 使用SQL添加一些數據
        // 你不能使用DAO,因為它表示的是最新的數據庫
        db.execSQL(...);

        // 準備下個版本
        db.close();

        // 重新打開數據庫版本2
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2);

         }
}

測試你的數據庫

當運行測試你的app時,如果沒有測試數據庫本身,你不需要創建全部的數據庫。Room允許在你的測試中模擬數據訪問層。這個過程是可能的,因為你的DAO并沒有泄漏任何數據庫的細節。當測試剩下的app部分時,你應該創建模擬你的DAP類。

這里有兩種測試數據庫的方式:

  • 在你的開發主機上
  • 在Android設備上

在你的主機上測試

Room使用SQLite支持庫,提供了匹配安卓框架類的接口。這種支持允許你傳遞支持類庫的自定義實現以測試你的數據庫。

即使這種方案允許你測試非??旖荩遣⒉恢档猛扑],這是因為你設備上以及你用戶設備上運行的SQLite版本可能和你主機上運行的版本并不匹配。

在Android設備上測試

這種推薦的測試數據庫方法是編寫運行在安卓設備上的JUnit測試。因為這些測試并不需要創建Activity,它們應該會比在UI上測試要快。

當設置你的測試時,你應該創建一個數據庫的內存版本來使得測試更密閉,如:

@RunWith(AndroidJUnit4.class)
public class SimpleEntityReadWriteTest {
    private UserDao mUserDao;
    private TestDatabase mDb;

    @Before
    public void createDb() {
        Context context = InstrumentationRegistry.getTargetContext();
        mDb = Room.inMemoryDatabaseBuilder(context, TestDatabase.class).build();
        mUserDao = mDb.getUserDao();
    }

    @After
    public void closeDb() throws IOException {
        mDb.close();
    }

    @Test
    public void writeUserAndReadInList() throws Exception {
        User user = TestUtil.createUser(3);
        user.setName("george");
        mUserDao.insert(user);
        List<User> byName = mUserDao.findUsersByName("george");
        assertThat(byName.get(0), equalTo(user));
    }
}

附加::沒有實體鍵的對象引用

從數據庫到對象間關系的映射是一個很常見的實踐,并且在服務端運行良好,在它們被訪問的時候進行高性能的惰性加載。

但是在客戶端,惰性加載并不可行,這是因為很有可能發生在主線程,在主線程查詢磁盤信息會導致很嚴重的性能問題。主線程有大概16ms來計算并繪制一個Activity的界面更新,因此甚至一個查詢僅僅耗費5ms,你的app仍然會耗光繪制畫面的時間,導致顯著的Jank問題。更糟的是,如果有個并發運行的數據庫事務,或者如果設備正忙于處理其他磁盤相關的繁重工作,查詢會花費更多的時間完成。如果你不使用惰性加載的方式,app會獲取多余其所需要的數據,從而導致內存消耗的問題。

ORM通常將該問題交給開發者決定,使得他們可以根據自己的用例選擇最佳的方式。不幸地是,開發者通常終止模型和UI之間的共享。當UI變更超時時,問題隨之發生并且很難預感和解決。

舉個例子,UI界面讀取一組Book列表,每本書擁有一個Author對象。你可能開始會設計你的查詢去使用惰性加載,從而Book實例使用getAuthor()方法查詢數據庫。過了一些時間,你意識到你需要在app的UI界面顯示作者名。你可以添加以下方法:

authorNameTextView.setText(user.getAuthor().getName());

但是這種看似沒有問題的代碼會導致Author表在主線程被查詢。

如果你急于查詢作者信息,這會變得很難去改變數據是如何加載的,如果你不再需要這個數據的話,例如當你app的UI不再需要顯示關于特定作者信息的時候。于是你的app必須繼續加載不再顯示的信息。這種方式更為糟糕,如果Author類引用了其他表,例如getBooks()方法。

由于這些原因,Room禁止實體間的對象引用。作為替換,你必須顯式地請求你所需要的數據。

簡單通俗地解釋一下Jank:第2幀畫面同步信號已經到來,由于第2幀數據還沒有準備就緒,顯示的還是第1幀。這種情況被Android開發組命名為“Jank”

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,546評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,570評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,505評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,017評論 1 313
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,786評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,219評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,287評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,438評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,971評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,796評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,995評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,540評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,230評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,918評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,697評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容