Android 工控终端实战:从秒级卡顿到毫秒响应,SQLite/LitePal 性能优化全记录
项目背景:一台运行在 rk3562 工控板(armeabi-v7a,性能有限)上的 Android 智能柜终端,业务涉及人员管理、箱门分配、操作记录等大量本地 SQLite 数据操作。ORM 用的是 LitePal。上线后陆续收到"领取时卡几秒""操作记录页黑屏""人员管理页死机"的反馈。本文完整记录这一系列性能问题的排查与修复过程。
一、最大的问题不在 SQL,而在 ORM 的"便利税"
排查下来,几乎所有卡顿的根因都不是"SQL 写得差",而是 LitePal 易用的 API 掩盖了查询成本,让昂贵的数据库操作被藏进了看起来无害的代码里。典型的三个坑:
- 属性 getter 里藏了查库 :
FaceSettingDbManager.requirePositionIn这样的属性,内部每次都执行LitePal.findFirst()。单独调用一次毫无感觉,放进 for 循环就是"每次迭代查一次库"。 - "数总数"用全表加载 :统计记录总数写成
queryRecordPage(..., 1, Int.MAX_VALUE).size,把全表物化成 Java 对象再数个数,记录量大时直接 OOM/黑屏。 - 搜索框每个字符触发一次全表模糊查询:没有索引时,每次输入都是一次全表扫描 + 排序。
下面按优化手法分类,逐一展开。
二、启动时建索引:最便宜的优化,最大的收益
ORM 框架一般不会帮你建索引,但 LitePal 留了下沉通道------直接拿 SQLiteDatabase 执行原生 SQL。我们在 Application.onCreate() 里统一建索引(MyApp.kt:134-155):
kotlin
private fun initDatabaseIndexes() {
try {
val db = LitePal.getDatabase()
// 箱门表常用查询字段索引
db.execSQL("CREATE INDEX IF NOT EXISTS idx_m2s_disabled_rp ON m2sdetaildbbean(disabled, rp)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_m2s_m3id_pindex_index ON m2sdetaildbbean(m3id, pindex, \"index\")")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_m2s_fixed_user_rp ON m2sdetaildbbean(fixedUserRp)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_m2s_rp ON m2sdetaildbbean(rp)")
// 上架表、人员表、订单表......
db.execSQL("CREATE INDEX IF NOT EXISTS idx_door_shelf_door_key ON doorshelfdbbean(doorKey)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_person_create_time ON persondbbean(create_time)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_person_end_time ON persondbbean(end_time_act)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_order_box_create ON orderdbbean(box_code, create_time)")
} catch (e: Exception) {
LogUtils.debugInfo("Database", "数据库索引初始化失败: ${e.message}")
}
}
三个细节:
IF NOT EXISTS+ 整体 try/catch:幂等,重复启动不报错,失败也不阻塞 App 启动。index是 SQLite 保留字 :列名叫index时必须用\"index\"转义,否则 SQL 直接报错。- 索引跟着查询走 :
idx_person_create_time是为了列表页ORDER BY create_time DESC不全表排序;idx_m2s_rp是为了"删除人员前查占用箱门"走索引。
三、消除循环内查库:getTld() 箱门分配的秒级优化
getTld() 是用户刷脸后分配箱门的核心函数,需要遍历所有箱门判断"哪个可用"。原实现在循环里每轮都隐式查库,200 个箱门就是数百次查询,延迟秒级。优化后(MainViewModel.kt:2849-2936):
1. 循环外一次性读配置,注释直接写明根因:
kotlin
// 循环外一次性读取设置,避免循环内每次查库
// (FaceSettingDbManager.getFaceSettingBean() 内部有 LitePal.findFirst)
val requirePositionIn = FaceSettingDbManager.requirePositionIn
val manageTldEpd = FaceSettingDbManager.manageTldEpd
2. 两张表并发查询 + 结果预聚合成 Map:
kotlin
val (doorShelfData, allList) = coroutineScope {
val doorShelfDeferred = async {
if (FaceSettingDbManager.manageTldEpd) LitePal.findAll(DoorShelfDbBean::class.java) else emptyList()
}
val allListDeferred = async { getCachedM2SList() }
doorShelfDeferred.await() to allListDeferred.await()
}
// 上架数量/编码/到期箱门在循环外用 groupingBy/groupBy 一次性聚合
val doorShelfCounts = doorShelfData.groupingBy {
DoorShelfDbBean.buildDoorKey(it.m3id, it.pindex, it.doorIndex)
}.eachCount()
3. 单次遍历分类 + 连接状态缓存 + 内联替代方法调用:
kotlin
val connectedCache = HashMap<String, Boolean>()
for (m2s in cabinetList) {
if (m2s.close != "0") continue // 内联替代 isCabinetClosed()
// 同一 M3 控制板下多个箱门,连接状态只查一次 ConcurrentHashMap
val isConnected = connectedCache.getOrPut(m3Ip) {
cabinetConnections[m3Ip]?.tcpClient?.isConnected() == true
}
...
}
四、1 秒 TTL 内存缓存 + 写路径主动失效
箱门状态表被"领取分配"和"首页可用数轮询"两处高频读取。加一层 1 秒 TTL 缓存(MainViewModel.kt:312-342):
kotlin
private var cachedAllM2SList: List<M2SDetailDbBean>? = null
private var cachedM2SListTime: Long = 0
private val m2sCacheLock = Any()
private val M2S_CACHE_TTL_MS = 1000L
private fun getCachedAllM2SList(): List<M2SDetailDbBean> {
val now = System.currentTimeMillis()
synchronized(m2sCacheLock) {
val cached = cachedAllM2SList
if (cached != null && now - cachedM2SListTime < M2S_CACHE_TTL_MS) return cached
}
val list = LitePal.findAll(M2SDetailDbBean::class.java)
synchronized(m2sCacheLock) {
cachedAllM2SList = list
cachedM2SListTime = now
}
return list
}
private fun invalidateM2SCache() {
synchronized(m2sCacheLock) {
cachedAllM2SList = null
cachedM2SListTime = 0
}
}
缓存一致性必须主动管理。 这里有一个真实踩坑:固定箱门解绑后,如果随机分配流程经缓存拿到解绑前的旧对象再 save(),会把刚清空的绑定关系覆盖回去。所以写路径(解绑保存后)必须显式调 invalidateM2SCache(),TTL 只是兜底。
五、"数总数"的正确姿势:SQL count 与 SELECT DISTINCT
操作记录页进入时要显示总条数和部门筛选项。原实现把整张表物化成对象,个别设备记录量一大直接黑屏。
count 替代全表加载 (OrderDbManager.kt:409-435):
kotlin
fun countRecords(...): Int {
return try {
val (whereClause, args) = buildRecordConditions(userName, userRp, deptId, boxCode, startTime, endTime, orderTypes)
if (whereClause.isNullOrEmpty()) {
LitePal.count(OrderDbBean::class.java)
} else {
LitePal.where(whereClause, *args).count(OrderDbBean::class.java)
}
} catch (e: Exception) { 0 }
}
注意这里的工程细节:筛选条件抽成了私有函数 buildRecordConditions(),同时供分页查询 queryRecordPage 和计数 countRecords 使用,防止两处条件口径漂移(并留了注释提醒后续维护者同步)。
部门筛选改用原生 DISTINCT (OrderDbManager.kt:437-461):
kotlin
LitePal.findBySQL(
"SELECT DISTINCT dept_id, dept_name FROM orderdbbean " +
"WHERE (dept_id IS NOT NULL AND dept_id != '') OR (dept_name IS NOT NULL AND dept_name != '')"
)?.use { cursor ->
while (cursor.moveToNext()) { /* 只取两列,不物化整个对象 */ }
}
经验:ORM 满足不了的场景(建索引、DISTINCT),直接用 getDatabase().execSQL / findBySQL 下沉到原生 SQL,ORM 只负责简单 CRUD。
六、UI 侧:搜索防抖 + 主线程查库下移
搜索框 400ms 防抖 (UserManageActivity.kt:101-109),原来每输入一个字符触发一次全表模糊查询:
kotlin
override fun afterTextChanged(s: Editable?) {
pendingSearchRunnable?.let { searchHandler.removeCallbacks(it) }
val runnable = Runnable {
mViewModel.loadCount()
mViewModel.loadList(true)
}
pendingSearchRunnable = runnable
searchHandler.postDelayed(runnable, 400L)
}
// onDestroy 中 removeCallbacks,防泄漏
删除人员的占用校验下沉到 IO 线程 ,并把 find() 改成 count()(UserManageViewModel.kt:198-208):
kotlin
fun deleteUser(user: PersonDbBean) {
launch({
// 占用箱门判断放到 IO 线程,避免主线程查库
val rp = user.qr_code
if (!rp.isNullOrEmpty()) {
val occupiedCount = LitePal.where("rp = ?", rp).count(M2SDetailDbBean::class.java)
if (occupiedCount > 0) {
error("该用户当前占用箱门,请先归还后再删除")
}
}
...
七、指纹识别:从"每次全表查库"到"启动一次预加载"
原实现每次指纹识别都 LitePal.findAll() 全表查库 + 逐条 Base64.decode,用户量增大后耗时线性增长。优化方案是内存缓存(MainViewModel.kt:510-668):
kotlin
private data class FingerCacheItem(
val userId: String, val qrCode: String, val userName: String,
val fingerIndex: Int, val fingerData: ByteArray // 已预解码
)
@Volatile
private var fingerCache: List<FingerCacheItem> = emptyList()
要点:
- 进程内只全量加载一次 ,加载时顺带做列裁剪------人员表含人脸特征
feature_data(byte\[\])等大字段,指纹查询只 select 需要的 6 列(PersonDbManager.kt:25-52); - 预解码 + 合法性校验 (只收 256 字节的合法模板),识别时直接拿
ByteArray比对,零运行时解码; - 增量刷新 :人员变更只替换该人员的缓存项(
filterNot { it.userId == userId } + updatedItems),不全量重查; - 加载期间的刷新请求不丢弃 :先入
pendingFingerCacheUserIds暂存,加载完成后统一补刷。
八、优化手法总览
| 场景 | 优化前 | 优化后 |
|---|---|---|
| 高频过滤/排序字段 | 无索引全表扫描 | 启动时 CREATE INDEX IF NOT EXISTS,含联合索引 |
| 分配循环读配置 | 每次迭代 getter 内查库 | 循环外读局部变量 |
| 两张表查询 | 串行两次查库 | coroutineScope { async } 并发 + 预聚合 Map |
| 门状态全表 | 高频重复 findAll |
1s TTL 缓存 + 写路径主动失效 |
| 记录总数 | 全表物化成对象 | LitePal.count(),条件构造共享 |
| 部门筛选项 | 全表扫描收集 | 原生 SELECT DISTINCT 只取两列 |
| 搜索输入 | 每字符一次全表模糊查 | 400ms 防抖 + onDestroy 清理 |
| 删除前校验 | 主线程 find() |
IO 线程 count() 走索引 |
| 指纹识别 | 每次全表查库 + Base64 解码 | 启动一次性预解码进内存,增量刷新 |
| 指纹查询带宽 | select * 含大字段 | 列裁剪只取 6 列 |
九、总结:低端设备上的性能观
在 rk3562 这类工控设备上做优化,目标不是"平均耗时降多少",而是三条硬指标:
- 主线程零查库------所有 LitePal 操作要么在 IO 线程,要么走内存缓存;
- 查询次数从 O(循环) 降到 O(1)------循环内不出现任何数据库/Map 之外的 IO;
- 对象物化量最小化------能 count 不 find,能 select 列不 select *,能游标取数不建实体。
另外两条工程经验:一是优化前先怀疑 ORM 的隐式成本 (getter 查库、全表物化),而不是怀疑 SQL 本身;二是引入缓存就要配套失效策略,TTL 兜底 + 写路径主动失效,缺一不可。