Appearance
图片加载 / Image Loading
一句话概览(总览首段)
客户端图片治理贯穿 “打包内置资源” 与 “在线动态加载” 两大场景。不论图片来自本地 APK/Bundle 还是网络,底层本质都是统一的生命周期:取字节 → 解码(含降采样) → 多级缓存(内存/磁盘) → 绑定生命周期并绘制。但是在资源放置、匹配与展示环节,各平台因为密度机制与内存归属的不同,演化出了截然不同的底层逻辑。
一、 图片业务场景与资源全景分类
在移动端开发中,图片按照来源与用途可以精确划分为两大部分,各部分的优化侧重点完全不同:
1. APP 本身打包的内置资源 (Assets / Drawable / XCAssets)
打包在客户端安装包内的静态资源,直接影响 APK / IPA 包体大小以及启动/首页渲染性能。
(a) UI 小图标 / 控件图 (Icons / Buttons / Tabs):
- 特点:尺寸小(如 24×24dp)、数量多、在多处 UI 中重复出现(如返回键、爱心、Tab 栏图标)。
- Android 放置策略:
- 首选矢量图 (
VectorDrawable):全面使用 XML 矢量图,一套代码适配所有分辨率,不占多余包体且永不模糊,支持 Tint 着色复用。 - 位图降级方案:若使用 PNG 位图,推荐只保留一个高密度桶(如
drawable-xxhdpi或drawable-xxxhdpi单桶),让低密度设备在运行时自动向下缩小(缩小免费且清晰)。
- 首选矢量图 (
- iOS 放置策略:
- 严格按苹果规范在 Asset Catalog 中提供
@2x和@3x两套切图,对应离散的 scale 体系,确保 1:1 点阵精确匹配。
- 严格按苹果规范在 Asset Catalog 中提供
- Flutter 放置策略:
- 在
pubspec.yaml中声明2.0x/、3.0x/资源目录变体,运行时由AssetImage按devicePixelRatio匹配。
- 在
(b) 本地大背景 / 运营静态图 (Backgrounds / Banners / Placeholders):
- 特点:像素面积大(如 1080×1920 甚至 4K),极易引发启动或首页内存暴涨(OOM)。
- Android 放置策略:
- 统一放入
drawable-nodpi目录。跳过系统的密度桶自动预缩放(避免低桶在旗舰机上被二次放大导致内存平方级暴涨),由代码配合inSampleSize在解码期按 View 真实尺寸进行降采样。
- 统一放入
- iOS 放置策略:
- 不分
@2x/@3x多个文件,使用 Asset Catalog 的 Single Scale(单源大图) 放一张高清大图,代码中配合contentMode = .scaleAspectFill等比铺满自适应。
- 不分
- Flutter 放置策略:
- 通常只提供一张高清原图,代码中控制逻辑宽高,并结合
cacheWidth/cacheHeight在解码期降采样。
- 通常只提供一张高清原图,代码中控制逻辑宽高,并结合
2. 在线/动态加载图片 (Network / Local File / Storage)
- 场景:网络请求图片、用户相册、本地缓存文件、动态 Banner。
- 核心治理:
- 异步线程与并发:避免阻塞主线程(UI 线程),建立线程池并发调度。
- 三级/多级缓存:内存缓存(内存 LRU + 弱引用)→ 磁盘缓存(DiskLruCache)→ 网络请求。
- 生命周期绑定:请求必须绑定 Activity / Fragment / Composable / Widget 的生命周期,页面销毁或滑出屏幕时自动取消网络连接与解码任务。
- UI 渐进体验:占位图(Placeholder)、BlurHash 高斯模糊渐进式加载、错误重试与加载动画。
二、 图片展示阶段的 4 大共同痛点(跨端底层机制)
无论图片来自本地还是网络,当字节流被送到解码器、解压成 Bitmap 上屏展示时,所有平台都会面临相同的底层物理规则:
1. 内存与色彩格式(Pixel Configuration & BPP)
图片在内存中占用的物理大小不看压缩文件(JPG/PNG)的磁盘大小,而由解码后的像素总面积与每像素字节数(BPP, Bytes Per Pixel) 决定:
色彩格式与内存开销对照:
ARGB_8888(默认):4 字节/像素(Alpha 8bit, R 8bit, G 8bit, B 8bit)。品质最高,占用内存最大。RGB_565:2 字节/像素(R 5bit, G 6bit, B 5bit)。不带 Alpha 透明通道的图片(如背景图、JPG 照片)使用该格式,内存开销直接减半(-50%) 。HARDWAREConfig (Android 8.0+ / API 26+):Bitmap 像素数据直接存储在 GPU 显存(Hardware Buffer) 中,App 堆内存占用为 0,极大降低 JVM / Native 堆 OOM 风险。代价是无法直接在 CPU 端修改像素(如不能直接绘图或做 CPU 滤波)。
2. 解码采样率与尺寸匹配(Downsampling)
严禁“大图小用”(例如将一张 4000×3000 的相机原图直接解压并设置到 100×100 的头像框中,这会直接消耗约 48MB 内存,引发 GC 抖动甚至 OOM)。
- Android / Native:解码时设置
BitmapFactory.Options.inSampleSize = n(采样率为),解码尺寸降为 ,内存开销下降为 。 - Compose 侧:依靠 Landscapist / Coil 原生
AsyncImage的约束驱动采样(Constraint-based sampling),自动获取 Composable 的 Constraints 动态计算最佳降采样尺寸(详见 Landscapist:Compose 图片加载封装)。 - Flutter 侧:使用
ImageProvider的cacheWidth/cacheHeight(或memCacheWidth),通过ResizeImage在dart:ui阶段把图片解压成缩略图(详见 Flutter 图片加载)。 - iOS 侧:使用
CGImageSourceCreateThumbnailAtIndexAPI,绕过全图解码,直接从文件读取字节生成指定尺寸的缩略图(详见 iOS 图片加载(UIImage / UIKit / ImageIO))。
3. 内存稳定与内存池复用(Memory Stability & Reuse)
- Android
inBitmap/BitmapPool机制:- 在 Android 4.4+,解码新位图时可以复用已不再使用的旧位图(
inBitmap)的内存空间,避免频繁创建/销毁 Bitmap 引发的堆内存碎片化与 GC 抖动(卡顿)。Glide 和 Coil 内部均内置了LruBitmapPool。
- 在 Android 4.4+,解码新位图时可以复用已不再使用的旧位图(
- Flutter 内存机制 (
ImageCache):- Flutter 走
dart:ui自绘,ImageCache是位于 Dart VM 堆上的双限 LRU(默认 1000 张 / ~100MB)。Flutter 没有 nativeBitmapPool内存池,超大图内存必须严格靠cacheWidth解码期降采样来控制。
- Flutter 走
- iOS 内存机制 (
NSCache):- 系统
NSCache负责内存缓存,具备自动响应系统内存警告(didReceiveMemoryWarning)并释放被逐出对象的能力。
- 系统
4. 超大图 / 巨图分块解码(Region Decoding)
- 针对几万像素的长图(如长文章截图、清明上河图),即使降采样也会失真,若原图整体解压则必定 OOM。
- 解决方案:使用
BitmapRegionDecoder(Android) 或区域切片渲染技术,仅解码和绘制当前屏幕可视区域(Rect 视窗)内的像素字节,手势滑动时动态平移解码窗口。
三、 跨端密度机制:连续 vs 离散 & 缺失不缩放
不同平台在处理屏幕密度与图片缩放时存在根本性设计差异:
| 平台 | 密度机制 | 匹配与缩放行为 |
|---|---|---|
| Android | 连续密度 (任意 dpi,如 2.625) | 桶 + 解码缩放:选最接近的桶,解压时二次缩放 (inTargetDensity / inDensity) 至精确物理尺寸 |
| iOS | 离散密度 (固定 @1x/@2x/@3x) | 离散精确匹配:1:1 贴图,无重采样,极清晰 |
| Flutter | 离散变体 (1.0x/2.0x/3.0x) | AssetImage 变体选择:挑选 scale ≥ dpr 的第一张变体;缺失时不重采样解码;固有逻辑尺寸 |
1. Android 的连续密度与“解码缩放”
- Android 设备厂商众多,屏幕密度是连续且任意的(如 420dpi 对应
scale = 2.625)。 - Android 系统引入 “密度桶 (Density Buckets)”(mdpi=1x, hdpi=1.5x, xhdpi=2x, xxhdpi=3x, xxxhdpi=4x)。
- 底层解码原理:系统把桶密度设为
inDensity,设备密度设为inTargetDensity,BitmapFactory在解压成内存位图时强制缩放: - 桶放反的后果:如果把一张 480×480 的图错放在
mdpi(1x)低桶,运行在xxhdpi(3x 屏)手机上,系统会将其强制放大解码为 1440×1440 像素,内存占用剧烈飙升 9 倍(),且边缘充斥插值锯齿与严重模糊!
2. iOS 的离散密度与“1:1 像素匹配”
- 苹果硬件生态完全自控,设备密度是离散且锁定的(只有
@1x、@2x、@3x三档 scale)。 - 系统直接根据设备的 scale 选择对应的切图,在屏幕上实现 1:1 物理像素点阵渲染,不做多余的内存重采样,因此显示极为锐利清晰。
3. Flutter 的“缺失不缩放”与变体陷阱
- Flutter 变体选择逻辑 (
AssetImage._chooseAsset):- 运行时获取设备的
devicePixelRatio(dpr,如 2.625 或 3.0)。 - 遍历资源变体目录,选择第一张
scale ≥ dpr的变体。 - 如果一张
scale ≥ dpr的变体都没有,就退回选择已配置的最高分辨率变体。
- 运行时获取设备的
- 与 Android 的核心区别:
- Android 原生:即使放错桶,系统也会在内存解码阶段强制进行重采样放大/缩小,保证物理尺寸(dp)符合预期(代价是低桶放大暴内存且模糊)。
- Flutter 机制:Flutter 不会在内存解码阶段帮帮你做这种跨密度的“自动重采样放大”。如果你只放了一张 1.0x 的图片,而在 3.0x 的高分屏手机上运行:
- 无宽高约束时:图片会按照原始 1x 像素渲染,在屏幕上显得非常小(只有 1/3 大小) 。
- UI 约束强行拉伸时:图片像素点填不满逻辑坑位,UI 引擎直接将位图硬拉伸铺满,导致肉眼可见的严重模糊与马赛克。
四、 本模块要回答的问题与硬核推导
为什么小图标放错桶或偷懒只放
xhdpi会导致高配手机卡顿/发虚?- 一句话答:位图「缩小免费且清晰,放大必糊」。若只放
xhdpi(2x),在 3x/4x 手机上会被系统强制放大 1.5~2 倍解码,内存按面积翻 2.25~4 倍且边缘失真。 - 解决原则:图标优先用
VectorDrawable;若用位图,放xxhdpi/xxxhdpi最高桶只让系统缩小,不让系统放大(详见 12/01)。
- 一句话答:位图「缩小免费且清晰,放大必糊」。若只放
drawable-nodpi中的背景大图,分辨率到底应该给高还是给低?- 一句话答:原图分辨率低于设备物理像素会被拉伸放大导致模糊;原图分辨率高于设备物理像素则只缩不放、保持清晰,但会多占解码内存。
- 工程实践:提供高分辨率单图(如 WebP 格式),配合代码层
inSampleSize(Android)或cacheWidth(Flutter)按 View 的真实物理尺寸动态降采样。
Glide / Coil / Fresco 与 Compose
AsyncImage、FlutterImage.network的体系演进?- 一句话答:Glide(Request 链 + LruBitmapPool)、Coil(Kotlin 协程 + 责任链)、Fresco(Producers 链 + Native 内存)是 Android 视图层的三大成熟引擎;Compose 侧的
AsyncImage是 Coil 的声明式封装;Flutter 则是独立的dart:ui自绘体系(ImageProvider+ImageCache),与 Native 引擎不互通(详见 01 / 02 / 04)。
- 一句话答:Glide(Request 链 + LruBitmapPool)、Coil(Kotlin 协程 + 责任链)、Fresco(Producers 链 + Native 内存)是 Android 视图层的三大成熟引擎;Compose 侧的
五、 子主题与全链路文档索引
| 序号 | 主题 | 对应文档 | 核心落脚点 |
|---|---|---|---|
| 01 | Landscapist: Compose 图片加载 | Landscapist:Compose 图片加载封装 | 约束驱动采样、插件架构、KMP 支持 |
| 02 | 图片加载方式总览与原理 | 图片加载方式总览与原理 | Glide/Coil/Fresco 三大引擎架构与四级缓存 |
| 03 | 横向对比:概念·考点·优势·性能 | 图片加载方式:概念 · 考点 · 优势 · 性能对比 | 六类加载方式综合性能与选型矩阵 |
| 04 | Flutter 图片加载原理 | Flutter 图片加载 | ImageProvider + ImageCache 双限 LRU、CachedNetworkImage |
| 05 | iOS 图片加载(UIKit / ImageIO) | iOS 图片加载(UIImage / UIKit / ImageIO) | @2x/@3x 精确匹配、CGImageSource 解码期降采样 |
| 跨模块 | Bitmap 内存与 GC 优化 | 07/08 Bitmap 内存 | 内存计算公式、RGB_565、inBitmap 复用、HARDWARE Config |
| 跨模块 | dp/dpi 与图片资源放置 | 12/01 资源放置 | 连续/离散密度桶放置算法、Flutter Asset 变体缺失实验 |
六、 复习检查题
为什么图片加载在 Android 与 Flutter 里是“平行的两套世界观”?
- 答:Android 走 Native
Bitmap(GlideBitmapPool/inBitmap复用,内存可存 Native/Hardware 堆);Flutter 走dart:ui自绘,图片经Codec解码后存在 Dart VM 堆上的ImageCache(LRU 双限)。两者的内存归属、复用机制、缓存 API 完全不互通。
- 答:Android 走 Native
为什么背景大图放
drawable-nodpi可以防止 OOM?- 答:放进普通密度桶(如
mdpi),在 3x/4x 旗舰机上会被系统误判为“低密度资源”而强制放大 3~4 倍解码,导致内存暴涨 9~16 倍而 OOM;放nodpi会阻止系统自动预缩放,配合inSampleSize降采样可以精准按屏幕物理尺寸解码。
- 答:放进普通密度桶(如