精通混合移动应用中的图像缓存与懒加载
Source: Dev.to
请提供您希望翻译的正文内容,我将按照要求保留源链接并翻译文本。
默认 WebView 缓存的问题
当你仅依赖标准的 <img> 标签时,资源管理会交给底层的移动浏览器引擎(iOS 上的 WKWebView,Android 上的 Chromium)来处理。这会带来几个问题:
- 激进的缓存清理 – WebView 会定期清除缓存以释放系统内存,导致用户必须重新下载刚才看到的头像和缩略图。
- 重复请求 – 同一张图片在屏幕上出现多次时,浏览器可能会发起多个并发的网络请求。
- 内存未管理 – 获取大尺寸图片而不进行清理,可能会因内存限制导致应用崩溃。
解决方案:自定义 LRU Blob 缓存
绕过 WebView 限制的最有效方法是拦截图片获取过程,将图片下载为 Blob 数据,并以 Object URL 的形式本地存储在内存中。提供的 ImageCacheService 使用最近最少使用(LRU)缓存策略优雅地实现了这一点。
1. 全局缓存服务
该架构的核心是一个在根级别提供的 Angular 服务。
- 基于 Map 的存储 – 使用标准的 JavaScript
Map保存原始源 URL 与生成的本地 Object URL 的映射。 - LRU 提升 – 当请求图片时,服务会检查缓存中是否已有该条目。如果有,则先删除再重新插入该条目,使其移动到
Map的“末尾”(标记为最近使用)。 - 内存管理 – 缓存设有硬上限(
MAX_CACHE_SIZE = 100)。当达到上限时,服务会识别最旧的条目(Map中的第一个键),调用URL.revokeObjectURL()释放浏览器内存,并删除该条目。
@Injectable({ providedIn: 'root' })
export class ImageCacheService {
private readonly MAX_CACHE_SIZE = 100;
private cache = new Map(); // original URL → object URL
private pending = new Map(); // ongoing fetches
async get(url: string): Promise {
if (this.cache.has(url)) {
const objUrl = this.cache.get(url)!;
// LRU bump
this.cache.delete(url);
this.cache.set(url, objUrl);
return objUrl;
}
if (this.pending.has(url)) {
return this.pending.get(url)!;
}
const fetchPromise = this.fetchAndCache(url);
this.pending.set(url, fetchPromise);
const result = await fetchPromise;
this.pending.delete(url);
return result;
}
private async fetchAndCache(url: string): Promise {
const response = await fetch(url);
const blob = await response.blob();
const objUrl = URL.createObjectURL(blob);
this.addToCache(url, objUrl);
return objUrl;
}
private addToCache(url: string, objUrl: string) {
if (this.cache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = this.cache.keys().next().value;
URL.revokeObjectURL(this.cache.get(oldestKey)!);
this.cache.delete(oldestKey);
}
this.cache.set(url, objUrl);
}
}
2. 防止重复请求
在列表或信息流中,同一用户头像可能会出现多次。服务维护一个 pending Map 来跟踪正在进行的 Promise 下载:
- 如果针对特定 URL 的 HTTP 请求已经在进行中,后续请求会挂载到已有的 promise 上,而不是再次发起网络请求。
- 当 fetch 完成(或失败)后,URL 会从
pendingmap 中移除。
UI 层:智能懒加载指令
在缓存逻辑被抽离到服务后,UI 需要一种方式来无缝使用它。独立的 LazyLoadDirective 作用于带有 appLazyLoad 属性的任意 <img> 标签。
1. 结构与占位符
指令初始化时,会优化 DOM 结构并设置加载状态:
- Picture 包装器 – 如果图片尚未位于
<picture>标签内,指令会以编程方式将<img>包裹在<picture>中,并添加lazy-picture-wrapper类,以便更容易进行 CSS 定位。 - 透明 GIF – 在真实图片被获取之前,指令会向
src属性注入一个轻量级的透明 base64 GIF (data:image/gif;base64,...)。 - 加载状态 – 会应用
img-loadingCSS 类,可用于骨架动画或加载指示器。
@Directive({
selector: '[appLazyLoad]',
standalone: true,
})
export class LazyLoadDirective implements OnInit, OnDestroy {
@Input('appLazyLoad') src!: string;
private abortController?: AbortController;
private unlisteners: (() => void)[] = [];
constructor(
private el: ElementRef,
private cache: ImageCacheService,
private renderer: Renderer2
) {}
ngOnInit() {
this.setupWrapper();
this.loadImage();
}
ngOnChanges() {
this.loadImage();
}
ngOnDestroy() {
this.abortController?.abort();
this.unlisteners.forEach((un) => un());
}
private setupWrapper() {
const img = this.el.nativeElement;
if (!img.parentElement?.matches('picture')) {
const picture = this.renderer.createElement('picture');
this.renderer.addClass(picture, 'lazy-picture-wrapper');
const parent = img.parentNode;
this.renderer.insertBefore(parent, picture, img);
this.renderer.removeChild(parent, img);
this.renderer.appendChild(picture, img);
}
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
this.renderer.addClass(img, 'img-loading');
}
private async loadImage() {
this.abortController?.abort();
this.abortController = new AbortController();
const signal = this.abortController.signal;
// Skip cache for data URIs
if (this.src.startsWith('data:')) {
this.applySrc(this.src);
return;
}
try {
const objectUrl = await this.cache.get(this.src);
if (signal.aborted) return;
this.applySrc(objectUrl);
} catch (e) {
// handle error (optional)
}
}
private applySrc(url: string) {
const img = this.el.nativeElement;
img.src = url;
this.renderer.removeClass(img, 'img-loading');
}
}
2. 优雅的取消
移动端用户滚动速度快。如果用户在图片下载完成前已经滚动过去,我们不应浪费处理能力去渲染它。
- AbortControllers – 指令为每次加载尝试创建一个新的
AbortController。 - 生命周期清理 – 当
src输入变化或组件销毁时,会调用控制器的abort()方法。 - 安全检查 – 在全局缓存解析出 Blob URL 后,指令会检查
signal.aborted。如果已取消,则停止 DOM 更新,防止旧图片在新图片上渲染导致的竞争条件。
3. 边缘情况
一个健壮的指令必须处理不需要获取的情况:
- Data URI – 如果提供的图片源已经是 base64 字符串(
startsWith('data:')),指令会跳过缓存,直接应用该源,并立即移除加载类。 - 事件监听器 – 监听原生的
load与error事件,以确保无论成功还是失败,img-loading类都能可靠地被移除。这些监听器存放在unlisteners数组中,并在销毁时清理,以防止内存泄漏。
通过将缓存逻辑(“大脑”)与 DOM 操作(UI 层)分离,这种架构为用户提供了高性能、原生感受的图片体验。
混合应用程序。