真实页面里最常遇到的几个问题:
- 图片为什么被拉变形?
- 头像为什么要圆形,商品图为什么要完整?
- 网络图加载中、失败、业务为空,能不能用同一张图糊弄过去?
- 一张 4000×3000 的照片为什么会把页面拖垮?
- Glide 已经有缓存了,为什么还要理解
LruCache?
这几个问题比"ImageView 有多少个属性"更重要。因为真实业务里,图片问题往往不是"不显示",而是显示得不稳、不准、不省内存。
这次 Demo 中的功能:切换 scaleType、看圆角/圆形、加载网络图、演示 fallback、加载 GIF、计算大图内存、执行采样解码、查看 LruCache 命中情况。
相关资料:
- Android 官方《高效加载大型位图》:确认
inJustDecodeBounds、inSampleSize、按目标尺寸解码这些是官方推荐的大图加载基本功。 - Glide 官方缓存文档:确认 Glide 的缓存查找顺序是
Active Resources、Memory Cache、Resource Disk Cache、Data Disk Cache,并且缓存 key 不只是 URL,还和尺寸、Transformation、Options、Signature 有关。 - Glide 官方占位图文档:确认
placeholder、error、fallback语义不同;占位图来自 Android resources,会在主线程加载;Transformation 不会作用到 placeholder/error/fallback。 - Fresco Getting Started:确认 Fresco 是集中式 Image Pipeline 初始化模式,能自动下载、缓存、显示,并在 View 离屏后清理内存。
scaleType:别先背枚举,先问能不能裁
ImageView 的 scaleType 很容易被写成表格题:center、centerCrop、fitCenter、fitXY......背完一圈还是不会选。
我觉得更好的判断方式是先问两句话:
- 这张图能不能被裁掉一部分?
- 这张图能不能被拉伸变形?
比如信息流封面,卡片大小必须整齐,不希望露白,这时候通常接受裁剪,所以用 centerCrop。商品详情图不一样,商品边角、文字、规格图都可能是有效信息,裁掉一部分就麻烦,所以更适合 fitCenter。至于 fitXY,除非你明确允许变形,否则最好别碰。
Demo 里我生成了一张带 LEFT、SUBJECT、RIGHT 标记的图片,就是为了让裁剪结果一眼能看出来。
kotlin
private val scaleTypes = listOf(
ImageView.ScaleType.CENTER to "center:不缩放,放不下就裁掉",
ImageView.ScaleType.CENTER_CROP to "centerCrop:填满容器,可能裁掉边缘",
ImageView.ScaleType.CENTER_INSIDE to "centerInside:完整显示,大图会缩小",
ImageView.ScaleType.FIT_CENTER to "fitCenter:完整显示并居中,默认常见",
ImageView.ScaleType.FIT_START to "fitStart:完整显示,靠左/上",
ImageView.ScaleType.FIT_END to "fitEnd:完整显示,靠右/下",
ImageView.ScaleType.FIT_XY to "fitXY:强行拉满,图片会变形"
)

切换按钮时,只改一件事:ImageView.scaleType。
kotlin
scaleButtons.forEachIndexed { index, button ->
button.setOnClickListener {
currentScaleIndex = index
binding.ivScaleTypeDemo.scaleType = scaleTypes[index].first
updateScaleTypeUi()
}
}
这段代码没什么花哨,但它很适合初学阶段。因为你点一遍就会发现:centerCrop 不是"更高级",它只是更适合封面;fitCenter 不是"有留白就不好",它是在保护完整信息;fitXY 虽然铺满了,但人物脸、商品图、海报都会被拉变形。

成熟团队做法
信息流、短视频封面、社区卡片常用 centerCrop,因为页面整齐更重要;电商详情图、证件图、说明图更偏向 fitCenter,因为信息完整更重要。这里不需要硬说某家公司内部怎么写,业务判断本身就足够明确。
相关技术点:ImageView.ScaleType、Matrix、centerCrop、fitCenter、fitXY。常见坑是为了"铺满"误用 fitXY,结果图片变形。
圆角和圆形:静态 UI 和网络图不要混成一锅
头像通常是圆的,商品图经常是圆角矩形,聊天图片可能还有更复杂的气泡形状。第 4 周不需要一上来就写自定义 View,先把常用方案分清楚就行。

