Room框架学习、使用

Room Persistence Library(官方介绍)
官方ORM(Object Relational Mapping)框架专题
Google官方推出的Android架构组件系列文章(六)Room持久化库

Room 的官方API 可以查看这里

Room


介绍

Room是谷歌官方的数据库ORM(对象关系映射)框架,使用起来非常方便。

Room提供了一个SQLite之上的抽象层,使得在充分利用SQLite功能的前提下顺畅的访问数据库。

对于需要处理大量结构化数据的App来说,把这些数据做本地持久化会带来很大的好处。常见的用例是缓存重要数据块。这样当设备无法连网的时候,用户仍然可以浏览内容。而用户对内容做出的任何改动都在网络恢复的时候同步到服务端。


引入

1、项目build.gradle中添加如下代码仓库

allprojects {
    repositories {
        jcenter()
        google()
    }
}

2、app Module中引入

 // Room依赖
    implementation 'android.arch.persistence.room:runtime:1.1.0'
    annotationProcessor "android.arch.persistence.room:compiler:1.1.0"

组成

Room中有三个主要的组件:

1、Database

数据库组件,底层连接的主要入口,主要作用:

  • 创建database holder
  • 使用注解定义实体类
  • 实体类定义了从数据库中获取数据的对象(DAO)

这个被注解的类是一个继承RoomDatabase的抽象类。在运行时,可以通过调用Room.databaseBuilder() 或者 Room.inMemoryDatabaseBuilder()来得到它的实例。


2、Entity

实体类组件, 一个类表示数据库的一个表。

注意

  • 1、你必须在Database类中的entities数组中引用这些entity类
  • 2、entity中的每一个field都将被持久化到数据库,除非使用了@Ignore注解。

3、DAO

DAO查询组件,DAO(Data Access Object) 数据访问对象是一个面向对象的数据库接口。
DAO是Room的主要组件,负责定义查询(添加或者删除等)数据库的方法。


4、示意图

image

示例代码

1、User.java ---- 实体类组件(Entity)

  • 建表:当一个类用@Entity注解并且被@Database注解中的entities属性所引用时(@Database(entities = {User.class}, version = 1)),Room就会在数据库中为那个entity创建一张表。
  • 表名:Room默认把类名作为数据库的表名,自定义表名需要使用@Entity注解的tableName属性,@Entity(tableName = "users")。
  • 建列:默认Room会为实体类中定义的每一个字段(field)都创建一个数据表列(column)。
  • 列名:默认使用字段名作为列名,如果想指定列名,可以使用 @ColumnInfo(name = "your_name")。
  • 持久化:要持久化一个字段(字段数据写入数据库),Room必须有获取它的渠道。你可以把字段写成public,也可以为它提供一个setter和getter。如果你使用setter和getter的方式,记住它们要基于Room的Java Bean规范。
  • 忽略:如果一个实体类中有你不想持久化的字段,那么你可以使用@Ignore来注释它们。
  • 主键:每个实体类Entity都必须至少定义一个field作为主键(primary key),主键自增需要使用@PrimaryKey的autoGenerate属性。
  • 组合主键:需要使用@Entity注解的primaryKeys属性,比如@Entity(primaryKeys = {"firstName", "lastName"}),多个字段联合形成一个主键组合,保证主键的唯一性
@Entity(tableName = "users")
public class User {
    @PrimaryKey(autoGenerate = true)
    private int uid;
 
    @ColumnInfo(name = "first_name")
    private String firstName;
 
    @ColumnInfo(name = "last_name")
    private String lastName;
 
    @Ignore
    Bitmap picture;
}

2、UserDao.java ---- DAO查询组件

@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);
}

3、AppDatabase.java ---- 数据库组件

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

4、获取database实例

获取database实例的时候应该保持单例模式,因为数据库的实例对内存的开销是比较大的,而且程序内一般也不需要多个database的实例。

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

相关概念

1、索引(Indices )

为了提高查询的效率,可能给特定的字段建立索引。
要为一个entity添加索引,在@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;
}

2、唯一性(uniqueness)

指定某个字段或者几个字段的值必须是唯一的,比如用户名或手机号之类的账户唯一标识字段。
可以通过把@Index注解的unique属性设置为true来实现唯一性

@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;
}

3、外键约束(Foreign Key)

一个表中的外键(Foreign Key) 指向另一个表中的主键(Primary Key),在更新和删除时起到约束的作用。比如,如果你想在删除主键表中的一条数据时可以同时删除外键约束表中相对应的数据,你可以在@ForeignKey注解中加上onDelete=CASCADE。

