Jetpack--Room

  • jetpack Room 数据库

  • 资料来源:

    https://developer.android.com/training/data-storage/room

  • 更新

    1
    2
    3
    4
    5
    6
    20.03.01 初始化
    20.03.03 补完
    20.03.12 补充测试
    20.04.28 补充内容
    20.06.24 补充数据库迁移
    21.02.02 补充存入 object

导语

  • 初始仅仅是速描官方文档,随时添加内容.

概览

  • Room 是一个基于 SQLite 的 ORM 数据库,架构组件中可以直接返回 LiveData.

  • 包含有3个部分

    • Entity: 数据库中的表.@Entity 修饰.
    • DAO: 包含访问数据库的方法.是从数据库面向关系到面向对象的转换. @DAO 修饰.
    • RoomDatabase: 数据库的持有者, @Database 修饰.
      • 必须继承自 RoomDatabase.
      • 在注释中添加与数据库关联的实体列表.
      • 包含具有 0 个参数且返回使用 @Dao 注释的类的抽象方法.
  • Room 架构图

  • 导入 Room,在应用的 build.gradle 声明.(特别注意使用kotlin开发需要用 kapt 而不是 annotationProcessor)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
      dependencies {
    def room_version = "2.2.3"
    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version" // For java
    kapt "androidx.room:room-compiler:$room_version" // For kotlin
    // optional - Kotlin Extensions and Coroutines support for Room
    implementation "androidx.room:room-ktx:$room_version"
    // optional - RxJava support for Room
    implementation "androidx.room:room-rxjava2:$room_version"
    // optional - Guava support for Room, including Optional and ListenableFuture
    implementation "androidx.room:room-guava:$room_version"
    // Test helpers
    testImplementation "androidx.room:room-testing:$room_version"
    }

  • 总而言之 Room 基本使用流程

    • 定义 @Entity 修饰的类.(数据库的表)
    • 定义 @Dao 修饰的数据库访问方法.
    • 实现一个继承自 RoomDatabase 的数据库持有者.
    • 还有类型转换等其他操作.
  • Room 最吸引我的是其与架构组件的配合,直接返回 LiveData 非常方便.

  • Room 会在编译时检查定义的映射,不通过直接报错.

  • 简单示例

    • Entity: 定义一个 User 的表,uid 主键 name 字段.kt 中常常与数据类配合.

      1
      2
      3
      4
      5
      @Entity
      data class User(
      @PrimaryKey val uid: Int,
      @ColumnInfo(name = "name") val Name: String?
      )
    • Dao: 一般是一个接口或抽象类.具体方法编译时 Room 会自动生成.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Dao
      interface UserDao {
      @Query("SELECT * FROM user")
      fun getAll(): List<User>

      @Query("SELECT * FROM user WHERE uid IN (:userIds)")
      fun loadAllByIds(userIds: IntArray): List<User>

      @Query("SELECT * FROM user WHERE first_name LIKE :name AND)
      fun findByName(name: String): User

      @Insert
      fun insertAll(vararg users: User)

      @Delete
      fun delete(user: User)
      }
    • Database: 数据库的持有者,同时持有 Dao 实例.

      1
      2
      3
      4
      @Database(entities = arrayOf(User::class), version = 1)
      abstract class AppDatabase : RoomDatabase() {
      abstract fun userDao(): UserDao
      }
    • 创建数据库实例.一个数据库实例的代价相当高,单进程一般采用单例模式.

      1
      2
      3
      4
      val db = Room.databaseBuilder(
      applicationContext,
      AppDatabase::class.java, "database-name"
      ).build()

定义实体

  • 定义一个实体,@Entity 实际上是定义数据库中的表.

    • @Entity 修饰(kotlin 中多用数据类)

    • @PrimaryKey 至少 1 个字段定义为主键.

    • 例:

      1
      2
      3
      4
      5
      @Entity
      data class User(
      @PrimaryKey var id: Int,
      var lastName: String?
      )
  • 主键 @PrimaryKey

    • 至少 1 个字段定义为主键.

    • 可以设置 autoGenerate 属性使 id 自增.

    • 复合主键需要在 @Entity 使用 primaryKeys 声明.

    • 例:

      1
      2
      3
      4
      5
      6
      //复合主键
      @Entity(primaryKeys = arrayOf("firstName", "lastName"))
      data class User(
      val firstName: String?,
      val lastName: String?
      )
  • 命名

    • 默认情况下表名是类名.可以修改 @Entity 的 tableName 属性改变默认的表名.

    • 字段名默认也是属性的名称,修改字段名需要添加 @ColumnInfo 到具体的属性.

    • 例:

      1
      2
      3
      4
      5
      @Entity(tableName = "users")
      data class User (
      @PrimaryKey val id: Int,
      @ColumnInfo(name = "first_name") val firstName: String?
      )
  • 忽略字段 @Ignore.

  • 表搜索(暂时忽略)

实体关系

  • 与其他的 ORM 数据库不同,Room 不允许实体对象之间互相引用.官方解释

  • 一对多

    • 利用外键实现.

    • 例: foreignKeys 声明外键,关联到了 User 类.还可以在 @ForeignKey 注释中添加 onDelete = CASCADE,在 User 的对应实例删除后告知 SQLite 删除该用户的所有图书.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      @Entity(foreignKeys = arrayOf(ForeignKey(
      entity = User::class,
      parentColumns = arrayOf("id"),
      childColumns = arrayOf("user_id"))
      )
      )
      data class Book(
      @PrimaryKey val bookId: Int,
      val title: String?,
      @ColumnInfo(name = "user_id") val userId: Int
      )
  • 嵌套对象

    • 效果仅仅是在声明时分离,实际生成时依旧是在一个表中.

    • 例: 查询时 street state city 都在 User 表中.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      data class Address(
      val street: String?,
      val state: String?,
      val city: String?,
      @ColumnInfo(name = "post_code") val postCode: Int
      )

      @Entity
      data class User(
      @PrimaryKey val id: Int,
      val firstName: String?,
      @Embedded val address: Address?
      )
  • 多对多

    • 因为 Room 不允许实体对象之间互相引用,多对多需要使用中间类配合外键实现.

    • Playlist 可以存放无限多的 Song 信息,同一首 Song 可以存在在不同的 Playlist 中.

    • 实体类声明

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      @Entity
      data class Playlist(
      @PrimaryKey var id: Int,
      val name: String?,
      val description: String?
      )

      @Entity
      data class Song(
      @PrimaryKey var id: Int,
      val songName: String?,
      val artistName: String?
      )
    • 中间类:包含对 Song 和 Playlist 的外键引用.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @Entity(tableName = "playlist_song_join",
      primaryKeys = arrayOf("playlistId","songId"),
      foreignKeys = arrayOf(
      ForeignKey(entity = Playlist::class,
      parentColumns = arrayOf("id"),
      childColumns = arrayOf("playlistId")),
      ForeignKey(entity = Song::class,
      parentColumns = arrayOf("id"),
      childColumns = arrayOf("songId"))
      )
      )
      data class PlaylistSongJoin(
      val playlistId: Int,
      val songId: Int
      )
    • DAO:根据歌曲搜索播放列表,根据播放列表搜素歌曲.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      @Dao
      interface PlaylistSongJoinDao {
      @Insert
      fun insert(playlistSongJoin: PlaylistSongJoin)

      @Query("""
      SELECT * FROM playlist
      INNER JOIN playlist_song_join
      ON playlist.id=playlist_song_join.playlistId
      WHERE playlist_song_join.songId=:songId
      """)
      fun getPlaylistsForSong(songId: Int): Array<Playlist>

      @Query("""
      SELECT * FROM song
      INNER JOIN playlist_song_join
      ON song.id=playlist_song_join.songId
      WHERE playlist_song_join.playlistId=:playlistId
      """)
      fun getSongsForPlaylist(playlistId: Int): Array<Song>
      }

SQLite 视图

  • 暂且不表.

存入任意类型

实际上 Room 直接支持存储的类型相当有限.经常使用的 list set 等类型都要曲线救国.

Room 支持通过 @TypeConverter 注解,将不支持类型转换为基本类型.

以存入 Set<String> 类型为例.通过 Gson 将任意对象转换为 json -> string 存储,读取时再反序列化.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Converters {  
@TypeConverter
fun setToString(set: Set<String>): String {
return Gson().toJson(set)
}

@TypeConverter
fun stringToSet(str: String): Set<String> {
val listType = object : TypeToken<Set<String>>() {}.type
return Gson().fromJson(str, listType)
}

}

转换类需要在 AppDatabase 声明

1
2
3
4
@TypeConverters(Converters::class)  
abstract class AppDatabase : RoomDatabase(){
...
}

ps: 参考 Android避坑指南,Gson与Kotlin碰撞出一个不安全的操作 有一个坑.

Gson 生产新对象是通过反射调用 Type 的默认构造函数,如果此时恰好 Type 是 kotlin 的 dataclass,即使你在 dataclass 中声明了参数是非空,但是没有对其初始化,通过 Gson 生成的新对象的参数就是 null 的.

当然这里调用 Gson 的都是 Room 实际数据都是有效的,可能不太会遇到这个问题.

DAO

  • DAO 定义了 SQL 语言到对象的映射.方便了单元测试.

  • 一般是接口类型(也可也是抽象类,需要有 RoomDatabase 为唯一参数的构造函数).编译时 Room 会生成对应的方法.

  • ps: 除非调用了 allowMainThreadQueries() ,否则 Room 不允许在主线程上运行.

  • @Insert

    • 插入数据库,

    • 例:

      1
      2
      3
      4
      5
      @Dao
      interface MyDao {
      @Insert(onConflict = OnConflictStrategy.REPLACE)
      fun insertUsers(vararg users: User)
      }
  • @Update

    • 更新表.

    • 例:

      1
      2
      3
      4
      5
      6
      @Dao
      interface MyDao {
      @Update
      fun updateUsers(vararg users: User)
      }

  • @Delete

    • 删除

    • 例子:

      1
      2
      3
      4
      5
      @Dao
      interface MyDao {
      @Delete
      fun deleteUsers(vararg users: User)
      }
  • @Query

    • 查询

    • 简单查询:返回所有表.

      1
      2
      3
      4
      5
      @Dao
      interface MyDao {
      @Query("SELECT * FROM user")
      fun loadAllUsers(): Array<User>
      }
    • 带参数: 在 SQL 中以 :变量 引用.传入多个多次引用也没问题.

      1
      2
      3
      4
      5
          @Dao
      interface MyDao {
      @Query("SELECT * FROM user WHERE age > :minAge")
      fun loadAllUsersOlderThan(minAge: Int): Array<User>
      }
    • 返回子集: 有时并不需要查询全表,只需要查询几个字段返回对象即可.需要先定义一个 POJO 对象(@ColumnInfo 修饰字段),再在 DAO 中声明.(@Embedded 亦可)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      data class NameTuple(
      @ColumnInfo(name = "first_name") val firstName: String?,
      @ColumnInfo(name = "last_name") val lastName: String?
      )

      @Dao
      interface MyDao {
      @Query("SELECT first_name, last_name FROM user")
      fun loadFullName(): List<NameTuple>
      }
    • 传入参数集合: 有时并不知道传入参数的具体数量,这时可以传入一个集合.

      1
      2
      3
      4
      5
      @Dao
      interface MyDao {
      @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
      fun loadUsersFromRegions(regions: List<String>): List<NameTuple>
      }
    • 与 LiveData 和 Rxjava 配合.(暂时省略)

  • 与 kotlin 协程

    • 可以将 suspend Kotlin 关键字添加到 DAO 方法,这样 DAO 的方法会在协程上执行,并且确保不会在主线程上运行.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Dao
    interface MyDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUsers(vararg users: User)

    @Update
    suspend fun updateUsers(vararg users: User)

    @Delete
    suspend fun deleteUsers(vararg users: User)

    @Query("SELECT * FROM user")
    suspend fun loadAllUsers(): Array<User>
    }
    • ps: 注意一点,协程与 LiveDate 都是异步回调,并不兼容.

