2GB 内存的 Android 工业终端上,我们是怎么把 OOM 按在地上摩擦的
实战记录:一台 RK 主板的生物识别终端(虹膜 + 人脸 + NFC + 485 串口锁板),常驻轮询、7×24 小时运行、内存只有 2GB。本文复盘我们在这台设备上沉淀的五条内存纪律,以及轮询场景下保持内存平稳的完整做法。
背景:这不是你熟悉的那个 Android
先说清楚我们面对的是什么设备:
- 硬件:RK 系主板,2GB RAM,跑 Android(minSdk 30),没有手机厂商那套深度系统裁剪;
- 负载:应用里常驻着虹膜和人脸两个生物识别 SDK,光是模型加载就吃掉几百 MB;同时还有 MQTT 长连接、485 串口轮询线程、人脸帧检测线程在跑;
- 运行时长:工业终端要求 7×24 小时在线,没人帮你点"清理后台",一次内存泄漏可以积累几天才爆发。
和消费机最大的差异在于:消费手机上,内存紧张时系统会杀后台、用户会重启 App,很多内存问题被"掩盖"了;而工业终端上,你的 App 就是前台唯一的主角,泄漏和 OOM 会被无限放大。而且 2GB 里 Android 系统本身占去一大半,留给应用的配额非常紧------我们实测,识别 SDK 全部初始化完成后,可用堆余量已经不多,任何"无所谓的一点点内存"都可能成为压垮骆驼的稻草。
项目早期我们就立了一条规矩:所有新功能开发前,先问自己------这个设计在 2GB 内存设备上会不会撑爆? 下面是这条规矩演化出来的五条实战纪律,每一条背后都有真实踩坑。
纪律一:状态更新先判变化,避免无效对象重建
问题现象
我们的终端有一个箱门状态轮询:通过 485 串口每 200ms 轮询一轮所有箱门的开关状态,解析回包后更新一个 MutableStateFlow<Map<Long, GateState>>,UI 层(箱门管理页)收集这个 Flow 刷新卡片颜色。
最早的实现是:每解析到一帧回包,就 toMutableMap() 复制一份 Map,塞入新值,赋给 StateFlow。看起来人畜无害?算一笔账:64 个箱门、200ms 一轮、每门一条回包,意味着每秒最多产生 320 次 Map 复制 + StateFlow 发射 + 下游 collect 触发。每个新 Map 都是一堆临时对象,GC 压力肉眼可见,内存曲线像锯齿一样抖动。
最终实现
核心改动只有一行判断:状态没变,什么都不做。
kotlin
// GatePollingManager(精简版)
private val _gateStates = MutableStateFlow<Map<Long, GateState>>(emptyMap())
val gateStates: StateFlow<Map<Long, GateState>> = _gateStates
private fun updateGateState(gateId: Long, state: Int) {
val newState = if (state == 0) GateState.CLOSED else GateState.OPEN
// 关键:只在状态真正变化时才更新内存、数据库和 UI
val oldState = _gateStates.value[gateId]
if (oldState != newState) {
lockBoardRepo.updateGateState(gateId, newState) // 数据库写
val current = _gateStates.value.toMutableMap()
current[gateId] = newState
_gateStates.value = current // 内存更新 + Flow 发射
doorStatusReporter.onGateLockStatusChanged(gateId, newState) // MQTT 上报
}
}
箱门的真实状态变化频率极低(一天开关几十次),加了这层过滤后,99.9% 的回包处理都退化为一次 Map 查询 + 枚举比较,不产生任何新对象。连带收益是:数据库写次数、MQTT 上报次数、UI 刷新次数同比例下降。
这个"先判变化"的思路贯穿了整个项目。另一个例子是超时检测:某箱门 60 秒没收到回包就标记为异常------但标记前同样先判 oldState == newState 就直接 return,避免异常状态被每秒重复写入数据库。
经验总结
高频数据流(轮询、传感器、串口回包)接不可变状态容器时,对象重建必须发生在"变化检测"之后,而不是之前 。StateFlow 本身会对 equals 相等的值去重,但前提是你别先创建一个新 Map------新引用永远不相等,去重形同虚设。
纪律二:RecyclerView 局部刷新,禁止无脑 notifyDataSetChanged
问题现象
箱门管理页用 GridLayoutManager 展示几十到上百个箱门卡片,每张卡片包含背景色、编号、占用人、密码等多个控件。轮询状态变化时如果 notifyDataSetChanged(),整个列表的 ViewHolder 全部重新 bind,主线程瞬间产生大量临时对象(字符串拼接、颜色解析、监听器重建),在 2GB 设备上表现为滑动掉帧 + GC 频繁。
最终实现
两个页面、两种武器:
1)高频小变化:手动 diff + notifyItemChanged
箱门卡片的状态刷新走这条路。Adapter 里缓存上一次的 stateMap,收到新 Map 后逐门比较,只刷新真正变化的项:
kotlin
// GateManagementActivity 内嵌 Adapter(精简版)
private var stateMap: Map<Long, GateState> = emptyMap()
fun updateStates(newMap: Map<Long, GateState>) {
if (stateMap == newMap) return // 配合纪律一:引用相同说明无变化
val oldMap = stateMap
stateMap = newMap
data.forEachIndexed { index, gate ->
if (oldMap[gate.id] != newMap[gate.id]) {
notifyItemChanged(index) // 只刷新变了的那几张卡片
}
}
}
注意第一行的 stateMap == newMap 短路------它能成立,恰恰是因为上游(纪律一里的 GatePollingManager)在无变化时不会发射新对象。数据层的"不变则不新建"和 UI 层的"引用判等短路"是配套的,缺一环都不成立。
2)整表刷新场景:ListAdapter + DiffUtil
U 盘升级页要展示 U 盘根目录下的 APK 列表,支持插拔自动刷新。这里数据是整批替换的,手动 diff 不划算,直接用官方的 ListAdapter + DiffUtil,把差异计算交给框架在后台线程完成:
kotlin
// UpdateFilesActivity(精简版)
private class ApkFileAdapter(
private val onClick: (ApkFileInfo) -> Unit
) : ListAdapter<ApkFileInfo, ApkFileAdapter.ViewHolder>(DIFF_CALLBACK) {
// ...
companion object {
val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ApkFileInfo>() {
override fun areItemsTheSame(o: ApkFileInfo, n: ApkFileInfo) =
o.file.absolutePath == n.file.absolutePath
override fun areContentsTheSame(o: ApkFileInfo, n: ApkFileInfo) = o == n
}
}
}
这个列表还有一个内存纪律值得一提:列表项只保存文件元数据(File 引用、大小、修改时间),不解析 APK 包内容、不缓存图标 Bitmap。一个几十 MB 的安装包,在列表里只占几十字节。
3)分页追加:notifyItemRangeInserted
箱门操作记录页按每页 50 条滚动分页加载(后文细说),追加分页时也不全量刷新,而是判断"新数据是旧数据的尾部追加"后只通知插入区间:
kotlin
fun updateData(newData: List<Record>) {
val oldSize = data.size
val isAppend = oldSize > 0 && newData.size > oldSize &&
data.first().id == newData.first().id &&
data.last().id == newData[oldSize - 1].id
data = newData
if (isAppend) notifyItemRangeInserted(oldSize, newData.size - oldSize)
else notifyDataSetChanged() // 只有刷新/换数据时才允许全量
}
经验总结
notifyDataSetChanged() 本身不是内存杀手,但它意味着"放弃所有差异信息"。在内存紧张的设备上,正确的选择是按场景分级:高频单点变化 → 手动判等 + notifyItemChanged;整表替换 → ListAdapter/DiffUtil;分页追加 → notifyItemRangeInserted 。顺手再加一条零成本优化:列表不需要动画时 recyclerView.itemAnimator = null,省掉动画产生的临时对象。
纪律三:Bitmap 即用即回收,绝不缓存到全局
问题现象
虹膜和人脸 SDK 的回调会源源不断地给出预览帧、抠图、特征图。一张 1280×720 的 ARGB_8888 位图就是 3.6MB,在 2GB 设备上,几张图没人回收就能把堆顶到危险线。更隐蔽的坑是:把 Bitmap 存到 ViewModel 或全局变量里"等下用",结果生命周期没人管,GC 也拿它没办法。
最终实现
我们定的规矩是:
- SDK 回调里拿到的 Bitmap / 图像字节数组,在同一个调用栈里用完就
recycle()(或置空引用交还 SDK),禁止赋值给 Activity/ViewModel/单例的成员变量; - 需要展示缩略图的场景,先
inSampleSize采样再解码,展示完随 View 回收; - 凡是"每帧都要用"的辅助数组,复用而不是每帧新建。
第三点在帧差唤醒功能里有个具体例子:我们按 80×60 网格对预览帧做运动检测,再按 4×4 划块统计(共 300 块)。如果每帧都 new 一个 int[300],每秒 30 帧就是 30 次分配。实际是检测任务持有一个可复用缓冲,每帧开头 Arrays.fill 清零:
java
// FaceDetectTask 内部(精简泛化版)
private int[] blockCounts; // 复用缓冲,不随帧重建
void detect(byte[] frame) {
if (blockCounts == null || blockCounts.length != blockCount) {
blockCounts = new int[blockCount];
}
Arrays.fill(blockCounts, 0); // 清零复用,无额外分配压力
// ...逐采样点统计各块变化数
}
【配图建议:对比图------复用缓冲 vs 每帧分配的 Allocation Tracker 内存曲线】
经验总结
图像数据的内存纪律可以压缩成一句话:谁创建谁销毁,销毁不跨栈。跨栈持有(全局变量、静态缓存)看起来"方便下次用",实际上是把回收责任推给了一个永远不会到来的时机。
纪律四:数据库批量事务写,轮询不碰写操作
问题现象
我们本地数据库用的是 LitePal(SQLite 封装)。早期轮询逻辑里,每帧箱门回包都执行一次 updateGateState() 写库------64 门 × 每秒 5 轮 = 每秒 300+ 次 SQLite 写。SQLite 每次写事务都要分配日志缓冲区、刷盘,不仅内存抖,Flash 寿命也受影响。
最终实现
两条措施:
1)轮询路径上消灭写操作。 就是纪律一的副产品------oldState != newState 才写库,箱门状态一天变几十次,写库从每秒 300 次降到每天几十次。
2)必须批量写的地方,一次事务搞定。 后台同步下发、批量导入这类场景,禁止逐条 save():
kotlin
// LockBoardRepository(精简版)
fun saveAllInTransaction(entities: List<GateEntity>) {
val db = LitePal.getDatabase()
db.beginTransaction()
try {
entities.forEach { it.saveOrUpdate(...) }
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
3)日志类数据:限定保留期 + 限频清理。 箱门操作记录只保留最近 7 天,且只存短文本和时间戳(不存任何图像)。清理动作有三个触发点------App 启动、写入新记录、进入记录页------但用 AtomicLong + CAS 把清理频率压到一天最多一次,避免高并发写入时反复全表扫描:
kotlin
// GateOperationRecordRepository(精简版)
private val lastCleanupAt = AtomicLong(0L)
private fun cleanupExpiredIfNeeded(now: Long, force: Boolean = false) {
while (true) {
val previous = lastCleanupAt.get()
if (!force && now - previous < DAY_MS) return // 一天内清过就跳过
if (lastCleanupAt.compareAndSet(previous, now)) break
}
LitePal.deleteAll(Record::class.java,
"operationTime < ?", (now - 7 * DAY_MS).toString())
}
经验总结
SQLite 写操作的成本不只是 IO,还有每次事务的缓冲区分配。原则:读可以高频,写必须低频且成批;日志类表必须有保留上限,且清理动作本身也要限频------否则"清理"这个操作自己就成了内存压力源。
纪律五:谨慎创建一次性协程和线程
问题现象
协程很便宜,但不是免费的------每个挂起的协程都有状态机和栈帧开销,裸 Thread 更贵(默认 1MB 栈)。早期代码里有"来一个事件就 launch 一个协程"的写法,比如每条串口回包都 scope.launch { ... },高峰期协程队列排长龙。
最终实现
三个手段:
1)常驻任务用固定线程,不用临时线程。 箱门轮询是 7×24 的常驻任务,用的是一个单线程 Executor 跑 IO + 主线程 Handler 递归调度下一轮,整个生命周期里就这两个线程,不随轮询次数增长:
kotlin
// GatePollingManager(精简版)
private val executor = Executors.newSingleThreadExecutor()
private val handler = Handler(Looper.getMainLooper())
private val taskRunnable = Runnable { startPolling() }
private fun startPolling() {
if (!isRunning || executor.isShutdown) return
executor.submit {
// 逐门发查询指令、等回包、判超时......
if (isRunning) handler.post(taskRunnable) // 递归调度下一轮
}
}
2)瞬时事件先合并再执行。 箱门指定人弹框里的搜索框,用户每敲一个字就查一次库太浪费,做法是取消上一个 Job、延迟 250ms 再执行------同一时刻最多有一个搜索协程在跑:
kotlin
search.addTextChangedListener(object : TextWatcher {
override fun onTextChanged(s: CharSequence?, ...) {
searchJob?.cancel()
searchJob = lifecycleScope.launch {
delay(250)
viewModel.loadAssignmentPeople(s?.toString().orEmpty())
}
}
})
3)异步写库统一收敛到一个 SupervisorJob + Dispatchers.IO 的 scope ,业务侧只调 recordAsync() 提交,不自己开协程,避免各处散落的生命周期失控。
经验总结
协程的正确姿势不是"想用就 launch",而是先分类:常驻循环 → 固定线程 + 递归调度;高频瞬时事件 → debounce 合并;零散异步任务 → 收敛到统一 scope。每条路径都能回答"同一时刻最多有几个协程活着",协程数量才可控。
配套工程实践:LeakCanary 常驻 debug 包
上面五条是"守",还需要一个"攻"的手段把漏网之鱼揪出来。我们的做法是:LeakCanary 只在 debug 包常驻,release 包一行都不带:
kotlin
dependencies {
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
// release 依赖里绝不允许出现 leakcanary
}
debugImplementation 保证 release 构建完全不包含它,零运行时成本。纪律上要求:每次提交前,在 debug 设备上把核心路径走一遍(识别、开门、设置页进出、弹框开关),确认 LeakCanary 没有新增告警。工业终端的泄漏不像手机 App 那样"重启就没事",它会积累几天后在客户现场爆成 OOM,现场排查的代价是开发期的一百倍。
另外提醒一个 2GB 设备上的细节:LeakCanary 分析堆时自身要吃内存,别把触发阈值调太灵敏,否则分析过程本身就可能把设备压垮------默认配置即可,不要为了"更干净"改激进参数。
轮询场景的完整答卷:长时间运行如何保持内存平稳
把上面的纪律串起来,看 485 箱门轮询这个最典型的常驻场景,它的完整内存画像是这样的:
| 环节 | 手段 | 内存效果 |
|---|---|---|
| 轮询执行 | 单线程 Executor + Handler 递归调度 | 线程数恒定为 2,不随时间增长 |
| 状态存储 | oldState != newState 才重建 Map |
平稳期零分配 |
| 数据库 | 变化才写;批量操作走单事务 | 平稳期零写事务 |
| UI 刷新 | 引用判等短路 + notifyItemChanged | 只刷新变化的卡片 |
| 日志记录 | 只存短文本,7 天保留,一天清理一次 | 表体积有硬上限 |
| 泄漏兜底 | debug 包 LeakCanary 常驻 | 问题在开发期暴露 |
这套组合跑下来,设备连续运行数周,应用内存占用基本是一条平线------没有锯齿抖动(GC 压力小),没有缓慢爬坡(无泄漏)。对工业终端来说,"内存平稳"比"内存小"更重要:小是可以优化的,平稳才意味着你把所有增长源都堵死了。
总结与适用场景
五条纪律回顾:
- 状态更新先判变化------高频数据流接不可变容器时,变化检测必须在对象重建之前;
- RecyclerView 按场景选刷新粒度------单点变化手动判等、整表替换用 DiffUtil、分页追加用 notifyItemRangeInserted,杜绝无脑全量刷新;
- Bitmap 即用即回收------谁创建谁销毁,销毁不跨栈,每帧用的缓冲要复用;
- 数据库低频成批写------读可以高频,写必须低频且合并事务,日志表要有保留上限和限频清理;
- 协程/线程先分类再创建------常驻用固定线程,瞬时事件 debounce,零散任务收敛到统一 scope。
这套经验不只适用于 RK 终端:任何内存小于 4GB、需要 7×24 常驻、无人值守的 Android 设备------自助售货机、门禁闸机、快递柜、手持 PDA------都适用。反过来,如果你的 App 是消费机上"用完即走"的普通应用,上面大部分手段的收益会被系统的进程回收机制稀释,不必过度设计。
最后一句话总结:在内存受限的常驻型设备上,内存优化的本质不是"省",而是"消灭一切随时间增长的东西"------对象分配、线程数、表体积、协程数,全部要有上界。