下面的代码就指定了Book表中的user_id字段为User表的外键,与User表的id字段一一对应,使用Entity的foreignKeys属性指定,写法如下:

@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;
}
  • 注意
    替换冲突『 @Insert(onConfilict=REPLACE) 』不适用于外键约束,onConfilict不是单独的sql命令,可以理解为一组REMOVE和REPLACE的操作,请参见SQLite文档的ON_CONFLICT语句,onConfilict有如下五种冲突解决算法。

4、对象嵌套

就是一个实体类中嵌入另一个实体类,可以多层嵌套。比如你在User中嵌套Address,如果你使用@Embedded注解Address的话,那么User表中就拥有了Address的所有字段了。
为了防止多个实体嵌套造成字段重复,你可以通过设置prefix属性来保持每列的唯一性。Room会将提供的值添加到嵌入对象的每个列名的开头。

@Entity
class User {
    @PrimaryKey
    public int id;
 
    public String firstName;
 
    @Embedded
    public Address address;
}
class Address {
    public String street;
    public String state;
    public String city;
 
    @ColumnInfo(name = "post_code")
    public int postCode;
}

数据查询(DAO)

DAO抽象出了一种操作数据库的简便方法。下面介绍一下常见的查询方式。

1、新增、插入(Insert)

创建一个DAO方法并使用@Insert注解,Room就会在工作线程中将所有参数插入到数据库。
如果@Insert方法仅仅接收一个参数,那它可以返回一个long,表示插入项的rowId。如果参数是一个数组或集合,它会返回long []或List<Long>。

@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);
}

2、修改、更新(Update)

根据每个entity的主键作为更新的依据,此方法可以返回一个int值,指示数据库中更新的行数。

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

3、删除(Delete)

使用主键找到要删除的entity,此方法可以返回一个int值,指示数据库中被删除的行数。

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

4、查询(Query)

@Query(查询)是DAO类中使用的主要注解。可以让你执行数据库读/写操作。每个@Query方法都会在编译时验证,如果查询语句有问题,会发生编译错误而不是运行时故障。

Room还会检查查询的返回值,如果返回的对象字段名和查询结果的相应字段名不匹配,Room将以下面两种方式提醒你:

  • 如果是某些字段名不匹配会给出警告。
  • 如果没有匹配的字段名则会给出错误提示。

4-1、简单查询

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

4-2、条件查询 (传参)

注意查询方法的入参在查询语句中的写法“:minAge”,另外,在使用in操作符进行查询时,别忘记加上“()”哦!

@Dao
public interface MyDao {
    //查询User表中年龄大于入参的数据集合
    @Query("SELECT * FROM user WHERE age > :minAge")
    public User[] loadAllUsersOlderThan(int minAge);

    //查询User表中年龄介于入参之间的数据集合
    @Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
    public User[] loadAllUsersBetweenAges(int minAge, int maxAge);
 
   //查询User表中名字中入参关键字的数据集合
    @Query("SELECT * FROM user WHERE first_name LIKE :search "
           + "OR last_name LIKE :search")
    public List<User> findUserWithName(String search);

    //查询User表中id符合入参集合中的数据集合
    @Query("SELECT * FROM user WHERE uid IN (:userIds)")
    List<User> loadAllByIds(int[] userIds);

}

4-3、部分查询

有时候我们仅仅需要的是数据表中的部分数据,这个时候我们可以指定DAO的查询方法只返回我们需要的字段,这样不仅节约资源而且查询更快。

方法是在查询语句中指定需要获取的字段,然后指定对应的实体类来获取查询的返回数据。

例如,User的实际字段有如下四个,但是我们只需要其中的first_name和last_name,那么我们可以重新定义一个实体类UserName,然后在查询方法中指定只查询first_name和last_name字段,并使用UserName实体来获取查询语句的返回数据。

注:这些“裁剪”的实体类也是可以使用@Embedded注解的。

@Entity(tableName = "users")
public class User {
    @PrimaryKey(autoGenerate = true)
    private int uid;

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

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

    @Ignore
    Bitmap picture;
}
public class UserName{
    @ColumnInfo(name="first_name")
    public String firstName;
 
    @ColumnInfo(name="last_name")
    public String lastName;
}
@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user")
    public List<NameTuple> loadFullName();
}

4-4、原始查询 RawQuery