Kotlin协程 Flow 与 LiveData

在协程和 Flow 使用 LiveData, 这是 Google 2019 年技术峰会的一个演讲主题,解决了我的一个问题.

问题:存在几乎相同的 3 个 Fragment,他们对应的 ViewModel / Room 等等都几乎相同,需要合并样板代码.

  • Room 直接返回 livedata 的类型是唯一的,且不支持泛型或者子类覆盖父类这样的操作,3 种类型无法在 Room 层面合并.
  • UI 层使用了 Databinding 必须传入 livedata 类型,无法使用其他类型,数据到 ViewModel 必须是 Livedata.

解决:Flow 流 解决了这个问题.

  • Room 包装返回的 Flow 流(异步流),可以简单理解为一直向下游扔数据的生产者,而且最棒的是 Room 返回的 Flow 具有持续的通知能力,从数据更新角度看和直接使用 livedata 完全相同.
  • Flow 流支持子类覆盖父类的操作.即 Room 返回一个子类的流,ViewModel 里是使用的父类的流.这样就能抽象父类了.
  • Flow 流到 livedata 非常方便,只需要调用 .asLiveData() 即可.google 做了很多工作.

简单的实例: 父类是 Item ,子类是 Alarm / Service /Wakelock.

  • Room 可以直接返回 Flow 流,和使用 livedata 一样简单.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//DAO 返回子类的流
