Android大图显示这一块我前前后后折腾了快两周才把线上图片相关的OOM率给压下去。之前第一篇聊了采样压缩说的是缩小尺寸再进内存的基本功这次这个二篇我把显示链路里的策略优化完整梳理一遍从尺寸预算、解码方案、缓存复用到长图手势尽量把每个选型背后的原因讲透。适合正在做图片列表优化、被超大图和长图搞得焦头烂额、以及想把手上的Glide/Fresco用明白的Android开发同学参考。1. 先拆问题大图显示到底卡在哪1.1 一张图为什么会把内存干爆先算一笔账。一张12000px乘8000px的全景照片如果以默认的ARGB_8888格式直接解码进内存每个像素占4个字节计算下来是12000 × 8000 × 4 384MB。这是什么概念大多数App给应用分配到的Java堆内存也就256MB到512MB一张图下来直接开席。即使只有一块手机屏幕那么大原图是4000px宽全量解码也要60多MB。很多同学会想那我不显示那么大的区域只让它显示在ImageView里不就行了问题在于如果你直接把原图交给BitmapFactory解码它不会管你ImageView多大它会按原图的像素尺寸老老实实把所有像素解出来内存占用按原图算和你在屏幕上看多大多小没关系。这就是大图显示第一个坑显示尺寸和解码尺寸是两回事控制不好就炸。1.2 显示链路的三处瓶颈内存只是最直观的瓶颈真正做优化的时候你会发现一条完整的大图显示链路至少有四个环节会卡解码耗时把JPEG/WebP压缩数据变成原始像素这个过程是CPU密集型的一张4000px的大图解码可能要100ms以上。在主线程直接调用用户滑动列表时就等着掉帧吧。内存占用原始像素驻留堆内存解码一张、释放一张的节奏如果控制不好GC频繁触发卡顿如影随形。纹理上传从Java堆到GPU纹理这个搬运过程需要时间而且大图超过GL_MAX_TEXTURE_SIZE限制时根本没法完整上传。绘制合成Canvas绘制一张超大Bitmap时层级多、区域大合成开销和内存带宽都会被拉高。所以大图显示策略不是单一技巧是一个链路问题。内存优化是基础解码策略是核心缓存复用是手段交互层的显示策略是最终体验四者缺一不可。下面按实际操作顺序一个个拆。2. 解码前先做尺寸预算别让大图进内存2.1 按控件尺寸计算采样率思路很简单ImageView只要显示1000px宽那么原图4000px我取1/4采样就够了没必要保留4000px的原始像素。Android里对应的做法是BitmapFactory.Options里的inSampleSize。但我见过太多人直接写inSampleSize 4或者干脆inSampleSize 2拍脑袋定参数。正确的做法是先读图片边界再结合目标控件尺寸计算。BitmapFactory.Options opts new BitmapFactory.Options(); opts.inJustDecodeBounds true; BitmapFactory.decodeFile(path, opts); int imageWidth opts.outWidth; int imageHeight opts.outHeight; // 目标尺寸以控件或列表项的实际尺寸为准而不是屏幕尺寸 int targetWidth imageView.getWidth(); int targetHeight imageView.getHeight(); int sampleSize 1; while (imageWidth / (sampleSize * 2) targetWidth imageHeight / (sampleSize * 2) targetHeight) { sampleSize * 2; } opts.inSampleSize sampleSize; opts.inJustDecodeBounds false; Bitmap bitmap BitmapFactory.decodeFile(path, opts);这段代码有几个细节值得说。inJustDecodeBounds true这一步只读文件头信息不会把整个图片解码进内存开销极小所以先跑一遍完全值得。计算采样率用while循环翻倍而不是直接拿宽高比取整是因为inSampleSize必须是2的幂。文档虽然描述为“大于等于1的整数时系统会向下取整为最近的2的幂”但我建议还是自己在代码里显式给出标准值可读性和可控性都更好。为什么要求2的幂因为采样实现是每隔sampleSize个像素取一个非2的幂会导致采样点分布不均匀边缘和细节容易出现奇怪伪影。用2的幂可以让解码器走更高效的快速路径代价最小。2.2 局部大图用区域解码有一种场景很特殊图片整体特别大但你只显示其中某个局部。比如地图截图、超长网页截图、高分辨率扫描件你只想看左上角或中间某一块。如果整张图解码进内存采样率照顾了局部清晰度整图内存还是爆炸。这时候要用BitmapRegionDecoder。它允许你只解析图片的某个矩形区域。BitmapRegionDecoder decoder BitmapRegionDecoder.newInstance(path, false); Rect rect new Rect(left, top, right, bottom); BitmapFactory.Options opts new BitmapFactory.Options(); opts.inSampleSize 1; Bitmap region decoder.decodeRegion(rect, opts);这个方案的核心是只解码可见区域配合手势拖动时实时更新Rect。实际做下来一个10000px宽的地图文件整解码要一百多MB用区域解码每帧只吃几十KB差距非常恐怖。但要注意BitmapRegionDecoder对图片格式有要求一般支持JPEG、PNG、WebP但PNG的区域解码性能差一些JPEG表现最好。如果项目里大量使用PNG大图建议先转WebP或JPEG再走区域解码。2.3 硬件位图和像素格式也别忽略解码阶段还有一个容易被漏掉的选择inPreferredConfig。默认是ARGB_88884字节/像素但如果图片本身没有透明度需求RGB_5652字节/像素能让内存直接减半。代价是色彩精度下降渐变和暗部可能出现色带。我一般只对缩略图、模糊背景图用RGB_565主图不碰色带问题在深色主题下特别显眼。Android 8.0之后还可以考虑inPreferredConfig HARDWARE硬件位图它直接存在GPU显存里不进Java堆。列表快速滑动时硬件位图的绘制效率非常高但缺点是Bitmap对象上的像素读写API基本不可用而且实时渲染状态不好追踪调试和上报内存数据时容易踩坑生产环境务必在列表滚动场景做充分测试。3. 缓存和复用跑得快的秘诀在省着用3.1 inBitmap复用的三个硬条件解码出来的Bitmap对象如果频繁创建又释放会产生大量内存碎片和GC压力。Android从3.0开始支持inBitmap在解码时将内存复用到已经废弃的Bitmap对象上避免重新分配。BitmapFactory.Options opts new BitmapFactory.Options(); opts.inBitmap reusableBitmap; opts.inSampleSize 2; opts.inMutable true; Bitmap bitmap BitmapFactory.decodeFile(path, opts);但inBitmap有严格的限制我用的时候被这些细节卡了好几次版本差异Android 4.4以下只能复用相同大小的Bitmap4.4及以上要求被复用的Bitmap内存必须大于等于新解码的Bitmap所需内存注意是大于等于不是相等。像素格式必须一致ARGB_8888和RGB_565不能混着复用否则解码会静默失败或直接抛异常。复用后原Bitmap的引用要全部移除否则解码器会认为它还在使用中拒绝复用。这里最常见的坑就是RecyclerView里ViewHolder还占着旧引用下一帧复用时会触发IllegalArgumentException报错信息就是“Problem reading a tile”。Glide内部其实帮你做好了inBitmap管理所以如果项目里能用Glide就别自己手写复用池容易得不偿失。3.2 分级缓存怎么配合很多团队的缓存方案是内存LruCache存缩略图磁盘缓存存压缩后的网络图。听起来合理但有一个隐患——LruCache的默认大小用得很随意动不动就设成“最大内存的1/8”结果大图列表里明明只显示小图缓存里却塞满了不同尺寸的大图最终内存还是爆。我的建议是分三级原图只落磁盘不进内存。这是底线任何情况都不让原图直接解码进LruCache。按目标尺寸解码后的图进LruCachekey用“文件路径 目标宽高 采样率”拼避免同一张图不同尺寸互相挤占。View级别再做一遍弱引用缓存专门服务当前可见区域的快速重绘。这里的核心逻辑是缓存的单位应该是“显示所需的那份像素”而不是“原图那份像素”。把这条定明白了LruCache大小其实不需要很大我一般给图片列表单独开一个16MB到32MB的LruCache就够用比全局大缓存有效得多。3.3 缓存键粒度要细别偷懒缓存键这个点太小了容易被忽略但线上查问题的时候它特别有价值。不要粗暴地只用URL做key建议包含图片文件/URL地址目标宽度和高度采样率图片格式JPEG/WebP/PNG后缀上解码参数版本号比如加一个“v2”因为代码升级后解码规则可能变化缓存不失效会出现新代码用旧缓存的问题。我踩过一次很典型的坑列表页和详情页加载同一张图片但两个页面的控件尺寸不同直接共用URL缓存结果详情页放大后糊成一片。后来把尺寸加到key里才解决。4. 长图与缩放手势交互侧的专项优化4.1 长图加载别用整解码方案微博、知乎那种超长截图经常会有一张2万像素高的图片。如果直接把整张解码进内存内存直接飙升绘制时还可能因为超出GPU纹理上限而变黑屏。正确姿势是“分块加载 动态绘制可见区域”业界已经有一个很成熟的库叫SubsamplingScaleImageView它的核心能力就是把大图按Tile切成若干小块只解码当前屏幕可见的那些块。手势缩放时自动切换采样级别小比例用低分辨率Tile放大到一定程度再加载高分辨率Tile。支持超大图几万像素不至于OOM。如果项目需求不复杂直接引这个库是性价比最高的方案。但如果想自己控逻辑记住三个要点不要持有整图Bitmap只持有Tile级别的Bitmap并且在上滑时及时释放不可见Tile。缩放级别和采样率要联动比如scale是1.0时inSampleSize4scale放大到2.0时inSampleSize2继续放大到4.0时inSampleSize1保证“显示多清晰解码多精细”。拖动时解码要异步先显示低分辨率占位再逐步替换为高清块否则滑动过程中频繁解码会卡到没法用。4.2 自写缩放手势的避坑指南如果你只想在自己的自定义View里加双击放大、手势缩放避免动不动就引大库那么可以基于Matrix来实现但有几个坑要提前规避。别在onDraw里做解码和图片裁剪。我见过不少同学的实现在onDraw里根据scale动态生成新Bitmap再draw结果每次手势变化都卡一下连着滑动基本就ANR了。正确的做法是手势变化只更新MatrixonDraw里直接canvas.drawBitmap(bitmap, matrix, paint)让GPU做缩放。矩阵变化开销很小真正的解码操作放到手势“停下”之后再触发。限制最大缩放比例。很多人把最大缩放设成无限大结果图糊成一团还卡得要死。建议最大缩放不超过原图分辨率/屏幕分辨率的比例或者固定一个经验值4到5倍超过这个值上高清重新采样否则纯属浪费内存。手势期间禁止频繁触发内存申请。快速双指缩放的帧率很高onTouchEvent里如果每帧都new对象内存抖动立刻显现。用Matrix和原始Bitmap直接绘制不要为了“更清晰”而临时解一个高分辨率Bitmap。4.3 列表场景下的提前释放大图显示策略里还有一个容易忽略的场景RecyclerView快速滑动。很多时候图片加载库帮你把图加载出来了但列表滑走之后ImageView还在持有这个Bitmap引用导致内存迟迟不释放滑多了之后GC连环触发卡出天际。配合RecyclerView的监听做释放非常有效recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrollStateChanged(NonNull RecyclerView recyclerView, int newState) { if (newState RecyclerView.SCROLL_STATE_IDLE) { // 只对可见范围内的item恢复加载 } else { // 滑动中暂停加载不可见item回收移出屏幕的图 } } });这里要注意的是别在onScrolled里做复杂操作它调用频率极高容易把主线程拖垮。状态判断放onScrollStateChanged加载逻辑交给图片库的优先级机制这样既能卡住内存又不会牺牲滑动流畅度。5. 线上问题排查与性能数据验证5.1 怎样抓到真实的OOM现场很多同学说“我本地试不出来线上就崩”。这很正常线上图片是五花八门的测试永远覆盖不全。我自己的经验是两条路同时走第一线上崩溃监控里把OOM相关的堆栈单独归一类。OOM的堆栈特征很明确一般是Thread running、MessageQueue.nativePollOnce或者在BitmapFactory.decodeFile附近。不要只看堆栈还要把当时的机型、Android版本、堆内存水位一起上报才能判断是“图片太大”还是“累积泄漏”。建议崩溃详情里把已用堆内存和堆上限两个值打出来这是定位OOM最直接的线索。第二本地用Android Studio的Profiler抓内存快照专门挑那种“疯狂滑动列表3分钟”的用例。观察内存曲线是不是阶梯式上升不回落如果是说明有Bitmap没被释放优先查是不是某个Singleton或者全局缓存持有了View引用。用Memory Profiler里的“Java Heap”区域能看到Bitmap对象占用的byte数组按Shallow Size排序谁大谁就是嫌疑对象。5.2 用哪些指标验证优化效果优化之前和优化之后一定要有数据对比否则你做的优化和玄学没区别。我个人常用的指标有这么几个启动加载首图时间从Activity创建到首张图片显示最好控制在300ms内。列表滑动帧率用systrace或者GPU呈现模式分析目标是不频繁低于30FPS。GC次数和GC时间dumpsys meminfo或者Profiler里看优化后GC次数应该有明显下降。OOM率/崩溃率对比上线前后的线上数据这是最终指标。拿一张具体的线上崩溃为例我们当时某款低端机上单图解码导致OOM崩溃率是0.31%。加了采样率计算 尺寸预算 分级缓存之后崩溃率掉到0.04%GC次数也明显减少。这些数字在排查时能帮你确认优化方向是否有效而不是靠感觉。5.3 常见问题速查表我在实现过程中整理了一套问题排查清单直接放在这里遇到类似问题可以对照着查。症状可能原因排查方向大图列表滑动卡顿主线程直接解码或GC频繁Profiler看decode耗时解码切线程处理检查Bitmap是否频繁创建图片发糊inSampleSize过大或缓存key缺少尺寸维度降低采样率把目标尺寸加进缓存key放大后模糊只用了原图有限采样倍数结合scale动态调整采样级别放大时加载高分辨率区域OOM集中出现在某些机型堆内存本来小图片又没做尺寸预算对比机型堆上限优先在低端机走更激进的采样策略图片加载后内存不降缓存持有未释放或图片库配置过大检查LruCache大小确认是否只有可见区域持有引用同一张图反复加载慢磁盘缓存没生效检查diskCacheStrategy和文件名key是否稳定这个表的价值在于遇到问题时不要急着改代码先判断是内存、解码、缓存还是交互层的哪个环节出了问题然后对症下药。写在最后一次架构层面的反思做完整轮优化我最大的体会是大图显示优化不是单一技巧堆叠而是一套以“像素尺寸预算”为主线的工程方法。从解码前的采样率计算到缓存复用再到长图手势的分块策略本质上都在回答同一个问题——当前屏幕上真正需要的像素是多少以及怎样以最小代价把这份像素准备好。我后来做新项目的时候会把“尺寸预算”直接做成一个工具类所有图片入口统一走这套逻辑再配合Glide的自定义配置线上再没出现过图片相关的OOM事故。如果你的项目长期被大图问题困扰建议不要打到哪补到哪先把这套预算-解码-缓存-显示的链路打通体验会有一个质的提升。