我们可以利用 RawQuery 进行原始SQL语句查询,示例代码:

 @Dao
 interface RawDao {
     @RawQuery
     User getUserViaQuery(SupportSQLiteQuery query);
 }
 SimpleSQLiteQuery query = new SimpleSQLiteQuery("SELECT * FROM User WHERE id = ? LIMIT 1", new Object[]{userId});
 User user2 = rawDao.getUserViaQuery(query);

1、Room将根据函数的返回类型("User ")生成代码,如果未能通过正确的查询,将导致运行时异常。
2、RawQuery方法只能用于读取查询。对于写入查询,请使用RoomDatabase.getOpenHelper().getWritableDatabase()


5、可观察的查询(Observable queries)

5-1、Query

当执行查询的时候,你通常希望app的UI能自动在数据更新的时候更新。为此,在query方法中使用 LiveData 类型的返回值。当数据库变化的时候,Room会生成所有的必要代码来更新LiveData。

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
     public LiveData<List<User>> loadAllUsersBetweenAges(int minAge, int maxAge);
}

使用步骤:

  • 创建一个LiveData实例来保存某种类型的数据。 这通常在您的ViewModel类中完成。
public class UserViewModel extends ViewModel {

private LiveData<List<User>> mUsers;

public LiveData<List<User>> getUsers() {
    if (mUsers== null) {
        mUsers= MyDao.loadAllUsersBetweenAges(10,30);
    }
    return mUsers;
}

...
}
  • 创建一个Observer对象,该对象定义onChanged()方法,该方法在LiveData对象的数据发生变化时回调, 通常是在UI控制器中创建Observer对象,比如Activity或Fragment。
public class UserActivity extends AppCompatActivity {

    private UserViewModel mModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mModel = ViewModelProviders.of(this).get(UserViewModel.class);
 
        final Observer<List<User>> userObserver = new Observer<List<User>>() {
            @Override
            public void onChanged(@Nullable List<User> users) {
                //update the ui
            }
        };

        mModel.getCurrentName().observe(this, userObserver);
    }
}
  • 使用observe()方法将Observer对象附加到LiveData对象。 observe()方法使用LifecycleOwner对象。 这将Observer对象订阅到LiveData对象,以便通知其更改。 您通常将Observer对象附加到UI控制器中,比如Activity或Fragment。

5-2、RawQuery

RawQuery方法可以返回可观察的类型,但您需要使用注释中的observedEntities()字段指定在查询中访问哪些表。
代码示例:

 @Dao
 interface RawDao {
     @RawQuery(observedEntities = User.class)
     LiveData<List<User>> getUsers(SupportSQLiteQuery query);
 }
 LiveData<List<User>> liveUsers = rawDao.getUsers(
     new SimpleSQLiteQuery("SELECT * FROM User ORDER BY name DESC"));

Rxjava

Room还可以让你定义的查询返回RxJava2的Publisher和Flowable对象。要使用这个功能,在Gradle dependencies中添加android.arch.persistence.room:rxjava2。然后你就可以返回RxJava2中定义的对象类型了,如下面的代码所示:

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

Cursor

如果你的app需要直接获得返回的行,你可以在查询中返回Cursor对象。但是非常不鼓励使用Cursor ,因为它无法保证行是否存在,或者行包含什么值。

public interface MyDao {
    @Query("SELECT * FROM user WHERE age > :minAge LIMIT 5")
    public Cursor loadRawUsersOlderThan(int minAge);
}

多表查询

一些查询可能要求查询多张表来计算结果。Room允许你书写任何查询,因此表连接(join)也是可以的。
而且如果响应是一个可观察的数据类型,比如Flowable或者LiveData,Room将观察查询中涉及到的所有表,检测出所有的无效表。

下面的代码演示了如何执行一个表连接查询来查出借阅图书的user与被借出图书之间的信息。
逻辑:入参userName ---- (user.name LIKE :userName) ----> user ---- (user.id = loab.userid) ----> loab ---- (loan.book_id = book.id) ----> book
语句:book <---- (loan.book_id = book.id) ---- loab <---- (user.id = loab.userid) ---- user <---- (user.name LIKE :userName) ---- userName

@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);
}
  • INNER JOIN:内连接,显示左表和右表符合连接条件的记录
  • JOIN: 如果表中有至少一个匹配,则返回行
  • LEFT JOIN: 即使右表中没有匹配,也从左表返回所有的行
  • RIGHT JOIN: 即使左表中没有匹配,也从右表返回所有的行
  • FULL JOIN: 只要其中一个表中存在匹配,就返回行

类型转换

1、Room对Java的基本数据类型以及其包装类型都提供了支持
2、但是有时候你可能使用了一个自定义的数据类型,并且你想将此类型的数据存储到数据库表中的字段里。

