設計圖片請求框架的緩存模塊
核心目標是通過分層緩存策略(內存緩存 + 磁盤緩存)提升圖片加載效率,同時兼顧內存占用和存儲性能。以下是針對 Android 面試官的回答思路,結合代碼注釋說明關鍵設計點:
一、緩存架構設計:分層緩存策略
采用內存緩存(LRU)+ 磁盤緩存(持久化)+ 網絡兜底的三級架構,優先從內存快速獲取,其次從磁盤讀取,最后網絡加載,減少重復請求和資源消耗。
二、內存緩存設計(LruCache)
核心作用:利用內存快速訪問特性,緩存近期使用的圖片,避免重復解碼 Bitmap。
實現要點:
- 使用 Android 內置的
LruCache
(或 Kotlin 的LinkedHashMap
手動實現 LRU),根據內存大小動態設置緩存上限(通常為應用可用內存的 1/8)。 - 以圖片 URL 的 MD5 值作為 Key,確保唯一性;Value 存儲解碼后的
Bitmap
。 - 結合
onTrimMemory()
回調,在系統內存緊張時主動釋放內存緩存。
代碼示例(帶注釋)
public class MemoryCache {private LruCache<String, Bitmap> lruCache;public MemoryCache(Context context) {// 計算內存緩存上限:取應用可用內存的1/8(避免OOM)int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);int cacheSize = maxMemory / 8;lruCache = new LruCache<String, Bitmap>(cacheSize) {// 重寫尺寸計算(Bitmap的內存占用以像素數衡量:width * height * bytePerPixel)@Overrideprotected int sizeOf(String key, Bitmap value) {return value.getByteCount() / 1024; // 單位KB}};}// 存入內存緩存(主線程調用需注意同步,但LruCache本身線程安全)public void put(String url, Bitmap bitmap) {if (get(url) == null) { // 避免重復存儲lruCache.put(hashKeyForUrl(url), bitmap);}}// 獲取內存緩存public Bitmap get(String url) {return lruCache.get(hashKeyForUrl(url));}// 清理緩存(在Activity/Fragment銷毀時調用,避免內存泄漏)public void clear() {if (!lruCache.isEmpty()) {lruCache.evictAll(); // 清空所有緩存}}// URL轉MD5,確保Key唯一且合法(避免特殊字符導致的問題)private String hashKeyForUrl(String url) {try {MessageDigest md = MessageDigest.getInstance("MD5");byte[] hashBytes = md.digest(url.getBytes());// 轉換為16進制字符串StringBuilder hexString = new StringBuilder();for (byte b : hashBytes) {String hex = String.format("%02X", b);hexString.append(hex.toLowerCase());}return hexString.toString();} catch (Exception e) {return String.valueOf(url.hashCode()); // 異常時用hashCode兜底}}
}
三、磁盤緩存設計(DiskLruCache)
核心作用:持久化存儲圖片文件,避免重復下載,同時減輕內存壓力。
實現要點:
- 使用 Android 推薦的
DiskLruCache
(需處理 Android 10 + 的分區存儲適配),按文件大小或時間實現 LRU 淘汰。 - 緩存路徑建議放在應用私有目錄(如
Context.getCacheDir()
),避免用戶刪除或權限問題。 - 異步處理磁盤 IO(如使用
ExecutorService
),避免阻塞主線程。 - 支持緩存有效期(如 7 天),定期清理過期文件。
代碼示例(帶注釋)
public class DiskCache {private static final int MAX_DISK_CACHE_SIZE = 50 * 1024 * 1024; // 50MBprivate static final int APP_VERSION = 1; // 版本號變更時清空緩存private static final String DISK_CACHE_SUBDIR = "image_cache"; // 子目錄名稱private DiskLruCache diskLruCache;private ExecutorService diskExecutor;public DiskCache(Context context) {diskExecutor = Executors.newSingleThreadExecutor(); // 單線程保證磁盤操作有序File cacheDir = new File(context.getCacheDir(), DISK_CACHE_SUBDIR);try {diskLruCache = DiskLruCache.open(cacheDir, APP_VERSION, 1, MAX_DISK_CACHE_SIZE);} catch (IOException e) {e.printStackTrace();}}// 異步寫入磁盤緩存(在子線程調用)public void asyncPut(String url, byte[] data) {diskExecutor.execute(() -> {String key = hashKeyForUrl(url);try (DiskLruCache.Editor editor = diskLruCache.edit(key)) {if (editor != null) {OutputStream outputStream = editor.newOutputStream(0);outputStream.write(data);editor.commit(); // 提交寫入}} catch (IOException e) {e.printStackTrace();try {if (editor != null) {editor.abort(); // 失敗時回滾}} catch (IOException ex) {ex.printStackTrace();}}});}// 同步讀取磁盤緩存(建議在子線程調用,避免ANR)public byte[] get(String url) {String key = hashKeyForUrl(url);try (DiskLruCache.Snapshot snapshot = diskLruCache.get(key)) {if (snapshot != null) {InputStream inputStream = snapshot.getInputStream(0);return inputStreamToByteArray(inputStream); // 轉換為字節數組}} catch (IOException e) {e.printStackTrace();}return null;}// 清理過期緩存(可結合定時任務或開機廣播觸發)public void cleanExpiredCache(long expirationMillis) {diskExecutor.execute(() -> {File cacheDir = diskLruCache.getDirectory();for (File file : cacheDir.listFiles()) {if (System.currentTimeMillis() - file.lastModified() > expirationMillis) {file.delete(); // 刪除超過有效期的文件}}try {diskLruCache.trimToSize(MAX_DISK_CACHE_SIZE); // 按大小LRU淘汰} catch (IOException e) {e.printStackTrace();}});}// 輸入流轉字節數組(工具方法)private byte[] inputStreamToByteArray(InputStream is) throws IOException {ByteArrayOutputStream os = new ByteArrayOutputStream();byte[] buffer = new byte[1024];int len;while ((len = is.read(buffer)) != -1) {os.write(buffer, 0, len);}return os.toByteArray();}
}
四、緩存協同邏輯
-
獲取圖片流程:
- 先查內存緩存,存在則直接使用(無需解碼,最快)。
- 內存無緩存則查磁盤緩存,存在則解碼為 Bitmap 并存入內存(下次直接讀內存)。
- 磁盤無緩存則發起網絡請求,下載后同時寫入磁盤和內存。
-
內存與磁盤的一致性:
- 磁盤緩存寫入完成后,再更新內存緩存,避免內存與磁盤數據不一致。
- 圖片尺寸適配:根據 ImageView 的目標尺寸(width/height)緩存對應尺寸的圖片,避免內存浪費(如存儲 1080p 圖片到僅需 200x200 的 View)。
五、面試官高頻問題補充
-
為什么選擇 LruCache 而不是 HashMap?
LruCache 內置 LRU 淘汰算法,自動管理內存釋放,避免 OOM;HashMap 需手動實現淘汰邏輯,容易導致內存泄漏。 -
磁盤緩存為什么用 DiskLruCache 而不是直接寫文件?
DiskLruCache 封裝了文件 IO 的原子性操作(如寫入失敗時回滾)、LRU 淘汰策略、版本管理(版本號變更時清空緩存),比手動管理文件更可靠。 -
如何處理緩存穿透和緩存擊穿?
- 緩存穿透(請求不存在的 Key):對無效 Key 進行短期內存緩存(如緩存空結果 1 分鐘)。
- 緩存擊穿(熱點 Key 失效):加分布式鎖(或本地鎖),確保同一 Key 的網絡請求僅發起一次。
-
Android 10 + 分區存儲對磁盤緩存的影響?
緩存路徑必須使用應用私有目錄(如getCacheDir()
),避免使用外部存儲公共目錄(需申請權限且可能被用戶清理)。
ScrollView里面嵌套兩個高度都為兩個屏幕RecycleView
整體思路闡述
當?ScrollView
?嵌套兩個高度為兩個屏幕的?RecyclerView
?時,要實現特定的?ACTION_MOVE
?事件處理邏輯,也就是讓?MOVE
?事件先由?RecyclerView1
?處理,等?RecyclerView1
?滾動到底部后將事件交給?ScrollView
?處理,待?RecyclerView2
?完全展示在屏幕上時再把事件交給?RecyclerView2
?處理,關鍵在于重寫?ScrollView
?的?onInterceptTouchEvent
?方法來精確控制事件的攔截與分發。同時,需要編寫方法來判斷?RecyclerView
?是否滾動到底部以及是否完全顯示在屏幕上。
代碼實現:
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.ScrollView;
import androidx.recyclerview.widget.RecyclerView;// 自定義 ScrollView 類,用于處理嵌套 RecyclerView 的觸摸事件
public class CustomScrollView extends ScrollView {// 聲明兩個 RecyclerView 成員變量private RecyclerView recyclerView1;private RecyclerView recyclerView2;// 構造函數,用于在代碼中創建 CustomScrollView 實例public CustomScrollView(Context context) {super(context);}// 構造函數,用于在 XML 布局中使用 CustomScrollViewpublic CustomScrollView(Context context, AttributeSet attrs) {super(context, attrs);}// 構造函數,帶有默認樣式屬性public CustomScrollView(Context context, AttributeSet attrs, int defStyleAttr) {super(context, attrs, defStyleAttr);}// 設置關聯的兩個 RecyclerViewpublic void setRecyclerViews(RecyclerView recyclerView1, RecyclerView recyclerView2) {this.recyclerView1 = recyclerView1;this.recyclerView2 = recyclerView2;}// 重寫 onInterceptTouchEvent 方法,用于攔截觸摸事件@Overridepublic boolean onInterceptTouchEvent(MotionEvent ev) {// 確保兩個 RecyclerView 已經被正確設置if (recyclerView1 != null && recyclerView2 != null) {// 根據觸摸事件的動作類型進行處理switch (ev.getAction()) {case MotionEvent.ACTION_MOVE:// 判斷 RecyclerView1 是否滾動到底部if (!isRecyclerViewAtBottom(recyclerView1)) {// 如果 RecyclerView1 未滾動到底部,不攔截事件,讓 RecyclerView1 處理return false;}// 判斷 RecyclerView2 是否完全顯示在屏幕上if (!isRecyclerViewFullyVisible(recyclerView2)) {// 如果 RecyclerView2 未完全顯示,攔截事件,由 ScrollView 處理return true;}break;}}// 其他情況,調用父類的 onInterceptTouchEvent 方法return super.onInterceptTouchEvent(ev);}// 判斷 RecyclerView 是否滾動到底部的方法private boolean isRecyclerViewAtBottom(RecyclerView recyclerView) {// 檢查 RecyclerView 的適配器是否為空if (recyclerView.getAdapter() == null) {return false;}// 獲取 RecyclerView 的 LayoutManagerRecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();// 檢查 LayoutManager 是否為空if (layoutManager == null) {return false;}// 獲取最后一個可見項的位置int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition();// 判斷最后一個可見項是否是列表中的最后一項return lastVisibleItemPosition == recyclerView.getAdapter().getItemCount() - 1;}// 判斷 RecyclerView 是否完全顯示在屏幕上的方法private boolean isRecyclerViewFullyVisible(RecyclerView recyclerView) {// 檢查 RecyclerView 的適配器是否為空if (recyclerView.getAdapter() == null) {return false;}// 獲取 RecyclerView 的 LayoutManagerRecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();// 檢查 LayoutManager 是否為空if (layoutManager == null) {return false;}// 獲取第一個可見項的位置int firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition();// 獲取最后一個可見項的位置int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition();// 判斷第一個可見項是否是列表中的第一項,且最后一個可見項是否是列表中的最后一項return firstVisibleItemPosition == 0 && lastVisibleItemPosition == recyclerView.getAdapter().getItemCount() - 1;}
}
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;// 主 Activity 類
public class MainActivity extends AppCompatActivity {// 聲明 CustomScrollView 和兩個 RecyclerView 成員變量private CustomScrollView customScrollView;private RecyclerView recyclerView1;private RecyclerView recyclerView2;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);// 設置布局文件setContentView(R.layout.activity_main);// 從布局文件中獲取 CustomScrollView 和兩個 RecyclerView 的實例customScrollView = findViewById(R.id.customScrollView);recyclerView1 = findViewById(R.id.recyclerView1);recyclerView2 = findViewById(R.id.recyclerView2);// 為 RecyclerView1 設置線性布局管理器recyclerView1.setLayoutManager(new LinearLayoutManager(this));// 為 RecyclerView2 設置線性布局管理器recyclerView2.setLayoutManager(new LinearLayoutManager(this));// 為 RecyclerView1 設置適配器,并傳入模擬數據recyclerView1.setAdapter(new MyAdapter(createDummyData()));// 為 RecyclerView2 設置適配器,并傳入模擬數據recyclerView2.setAdapter(new MyAdapter(createDummyData()));// 將兩個 RecyclerView 關聯到 CustomScrollView 中customScrollView.setRecyclerViews(recyclerView1, recyclerView2);}// 創建模擬數據的方法private List<String> createDummyData() {// 創建一個字符串列表來存儲模擬數據List<String> data = new ArrayList<>();// 循環添加 50 條模擬數據for (int i = 0; i < 50; i++) {data.add("Item " + i);}return data;}
}
?
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;// RecyclerView 的適配器類
public class MyAdapter extends RecyclerView.Adapter<MyAdapter.ViewHolder> {// 存儲要顯示的數據列表private List<String> data;// 構造函數,傳入數據列表public MyAdapter(List<String> data) {this.data = data;}// 創建 ViewHolder 實例@NonNull@Overridepublic ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {// 從布局文件中加載單個列表項的視圖View view = LayoutInflater.from(parent.getContext()).inflate(android.R.layout.simple_list_item_1, parent, false);// 創建 ViewHolder 實例并傳入視圖return new ViewHolder(view);}// 綁定數據到 ViewHolder@Overridepublic void onBindViewHolder(@NonNull ViewHolder holder, int position) {// 將指定位置的數據設置到 TextView 中holder.textView.setText(data.get(position));}// 獲取數據列表的大小@Overridepublic int getItemCount() {return data.size();}// ViewHolder 類,用于緩存視圖組件public static class ViewHolder extends RecyclerView.ViewHolder {// 聲明 TextView 成員變量TextView textView;// 構造函數,傳入視圖public ViewHolder(@NonNull View itemView) {super(itemView);// 從視圖中獲取 TextView 實例textView = itemView.findViewById(android.R.id.text1);}}
}
代碼調用邏輯說明
- 初始化階段:在?
MainActivity
?的?onCreate
?方法中,首先通過?setContentView
?設置布局文件,然后從布局文件中獲取?CustomScrollView
?和兩個?RecyclerView
?的實例。接著為兩個?RecyclerView
?設置?LinearLayoutManager
?和?MyAdapter
,并調用?customScrollView.setRecyclerViews
?方法將兩個?RecyclerView
?關聯到?CustomScrollView
?中。 - 觸摸事件處理階段:當用戶進行觸摸操作時,觸摸事件會先傳遞到?
CustomScrollView
?的?onInterceptTouchEvent
?方法。在該方法中,會根據?RecyclerView1
?是否滾動到底部以及?RecyclerView2
?是否完全顯示在屏幕上的情況來決定是否攔截事件。如果?RecyclerView1
?未滾動到底部,不攔截事件,讓?RecyclerView1
?處理;如果?RecyclerView2
?未完全顯示,攔截事件,由?CustomScrollView
?處理;其他情況則調用父類的?onInterceptTouchEvent
?方法。