前两周又有一个同事拿着低端机过来找我,说信息流列表滑动超过一分钟就开始掉帧,再过一阵直接闪退。不用看日志我也能猜到七八分——RecyclerView 配 Glide 的场景,卡顿和内存占用几乎永远纠缠在一起。很多人把锅甩给"图片太多",但实际上,内存优化不是少放几张图,而是把每一张图在屏幕上占住的字节数控制住。这篇文章我把 RecyclerView 和 Glide 两边能做的内存优化动作拆开讲,穿插一个我自己排查线上事故的完整过程,最后给几个可以直接抄走的经验阈值。适合列表已经能跑起来、但内存曲线越来越难看的开发者参考。
1. 内存不是"图片太多"撑大的:先拆清楚两边各占多少
先把内存账算清楚。一张 1080x1920 的 ARGB_8888 图片,像素数是 1080 × 1920 = 2,073,600,每个像素占 4 字节,刚解码完就是 8.3MB。这个数字不会因为你手机屏幕只有 390×844 就自动变小。显示区域小,只是看起来小,内存未必小——这句话是列表 OOM 的第一个真相。
RecyclerView 本身其实挺省内存。它只创建可见区域加上缓存池里少量 ViewHolder,几十个 View 撑死也就几 MB。真正让内存失控的通常有两条线:
- 列表 item 里加载的图片,解码后按像素占用内存,像素尺寸决定内存,文件大小反而无所谓;
- Glide 有自己的一套内存缓存和 BitmapPool,图片滑动过去之后不会立刻消失,而是被放在缓存里等待复用,这是流畅滑动的前提,也是内存上涨的主要原因。
所以项目里经常出现"列表也就 20 条数据,内存却飙到三四百兆"的现象。原因不是 item 数量多,而是每张原图可能 2000×1500,一条 item 就吃掉 12MB,加上 Glide 内存缓存和 BitmapPool 里的残留,滑两屏就可能堆出上百 MB。
这里有一个关键认知:RecyclerView 只负责页面索引和 UI 复用,它不直接拥有 Bitmap;Glide 才是那张图真正的"内存持有者"。所以我们在聊优化的时候,必须分两层去看:一层是列表层,别让 View 和数据对象把内存拖死;另一层是图片层,别让每一张图的字节数超出实际显示需求。
这里顺带纠正一个特别常见的误区:很多人一 OOM 就怀疑 Glide 泄漏,其实大多数时候是"原图尺寸没有被裁剪,加载到了巨大的内存尺寸"。先记住这个结论,后面第四章会给你看具体案例。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RecyclerView 侧的止血动作:这几个配置不改,优化就是空谈
2.1 先把"数据对象里存 Bitmap"的老毛病揪出来
我接手过的项目里,不止一次看到类似 NewsBean.bitmap 这种字段。老代码可能是以前用 AsyncTask 加载图片留下的,后来接入 Glide,但 Bean 里的 Bitmap 没清理,列表数据一存,所有图片全都压在内存里。这种情况无论你在 Glide 上怎么调格式、怎么调缓存策略,都救不回来。
正确做法是数据对象里只存 URL、图片 ID、资源路径等轻量字段,真正显示交给 Glide。这是 RecyclerView 内存优化的第一道止血,也是很多人忽略的根因。
2.2 合理控制 ViewHolder 池和 itemViewType 数量
RecyclerView 的复用机制本身不消耗多少内存,但有两个动作会打破它的复用:
- 随意调用
holder.setIsRecyclable(false)。这个方法一旦被滥用,ViewHolder 不会回到池子里,滑出去之后就被丢弃,需要重新 inflate,等于抛弃了整个缓存体系。itemView 里包含 ImageView 时,反复 inflate 还会带来 GC 压力。 - 大量 itemViewType。每个
itemViewType在 RecycledViewPool 里是独立队列,类型越多,池子越碎片化。如果你的服务端返回的type字段几百种变化,那基本上每个 item 都需要重新创建 ViewHolder。尽量把类型收敛到几种,比如"图文、纯图、视频、广告"四个类型就够了。
2.3 嵌套列表一定要共享 ViewPool
如果外层 item 里嵌了一个横向 RecyclerView(比如"猜你喜欢"的商品行),每个 inner RecyclerView 默认都会新建一个 RecycledViewPool,等于横向滑动的 item 视图无法被外层复用。正确做法是给所有内层列表设置同一个 setRecycledViewPool(recycledViewPool),并单独 setAdapter。这一步对手游商城、电商类首页尤其有用,能省下不少 View 对象和相应图片引用。
代码大概是这样:
kotlin复制val sharedPool = RecyclerView.RecycledViewPool()
innerList1.setRecycledViewPool(sharedPool)
innerList2.setRecycledViewPool(sharedPool)
如果是大量内嵌列表,最好再用一个变量池(比如 HashMap<Int, RecycledViewPool>)按数据类型分别管理。
2.4 setHasFixedSize 和 ItemAnimator 不是内存关键,但会影响 GC
很多人把 setHasFixedSize(true) 当救命稻草,其实它影响的是 layout 效率,不是内存。但有一个操作在图片列表里值得认真考虑:给图片为主的列表关闭默认 ItemAnimator。
kotlin复制recyclerView.itemAnimator = null
默认的 ItemAnimator 在增删 item 时会"暂留"旧视图,方便做位移动画。这些旧视图里如果有 ImageView 和图片引用,动画期间相当于把离屏 item 的内存又软性保留一会儿。列表内容频繁更新时,这种暂留会反复堆积,间接导致 GC 变多。对信息流这种"滑到就展示"的场景,关掉动画换来的流畅度通常比动画本身更值得。
2.5 分页加载而不是一次塞一万条
RecyclerView 不会为看不见的 item 保留 View,但你的数据源 List 会保留数据对象本身。如果一条 item 的 model 里有长文本、列表、复杂嵌套对象,一万条就是几万甚至几十万个对象,即使没有图片也够让 GC 哭。列表一定做分页,经典方案是每页 20~50 条,结合 Paging3 或自己写加载更多回调都可以。另一个隐含点:全量刷新用 notifyDataSetChanged() 会让所有可见 item 重新 bind 一次,图片请求全部重发,这不会立刻涨内存,但会放大瞬时负载。配合 DiffUtil 做局部刷新,已经是列表优化的事实标准。
3. Glide 的内存阀门:尺寸、格式、缓存、复用、生命周期,逐个调
Glide 的优化动作比 RecyclerView 更多,而且每一刀下去都直接作用在"字节数"上。我按影响从大到小给你过一遍。
3.1 override 永远是最容易捡的分
Glide 解码时默认参考 ImageView 的尺寸。如果 ImageView 是 wrap_content 或者还没被测量完成,Glide 拿不到有效宽高,就会按原始图尺寸解码。这就是那句经典问题——"为什么我放了一张 200×200 的图,Glide 内存占用还是 8MB?"因为原图可能真的是 2000×1500,ImageView 撑不开,Glide 也没法帮你缩。
标准做法是给目标 ImageView 固定尺寸,然后用 override 显式声明:
kotlin复制Glide.with(itemView)
.load(url)
.override(480, 800)
.into(imageView)
如果 ImageView 尺寸完全由布局决定,你可以在 bind 的时候先拿 view 的宽高再加载,或者直接在 View 上做一次 onPreDraw 之后再 load。绝大多数信息流卡片,图片显示区域是固定的,直接在布局里写死宽高比或尺寸,效果比在代码里算来得稳定。
这里必须强调:override 的数字要尽量和实际显示尺寸接近,不要无脑填大。你填了 2000×2000,内存照样是 2000×2000 的占用。
3.2 色彩格式 RGB_565 能帮你直接砍掉一半内存
同一张图,ARGB_8888 是 4 字节每像素,RGB_565 是 2 字节每像素。对大多数不带透明通道的照片、截图类内容,RGB_565 在肉眼看不清差别的情况下直接省一半内存。Glide 可以通过默认 RequestOptions 开启:
kotlin复制GlideBuilder().setDefaultRequestOptions(
RequestOptions().format(DecodeFormat.PREFER_RGB_565)
)
配合 AppGlideModule 的写法是:
kotlin复制@GlideModule
class MyAppGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
builder.setDefaultRequestOptions(
RequestOptions().format(DecodeFormat.PREFER_RGB_565)
)
}
}
注意一个小坑:如果你用圆形变换、圆角、或者页面里需要透明通道的图,RGB_565 偶尔会出现边缘锯齿或色斑。所以 AppGlideModule 里可以建立一个"透明通道图片集合",对列表头像/封面用 565,对带蒙层或圆角头的特殊情况单独 override 成 8888。不要为了省内存牺牲明显观感,也不必因为个别图有瑕疵就全局退回去。
3.3 缓存策略不是越激进越好
Glide 的缓存分两层:内存缓存和磁盘缓存。很多人一听内存优化就跳过内存缓存,这其实不聪明。
- 小图、高频复用图(头像、列表缩略图)应该保留内存缓存,否则每次滑回来都要重新解码,CPU 和内存压力更大。
- 超大图、一次性展示图(开屏图、Banner 大图)可以在加载完成后
skipMemoryCache(true),避免大图长期躺在 LruCache 里。
磁盘缓存策略方面,DiskCacheStrategy.RESOURCE 会把"变换后、符合 override 尺寸"的图写进磁盘,适合同一个尺寸反复加载的场景;DiskCacheStrategy.DATA 缓存原始图,适合同一张图将来要按不同尺寸加载的场景。我建议列表场景用 RESOURCE,因为 override 之后的图下次直接读盘,不需要再解码原图,能减少一次全尺寸解码的瞬时峰值。
3.4 BitmapPool 不是越大越好
Glide 自带 BitmapPool,复用的是解码后的 Bitmap 对象,目的就是减少频繁分配和 GC。你可以在 AppGlideModule 里通过 builder.setMemoryCache(...) 和 builder.setBitmapPool(...) 调整它们的大小。
比较稳妥的做法是别手动调成一个夸张数字,先用默认值,然后用 Glide.get(context).setMemoryCategory(MemoryCategory.LOW) 对全局进行"降压"。这个方法特别适合信息流类 App——它会把内存缓存减到默认的一半左右,换取整体 App 的内存占用更可控。如果某个页面(比如大图详览)需要更强大的缓存能力,可以临时切回 MemoryCategory.HIGH,页面退出再切回 LOW。
3.5 生命周期用 View 级别,别老抱着 Activity
Glide.with() 的第一个参数决定了 RequestManager 的生命周期。如果你在 ViewHolder 里写 Glide.with(activity),当这个 ViewHolder 被回收、页面已经销毁时,RequestManager 还持有 activity 引用,容易拖慢回收。更安全的写法是:
kotlin复制Glide.with(holder.itemView)
Glide 4.x 支持传入 View,会以 View 作为生命周期锚点,View detached 或销毁时请求自动取消。对于 RecyclerView 场景,这是最顺手的写法。如果特殊业务要求请求跨页面存在(比如进详情页之前预加载),可以用 Application context,但要自己负责清理,别默认全局用。
3.6 缩略图和占位图也要小心
.thumbnail(0.1f) 和高性能占位图都是好用的"防白屏"手段,但很多人会犯一个错:占位图本身用了一张巨大的图,或者缩略图原图尺寸巨大。占位图在加载期间会一直存在在内存里,thumbnail 的小图解码后也会占用一份内存。占位图尽量用 VectorDrawable 或小尺寸 drawable,别把"首次加载占位图"和"加载完成图"叠成两份大内存。
4. 一次真实事故复盘:列表越滑越卡,内存为什么下不去
下面这条排查链路,是我在实际项目里完整走过的,非常适合作为你自己的排查模板。
当时的情况:一个图文信息流首页,每个卡片显示一张封面图,页面大概有 10 条一屏可滑。用户反馈:滑到中段开始顿,再滑一阵直接被系统杀掉。用低端测试机复现,Android Profiler 里的内存曲线是典型的锯齿爬升——每次 GC 掉一些,但整体峰值一直在往上走,几百 MB 都不回头。
第一步,用 Memory Profiler 抓 Heap Dump,直接搜 Bitmap 类。结果很惨:内存里有 30 多张 2000×1500 的 ARGB_8888 位图,每张约 12MB,光这批就是 400MB 上下。UI 上同时显示的只有三五张,剩下的全在 Glide 内存缓存和 BitmapPool 里。
第二步,看代码。加载语句是:
kotlin复制Glide.with(holder.itemView)
.load(url)
.into(holder.imageView)
ImageVIew 在布局里写的是 wrap_content,高度靠图片撑。Glide 测不到准确尺寸,又没写 override,就直接按原图尺寸解码。也就是说,显示只有 480×800,但内存里跑的是 2000×1500。这个放大倍数非常可怕。
第三步,量化一下。当时我们把卡片所有图片的显示尺寸对齐到 480×800,并加了一个统一 override(480, 800),然后参考第 3 节把默认格式切到 PREFER_RGB_565。改动后,同一台测试机、同一段滚动路径,内存峰值从 364MB 降到了 198MB,GC 频率肉眼可见地下降,闪退不再出现。
第四步,我们又加了一个内存缓存"体检"方法,方便线上和线下同时观察:
kotlin复制val memoryCache = Glide.get(context).memoryCache
Log.d("GlideMemory", "cache=${memoryCache.currentSize} / ${memoryCache.maxSize}")
运行之后发现,改造后 Glide 内存缓存普遍在 20MB 以内,BitmapPool 平时也只有十几 MB。这个数字看起来才正常。如果哪天突然涨到 100MB 以上,基本可以断定某处又加载了大图或者某个尺寸没被 override 管理住。
这次事故给我的经验就一句话:列表卡顿/OOM,百分之七八十的根因在"加载尺寸远大于显示尺寸"上,而不是"列表数据太多"。
5. 用数据说话:内存画像、验证手段与几个关键指标
优化做完不能只靠"感觉顺了"来收尾。我做内存优化时一定会上三件套:Android Profiler、LeakCanary、adb 命令。
- Memory Profiler 用来观测内存曲线和 GC 频率。优化前后用同一条滚动路径滑 1 分钟,截图对比峰值和 GC Interval。
- LeakCanary 用来找 Activity/Fragment 泄漏。Glide 造成泄漏的场景比较少见,反倒是在屏幕销毁后还持有
Glide.with(activity)的请求时,可能短暂持有引用。 - adb shell dumpsys meminfo 包名 可以在真机上快速看 Java Heap、Native Heap 和总量。注意观察 Bitmap 通常落在 Graphics/Native 区间。
还有一个我常用的笨办法:在 Application 的 onTrimMemory 里打日志,级别如果频繁出现 TRIM_MEMORY_RUNNING_MODERATE 甚至 RUNNING_LOW,说明 App 内存压力很高。优化前后对比这个日志的出现频率,比任何观感都靠谱。
另外,任何内存优化都逃不过"单张图片字节数"这个基本功。下面这张表建议贴到你们团队的 wiki 里:
| 图片像素尺寸 | ARGB_8888(4B/px) | RGB_565(2B/px) |
|---|---|---|
| 200×200 | 160KB | 80KB |
| 480×800 | 1.5MB | 0.77MB |
| 1080×1920 | 8.3MB | 4.1MB |
| 2000×1500 | 12MB | 6MB |
你只要看一眼列表里 ImageView 的实际尺寸,再乘上 Glide 缓存里可能存在的复本数量,就能估算出最坏内存占用。比如 12 个 item 各自显示 480×800 的图,用 ARGB_8888,同一时间可见 + 缓存里的复本最多 36 张,就是 54MB——对一个小内存设备来说已经很有压力。改成 RGB_565 和合理 override 后,同样条件下 12 张只有 9MB 左右。这差距直接决定了"能滑 10 分钟"还是"滑 1 分钟就崩"。
6. 落地经验阈值和取舍:几条可以抄走的规则
最后把我这些年踩坑踩出来的经验收敛成几条可以执行的标准,不一定适配所有业务,但大多数信息流和列表页面都能直接参考。
第一,图片加载尺寸不要超过显示尺寸的 1.5 倍。如果卡片图显示区域是 480×800,那 override 就是 480×800,最多放宽到 720×1200,再多就是浪费。超过这个比例,内存占用曲线会非常陡。
第二,列表页同时可见的图片内存总量,控制在 App 峰值内存的 10%~15% 以内。假设 192MB 的 heap,那么可见图片内存别超过 20~30MB。超过这个值,GC 就会开始频繁干活,滑动卡顿随之而来。
第三,低端机要单独测,不要只在旗舰机上"感觉挺顺"。用 adb shell am set-debug-app --persistent 包名 配合低内存设备测试,能快速暴露问题。真正决定线上口碑的,往往是最低配那批用户。
第四,别为了省内存把所有图片全 skipMemoryCache。这是另一个极端。高频复用小图仍然要内存缓存,否则每次滑动都会重新解码,CPU 上去后整体流畅度更差。内存优化的核心是"控制缓存里图片的尺寸和总大小",而不是"去掉缓存"。
最后说一个我自己的操作习惯:线上信息流类页面,我会默认对封面图、卡片图统一走一套 GlideOptions,里面固定 override 尺寸、RGB_565、DiskCacheStrategy.RESOURCE,再加一条 centerCrop。这套选项覆盖了 90% 的列表场景。只有头像、详情大图、横滑广告位等特殊位置,才单独 override 自己的配置。这样一来,新同学接需求时不容易写飞,内存曲线也基本稳定在一个区间里。等你把这种"默认配置 + 特殊情况单独覆盖"的规范立起来,RecyclerView 和 Glide 的内存问题就不再是时不时跳出来咬你一口的怪事,而是一个可以被预算和阈值管住的普通指标。