Demo 里用的是 ShapeableImageView:
xml
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivRounded"
android:layout_width="92dp"
android:layout_height="92dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/RoundedImageView16dp"
app:strokeColor="#A7F3D0"
app:strokeWidth="2dp" />
样式在 themes.xml 里:
xml
<style name="RoundedImageView16dp" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">16dp</item>
</style>
<style name="CircleImageView" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">50%</item>
</style>
这个方案适合静态 UI:布局里就能看出形状,不需要你在代码里绕一圈。网络图如果要直接裁圆或圆角,也可以用 Glide 的 Transformation:
kotlin
Glide.with(this)
.load(avatarUrl)
.transform(CircleCrop())
.into(imageView)
这里有个小坑:Glide 官方文档里明确说,Transformation 只作用在最终加载出来的资源上,不会作用到 placeholder、error、fallback。也就是说,如果最终头像是圆的,占位图最好本身就准备成圆形,或者用 ShapeableImageView 统一裁剪外层。
成熟团队做法
头像、商品图、封面图通常会沉淀成统一组件,而不是每个页面自己裁。静态形状可以交给 ShapeableImageView,网络图裁剪可以交给图片库的 Transformation。复杂到聊天气泡、异形卡片时,再考虑 BitmapShader、clipPath 或自定义 View。
相关技术点:ShapeableImageView、shapeAppearanceOverlay、CircleCrop、RoundedCorners、BitmapShader。常见坑是 placeholder 和最终图形状不一致,加载中闪一下方图。
网络图加载:placeholder、error、fallback 不是一回事
以前我也容易把默认图全叫"占位图"。但查完 Glide 文档后,这三个词应该分开:
placeholder:加载中显示。error:加载失败显示。fallback:请求对象为null,但这个空值是业务允许的正常状态。
比如用户没设置头像,这是正常状态,不应该和"网络失败"混在一起。
Demo 的网络图加载这样写:
kotlin
Glide.with(this)
.load(NETWORK_IMAGE_URL)
.placeholder(R.drawable.bg_week4_placeholder)
.error(R.drawable.vd_week4_landscape)
.fallback(R.drawable.vd_week4_landscape)
.override(640, 360)
.centerCrop()
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.signature(ObjectKey("week4-rewrite-v1"))
.listener(object : RequestListener<Drawable> {
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
binding.tvLoadState.text = "网络图加载成功:DataSource=$dataSource。"
return false
}
})
.into(binding.ivNetwork)
这里有几个细节比代码本身更重要:
override(640, 360)是限制解码尺寸,不让大图按原始尺寸进内存。DiskCacheStrategy.AUTOMATIC是 Glide 默认推荐方向,通常不用手动关缓存。signature(ObjectKey(...))用来处理"URL 不变但图片内容变了"的缓存更新问题。DataSource可以告诉你这次来自网络、磁盘缓存还是内存缓存。
空头像的 Demo 则专门演示 fallback:
kotlin
val avatarUrl: String? = null
Glide.with(this)
.load(avatarUrl)
.placeholder(R.drawable.bg_week4_placeholder)
.fallback(R.drawable.vd_week4_landscape)
.error(R.drawable.bg_week4_placeholder)
.transform(CircleCrop())
.into(binding.ivNetwork)
这段代码的意义不是"展示一张默认图",而是把业务语义分出来:没有头像,不等于加载失败。

