第3章 数据模型设计原则:字段映射、主键选择与索引优化

3.1 设计原则全景

蓝牙数据经由回调抵达 Room 存储层之前,必须完成一次关键的结构化转型──从无状态的字节流转变为具有业务含义的持久化模型。设计不良的模型会导致数据解析混乱、UI 刷新失控、查询性能低下。本章锚定四项核心原则:精确字段映射、稳定主键、高效索引与不可变数据类。

核心思路:数据模型是蓝牙物理世界与 App 逻辑世界之间的契约。前期花 10 分钟设计模型,能为后期节省数小时的调试时间。
蓝牙数据存储模型设计原则架构 蓝牙数据包 (ByteArray) 数据解析层 (Parser) Room Entity (Data Class) 主键策略 (Primary Key) 组合PK / 业务PK 索引优化 (Index) 覆盖索引 / 唯一索引 不可变数据类确保线程安全、可预测行为及高效的运行时差异计算

3.2 蓝牙数据字段映射

蓝牙特征值通常以 ByteArray 形式交付。直接存储原始字节会彻底丧失 SQLite 的查询与过滤能力,必须将字节解析为明确的基础类型。

3.2.1 标准映射规则

蓝牙原始类型目标 Room 字段注意事项
UInt8 / SInt8Int / ShortKotlin 无原生 unsigned,用 toUByte()
UInt16 / SInt16Int大小端字节序必须与设备规格一致
32-bit FloatFloat使用 ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).getFloat()
字符串 / JSONString指定字符编码(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,不在实体内部提供 var setter。
  • 如果 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 表示不一致,解析前未做校验。