做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_content,Glide拿不到一个确定的显示尺寸,就只能按原图加载,内存直接就爆了。
正确的姿势是: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就用View(holder.itemView),能绑定页面生命周期就绝不传ApplicationContext。
2.2 数据更新用DiffUtil,别再无脑notifyDataSetChanged
很多列表页以前更新数据都是直接notifyDataSetChanged(),这个方法会触发整个列表的重新布局和所有可见Item的重新绑定。也就是说,即使只有一条数据变了,其他没变的卡片也会重新执行onBindViewHolder,图片会重新加载(虽然有缓存),界面会闪烁,内存压力会被无谓放大。
现在比较标准的做法是用ListAdapter + DiffUtil。ListAdapter内部用AsyncListDiffer在后台线程计算新旧列表的差异,然后只对变化的Item做局部更新:
class FeedAdapter : ListAdapter<FeedItem, FeedAdapter.ImageViewHolder>(DiffCallback) { object DiffCallback : DiffUtil.ItemCallback<FeedItem>() { 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 合理设置缓存池和setHasFixedSize
RecyclerView内部有三个级别的缓存: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:id="@+id/imageView" android:layout_width="200dp" android:layout_height="200dp" android:scaleType="centerCrop" />布局里给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又不是centerCrop,Glide的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后附加处理参数生成缩略图,列表页请求w=512的缩略图,详情页请求w=1080的大图,各用各的,互不干扰。
- 如果服务端不支持处理参数,就统一用一个全局约定的列表页加载尺寸,不要每个页面各写各的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和error:
class 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 Heap:Java对象占用(Bitmap在部分场景也计入)
- Native Heap:Glide解码、硬件位图等也可能计入Native
- Graphics:与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复用、图片尺寸、缓存策略、请求生命周期管理,层层叠加起来,才换来一个流畅稳定的列表页。这些积累下来的经验,比任何单一技巧都值钱。