成熟团队做法
图片加载一般不会让 ImageView 空着。加载中、失败、业务为空都有明确兜底。列表页尤其要注意:Glide 文档提到 placeholder 是从 Android resources 在主线程加载的,所以占位图要轻,不要给每个 item 放复杂大图或复杂 XML。
相关技术点:Glide.with、placeholder、error、fallback、override、signature、DataSource、DiskCacheStrategy.AUTOMATIC。常见坑是为了"保证最新"粗暴 skipMemoryCache(true) 或 DiskCacheStrategy.NONE,结果列表重新加载、重新解码、重新走网络。
GIF:能动不代表应该一直动
Glide 加载 GIF 很简单:
kotlin
Glide.with(this)
.asGif()
.load(GIF_URL)
.placeholder(R.drawable.bg_week4_placeholder)
.error(R.drawable.vd_week4_landscape)
.override(360, 240)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.into(binding.ivGif)
但 GIF 的问题从来不是"能不能播",而是"该不该一直播"。GIF 需要处理多帧,列表里一屏动图同时播放,很容易让 CPU、内存、掉帧一起变难看。
Demo 里我加了一个小控制:加载成功后限制循环次数。
kotlin
resource.setLoopCount(3)
真实业务里会更严格:列表里只显示首帧,用户点击后播放;滑出屏幕就暂停或清理;服务端能转 WebP/APNG 时,尽量别把原始大 GIF 原样发给客户端。
成熟团队做法
评论区表情、聊天动图、社区 Feed 里的动图都需要播放策略。成熟团队不会让一屏 GIF 全部自动无限循环,而是做首帧静止、点击播放、可见性暂停、格式转码。
相关技术点:Glide.asGif()、GifDrawable、setLoopCount()、WebP、APNG、列表可见性。常见坑是动图和静态图共用同一套策略,结果列表滑动明显卡顿。
大图内存:ImageView 只要 600×400,就别把 4000×3000 原图塞进去
Android 官方加载大 Bitmap 的文档讲得很清楚:不要直接加载原图,先读尺寸,再按目标尺寸解码。
一张 4000×3000 的照片,如果用默认 ARGB_8888,大约是:
kotlin
4000 * 3000 * 4 = 48,000,000 bytes
也就是约 45.8MB。一个页面如果同时放几张这种图,不出问题才奇怪。
Demo 里没有真的创建 4000×3000 的 Bitmap,而是先"算账":
kotlin
val sampleSize = ImageLoadOptimizer.calculateInSampleSize(
originWidth = 4000,
originHeight = 3000,
reqWidth = 600,
reqHeight = 400
)
val originalMemory = ImageLoadOptimizer.estimateBitmapMemory(4000, 3000)
val sampledMemory = ImageLoadOptimizer.estimateAfterSample(4000, 3000, sampleSize)
val rgb565Memory = ImageLoadOptimizer.estimateAfterSample(
4000,
3000,
sampleSize,
Bitmap.Config.RGB_565
)
真正的 inSampleSize 计算放在工具类里:
kotlin
fun calculateInSampleSize(
options: BitmapFactory.Options,
reqWidth: Int,
reqHeight: Int
): Int {
val height = options.outHeight
val width = options.outWidth
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
while (halfHeight / inSampleSize >= reqHeight &&
halfWidth / inSampleSize >= reqWidth
) {
inSampleSize *= 2
}
}
return inSampleSize
}
完整流程是两次解码:第一次只读尺寸,不分配像素内存;第二次带着 inSampleSize 解码。
kotlin
fun decodeSampledBitmapFromResource(
res: Resources,
resId: Int,
reqWidth: Int,
reqHeight: Int,
config: Bitmap.Config = Bitmap.Config.ARGB_8888
): Bitmap? {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeResource(res, resId, options)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
options.inPreferredConfig = config
return BitmapFactory.decodeResource(res, resId, options)
}
RGB_565 也不是万能优化。它确实每像素 2 字节,比 ARGB_8888 省一半,但它没有透明通道,颜色表现也会差一些。头像、照片、商品图能不能用,要看业务接受度,不要一刀切。
成熟团队做法
大图优化的共识不是"出了 OOM 再 try-catch",而是在加载前控制尺寸。上传前压缩、列表缩略图、详情页大图分块、不同网络条件下请求不同尺寸,都是围绕这个原则展开的。
相关技术点:BitmapFactory.Options、inJustDecodeBounds、inSampleSize、inPreferredConfig、RGB_565、BitmapRegionDecoder、Memory Profiler。常见坑是 ImageView 很小,Bitmap 却按原图解码。
缓存:理解 LruCache,但不要拿它替代 Glide

