2GB 内存的 Android 工业终端上,我们是怎么把 OOM 按在地上摩擦的

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 也拿它没办法。

最终实现

我们定的规矩是:

  1. SDK 回调里拿到的 Bitmap / 图像字节数组,在同一个调用栈里用完就 recycle()(或置空引用交还 SDK),禁止赋值给 Activity/ViewModel/单例的成员变量;
  2. 需要展示缩略图的场景,先 inSampleSize 采样再解码,展示完随 View 回收;
  3. 凡是"每帧都要用"的辅助数组,复用而不是每帧新建

第三点在帧差唤醒功能里有个具体例子:我们按 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 压力小),没有缓慢爬坡(无泄漏)。对工业终端来说,"内存平稳"比"内存小"更重要:小是可以优化的,平稳才意味着你把所有增长源都堵死了。

总结与适用场景

五条纪律回顾:

  1. 状态更新先判变化------高频数据流接不可变容器时,变化检测必须在对象重建之前;
  2. RecyclerView 按场景选刷新粒度------单点变化手动判等、整表替换用 DiffUtil、分页追加用 notifyItemRangeInserted,杜绝无脑全量刷新;
  3. Bitmap 即用即回收------谁创建谁销毁,销毁不跨栈,每帧用的缓冲要复用;
  4. 数据库低频成批写------读可以高频,写必须低频且合并事务,日志表要有保留上限和限频清理;
  5. 协程/线程先分类再创建------常驻用固定线程,瞬时事件 debounce,零散任务收敛到统一 scope。

这套经验不只适用于 RK 终端:任何内存小于 4GB、需要 7×24 常驻、无人值守的 Android 设备------自助售货机、门禁闸机、快递柜、手持 PDA------都适用。反过来,如果你的 App 是消费机上"用完即走"的普通应用,上面大部分手段的收益会被系统的进程回收机制稀释,不必过度设计。

最后一句话总结:在内存受限的常驻型设备上,内存优化的本质不是"省",而是"消灭一切随时间增长的东西"------对象分配、线程数、表体积、协程数,全部要有上界。


相关推荐
Godikov2 小时前
暗光下摄像头"见鬼"了:一个 2GB 内存 Android 终端的帧差唤醒踩坑记
android
mmsx2 小时前
Android 测绘开发实战 · 专栏导读:20 篇文章从 osmdroid 入门到 MapLibre 进阶
android·源码·地图·osmdroid·maplibre
AI_Cloud_推荐2 小时前
Android集成百度人脸离线SDK实战:从环境搭建到活体检测(附避坑清单)
android·人工智能·百度·云计算·视觉检测·智能硬件
hai_android3 小时前
Android JNI 示例详解:Java 与 C++ 互调演示
android·java
IT毕设实战小研3 小时前
基于大数据的跨国外派人员适应满意度与留存影响因素可视化分析
android·java·大数据·python·django·课程设计
没文化的阿浩4 小时前
【Linux系统】进程状态详解
android·linux·c++·c
tianshi485115 小时前
Android 应用启动窗口(Splash Screen)的创建与销毁流程
android
脚踏实地,坚持不懈!15 小时前
ICU Calendar 实际工作问题排查手册
android