第4章 设计蓝牙设备数据实体:BluetoothDeviceEntity
在 Room 数据库中管理蓝牙多设备数据,首先需要定义清晰的数据实体(Entity)。实体对应数据库中的表,每个字段映射为表的一列。本章以 BluetoothDeviceEntity 为核心,详细设计其字段结构、数据类型、索引策略,并给出完整的 Kotlin 代码实现。
4.1 字段设计原则
蓝牙设备的关键属性应全部纳入实体,同时满足 Room 对主键、索引、空值、默认值的约束。常见必须字段包括:
- 地址(address):蓝牙 MAC 地址,全球唯一,作为主键。
- 名称(name):设备广播名或缓存名,可为空。
- 信号强度(rssi):扫描时的 RSSI 值,整型,允许负值。
- 最后连接时间(lastConnectedTime):时间戳,长整型,用于排序和淘汰逻辑。
- 设备类型(type):枚举或整数,区分经典蓝牙 / BLE。
- 额外元数据:可通过 JSON 字符串存储厂商信息、服务 UUID 等。
4.2 字段定义与数据类型对照表
| 字段 | Kotlin 类型 | SQLite 类型 | 约束 | 说明 |
|---|---|---|---|---|
address |
String |
TEXT |
PRIMARY KEY, NOT NULL | 蓝牙 MAC 地址,格式如 “AA:BB:CC:DD:EE:FF” |
name |
String? |
TEXT |
可空 | 设备广播名或用户自定义名称 |
rssi |
Int |
INTEGER |
NOT NULL | 最近一次扫描的信号强度 (dBm) |
lastConnectedTime |
Long |
INTEGER |
可空 | 时间戳(毫秒),null 表示从未连接 |
type |
Int |
INTEGER |
NOT NULL | 1=经典蓝牙,2=BLE,3=双模 |
metadataJson |
String? |
TEXT |
可空 | JSON 格式扩展字段,如厂商 ID、服务列表 |
字段选型提示:主键应使用不可变的 address 字符串,避免使用自增 ID。因为蓝牙设备地址唯一且不会随扫描会话改变。若需要按信号强度排序,请对
rssi 列建立降序索引。4.3 Room Entity 代码实现
使用 Kotlin 数据类,配合 Room 注解标记主键、列名、索引。代码如下:
@Entity(
tableName = "bluetooth_device",
indices = [
Index(value = ["rssi"], orders = [Index.Order.DESC])
]
)
data class BluetoothDeviceEntity(
@PrimaryKey
@ColumnInfo(name = "address")
val address: String,
@ColumnInfo(name = "name")
val name: String? = null,
@ColumnInfo(name = "rssi")
val rssi: Int = -100,
@ColumnInfo(name = "last_connected_time")
val lastConnectedTime: Long? = null,
@ColumnInfo(name = "type")
val type: Int = 2, // 默认 BLE
@ColumnInfo(name = "metadata_json")
val metadataJson: String? = null
)
代码说明:
- 表名使用
-
- 为
- 使用可空字段处理名称、最后连接时间、元数据等非必填项。
- 默认
- 表名使用
bluetooth_device,避免与 SQLite 保留字冲突。-
address 为主键,类型为 String,存储格式无空格。- 为
rssi 字段建立降序索引,加速按信号强度排序查询。- 使用可空字段处理名称、最后连接时间、元数据等非必填项。
- 默认
type = 2(BLE),适合大多数现代应用场景。4.4 Entity 与蓝牙扫描数据的映射关系
当蓝牙设备被扫描到时,Android 的 ScanResult 对象包含所有原始数据。映射关系如下表:
| ScanResult 方法 | 映射字段 | 转换注意事项 |
|---|---|---|
getDevice().getAddress() |
address |
直接赋值,保证大写字母格式 |
getDevice().getName() ?? scanRecord.deviceName |
name |
可空,若广播名不存在则尝试扫描记录中的名称 |
getRssi() |
rssi |
直接赋值 Int 类型 |
System.currentTimeMillis() |
lastConnectedTime |
首次扫描时置 null,连接成功后再更新 |
getDevice().getType() |
type |
Android 常量 DEVICE_TYPE_CLASSIC=1, BLE=2, DUAL=3 |
| 扫描结果中的额外数据 | metadataJson |
使用 Gson 或 Kotlinx Serialization 序列化为 JSON |
4.5 内联 SVG:BluetoothDeviceEntity 字段结构图
下图为实体与数据库表字段的对应架构,展示了主键、索引、数据类型及与扫描数据源的映射关系:
4.6 常见设计陷阱与优化建议
- 主键选择:不要使用自增 ID,因为同一设备的多次扫描应合并为一条记录,使用 address 作主键可避免重复插入(使用
@Insert(onConflict = OnConflictStrategy.REPLACE))。 - 信号强度索引:如果应用需要按信号强弱显示设备列表,必须为
rssi建立降序索引。对于大量设备(>1000),索引能显著提升排序性能。 - 最后连接时间的空值处理:在查询中需要按未连接设备排序时,可使用
COALESCE(lastConnectedTime, 0)或 Kotlin 中自定义 TypeConverter 将 null 映射为 0。 - 元数据 JSON 存储:推荐使用 Room 的
@TypeConverter自动序列化和反序列化,避免手动拼接字符串。示例 TypeConverter 如下:
class Converters {
@TypeConverter
fun fromMetadataJson(value: String?): Metadata? {
return value?.let { Gson().fromJson(it, Metadata::class.java) }
}
@TypeConverter
fun toMetadataJson(metadata: Metadata?): String? {
return metadata?.let { Gson().toJson(it) }
}
}
避坑指南:不要在实体中直接存储
BluetoothDevice 或 ScanResult 的引用(如 Parcelable 对象),因为它们依赖 Android 系统上下文且不能序列化到 SQLite 中。必须提取基本类型字段进行扁平化存储。4.7 调试:查看生成的数据库表结构
使用 Android Studio 的 Database Inspector 或运行以下 SQL 查询验证实体字段是否生效:
SELECT name, type, `notnull`, pk
FROM pragma_table_info("bluetooth_device");
预期输出应包含 6 行字段,其中 address 的 pk=1(主键),rssi 的 notnull=1。若需要检查索引,执行:
PRAGMA index_list("bluetooth_device");
如果发现字段顺序或类型不符合预期,检查实体注解中
@ColumnInfo 的 name 属性是否与代码一致,避免下划线命名冲突。