1. 项目背景与性能瓶颈定位
1.1 工控终端为什么对响应速度如此敏感
工控终端和普通消费级Android设备完全是两个物种。我接触过的工控场景里,设备通常要同时处理串口数据采集、PLC通信、扫码枪输入、本地数据库读写、UI实时刷新这几件事,而且很多场景是7×24小时不间断运行。操作员在产线上按一个按钮,如果界面卡了半秒才响应,轻则影响节拍,重则导致整条线的动作时序错乱。
我手上这个项目是一台基于Android 9的工业平板,8核A53处理器,2GB RAM,16GB eMMC存储。功能不复杂:实时采集4路传感器数据(每路100ms一次),写入本地SQLite数据库,同时UI上要展示实时曲线和历史查询。刚交付的时候,数据量小,跑得挺欢。三个月后现场反馈“越用越卡”,最严重的时候点击查询按钮要等3到5秒才有反应,曲线刷新直接卡成幻灯片。
这个标题里的“从秒级卡顿到毫秒响应”,说的就是这段优化经历。下面我把整个排查和优化过程完整拆开讲,涉及SQLite的索引设计、LitePal的使用陷阱、批量写入的事务处理、UI线程与数据库线程的隔离,以及一些工控场景特有的取舍。
1.2 先量化问题:卡顿到底卡在哪里
优化最忌讳凭感觉。我第一步不是改代码,而是先建立可量化的观测手段。具体做法是在关键路径上打时间戳,用System.nanoTime()记录每个环节的耗时,然后输出到日志里。
long t0 = System.nanoTime(); List<SensorData> list = LitePal.findAll(SensorData.class); long t1 = System.nanoTime(); Log.d("PERF", "query cost: " + (t1 - t0) / 1_000_000 + " ms, count=" + list.size());跑了一天下来,数据很清晰:
| 环节 | 平均耗时 | 最坏耗时 | 数据量 |
|---|---|---|---|
| 单条insert | 8ms | 45ms | 每100ms一条 |
| 历史查询(无索引) | 1200ms | 3800ms | 约80万行 |
| UI曲线刷新 | 300ms | 900ms | 200个点 |
| 数据库文件大小 | 约420MB | - | 三个月累积 |
看到这个表,问题基本就定位了。单条insert 8ms看起来不多,但它是每100ms触发一次,而且是在主线程里调的,累积起来就是持续的UI抖动。历史查询1.2秒起步,是因为time字段压根没建索引,每次都是全表扫描80万行。曲线刷新慢,是因为在onDraw里做了数据转换。
提示:工控项目一定要在开发阶段就埋好性能日志,别等现场反馈卡顿再去猜。现场环境你没法调试,只能靠日志回溯。
1.3 优化目标的确立
量化之后我给自己定了几个硬指标:单条写入控制在1ms以内,批量写入1000条控制在200ms以内,历史查询(带时间范围)控制在50ms以内,UI刷新稳定在16ms一帧。这几个数字不是拍脑袋来的,16ms是60fps的帧预算,50ms是人眼感觉“即时”的阈值,1ms是给高频写入留的余量。
2. SQLite层面的核心优化
2.1 索引设计:从全表扫描到索引命中
80万行的表,time字段没索引,查询WHERE time BETWEEN ? AND ?就是全表扫描。这个道理谁都懂,但工控场景有个坑:传感器数据的时间戳是递增写入的,很多人觉得“数据本来就是按时间排的,应该很快”。实际上SQLite不会因为你插入有序就自动优化范围查询,没有索引就是老老实实扫。
建索引的语句很简单:
CREATE INDEX idx_sensor_time ON sensor_data(time); CREATE INDEX idx_sensor_device_time ON sensor_data(device_id, time);第一个索引解决纯时间范围查询,第二个解决“某设备某时间段”的复合查询。这里有个选择:要不要建复合索引?我的判断依据是查询模式。现场90%的查询都是“某台设备+某时间段”,所以复合索引(device_id, time)的收益最大,因为device_id等值匹配后time可以直接走索引范围。
建完索引后重新测:
| 查询类型 | 优化前 | 优化后 |
|---|---|---|
| 纯时间范围 | 1200ms | 35ms |
| 设备+时间范围 | 1500ms | 12ms |
| 全表count | 800ms | 800ms |
注意最后一行,SELECT COUNT(*)在没有WHERE条件时依然慢,因为它必须扫全表。这个后面用别的办法解决。
注意:索引不是越多越好。每建一个索引,insert和update都要多维护一棵B树。工控场景写入频繁,索引数量要克制。我的原则是:只为高频查询建索引,低频的统计类查询用其他手段。
2.2 事务批量提交:把1000次写入压成1次
单条insert 8ms,这个耗时大头其实不是写入本身,而是每次insert都触发一次事务提交,每次提交都要fsync到磁盘。eMMC的fsync延迟在工控环境里波动很大,8ms已经算好的。
解决办法是把多条写入合并到一个事务里。SQLite默认每条语句自动提交,改成手动事务后,1000条写入只需要一次fsync。
SQLiteDatabase db = helper.getWritableDatabase(); db.beginTransaction(); try { for (SensorData data : batch) { db.insert("sensor_data", null, buildValues(data)); } db.setTransactionSuccessful(); } finally { db.endTransaction(); }实测1000条批量写入从原来的8000ms降到180ms,提升约44倍。这个提升幅度在工控场景里是决定性的,因为采集频率高的时候,写入队列积压会直接导致数据丢失。
LitePal里对应的是LitePal.saveAll(list),它内部也是走事务的。但我实测下来LitePal的saveAll在数据量大时会有额外的对象映射开销,所以高频写入路径我最终改成了原生SQLiteDatabase。
2.3 WAL模式:读写不再互相阻塞
工控场景有个典型矛盾:采集线程在拼命写,UI线程在同时读。默认的journal模式下,写操作会锁住整个数据库,读操作只能等。表现出来就是UI查询偶尔卡顿。
开启WAL(Write-Ahead Logging)模式后,读写可以并发,写不阻塞读,读不阻塞写。
@Override public void onConfigure(SQLiteDatabase db) { super.onConfigure(db); db.enableWriteAheadLogging(); }开启WAL后,UI查询的P99延迟从原来的400ms降到60ms左右。代价是会多出-wal和-shm两个文件,数据库目录看起来“不干净”,但工控设备不在乎这个。
提示:WAL模式下数据库文件不能放在某些网络文件系统上,工控设备如果是本地eMMC存储就没问题。另外WAL文件会持续增长,需要定期做checkpoint,SQLite默认每1000页自动checkpoint一次,一般够用。
2.4 分页与游标:别一次性把80万行读进内存
历史查询如果返回几万行,光是构造Java对象就能把内存打爆。工控设备RAM本来就紧张,2GB要分给系统、UI、采集,留给数据库的没多少。
我的做法是强制分页,UI层永远只请求当前可见的200行,翻页时再查下一页。配合索引,每次查询都是毫秒级。
SELECT * FROM sensor_data WHERE device_id = ? AND time BETWEEN ? AND ? ORDER BY time DESC LIMIT 200 OFFSET ?;这里有个细节:OFFSET在数据量大时也会变慢,因为SQLite要先跳过前面N行。更好的做法是用游标分页,记录上一页最后一条的time,下一页用WHERE time < ?来查。工控场景数据是时序的,这个方案天然适用。
3. LitePal使用中的那些坑
3.1 LitePal的便利性与隐藏成本
LitePal确实好用,LitePal.findAll(SensorData.class)一行代码搞定查询,模型类继承LitePalSupport就行。但便利是有代价的,我在优化过程中发现了几个隐藏成本。
第一个是反射开销。LitePal在构造对象时大量使用反射来映射字段,单条查询可能感觉不到,但批量查询1万条时,反射开销能占到总耗时的30%以上。我实测过,同样查询1万条,LitePal比手写Cursor遍历慢约200ms。
第二个是findAll默认没有分页,它会一次性把符合条件的所有行都加载成对象。80万行全加载,内存直接OOM。这个坑我在测试环境踩过一次,应用直接崩了。
3.2 什么时候该放弃LitePal
我的判断标准是这样的:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 配置类小表CRUD | LitePal | 开发快,数据量小,反射开销可忽略 |
| 高频写入(>10次/秒) | 原生SQLiteDatabase | 避免对象映射开销,事务控制更精细 |
| 大数据量查询 | 原生Cursor+分页 | 避免一次性加载,内存可控 |
| 复杂统计查询 | 原生SQL | LitePal的聚合查询支持有限 |
最终我的项目里是混合使用的:设备配置表用LitePal,传感器数据表用原生SQLite。这不是“二选一”,而是各取所长。
3.3 LitePal的升级与迁移陷阱
LitePal的LitePalMigration在表结构变更时很方便,但工控场景有个特殊要求:数据库里存的是生产数据,不能丢。LitePal默认的升级策略是drop掉旧表重建,这在消费级应用里无所谓,在工控里是灾难。
我的做法是关掉LitePal的自动升级,自己写onUpgrade,用ALTER TABLE ADD COLUMN来增量修改,保证数据不丢。
@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE sensor_data ADD COLUMN quality INTEGER DEFAULT 0"); } }注意:
ALTER TABLE只能加列,不能改列类型或删列。如果确实要改结构,得建新表、导数据、删旧表、改名,这一套操作必须放在事务里,中途失败要能回滚。
4. UI层与线程模型的优化
4.1 数据库操作绝不能放在主线程
这是老生常谈,但工控项目里我见过太多在主线程里查数据库的代码。原因往往是“数据量小的时候不卡”,等数据量上来了就晚了。
我的方案是采集线程、写入线程、查询线程完全分离。采集线程只管从串口读数据放进阻塞队列,写入线程从队列取数据批量写库,查询线程响应UI请求。三者通过队列和回调通信,互不阻塞。
// 采集线程 sensorQueue.put(new SensorData(...)); // 写入线程 List<SensorData> batch = new ArrayList<>(1000); sensorQueue.drainTo(batch, 1000); if (!batch.isEmpty()) { dbManager.batchInsert(batch); }这个模型的好处是:采集不会因为写库慢而丢数据(队列缓冲),写库不会因为UI查询而阻塞(WAL模式),UI不会因为任何数据库操作而卡顿(异步查询+回调)。
4.2 曲线绘制的性能优化
UI上那条实时曲线,最初是在onDraw里遍历数据点、计算坐标、画线。200个点的时候还行,数据点一多就卡。
优化思路是分层:数据转换和坐标计算放在后台线程,onDraw只负责把算好的坐标画出来。具体做法是维护一个float[]数组存坐标,后台线程更新数组,onDraw直接drawLines。
@Override protected void onDraw(Canvas canvas) { if (points != null && points.length >= 4) { canvas.drawLines(points, paint); } }另外,曲线刷新不需要每来一个数据点就重绘。我用了一个16ms的定时器,每帧取最新数据更新一次,这样既保证流畅又不会过度绘制。
4.3 列表查询的懒加载
历史数据列表用的是RecyclerView,配合分页加载。滚动到底部时触发下一页查询,查询在后台线程执行,结果通过Handler回主线程更新adapter。
这里有个细节:快速滚动时会触发多次分页请求,如果不做去重,会有重复数据。我的做法是用一个AtomicBoolean标记当前是否有查询在进行,有就跳过。
if (!isLoading.compareAndSet(false, true)) { return; } // 执行查询... isLoading.set(false);5. 常见问题与排查技巧实录
5.1 数据库文件膨胀与清理策略
三个月420MB,一年就是1.7GB,16GB的eMMC扛不住。工控场景的数据保留策略通常是“保留最近N天”或“保留最近N条”。
我采用的是按时间分区清理:每天凌晨检查一次,删除30天前的数据。删除时要注意,直接DELETE FROM sensor_data WHERE time < ?会产生大量WAL日志,而且不会立即释放磁盘空间。需要配合VACUUM来回收空间。
DELETE FROM sensor_data WHERE time < ?; VACUUM;但VACUUM会锁库,耗时也长。我的做法是分批次删除,每次删1万条,删完做一次checkpoint,全部删完后再VACUUM。整个过程放在设备空闲时段(比如凌晨3点)执行。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 查询突然变慢 | 索引失效或未建 | EXPLAIN QUERY PLAN | 补索引 |
| 写入延迟波动大 | eMMC fsync抖动 | 打点统计P99 | 批量事务+WAL |
| 应用偶发ANR | 主线程查库 | 看ANR堆栈 | 异步查询 |
| 数据库文件不释放 | WAL未checkpoint | 看-wal文件大小 | 手动checkpoint |
| 内存持续增长 | Cursor未关闭 | MAT分析 | try-with-resources |
| 升级后数据丢失 | LitePal自动drop | 看升级日志 | 自定义onUpgrade |
5.3 几个我踩过的坑
第一个坑:Cursor忘记关闭。早期代码里查询完直接返回结果,Cursor没关,跑一天下来文件描述符耗尽,应用崩溃。后来全部改成try-with-resources。
第二个坑:在onUpgrade里做耗时操作。有次升级要迁移50万行数据,直接在onUpgrade里跑,导致应用启动超时被系统杀掉。后来改成升级时只改结构,数据迁移放到后台任务里异步做。
第三个坑:WAL模式下数据库文件拷贝。工控设备有时候要备份数据库,直接拷贝.db文件会丢失WAL里的未checkpoint数据。正确做法是先PRAGMA wal_checkpoint(TRUNCATE),再拷贝。
提示:工控项目的数据库备份一定要走SQLite的备份API或者先checkpoint,直接文件拷贝在WAL模式下是不安全的。
6. 优化效果与实测数据
6.1 优化前后的完整对比
把所有优化项叠加后,重新跑了一轮完整测试:
| 指标 | 优化前 | 优化后 | 提升倍数 |
|---|---|---|---|
| 单条写入 | 8ms | 0.8ms | 10x |
| 1000条批量写入 | 8000ms | 180ms | 44x |
| 历史查询(时间范围) | 1200ms | 35ms | 34x |
| 历史查询(设备+时间) | 1500ms | 12ms | 125x |
| UI曲线刷新 | 300ms | 8ms | 37x |
| 数据库文件(3个月) | 420MB | 180MB | 2.3x |
文件变小是因为清理策略生效,加上WAL的checkpoint回收了空间。
6.2 现场运行验证
优化后的版本在现场跑了两个月,操作员反馈“跟刚装的时候一样快”。后台日志显示,查询P99延迟稳定在50ms以内,写入队列没有积压,内存占用平稳。
这里我想强调一点:性能优化不是一劳永逸的。数据量在增长,查询模式可能变化,设备状态会老化。我在应用里加了一个简单的自监控模块,每天记录一次关键指标(查询耗时、写入耗时、数据库大小、内存占用),超过阈值就写警告日志。这样下次出问题,我能第一时间知道是哪个环节退化了。
6.3 关于工具链的一点经验
调试SQLite的时候,EXPLAIN QUERY PLAN是最有用的工具,没有之一。它能告诉你查询走了哪个索引、有没有全表扫描。我几乎每次写复杂查询都会先跑一遍。
可视化工具方面,DB Browser for SQLite在PC上分析现场导出的数据库文件很好用,能直接看表结构、索引、数据分布。Android Studio自带的Database Inspector在调试时也能实时看数据库,但工控设备经常连不上调试桥,所以现场问题还是靠日志和导出文件分析。
最后分享一个我个人的习惯:每次做性能优化,我都会在代码里留一个PERF标签的日志开关,默认关闭,需要时打开。这样既不影响正常运行,又能在需要时快速拿到数据。这个习惯帮我省了很多次现场排查的时间。