interface AlarmDao {
@Query("select * from alarm")
fun loadAlarms(): Flow<List<Alarm>>
...
}
//FRepository 接口声明的是父类的流
interface FRepository {
fun getLists(): Flow<List<Item>>
...
}
//FRepository 实现中可以直接覆盖
class IAlarmR(private val alarmDao: AlarmDao) :FRepository {
override fun getLists(): Flow<List<Item>> {
return alarmDao.loadAlarms()
}
...
}
// viewmodel 中 .asLiveData() 就可以直接使用.
class FViewModel(private var FRepository: FRepository) : ViewModel() {
var list: LiveData<List<Item>> = FRepository.getLists().asLiveData()
...
}

数据库迁移

  • 版本升级过程中,难免存在数据库的更改,Room 对数据库的迁移也必须提供支持.

  • 具体过程并不复杂,在声明数据库持有者时同时要声明数据库的版本号.

    • 每个版本 Room 数据库的升级都需要实现一个 Migration 类.
    • 每个指定一个 Migration 类都有 startVersion 和 endVersion.声明自己对应 Room 哪两个版本之间的升级.
    • 运行时 Room 会依次调用 Migration 类的 migrate() 完成升级.
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, `name` TEXT, " +
    "PRIMARY KEY(`id`))")
    }
    }

    val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
    }
    }

    Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
    .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build()

