在 Android ListView/RecyclerView 中加載網絡圖片是高頻剛需。核心挑戰:**列表快速滑動時,Item 視圖復用導致圖片錯位、OOM、閃爍**。業界標準解法:**三級緩存 (內存/文件/網絡) + 線程池限流 + 視圖復用檢測**。本文深度解析一個經典自研圖片加載器的核心架構與關鍵代碼,助你理解 Glide/Picasso 等成熟庫的設計精髓。
FileCache)public class FileCache {
private File cacheDir;
public FileCache(Context context) {
// 優先用外部存儲 (SD卡),否則用內部緩存目錄
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
cacheDir = new File(Environment.getExternalStorageDirectory(), "your_app_cache");
} else {
cacheDir = context.getCacheDir();
}
if (!cacheDir.exists()) cacheDir.mkdirs();
}
// 以 URL hashCode 為文件名,避免特殊字符/過長問題
public File getFile(String url) {
return new File(cacheDir, String.valueOf(url.hashCode()));
}
public void clear() {
File[] files = cacheDir.listFiles();
if (files != null) for (File f : files) f.delete();
}
}
MemoryCache) — SoftReference + 同步 Mappublic class MemoryCache {
// LinkedHashMap 支持 LRU (accessOrder=true),Collections.synchronizedMap 線程安全
private final Map> cache =
Collections.synchronizedMap(new LinkedHashMap<>(10, 1.5f, true));
public Bitmap get(String key) {
SoftReference ref = cache.get(key);
return ref != null ? ref.get() : null; // SoftReference 內存緊張時可被 GC 回收
}
public void put(String key, Bitmap bitmap) {
cache.put(key, new SoftReference<>(bitmap));
}
public void clear() { cache.clear(); }
}
為何用 SoftReference?:內存不足時 GC 可回收圖片對象,**防止 OOM**,比強引用 LruCache 更「良民」,但命中率略低。生產庫 (Glide) 常用 LruResourceCache (強引用 + 淘汰算法) 精確控制內存上限。
ImageLoader)public class ImageLoader {
private static final int THREAD_NUM = 5; // 線程池大小,根據 CPU 核心數調整
private static final int TIME_OUT = 30_000; // 網絡超時
private static final int DEFAULT_RES = R.drawable.placeholder; // 佔位圖
private final MemoryCache memoryCache = new MemoryCache();
private final FileCache fileCache;
private final ExecutorService executor = Executors.newFixedThreadPool(THREAD_NUM);
private final Handler handler = new Handler(Looper.getMainLooper()); // 綁定主線程
// 防復用映射:ImageView -> 當前綁定的 URL
private final Map imageViews =
Collections.synchronizedMap(new WeakHashMap<>());
public ImageLoader(Context context) {
fileCache = new FileCache(context);
}
// 入口方法
public void displayImage(String url, ImageView imageView) {
imageViews.put(imageView, url); // 記錄綁定關係
Bitmap bitmap = memoryCache.get(url);
if (bitmap != null) {
imageView.setImageBitmap(bitmap); // 內存命中,直接設置
return;
}
// 未命中,提交異步任務
queuePhoto(url, imageView);
}
private void queuePhoto(String url, ImageView imageView) {
executor.submit(new PhotoLoader(url, imageView));
}
// 異步加載任務
private class PhotoLoader implements Runnable {
private final String url;
private final ImageView imageView;
PhotoLoader(String url, ImageView imageView) {
this.url = url; this.imageView = imageView;
}
@Override
public void run() {
// 1. 任務開始前檢測:ImageView 是否已綁定新 URL (列表滑動復用)
if (isViewReused()) return;
// 2. 獲取 Bitmap (文件緩存 -> 網絡)
Bitmap bitmap = getBitmap(url);
// 3. 下載完成後再次檢測防復用
if (isViewReused()) return;
// 4. 更新內存緩存
if (bitmap != null) memoryCache.put(url, bitmap);
// 5. 切主線程更新 UI
handler.post(new BitmapDisplayer(bitmap, imageView, url));
}
private boolean isViewReused() {
String tag = imageViews.get(imageView);
return tag == null || !tag.equals(url);
}
// 讀取文件或下載
private Bitmap getBitmap(String url) {
File f = fileCache.getFile(url);
Bitmap bmp = decodeFile(f); // 嘗試讀文件緩存
if (bmp != null) return bmp;
// 網絡下載
try {
URL imageUrl = new URL(url);
HttpURLConnection conn = (HttpURLConnection) imageUrl.openConnection();
conn.setConnectTimeout(TIME_OUT);
conn.setReadTimeout(TIME_OUT);
conn.setInstanceFollowRedirects(true);
try (InputStream is = conn.getInputStream();
OutputStream os = new FileOutputStream(f)) {
copyStream(is, os); // 流拷貝存文件
}
conn.disconnect();
return decodeFile(f); // 解碼文件
} catch (Throwable ex) {
if (ex instanceof OutOfMemoryError) clearCaches();
return null;
}
}
private Bitmap decodeFile(File f) {
try (FileInputStream fis = new FileInputStream(f)) {
return BitmapFactory.decodeStream(fis);
// 生產環境必加:inSampleSize 壓縮大圖、inBitmap 復用內存
} catch (FileNotFoundException e) { return null; }
}
private void copyStream(InputStream is, OutputStream os) throws IOException {
byte[] buf = new byte[1024];
int len;
while ((len = is.read(buf)) != -1) os.write(buf, 0, len);
}
}
// 主線程顯示任務
private class BitmapDisplayer implements Runnable {
private final Bitmap bitmap;
private final ImageView imageView;
private final String url;
BitmapDisplayer(Bitmap bitmap, ImageView imageView, String url) {
this.bitmap = bitmap; this.imageView = imageView; this.url = url;
}
@Override
public void run() {
// 最後一道防線:設置前再次驗證綁定關係
String tag = imageViews.get(imageView);
if (tag == null || !tag.equals(url)) return;
if (bitmap != null) imageView.setImageBitmap(bitmap);
else imageView.setImageResource(DEFAULT_RES);
}
}
public void clearCaches() {
memoryCache.clear();
fileCache.clear();
}
}
| 層級 | 存儲介質 | 讀取速度 | 容量 | 生命週期 | 作用 |
|---|---|---|---|---|---|
| 內存 | RAM (SoftRef Map) | 極快 (ns) | 小 (MB 級) | 進程存活 | 高頻熱點圖片秒現 |
| 文件 | ROM (SD卡/內部存儲) | 快 (ms) | 大 (GB 級) | 持久/手動清理 | 離線可用、節省流量、二次啟動快 |
| 網絡 | 遠程服務器 | 慢 (不可控) | 無限 | 服務器決定 | 源頭數據、首次加載、緩存失效回源 |
Executors.newFixedThreadPool(5):固定 5 線程,**防止滑動時無限創建線程導致 OOM/CPU 飆升**。Runtime.getRuntime().availableProcessors() * 2 或固定 3-5 (IO 密集型)。ThreadPoolExecutor + 優先級隊列 (可見圖優先加載)。問題:ListView 滑動時,Item View 被回收復用。任務 A 為 View1 加載 url1,任務未完成時 View1 被復用顯示 url2。任務 A 完成後若直接 setImageBitmap,View1 會錯誤顯示 url1 圖片 (閃爍/錯位)。
解法:
imageViews.put(imageView, url) 記錄「View 當前綁定 URL」。imageViews.get(imageView).equals(url)。此機制配合 WeakHashMap (View 被 GC 時自動移除條目),完美解決復用錯位。
new Handler(Looper.getMainLooper()) 確保 UI 更新在主線程。CalledFromWrongThreadException。圖片異步加載器的核心邏輯可濃縮為:**「三級緩存分層 + 固定線程池限流 + Tag 防復用 + Handler 主線程更新」**。理解這套機制,不僅能應對面試,更能在定制化需求 (如預加載、圖片處理管道、進度監聽) 時靈活擴展,或在排查 Glide 疑難雜症時直擊本質。**造輪子是為了懂輪子,上線請用成熟庫。**