图片加载统一门面:用 ImageUrlProcessor 动态裁剪与门面模式告别混乱的图片代码

移动端图片加载通常涉及多个层次:URL 处理、加载行为、生命周期管理、性能观测。如果所有页面都直接调用底层图片库,这些问题很难统一解决。比如某个页面忘记传尺寸参数,导致下载原图;另一个页面自己拼接裁剪参数,和服务端约定不一致;还有页面为了避免闪烁关闭缓存,结果造成流量浪费。更麻烦的是,一旦底层图片库升级或替换,全项目都要改。

我们项目里的图片体系主要落在 common/imageloader 下。ImageLoaderUtil 是业务侧统一入口,Glide 的 GlideImageLoaderStrategy 承接底层实现,ImageUrlProcessor 负责 URL 处理,ImagePreloaderStaggeredGridPreloader 负责预加载,transformation 包提供圆角、圆形、模糊等转换,progress 包提供下载进度回调。这个结构和业务高度相关:内容卡片、详情页、搜索结果、Feed、视觉搜索、启动页都大量依赖图片,同一张图片会在不同场景下以不同尺寸、质量和裁剪策略出现。

门面 + 配置对象 + ImageUrlProcessor + 预加载

我们采用了”门面 + 配置对象 + ImageUrlProcessor + 预加载”四段式:页面只描述目标场景、ImageView 容器和期望质量,基础设施决定真实 URL、裁剪宽高、DPR、格式、质量、优先级、回调和缓存策略。

data class ImageRequest(
    val source: ImageSource,
    val scene: String,
    val targetSize: Size,
    val scaleType: ScaleType,
    val placeholder: Placeholder?,
    val errorPlaceholder: Placeholder?,
    val cornerRadiusDp: Float,
    val cachePolicy: ImageCachePolicy,
    val priority: Priority,
    val lifecycle: LifecycleRef?
)

interface ImageLoaderFacade {
    fun load(request: ImageRequest, target: ImageTarget): Disposable
    fun preload(request: ImageRequest): Disposable
    fun clear(target: ImageTarget)
    fun pause(scene: String)
    fun resume(scene: String)
    fun trimMemory(level: Int)
}

ImageUrlProcessor:动态裁剪的核心

ImageUrlProcessor 的核心价值不是简单在 URL 后面拼一个固定宽高,而是把 UI 容器尺寸、屏幕密度、业务场景、网络状态、图片原始比例、服务端裁剪能力统一计算成”最合适的远端图片规格”。

例如一个 180dp 宽的卡片,在 3x 屏幕上需要约 540px 的物理宽度;如果容器高度由比例决定,可以按目标宽高生成裁剪参数;如果弱网或省流模式开启,可以降低质量等级;如果进入预加载场景,可以选择稍低优先级但保持同一裁剪 key,保证预加载和正式加载命中同一缓存。

class ImageUrlProcessor(
    private val device: DeviceInfo,
    private val network: NetworkProfile,
    private val cdn: CdnRule
) {
    fun process(source: ImageSource, target: ImageTargetInfo, scene: String): ProcessedImage {
        val policy = scenePolicy(scene)
        val pxWidth = bucket(target.widthDp * device.density, policy.sizeBucket)
        val pxHeight = target.heightDp
            ?.let { bucket(it * device.density, policy.sizeBucket) }
            ?: policy.aspectRatio?.let { (pxWidth / it).toInt() }

        val quality = if (network.saveDataMode) policy.quality.downgrade() else policy.quality
        val format = if (policy.allowFormatUpgrade && device.supportModernFormat)
            ImageFormat.MODERN else ImageFormat.COMPAT

        val url = cdn.buildUrl(source.safeId, pxWidth, pxHeight, quality, format)
        return ProcessedImage(
            url = url,
            cacheKey = "${source.stableId}:$pxWidth:$pxHeight:$quality:$format",
            logAlias = "scene=$scene,width=$pxWidth,quality=$quality,format=$format"
        )
    }

    private fun bucket(value: Float, bucket: Int): Int {
        return ((value / bucket).roundToInt() * bucket).coerceAtLeast(bucket)
    }
}

尺寸分桶非常关键。不能每一个像素差异都生成不同 URL,否则缓存会碎片化。把 517px、528px、540px 归一到同一个 540px 桶,兼顾清晰度和缓存命中。

预加载调度器:让位给可见内容

预加载体系的目标是提前准备”即将可见”的图片,而不是盲目下载所有图片。预加载管理器需要支持去重、优先级控制、窗口控制、网络条件判断和取消机制:

class ImagePreloadManager(
    private val loader: ImageLoaderFacade,
    private val policy: PreloadPolicy
) {
    private val running = mutableMapOf<String, Disposable>()

    fun preloadNext(scene: String, items: List<ImageSource>, viewport: ViewportInfo) {
        if (!policy.allowPreload()) return
        val candidates = policy.pickCandidates(items, viewport)

        running.keys.filter { it !in candidates.map { it.stableKey() } }
            .forEach { key -> running.remove(key)?.dispose() }

        candidates.forEach { source ->
            val key = source.stableKey()
            if (running.containsKey(key)) return@forEach
            running[key] = loader.preload(ImageRequest(
                source = source, scene = scene,
                targetSize = policy.preloadSize(),
                cachePolicy = ImageCachePolicy.DiskOnly,
                priority = Priority.Low, lifecycle = null, ...
            ))
        }
    }
}

当前屏图片永远优先于预加载。预加载任务应使用低优先级,必要时暂停或取消。

落地中的关键约束

门面不要泄漏底层类型。如果 ImageRequest 中出现了具体图片库的 Transformation 或 RequestOptions,隔离就失败了。底层类型只能存在于 Adapter 内部。

URL 处理必须有统一入口。即使某些页面需求特殊,也应通过扩展 ImageOptions 或资源类型配置来表达,而不是允许页面手写拼接逻辑。

监控不要记录完整 URL。图片 URL 常包含签名、尺寸参数、资源路径,日志中应使用别名、哈希和安全维度。取消不算失败——列表复用和页面销毁会产生大量取消事件,如果直接计入失败率,会误导稳定性判断。


图片加载统一门面的价值,在于把”页面怎么调用图片库”升级为”客户端如何管理图片资源”。工程上要避免两个极端:完全裸用底层库,短期简单但长期难维护;把门面设计得过度复杂,让业务构造请求比直接加载还麻烦。更合理的方式是提供稳定默认值和少量明确扩展点:普通图片一行配置即可加载,复杂场景可以通过 ImageRequest 精细声明。最终,一个成熟的图片体系应该让业务只关心资源和展示意图,让基础设施负责 URL 处理、缓存策略、预加载、生命周期、监控和降级。