为了实现自定义数据类型的转换,你需要一个类型转换器TypeConverter,它将负责处理自定义数据类和Room可以保存的已知类型之间的转换。

比如,如果我们想要保存Date实例,那么第一步

  • 写一个TypeConverter类,实现Date类型和Long类型的数据转换
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类型的数据相互转换。

  • 使用TypeConverter类,实现持久化Date类型的数据
    这里就需要把这个转换类Converters添加到我们的数据库组件AppDatabase中了
@Database(entities = {User.java}, version = 1)
@TypeConverters({Converter.class})
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

数据库升级

  • 基本使用
    实现就是在对应的Database中通过Migration进行升级,使用的方式是:
    1、利用Migration 构建数据库升级语句
    //数据库升级
    private static final Migration migration_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase database) {
            
        }
    };

2、在数据库的构造方法中通过addMigrations()方法传入对应的Migration

 public synchronized static AppDatabase getInstance(byte[] passphrase) {
        if (INSTANCE == null) {
            synchronized (AppDatabase.class) {
                if (INSTANCE == null) {
                    INSTANCE = Room
                            .databaseBuilder(mContext.getApplicationContext(), AppDatabase.class, sDbPath)
                            .openHelperFactory(new HelperFactory(passphrase))
                            .allowMainThreadQueries()
                            .addMigrations(migration_1_2)
                            .build();
                }

            }
        }
        return INSTANCE;
    }

3、数据库版本(version )+1

@Database(entities = {MarkBook.class}, version = 2, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
....

总结:就是把数据库的变化通过SQL语句传到数据库的构造方法中。


  • 常用的几种数据库升级

新增表

    //数据库升级
    private static final Migration migration_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase database) {
            database.execSQL("CREATE TABLE IF NOT EXISTS SYMBOL_SETTING (_id INTEGER primary key NOT NULL, dbName TEXT, symbolJson TEXT)");
        }
    };

增加字段

    //数据库升级
    private static final Migration migration_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase database) {
            database.execSQL("ALTER TABLE SURVEY_RECORD ADD STATUS INTEGER  DEFAULT 0");
        }
    };

数据库多次升级

    //数据库升级,SURVEY_RECORD新增SJBZ、SZFHZZZB字段
    private static final Migration migration_2_3 = new Migration(2, 3) {
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase database) {
            database.execSQL("ALTER TABLE SURVEY_RECORD ADD SJBZ TEXT");
            database.execSQL("ALTER TABLE SURVEY_RECORD ADD SZFHZZZB TEXT");
        }
    };

    //数据库升级
    private static final Migration migration_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase database) {
            database.execSQL("ALTER TABLE SURVEY_RECORD ADD STATUS INTEGER  DEFAULT 0");
        }
    };

    public synchronized static TaskDatabase getInstance(byte[] passphrase) {
        if (INSTANCE == null) {
            synchronized (TaskDatabase.class) {
                if (INSTANCE == null) {
                    INSTANCE = Room
                            .databaseBuilder(mContext.getApplicationContext(), TaskDatabase.class, sDbPath)
                            .openHelperFactory(new HelperFactory(passphrase))
                            .allowMainThreadQueries()
                            .addMigrations(migration_1_2,migration_2_3)
                            .build();
                }

            }
        }
        return (INSTANCE);
    }

备注:数据库升级的时候最好文字说明一下这次升级了什么,不要因为有SQL语句就不写注解了,这样不好。

以普通的方式打开SQLite数据库

有时候我们需要以普通的方式打开SQLite数据库,以方便我们使用原生的更新、删除、查询语句

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

推荐阅读更多精彩内容

  • 通常来说,重疾险、寿险、意外险是我们保障的主力军。而医疗险则是性价比很高的补充。那么我们如何为不同家庭成员,做好具...
    water2017阅读 458评论 0 0
  • iOS上传图片后台旋转展示问题 在一次上传图片到服务器后,去后台页面查看,发现iOS显示的图片总是旋转90℃的,而...
    reloadRen阅读 4,420评论 0 49
  • 你云虹般衣衫蹁跹蛇舞,以柔媚, 苍天大地臣服你伟迹,恭迎登堂 像女人向来朝拜深奥的圣洁分娩, 踏上造物边岸,佯装纵...
    SupA阅读 256评论 0 1
  • 开篇第三章 一、网页上有很多信息的列表,如新闻列表、图片列...
    多语阅读 213评论 0 0