
做Android开发这几年我接手过好几个“列表页内存优化”的需求无一例外都和两个组件脱不开关系RecyclerView负责列表展示Glide负责图片加载。先说一个让人印象深刻的结论如果你发现列表一滑动内存就暴涨、机身发热、甚至直接闪退大概率不是手机不行而是这两个组件在配合上出了问题。今天这篇我围绕RecyclerView和Glide把内存优化这件事完整过一遍从原理到实操从工具定位到问题排查都是我实际踩过坑之后梳理出来的经验适合正在做信息流、电商、社交类App的朋友参考。1. 先算清楚账一张图片到底占多少内存1.1 Bitmap内存计算文件大小不等于内存大小很多新手容易犯一个认知错误图片在磁盘上只有300KB那加载到内存应该也只占300KB吧。完全不是图片文件在磁盘上经过压缩编码JPEG、PNG、WebP而加载到内存后是以Bitmap位图的形式存在的内存占用只跟像素尺寸和颜色格式有关跟文件体积基本没有关系。计算Bitmap内存的公式很简单内存占用 图片宽度 × 图片高度 × 每像素字节数Android里常见颜色格式ARGB_8888每像素4字节默认格式支持透明通道颜色质量最好RGB_565每像素2字节没有透明通道颜色精度略低ALPHA_8每像素1字节只有透明通道RGBA_F16每像素8字节高档位格式很少用举例来说一张1080×1920的图片用ARGB_8888加载1080 × 1920 × 4 8294400字节 ≈ 7.9MB一张4000×3000的手机照片同样用ARGB_8888加载4000 × 3000 × 4 48000000字节 ≈ 45.8MB注意大多数App能分到的Java堆内存也就256MB到512MB不同厂商阈值不同一张大图就占据40多MB再叠加几个页面、几张图同时存在内存爆炸几乎是必然的。1.2 信息流页面的内存是怎么慢慢涨上去的拿一个典型的信息流页面举例一屏显示5个卡片每个卡片里有一张大图假设图的分辨率是2000×1500那么一屏图片的内存占用就是2000 × 1500 × 4 × 5 60000000字节 ≈ 57.2MB这还没算RecyclerView本身的ItemView、文本、阴影、动画等占用的内存。一旦快速滑动旧页面还在内存中新的一屏又开始加载内存占用很容易超过100MB。如果加载的是原始尺寸的图片、又没有正确复用和取消请求内存就会持续攀升直到OOM。理解了这个账再去看RecyclerView和Glide的各种优化手段思路就会清晰很多优化的本质就是“减少Bitmap占用的字节数”和“减少同时存在的Bitmap数量”。2. RecyclerView的基础内存优化从布局到数据刷新2.1 ViewHolder复用与onBindViewHolder轻量化RecyclerView能做到列表流畅核心就是ViewHolder复用。滑动时离开屏幕的ItemView不会被销毁而是被回收进缓存池等新Item滑入时直接拿回来用省去了大量inflate和findViewById的开销。ViewHolder复用的机制本身不会带来内存问题真正的问题往往出在onBindViewHolder方法里。这个方法在每次Item滑入屏幕时都会执行一旦在里面做了耗时操作或大量对象创建不仅卡顿还会频繁触发GC导致内存抖动。我见过一些比较典型的反面写法override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item data[position] // 反面案例每次都创建新对象 val color #${item.color} holder.titleView.setTextColor(Color.parseColor(color)) // 反面案例做耗时计算 val format SimpleDateFormat(yyyy-MM-dd HH:mm:ss).format(item.time) holder.timeView.text format // 反面案例没有设置固定尺寸让Glide去猜 Glide.with(holder.itemView.context) .load(item.imageUrl) .into(holder.imageView) }这段代码里有几个坑SimpleDateFormat每次bind都会创建其实是可以通过成员变量复用的字符串拼接其实影响很小但每次parseColor都是额外开销。真正要命的是最后那个图片加载——如果ImageView没有固定尺寸或者父布局是wrap_contentGlide拿不到一个确定的显示尺寸就只能按原图加载内存直接就爆了。正确的姿势是onBindViewHolder里只做最轻量的赋值和数据绑定所有能提前初始化的对象都放ViewHolder里图片加载只负责传递URL。class ImageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val imageView: ImageView itemView.findViewById(R.id.imageView) private val dateFormat SimpleDateFormat(MM-dd HH:mm, Locale.getDefault()) fun bind(item: FeedItem) { imageView.setImageResource(R.drawable.placeholder) Glide.with(itemView.context) .load(item.imageUrl) .apply(GlideOptions.listImageOptions()) .into(imageView) timeView.text dateFormat.format(item.time) } }关于Glide.with里传什么Context这里有个很多文章都含糊带过、但实战中特别关键的点。Glide.with接受Activity、Fragment、View等参数它内部会绑定一个生命周期感知组件这样当Activity销毁时Glide会自动取消尚未完成的请求并释放相关资源。如果你图省事传ApplicationContext页面销毁后请求还在后台继续跑解码完的Bitmap没人消费白白占着内存。我在项目里的习惯是能用View就用Viewholder.itemView能绑定页面生命周期就绝不传ApplicationContext。2.2 数据更新用DiffUtil别再无脑notifyDataSetChanged很多列表页以前更新数据都是直接notifyDataSetChanged()这个方法会触发整个列表的重新布局和所有可见Item的重新绑定。也就是说即使只有一条数据变了其他没变的卡片也会重新执行onBindViewHolder图片会重新加载虽然有缓存界面会闪烁内存压力会被无谓放大。现在比较标准的做法是用ListAdapter DiffUtil。ListAdapter内部用AsyncListDiffer在后台线程计算新旧列表的差异然后只对变化的Item做局部更新class FeedAdapter : ListAdapterFeedItem, FeedAdapter.ImageViewHolder(DiffCallback) { object DiffCallback : DiffUtil.ItemCallbackFeedItem() { override fun areItemsTheSame(oldItem: FeedItem, newItem: FeedItem): Boolean { return oldItem.id newItem.id } override fun areContentsTheSame(oldItem: FeedItem, newItem: FeedItem): Boolean { return oldItem newItem } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_feed, parent, false) return ImageViewHolder(view) } override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { holder.bind(getItem(position)) } }这样每次数据更新时只需要调用submitList(newList)Adapter会异步算出差集只刷新变化的部分。实测一个500条数据的列表用DiffUtil和用notifyDataSetChanged相比内存峰值能低10%-20%滑动过程也更平稳因为GC次数减少了。2.3 合理设置缓存池和setHasFixedSizeRecyclerView内部有三个级别的缓存mAttachedScrap屏幕内复用、mCachedViews默认缓存2个ViewHolder通过setItemViewCacheSize调整、RecycledViewPool跨RecyclerView共享的ViewHolder池。很多优化文章一上来就说“把setItemViewCacheSize调大比如调到20”但这其实是个典型的陷阱。mCachedViews里缓存的ViewHolder会持有整个ItemView而ItemView里的ImageView如果还持有图片引用这个ViewHolder占用的内存很可能达到几MB甚至十几MB。缓存20个ItemView就意味着多出20张图片的内存占用。我开始也试过把缓存调到10结果列表确实顺了一点但内存曲线明显上了一个台阶。我的建议是默认的2个足够不需要动。只有当你明确知道某个列表来回滑动同一批Item非常频繁才考虑调到4到6而且一定要结合Memory Profiler观察内存变化。同时如果列表项中包含大量图片可以在onViewRecycled里主动清理图片避免缓存池里的ViewHolder长期持有大图。setHasFixedSize(true)是一个被很多人忽略的小选项当你知道Item的尺寸不会影响RecyclerView自身宽度/高度时设置它为true可以在数据变化时跳过重新测量布局的步骤减少布局计算带来的内存和CPU开销。2.4 嵌套列表的注意事项信息流App最忌讳的就是“竖向RecyclerView嵌套竖向RecyclerView”。这种结构不仅会让嵌套的内层RecyclerView整体走一次测量和布局流程而且每个内层列表都维护自己的缓存池和预取机制整体内存开销会翻倍。如果产品上无法避免嵌套有几个做法内层RecyclerView设置setNestedScrollingEnabled(false)禁止嵌套滚动减少滚动冲突和额外绘制内层RecyclerView不用自己的缓存池与父列表共享RecycledViewPool如果内层是固定几个Tab横向切换可以考虑ViewPager2 共用Pool但说到底最推荐的方案是用多种ItemType 一个RecyclerView通过getItemViewType来区分不同卡片布局避免任何形式的嵌套。3. Glide加载策略优化尺寸、缓存与格式三板斧3.1 Glide的Bitmap池和LruCache工作机制Glide的性能之所以好很大程度上是因为它维护了两层内存机制LruCache缓存最近使用的图片资源和BitmapPool复用Bitmap内存。LruCache保存的是最近解码完成的图片资源命中后再次加载相同URL基本不消耗解码时间。BitmapPool则更加精妙它回收已经不再使用的Bitmap内存块供后续加载相同尺寸、相同配置的Bitmap时直接复用大大减少创建和回收Bitmap的次数降低GC压力。默认情况下Glide的内存缓存大小并不是随意定的它通过MemorySizeCalculator根据设备的maxMemory计算出一个相对合理的值大概是可用内存的20%左右。在大多数场景下这个默认值是够用的不要为了“优化”盲目调大缓存越大不一定越流畅反而可能挤占其他页面的可用内存。3.2 尺寸匹配override与scaleType是黄金搭档这是我做列表页内存优化时收获最大、也最想强调的一个点。Glide在加载图片时会根据目标ImageView的尺寸来决定解码时采样多少像素。如果ImageView宽高都确定Glide会尽量按这个尺寸解码如果ImageView是wrap_content或者父容器没有约束导致宽高测量结果不明确Glide就很可能按图片原始尺寸解码内存占用直接拉满。看一个常见的坑ImageView android:idid/imageView android:layout_width200dp android:layout_height200dp android:scaleTypecenterCrop /布局里给ImageView固定了200dp×200dp看起来没问题。但如果这个Item被复用在不同的屏幕密度设备上200dp在xxhdpi设备上实际是600px在xxxhdpi上是800px。不同设备加载时Glide解码出的Bitmap尺寸自然不同内存占用也各不相同。这还只是密度问题更极端的是有的ImageView干脆没设尺寸。我的做法是在布局里固定宽高比或者用定宽定高同时代码里用override给一个明确的像素尺寸Glide.with(itemView.context) .load(item.imageUrl) .override(512, 512) .centerCrop() .into(holder.imageView)这里512×512只是一个示例实际项目中应该根据设计稿和最大展示尺寸来定。原则上列表页的图片加载尺寸不要超过实际显示尺寸的两倍超过的部分完全是无意义的内存开销。关于override与scaleType的配合override指定了解码后的Bitmap尺寸centerCrop则决定了如何从这张Bitmap裁剪填充到ImageView。如果不写centerCrop而且ImageView的scaleType又不是centerCropGlide的RequestOptions默认会尽量用fitCenter效果和ImageView的显示方式可能不一致导致图片变形或空白边。所以记住override、centerCrop、ImageView的scaleType三者要按同一个目标尺寸对齐。3.3 缓存策略什么时候改什么时候千万别动Glide默认开启磁盘缓存和内存缓存绝大多数场景下不要关闭。但有不少人来问我“我明明加载同一个URL为什么内存缓存没生效” 排查下来基本都是因为同一个URL在不同View上用了不同的override尺寸。Glide的缓存Key里包含URL、签名、变换、尺寸等信息尺寸不同就相当于两份缓存多来几种尺寸内存缓存就被各种尺寸的副本塞满了。举例说明同一个URL在列表页用override(512, 512)加载在详情页用override(1080, 1080)加载在头像位置用override(100, 100)加载这三个请求生成的Bitmap尺寸完全不同Glide的内存缓存里就会存三份。这还只是一个URL如果是几十个URL内存缓存很容易被塞爆。解决思路有两条路径让服务端按不同场景提供不同尺寸的图片URL。比如七牛、阿里云OSS等对象存储都支持在URL后附加处理参数生成缩略图列表页请求w512的缩略图详情页请求w1080的大图各用各的互不干扰。如果服务端不支持处理参数就统一用一个全局约定的列表页加载尺寸不要每个页面各写各的override。至于skipMemoryCache(true)一般情况下不建议在业务里频繁调用。它适合的场景是一次性展示后就不再需要的超大图比如启动页、闪屏且加载尺寸明显超出常规页面。平时列表加载千万别乱用一旦关了内存缓存每次滑回来又得重新解码内存占用可能更高因为解码过程会临时创建大量中间对象。3.4 缩略图和预加载的正确用法Glide的thumbnail可以在加载大图之前先显示一个小尺寸的占位版本显著提升视觉体验Glide.with(itemView.context) .load(item.imageUrl) .thumbnail(0.1f) .into(holder.imageView)thumbnail(0.1f)的意思是先用目标尺寸的10%解码一张缩略图快速显示原图加载完成后再替换。内存上缩略图本身占用的Bitmap非常小原图加载完会替换掉它因此对峰值内存影响很小。这个方案适合大图详情页不太适合列表小图因为列表本身加载的就是小尺寸再套thumbnail意义不大。Glide还有preload方法可以提前加载某张图片到内存缓存Glide.with(context) .load(url) .override(512, 512) .preload()preload适合的场景是你已经预知用户接下来会看到某张图比如查看相册的下一张。用在随机无限滚动的信息流里反而不合适因为预加载会占用的内存会影响当前页面的缓存空间甚至可能把当前正在展示的图片挤出LruCache得不偿失。3.5 Glide全局配置从Application入手如果项目里图片加载策略比较统一可以通过AppGlideModule做一个全局配置减少每个页面重复设置GlideModule class MyGlideModule : AppGlideModule() { override fun applyOptions(context: Context, builder: GlideBuilder) { val memoryCacheSize 20 * 1024 * 1024 builder.setMemoryCache(LruResourceCache(memoryCacheSize.toLong())) val bitmapPoolSize 20 * 1024 * 1024 builder.setBitmapPool(LruBitmapPool(bitmapPoolSize.toLong())) } }但这里必须提醒一句不要看了我的代码就照抄。Glide的默认缓存大小是参考设备内存算出来的多数场景下直接用默认值更安全。我之所以在项目里自定义缓存大小是因为那个App的图片尺寸已经通过override严格统一了并且整个应用对内存上限有硬性要求。如果你还没做到尺寸统一去改缓存大小很容易让列表页和详情页互相挤占缓存空间。先做尺寸规范再考虑缓存配置这个顺序不能乱。还有一种常见的全局设置是给所有请求加默认的placeholder和errorclass MyGlideModule : AppGlideModule() { override fun registerComponents(context: Context, glide: Glide, registry: Registry) { // 这里可以注册自定义组件 } }不过placeholder的设置更常用的是在代码里GlideOptions object GlideOptions { val listImageOptions: RequestOptions RequestOptions() .placeholder(R.drawable.placeholder) .error(R.drawable.error) .override(512, 512) .centerCrop() }把通用选项封装成单例避免每个bind里都new一个RequestOptions也是一种减少对象创建、降低GC频率的优化手段。3.6 图片格式的取舍RGB_565能省一半内存但要谨慎前面提到RGB_565每像素只占2字节是ARGB_8888的一半。对颜色简单、不透明、没有渐变的图片用RGB_565视觉上几乎看不出差别内存却能省下一半。Glide 4.x里有一个细节它并不总是用ARGB_8888在某些情况下会自动选择RGB_565比如图片本身没有透明通道且是JPEG格式时。但如果想强制指定可以这样Glide.with(itemView.context) .load(item.imageUrl) .asBitmap() .format(DecodeFormat.PREFER_RGB_565) .into(holder.imageView)需要注意如果图片带圆角、阴影、半透明效果RGB_565那种色带和锯齿问题会非常明显尤其是深色渐变背景上。所以这个开关适合用在纯色商品图、头像等场景用在精致度要求高的UI上要谨慎。4. 滑动场景专项优化让列表又稳又顺4.1 滑动时暂停加载信息流页面的一个典型场景是用户快速滑动列表屏幕内外一瞬间切换了几十条Item。每个Item都立即发图片请求但很多请求刚发出去Item就已经滑出屏幕了解码完的Bitmap没有机会展示白白浪费内存和CPU。比较标准的做法是监听RecyclerView的滑动状态在快速滑动时暂停Glide的请求等滑动停止后再恢复加载recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { when (newState) { RecyclerView.SCROLL_STATE_DRAGGING, RecyclerView.SCROLL_STATE_SETTLING - { Glide.with(context).pauseRequests() } RecyclerView.SCROLL_STATE_IDLE - { Glide.with(context).resumeRequests() } } } })pauseRequests会让当前所有未完成的请求暂停但已经加载完成的图片不受影响。滑动停止后resumeRequests再恢复加载。这个优化在图片较大、网络较慢的场景下效果特别明显内存峰值能下降不少因为不会有一堆“滑出去就没用了”的请求继续解码。注意一个细节如果列表里还有比较重要的图比如首图、封面图可以在暂停时排除掉这些item或者用Priority调高这些请求的优先级。但一般业务不需要这么精细先做整体暂停/恢复就够了。4.2 onViewRecycled里取消请求ViewHolder被回收时如果ImageView还持有上次加载的图片并且图片加载请求还在执行中可能会在Item复用到其他位置时出现“旧图瞬间闪烁、然后被新图替换”的问题更严重的是请求在后台持续占用内存。正确做法是重写onViewRecycled方法在ViewHolder被回收时主动清理图片资源override fun onViewRecycled(holder: ImageViewHolder) { super.onViewRecycled(holder) Glide.with(holder.itemView.context).clear(holder.imageView) }这里调用的Glide.with().clear()不仅仅会取消ImageView上的加载请求还会把ImageView上的Drawable置空。很多开发者有个误解以为clear只是取消请求其实它会释放图片引用。如果不做这一步被回收的ViewHolder会一直持有大图缓存池里的ItemView越多残留的Bitmap内存就越多。4.3 图片复用与错位问题给ImageView设置tag并配好固定尺寸的URL是解决图片错位最常用的方法。Glide内部其实已经做了这个事当你调用into(holder.imageView)时Glide会把当前请求和一个target绑定到ImageView上。如果复用时发生了新的加载请求会先取消旧的请求再设置新的图片。所以只要遵循规范错位问题基本不会出现。但在实际项目中我们还会遇到另一种情况同一个URL在短时间内在多个不同的ViewHolder里反复加载。比如列表中同一张图出现多次或者上下滑动快速经过。这个时候如果每次都走Glide的完整加载链路即使内存缓存命中了也还是会有一些额外开销。优化方式是在列表页启动时可以对服务端支持裁剪的图片URL做一次“预加载”或者“预热”把很可能用到的图片提前放进缓存。更推荐的做法是利用服务端的图片处理参数让同一个业务ID的图片URL保持稳定。比如商品图的URL是https://cdn.example.com/product/123.jpg?imageView2/2/w/512这个URL稳定不变Glide的缓存才能真正命中。如果每次请求都在URL后面拼上时间戳或者随机参数缓存形同虚设内存和流量的开销都会成倍增加。5. 实战排查用Profiler和dumpsys定位内存问题5.1 Memory Profiler怎么看内存曲线Android Studio自带的Memory Profiler是我做内存优化时最常用的工具。用法很简单运行App打开Profiler窗口进入Memory面板点击Record开始录制然后操作列表滑动过一会儿停止录制就能看到内存曲线。重点观察几个信号快速滑动时内存曲线突然飙升几十MB多半是图片加载尺寸偏大内存曲线呈现出规律的“锯齿状”说明有大量对象在反复创建和回收GC频繁退出页面后内存没有回落说明有泄漏或者图片资源没有被释放如果发现曲线持续上升不回落可以抓一个Heap Dump点击Dump Java Heap按钮等一会儿会生成一个hprof文件然后在Analyzer Tasks里选择Detect Leaked Activities或者直接用Class List按Retained Size排序。绝大多数情况下内存占用排在前面的都是Bitmap对象看它们的字节数可以直接验证我们前面说的“尺寸不匹配”问题。5.2 adb shell dumpsys meminfo看整体分布Memory Profiler在IDE里用起来方便但有些场景不方便开Android Studio比如在真机上做回归测试。这时候可以用adb命令adb shell dumpsys meminfo package_name输出里会有一个TOTAL和一个Java Heap、Native Heap的概览还会细分到每个进程的各个部分。重点关注TOTAL进程总内存Java HeapJava对象占用Bitmap在部分场景也计入Native HeapGlide解码、硬件位图等也可能计入NativeGraphics与GPU相关的内存如果Graphics特别高说明GPU资源占用严重往往是图片太多或者有大量动画、阴影效果。5.3 排查时最常见的三个故障场景场景一快速滑动时OOM。这个基本就是图片尺寸问题。检查onBindViewHolder里的Glide请求是不是没有override或者ImageView宽高没有确定值。先用统一尺寸加载一张图跑一遍内存大概率明显下降。场景二内存曲线稳步上升但退出页面后不降。先查Activity有没有泄漏用LeakCanary或者手动dump看Activity实例数再看onViewRecycled有没有清理图片以及Glide.with传的Context是不是Application。这几个原因排查完基本能定位。场景三列表平时正常偶尔卡顿几秒。这种往往是GC频繁。主要查onBindViewHolder里是不是创建了大量临时对象或者图片加载尺寸不统一导致Glide频繁解码、频繁触发BitmapPool回收。6. 常见问题速查与避坑笔记6.1 问题速查表现象原因解决方案快速滑动时OOM图片加载尺寸过大没有按显示尺寸解码override centerCrop服务端缩略图滑动时内存持续上涨图片请求没有在onViewRecycled里取消Glide.with().clear(holder.imageView)退出页面后内存不降Activity泄漏或Glide用了ApplicationContext使用生命周期感知的with(fragment/activity)同一个URL图片反复加载每次请求尺寸不同内存缓存命中率低统一override尺寸或按场景分URL列表偶发卡顿、GC频繁onBindViewHolder里创建对象过多轻量化bind方法使用DiffUtil图片错位、闪烁复用时旧图未清理clear placeholder避免请求复用target缓存越来越大不释放调大了ItemViewCacheSize恢复默认2不要盲目调大6.2 一些值得长期坚持的细节习惯我做了几次列表页优化之后慢慢形成了一些固定习惯这里分享几个我觉得性价比特别高的第一列表页图片尺寸统一管理。在项目里我一般用一个常量类存放所有列表页的图片加载尺寸比如ListImageWidth 512详情页宽图 1080头像 128。这样不会出现页面A用400、页面B用600的混乱局面。这个看似不起眼的规范对Glide内存缓存命中率的提升帮助非常大。第二每次改完必须用Profiler记录前后对比。不要凭感觉说“好像流畅了”用数据说话。记录三个指标内存峰值、GC次数、掉帧数。改一次对比一次你能非常直观地看到优化手段的效果。第三谨慎对待“快速实现”类的优化技巧。开发圈里流传着很多“经验”调大缓存池、关闭硬件加速、把所有文件都丢到内存里。这些技巧往往有特定的适用场景脱离场景盲目使用很可能把一个内存问题变成另一个内存问题。6.3 服务端配合是最大的杠杆最后想再强调一个容易被忽视的点内存优化不只是客户端的事。如果服务端能支持裁剪缩略图直接返回一个合理的尺寸客户端这边只需要加载对应尺寸的图内存和流量双降。我遇到过不少项目服务端的图片是原始照片或者设计稿导出的超大图客户端这边即使用override缩到100dp显示Glide在解码时依然要去网络下载一张几MB的原图再在本地做采样。这种情况下客户端能做的只是“事后再裁剪”浪费的流量和内存其实已经发生了。更好的方案是让服务端或CDN/OSS处理图片尺寸客户端只要请求xxx.jpg?imageMogr2/thumbnail/512x512这样的缩略图地址加载速度、内存占用、视觉清晰度都能兼顾。实现这个通常只要和后台同事对齐一个规则给客户端的所有图片字段提供一套带缩略图参数的URL或者给一个图片处理服务的接口。在图片流类App里这个投入产出比远高于客户端内部砸代码做优化。经过几个项目的磨炼我的体会是内存优化不是一个配置文件、一行代码就能搞定的事它更像是一个系统性工程。布局结构、ViewHolder复用、图片尺寸、缓存策略、请求生命周期管理层层叠加起来才换来一个流畅稳定的列表页。这些积累下来的经验比任何单一技巧都值钱。