第4周:ImageView 最怕的不是不会显示图片,而是显示得“不对劲”

真实页面里最常遇到的几个问题:

  • 图片为什么被拉变形?
  • 头像为什么要圆形,商品图为什么要完整?
  • 网络图加载中、失败、业务为空,能不能用同一张图糊弄过去?
  • 一张 4000×3000 的照片为什么会把页面拖垮?
  • Glide 已经有缓存了,为什么还要理解 LruCache

这几个问题比"ImageView 有多少个属性"更重要。因为真实业务里,图片问题往往不是"不显示",而是显示得不稳、不准、不省内存。

这次 Demo 中的功能:切换 scaleType、看圆角/圆形、加载网络图、演示 fallback、加载 GIF、计算大图内存、执行采样解码、查看 LruCache 命中情况。

相关资料:

  • Android 官方《高效加载大型位图》:确认 inJustDecodeBoundsinSampleSize、按目标尺寸解码这些是官方推荐的大图加载基本功。
  • Glide 官方缓存文档:确认 Glide 的缓存查找顺序是 Active ResourcesMemory CacheResource Disk CacheData Disk Cache,并且缓存 key 不只是 URL,还和尺寸、Transformation、Options、Signature 有关。
  • Glide 官方占位图文档:确认 placeholdererrorfallback 语义不同;占位图来自 Android resources,会在主线程加载;Transformation 不会作用到 placeholder/error/fallback。
  • Fresco Getting Started:确认 Fresco 是集中式 Image Pipeline 初始化模式,能自动下载、缓存、显示,并在 View 离屏后清理内存。

scaleType:别先背枚举,先问能不能裁

ImageViewscaleType 很容易被写成表格题:centercenterCropfitCenterfitXY......背完一圈还是不会选。

我觉得更好的判断方式是先问两句话:

  1. 这张图能不能被裁掉一部分?
  2. 这张图能不能被拉伸变形?

比如信息流封面,卡片大小必须整齐,不希望露白,这时候通常接受裁剪,所以用 centerCrop。商品详情图不一样,商品边角、文字、规格图都可能是有效信息,裁掉一部分就麻烦,所以更适合 fitCenter。至于 fitXY,除非你明确允许变形,否则最好别碰。

Demo 里我生成了一张带 LEFTSUBJECTRIGHT 标记的图片,就是为了让裁剪结果一眼能看出来。

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.ScaleTypeMatrixcenterCropfitCenterfitXY。常见坑是为了"铺满"误用 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 只作用在最终加载出来的资源上,不会作用到 placeholdererrorfallback。也就是说,如果最终头像是圆的,占位图最好本身就准备成圆形,或者用 ShapeableImageView 统一裁剪外层。

成熟团队做法

头像、商品图、封面图通常会沉淀成统一组件,而不是每个页面自己裁。静态形状可以交给 ShapeableImageView,网络图裁剪可以交给图片库的 Transformation。复杂到聊天气泡、异形卡片时,再考虑 BitmapShaderclipPath 或自定义 View。

相关技术点:ShapeableImageViewshapeAppearanceOverlayCircleCropRoundedCornersBitmapShader。常见坑是 placeholder 和最终图形状不一致,加载中闪一下方图。

网络图加载:placeholdererrorfallback 不是一回事

以前我也容易把默认图全叫"占位图"。但查完 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.withplaceholdererrorfallbackoverridesignatureDataSourceDiskCacheStrategy.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()GifDrawablesetLoopCount()、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.OptionsinJustDecodeBoundsinSampleSizeinPreferredConfigRGB_565BitmapRegionDecoder、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 官方缓存文档里把缓存链路拆得很清楚:

  1. Active Resources:当前正在使用的资源。
  2. Memory Cache:最近加载过、还在内存里的资源。
  3. Resource Disk Cache:解码、缩放、变换后的结果图。
  4. Data Disk Cache:原始图片数据。

这也解释了一个常见误区:Glide 的缓存 key 不只是 URL。尺寸、Transformation、Options、数据类型、Signature 都会影响缓存。也就是说,同一个 URL,用不同尺寸、不同裁剪方式加载,可能会有不同缓存结果。

成熟团队做法

成熟团队会关心缓存命中率、预加载、滑动取消、内存裁剪、磁盘策略和生命周期,而不是只问"有没有缓存"。Glide 默认已经做了大量工作,业务侧更应该先用对:控制尺寸、控制 transformation、正确设计 signature,不要动不动关缓存。

相关技术点:LruCacheActive ResourcesMemory CacheResource Disk CacheData Disk CacheBitmapPoolsignatureDiskCacheStrategy。常见坑是 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 质量问题,不是业务代码造成的,而是资源组织、样式复用和主题设计一开始就没规划好。

图片如此,按钮如此,后面的页面也一样。

相关推荐
Mart!nHu1 小时前
Android10 添加以太网网络共享功能
android·以太网共享
修炼者3 小时前
bitmap和drawable的互相转换
android
美狐美颜SDK开放平台4 小时前
美颜SDK接入流程详解:Android、iOS、鸿蒙兼容方案解析
android·人工智能·ios·华为·harmonyos·美颜sdk·视频美颜sdk
笔夏5 小时前
【安卓学习之FloatingActionButton】按钮太小
android·学习
XD7429716366 小时前
科技早报晚报|2026年5月15日:无摄像头空间感知、Android 设备实验室与视频检索代理,今天更值得跟进的 3 个技术机会
android·科技·音视频·开源项目·边缘ai·开发者工具
应用市场6 小时前
Android Verified Boot 2.0 安全启动原理详解
android·安全
只可远观6 小时前
Android XML命令式和Jetpack Compose声明式UI
android·xml
他是龙5516 小时前
DVWA 靶场深度解析:文件包含 & 文件上传(Low → Impossible)
android
_李小白7 小时前
【Android车载学习笔记】第一天:Android Automotive OS介绍
android·笔记