Android 工控终端实战:从秒级卡顿到毫秒响应,SQLite/LitePal 性能优化全记录

0 阅读7分钟

Android 工控终端实战:从秒级卡顿到毫秒响应,SQLite/LitePal 性能优化全记录

项目背景:一台运行在 rk3562 工控板(armeabi-v7a,性能有限)上的 Android 智能柜终端,业务涉及人员管理、箱门分配、操作记录等大量本地 SQLite 数据操作。ORM 用的是 LitePal。上线后陆续收到"领取时卡几秒""操作记录页黑屏""人员管理页死机"的反馈。本文完整记录这一系列性能问题的排查与修复过程。

一、最大的问题不在 SQL,而在 ORM 的"便利税"

排查下来,几乎所有卡顿的根因都不是"SQL 写得差",而是 LitePal 易用的 API 掩盖了查询成本,让昂贵的数据库操作被藏进了看起来无害的代码里。典型的三个坑:

  1. 属性 getter 里藏了查库FaceSettingDbManager.requirePositionIn 这样的属性,内部每次都执行 LitePal.findFirst()。单独调用一次毫无感觉,放进 for 循环就是"每次迭代查一次库"。
  2. "数总数"用全表加载:统计记录总数写成 queryRecordPage(..., 1, Int.MAX_VALUE).size,把全表物化成 Java 对象再数个数,记录量大时直接 OOM/黑屏。
  3. 搜索框每个字符触发一次全表模糊查询:没有索引时,每次输入都是一次全表扫描 + 排序。

下面按优化手法分类,逐一展开。

二、启动时建索引:最便宜的优化,最大的收益

ORM 框架一般不会帮你建索引,但 LitePal 留了下沉通道——直接拿 SQLiteDatabase 执行原生 SQL。我们在 Application.onCreate() 里统一建索引(MyApp.kt:134-155):

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. 循环外一次性读配置,注释直接写明根因:

// 循环外一次性读取设置,避免循环内每次查库
// (FaceSettingDbManager.getFaceSettingBean() 内部有 LitePal.findFirst)
val requirePositionIn = FaceSettingDbManager.requirePositionIn
val manageTldEpd = FaceSettingDbManager.manageTldEpd

2. 两张表并发查询 + 结果预聚合成 Map:

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. 单次遍历分类 + 连接状态缓存 + 内联替代方法调用:

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):

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):

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 使用,防止两处条件口径漂移(并留了注释提醒后续维护者同步)。

部门筛选改用原生 DISTINCTOrderDbManager.kt:437-461):

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),原来每输入一个字符触发一次全表模糊查询:

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):

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):

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
门状态全表高频重复 findAll1s TTL 缓存 + 写路径主动失效
记录总数全表物化成对象LitePal.count(),条件构造共享
部门筛选项全表扫描收集原生 SELECT DISTINCT 只取两列
搜索输入每字符一次全表模糊查400ms 防抖 + onDestroy 清理
删除前校验主线程 find()IO 线程 count() 走索引
指纹识别每次全表查库 + Base64 解码启动一次性预解码进内存,增量刷新
指纹查询带宽select * 含大字段列裁剪只取 6 列

九、总结:低端设备上的性能观

在 rk3562 这类工控设备上做优化,目标不是"平均耗时降多少",而是三条硬指标:

  1. 主线程零查库——所有 LitePal 操作要么在 IO 线程,要么走内存缓存;
  2. 查询次数从 O(循环) 降到 O(1)——循环内不出现任何数据库/Map 之外的 IO;
  3. 对象物化量最小化——能 count 不 find,能 select 列不 select *,能游标取数不建实体。

另外两条工程经验:一是优化前先怀疑 ORM 的隐式成本(getter 查库、全表物化),而不是怀疑 SQL 本身;二是引入缓存就要配套失效策略,TTL 兜底 + 写路径主动失效,缺一不可。