数据库测试

  • room 可以在 Android 设备或者主机上进行测试,不过还是推荐采用 Android 设备测试,以确保数据库正常工作.

  • 一些基本的注解等不再重复(后面针对 Android 测试有专门一篇总结).

  • 其过程与我们使用数据库几乎无二.只是生成数据库要选择 Room.inMemoryDatabaseBuilder.

  • 例(删减):

    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
    class AppDatabaseTest {

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()
    private lateinit var appInfoDao: AppInfoDao
    private lateinit var wakeLockDao: WakeLockDao
    private lateinit var db: AppDatabase


    @Before
    fun setUp() {
    val context = ApplicationProvider.getApplicationContext<Context>()
    db = Room.inMemoryDatabaseBuilder(
    context, AppDatabase::class.java
    ).build()
    appInfoDao = db.appInfoDao()
    wakeLockDao = db.wakeLockDao()
    }

    @After
    fun tearDown() {
    db.close()
    }

    @Test
    @Throws(Exception::class)
    fun loadWithoutInserted() {
    val appinfos = runBlocking {
    appInfoDao.loadAllAppInfos()
    }
    val wakeLocks = runBlocking {
    wakeLockDao.loadAllWakeLocks(TestData.packageName)
    }

    assertTrue(appinfos.isEmpty())
    assertTrue(wakeLocks.isEmpty())
    }
    }

测试数据库迁移

  • 测试迁移需要用到 Maven.(需要在依赖中加入 Maven)

  • Maven 是通过 Room 导出的架构文件构建不同版本的数据库进行测试.

  • 设置导出 build.gradle,重写编译后会在 ‘/app/schemas/‘ 下生成 json 文件,是 room 到 sqlite 的具体映射.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    android {
    ...
    defaultConfig {
    ...
    javaCompileOptions {
    annotationProcessorOptions {
    arguments = ["room.schemaLocation":
    "$projectDir/schemas".toString()]
    }
    }
    }
    }
  • 设置资源文件到 Maven

    1
    2
    3
    4
    5
    6
    android {
    ...
    sourceSets {
    androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
    }
  • 测试单个迁移(有坑),Maven 会测试数据库本身是否能正常升级,但是还需要我们自行验证升级时,数据是否正常.

    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
    @RunWith(AndroidJUnit4::class)
    class MigrationTest {
    private val TEST_DB = "migration-test"

    @Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
    InstrumentationRegistry.getInstrumentation(),
    MigrationDb::class.java.canonicalName,
    FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    @Throws(IOException::class)
    fun migrate1To2() {
    var db = helper.createDatabase(TEST_DB, 1).apply {
    // 插入一些数据,这里不能用 dao 定义的方法.
    execSQL(...)

    // Prepare for the next version.
    close()
    }

    // 开始迁移到 version 2
    db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)

    // 接下来需要检查 version 1 时,插入的数据是否正常.
    ...
    }
    }

