第3章 数据模型设计原则:字段映射、主键选择与索引优化
3.1 设计原则全景
蓝牙数据经由回调抵达 Room 存储层之前,必须完成一次关键的结构化转型──从无状态的字节流转变为具有业务含义的持久化模型。设计不良的模型会导致数据解析混乱、UI 刷新失控、查询性能低下。本章锚定四项核心原则:精确字段映射、稳定主键、高效索引与不可变数据类。
核心思路:数据模型是蓝牙物理世界与 App 逻辑世界之间的契约。前期花 10 分钟设计模型,能为后期节省数小时的调试时间。
3.2 蓝牙数据字段映射
蓝牙特征值通常以 ByteArray 形式交付。直接存储原始字节会彻底丧失 SQLite 的查询与过滤能力,必须将字节解析为明确的基础类型。
3.2.1 标准映射规则
| 蓝牙原始类型 | 目标 Room 字段 | 注意事项 |
|---|---|---|
| UInt8 / SInt8 | Int / Short | Kotlin 无原生 unsigned,用 toUByte() |
| UInt16 / SInt16 | Int | 大小端字节序必须与设备规格一致 |
| 32-bit Float | Float | 使用 ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).getFloat() |
| 字符串 / JSON | String | 指定字符编码(UTF-8 / ASCII) |
| 位掩码 | Int / Boolean | 用位运算符提取特定位 |
常见错误:字节序不匹配。 Nordic 芯片多数采用 Little-Endian,TI 部分芯片使用 Big-Endian。一定要查阅蓝牙规格书确认。字节序错误会导致数值严重偏离预期且难以排查。
// 错误示范:直接存储原始字节,查询时无法按数值过滤
@Entity
data class InvalidRawData(
@PrimaryKey val id: Int,
val rawBytes: ByteArray // 失去 SQL 查询能力
)
// 正确示范:将字节解析为有业务含义的字段
@Entity
data class TemperatureReading(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val deviceId: String,
val celsius: Float,
val humidity: Float,
val timestamp: Long
)
// 解析示例(在 Parser 层完成)
fun parseTemperaturePacket(bytes: ByteArray): TemperatureReading {
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val deviceId = bytes.sliceArray(0..5).decodeToString()
val celsius = buffer.getFloat(6)
val humidity = buffer.getFloat(10)
return TemperatureReading(
deviceId = deviceId,
celsius = celsius,
humidity = humidity,
timestamp = System.currentTimeMillis()
)
}
3.3 主键选择策略
主键是 Room 实体的唯一标识符,选择不当会导致数据覆盖、重复插入或性能衰退。
| 主键策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
autoGenerate Int/Long | 简单、写入快 | 无业务含义,无法天然去重 | 纯日志型数据 |
| 时间戳 | 天然有序 | 高频并发下可能冲突,依赖系统时钟 | 单设备低频采集 |
| 组合主键 | 唯一性最强,有业务含义 | 字段较长,查询需多条件 | 多设备、多会话、需精确去重 |
| UUID / 哈希值 | 全局唯一 | 存储空间大,字符串查询开销较高 | 分布式数据同步 |
推荐方案:对于绝大多数蓝牙采集场景,使用
deviceAddress + packetSequenceNumber 作为组合主键,既能保证唯一性,又能快速定位丢失的数据包。
@Entity(
primaryKeys = ["deviceAddress", "packetSequenceNumber"]
)
data class SensorPacket(
val deviceAddress: String,
val packetSequenceNumber: Int,
val rssi: Int,
val temperature: Float
)
3.4 索引优化
Room 底层由 SQLite 驱动。索引能大幅加速查询,但会降低写入速度并增加存储空间。蓝牙数据通常写入量大、查询模式固定,必须针对高频过滤字段建立索引。
3.4.1 索引设计原则
- 过滤字段优先:经常出现在
WHERE子句中的字段(如deviceId、timestamp)必须加索引。 - 低基数字段慎用:布尔值、枚举值等低基数(cardinality)字段加索引收益极低,甚至可能降低查询效率。
- 复合索引匹配查询顺序:如果查询条件是
WHERE deviceId = ? AND timestamp > ?,建立(deviceId, timestamp)复合索引能最大化利用索引覆盖。
性能对比:对 10 万条蓝牙数据按时间范围查询,无索引时全表扫描耗时约 280ms;加 B-tree 索引后耗时降至 1ms 以内。代价是写入速度下降约 15%–20%,存储增加约 5%–10%。
// 蓝牙设备数据表的索引优化示例
@Entity(
tableName = "bluetooth_readings",
indices = [
Index(value = ["deviceId"], unique = false),
Index(value = ["timestamp"], unique = false),
Index(value = ["deviceId", "timestamp"], unique = false, orders = [Index.Order.ASC, Index.Order.DESC])
]
)
data class BluetoothReading(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val deviceId: String,
val timestamp: Long,
val rssi: Int,
val payload: String
)
3.5 不可变数据类
Kotlin 标准推荐使用 data class 搭配 val 声明 Room 实体。不可变对象具备天然的线程安全性,在 Room 的运行时差异计算(DiffUtil)中行为可预测,且不易因引用泄漏导致偶发崩溃。
3.5.1 不可变实体的实现约定
- 所有属性声明为
val,不在实体内部提供varsetter。 - 如果 Room 编译要求无参构造,使用
@JvmOverloads或为所有属性提供默认值。 - 避免在实体类中声明
ByteArray或List<T>等复杂对象作为常规字段(Room 无法直接存储集合,需使用@TypeConverter)。
// 推荐的不可变数据类风格
@Entity
data class DeviceInfo(
@PrimaryKey val macAddress: String,
val name: String,
val lastSeen: Long,
val connectionCount: Int
)
// 如果需要无参构造(例如配合某些反射框架)
@Entity
data class DeviceInfo(
@PrimaryKey val macAddress: String = "",
val name: String = "",
val lastSeen: Long = 0L,
val connectionCount: Int = 0
)
关于 ByteArray 的特殊提示:不要将原始
ByteArray 直接作为实体字段持久化,也不要将其作为主键的一部分。SQLite 对 BLOB 的等式运算不可靠,且调试时难以直接查看。建议先将字节转换为 Hex 字符串或解析出具体字段后再放入实体。
3.6 常见错误汇总
- 大小端混淆:解析多字节数值时未指定
ByteOrder,导致温度/压力等数值异常放大或缩小。 - 主键冲突:使用时间戳作为唯一主键,设备高频发送时同一毫秒多条数据互相覆盖。
- 过度索引:为每一个字段都建立索引,写入性能急剧下降,存储空间膨胀。
- 可变对象陷阱:在实体中使用
var且未在@Ignore中正确处理,导致 Room 的 diff 机制失效,UI 无法自动刷新。 - 类型转换遗漏:蓝牙浮点数的字节排列与 Java/Kotlin 的 IEEE 754 表示不一致,解析前未做校验。