我保留了一个手写 LruCache理解缓存到底在干什么。
kotlin
class ImageMemoryCache(maxSizeKB: Int = 0) {
private val cache: LruCache<String, Bitmap>
init {
val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
val cacheSize = if (maxSizeKB > 0) maxSizeKB else maxMemory / 8
cache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, value: Bitmap): Int {
return value.byteCount / 1024
}
}
}
fun get(key: String): Bitmap? = cache.get(key)
fun put(key: String, bitmap: Bitmap) {
if (get(key) == null) cache.put(key, bitmap)
}
}
LruCache 的核心是"最近用过的先留下,最久没用的先淘汰"。它适合理解原理,也适合极轻量场景。但生产项目里的网络图片,优先交给 Glide、Coil、Fresco 这类成熟库。
Glide 官方缓存文档里把缓存链路拆得很清楚:
Active Resources:当前正在使用的资源。Memory Cache:最近加载过、还在内存里的资源。Resource Disk Cache:解码、缩放、变换后的结果图。Data Disk Cache:原始图片数据。
这也解释了一个常见误区:Glide 的缓存 key 不只是 URL。尺寸、Transformation、Options、数据类型、Signature 都会影响缓存。也就是说,同一个 URL,用不同尺寸、不同裁剪方式加载,可能会有不同缓存结果。
成熟团队做法
成熟团队会关心缓存命中率、预加载、滑动取消、内存裁剪、磁盘策略和生命周期,而不是只问"有没有缓存"。Glide 默认已经做了大量工作,业务侧更应该先用对:控制尺寸、控制 transformation、正确设计 signature,不要动不动关缓存。
相关技术点:LruCache、Active Resources、Memory Cache、Resource Disk Cache、Data Disk Cache、BitmapPool、signature、DiskCacheStrategy。常见坑是 URL 不变图片变了,却不知道用 signature 更新缓存。
这周最容易踩的坑
1. 为了铺满容器乱用 fitXY
fitXY 很容易把图拉变形。封面整齐用 centerCrop,图片完整用 fitCenter,不要拿 fitXY 当万能解法。
2. placeholder、error、fallback 混用
加载中、加载失败、业务为空是三种不同语义。全部用同一张图会让用户和开发者都不知道当前到底发生了什么。
3. 占位图太复杂
Glide 文档明确说 placeholder 从 Android resources 在主线程加载。列表里大量复杂占位图会增加主线程压力。
4. 以为 Transformation 会处理占位图
Glide Transformation 不作用于 placeholder/error/fallback。头像如果最终是圆形,占位图也要提前准备好圆形,或者用外层 View 统一裁剪。
5. 大图直接解码
ImageView 只显示 600×400,没必要把 4000×3000 原图完整解码进内存。先读尺寸,再采样解码。
6. 关缓存解决"图片不更新"
不要第一反应就 skipMemoryCache(true) 或 DiskCacheStrategy.NONE。图片内容变了但 URL 不变,优先考虑改 URL、改版本标识,或者用 signature()。
这一周真正学到的是什么
- 图片显示前,先判断裁剪、留白、变形哪个能接受。
- 图片加载中、失败、为空,要有不同兜底语义。
- 大图加载前,先算内存,再按目标尺寸解码。
- GIF 能播放,但不能默认一屏全动。
- 缓存不是"有没有"的问题,而是缓存什么、按什么 key 缓存、什么时候失效。
- Demo 里可以手写
LruCache理解原理,但生产里应该优先用 Glide/Coil/Fresco。
我觉得这一周最该记住的一句话是:图片不是放进 ImageView 就结束了,真正的工作在加载前和加载后。
加载前要知道尺寸、形状、业务语义;加载后要知道缓存、错误、内存和生命周期。如果这些都没管,页面看起来也许能显示,但一到列表、弱网、大图和动图场景,问题就会一起冒出来。
下一步怎么走
第 5 周会进入 XML 资源、样式和主题。第 4 周留下的一个伏笔也会在第 5 周继续用上:很多 UI 质量问题,不是业务代码造成的,而是资源组织、样式复用和主题设计一开始就没规划好。
图片如此,按钮如此,后面的页面也一样。