: 按照上面的运行测试会提示 AndroidJUnit4 找不到..解决办法: @Rule 改为 @get:Rule

  • 测试全部

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    private val ALL_MIGRATIONS = arrayOf(
    MIGRATION_1_2, MIGRATION_2_3
    )

    @Test
    @Throws(IOException::class)
    fun migrateAll() {
    // 初始版本 1
    helper.createDatabase(TEST_DB, 1).apply {
    close()
    }
    // Open latest version of the database. Room will validate the schema
    // once all migrations execute.
    Room.databaseBuilder<AppDatabase>(
    InstrumentationRegistry.getInstrumentation().targetContext,
    AppDatabase::class.java, TEST_DB
    ).addMigrations(*ALL_MIGRATIONS).build().apply {
    openHelper.writableDatabase
    close()
    }
    }
  • : 官网的示例是 kotlin,但是写的是 AppDatabase.class..kotlin 中应该是 AppDatabase::class.java..官方也不细心系列..

几个 Room 升级实例

增加了新表

1
2
3
4
5
6
7
8
@Entity(tableName = "alarm_st")
data class AlarmSt(
@PrimaryKey
@ColumnInfo(name = "alarmName_st")
override var name: String = "",
override var flag: Boolean = true,
override var allowTimeinterval: Long = 0,
) : ItemSt()

对应的升级也很简单,执行建表 SQL 即可.

1
2
3
4
5
6
7
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"CREATE TABLE IF NOT EXISTS `alarm_st` (`alarmName_st` TEXT NOT NULL, `flag` INTEGER NOT NU`allowTimeinterval` INTEGER NOT NULL, PRIMARY KEY(`alarmName_st`))"
)
}
}

迁移已有表

这里可以有多种情况了

  • 更换表名
  • 表需要添加新的字段.
  • 根据已有单张表,创新新的表.
  • 根据已有多张表,创建新的表.
  • 还有一种比较特殊是,Room 2.2.0 版本以后支持字段的默认值,但 2.2 之前不支持.跨 2.2 版本升级,需要重建表以解决默认值的问题.

以上的过程可以以下面的示例覆盖.

  • A 表要升级,需要新增字段,但是这个字段的值在 B 表中,表A 和 表B 的主键是一一对应的.(外键约束)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//增加了 packageName 字段
@Entity(tableName = "alarm_st")
data class AlarmSt(
@PrimaryKey
@ColumnInfo(name = "alarmName_st")
override var name: String = "",
override var flag: Boolean = true,
override var allowTimeinterval: Long = 0,
override var packageName: String = ""
) : ItemSt()

//packageName 的来源
@Entity(tableName = "alarm")
data class Alarm(
@PrimaryKey
@ColumnInfo(name = "alarmName")
override var name: String = "",
@ColumnInfo(name = "alarm_packageName")
override var packageName: String = "",
...
) : BaseItem, Item() {...}

升级过程,Room 推荐是先创建一张临时表,再删除旧表,最后更改表名.

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
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `alarm_st_tmp` (
`alarmName_st` TEXT NOT NULL,
`flag` INTEGER NOT NULL,
`allowTimeinterval` INTEGER NOT NULL,
`packageName` TEXT NOT NULL,
PRIMARY KEY(`alarmName_st`)
)
""".trimMargin()
)
database.execSQL(
"""
INSERT INTO alarm_st_tmp (alarmName_st, flag, allowTimeinterval, packageName)
SELECT st.alarmName_st, st.flag, st.allowTimeinterval, s.alarm_packageName packageName
FROM alarm_st st
INNER JOIN alarm s
ON st.alarmName_st = s.alarmName
""".trimIndent()
)

database.execSQL("DROP TABLE alarm_st")
database.execSQL("ALTER TABLE alarm_st_tmp RENAME TO alarm_st")
}
}

尾巴

  • 最近几篇是真的水,都是官方文档的总结,接下来会进入项目,应该会好一点.
  • GA 显示改版后 Blog 阅读量直接降到了三位数..旧链接失效影响还是蛮大的.
  • 慢慢来,一点点走结实了.