diff --git a/app/src/main/java/com/ttstd/dialer/base/BaseApplication.java b/app/src/main/java/com/ttstd/dialer/base/BaseApplication.java index 306448f..4f69095 100644 --- a/app/src/main/java/com/ttstd/dialer/base/BaseApplication.java +++ b/app/src/main/java/com/ttstd/dialer/base/BaseApplication.java @@ -20,6 +20,7 @@ import com.ttstd.dialer.BuildConfig; import com.ttstd.dialer.alarmclock.AlarmManagerHelper; import com.ttstd.dialer.config.CommonConfig; import com.ttstd.dialer.config.SystemIntentAction; +import com.ttstd.dialer.data.cache.CacheManager; import com.ttstd.dialer.manager.AppManager; import com.ttstd.dialer.manager.MapManager; import com.ttstd.dialer.manager.WeatherManager; @@ -137,6 +138,9 @@ public class BaseApplication extends Application { } registerReceivers(); + + // 启动时异步清理磁盘缓存(过期淘汰 + 容量上限),不阻塞启动 + CacheManager.getInstance(this).cleanupAsync(); } } diff --git a/app/src/main/java/com/ttstd/dialer/data/cache/CacheManager.java b/app/src/main/java/com/ttstd/dialer/data/cache/CacheManager.java index 367500e..29fe65a 100644 --- a/app/src/main/java/com/ttstd/dialer/data/cache/CacheManager.java +++ b/app/src/main/java/com/ttstd/dialer/data/cache/CacheManager.java @@ -7,6 +7,8 @@ import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import java.lang.reflect.Type; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** * 多级缓存统一入口:L1 内存(LruCache) → L2 磁盘(文件+Gson)。 @@ -19,6 +21,8 @@ public final class CacheManager { private static volatile CacheManager sInstance; + private static final ExecutorService sCleanupExecutor = Executors.newSingleThreadExecutor(); + private final MemoryCache mMemoryCache; private final DiskCache mDiskCache; @@ -105,4 +109,20 @@ public final class CacheManager { mMemoryCache.clear(); mDiskCache.clear(); } + + /** + * 异步清理磁盘缓存(过期淘汰 + 容量上限),可在主线程调用,内部切到工作线程。 + * 建议在 Application 启动时调用一次。 + */ + public void cleanupAsync() { + sCleanupExecutor.execute(this::cleanup); + } + + /** + * 同步清理磁盘缓存,必须在工作线程调用。 + */ + @WorkerThread + public void cleanup() { + mDiskCache.cleanup(); + } } diff --git a/app/src/main/java/com/ttstd/dialer/data/cache/DiskCache.java b/app/src/main/java/com/ttstd/dialer/data/cache/DiskCache.java index 9ede797..e18d340 100644 --- a/app/src/main/java/com/ttstd/dialer/data/cache/DiskCache.java +++ b/app/src/main/java/com/ttstd/dialer/data/cache/DiskCache.java @@ -24,6 +24,10 @@ import java.io.Writer; import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; /** * L2 磁盘缓存:文件 + Gson 序列化,App 冷启动后仍可秒出数据。 @@ -41,6 +45,12 @@ public final class DiskCache { private static final String CACHE_DIR_NAME = "api_disk_cache"; private static final String TMP_SUFFIX = ".tmp"; + /** + * 容量上限:最多保留的缓存条目数(超出按文件修改时间淘汰最旧)。 + * API 响应体普遍较小,300 条足以覆盖常用接口并约束磁盘占用,可按需调整。 + */ + private static final int MAX_ENTRIES = 300; + private static final String KEY_SAVE_TIME = "saveTime"; private static final String KEY_SOFT_TTL = "softTtl"; private static final String KEY_HARD_TTL = "hardTtl"; @@ -115,6 +125,7 @@ public final class DiskCache { if (!tmp.renameTo(file)) { Log.w(TAG, "Rename tmp cache file failed, key=" + key); } + maybeEnforceCapacity(); } catch (IOException e) { Log.w(TAG, "Write disk cache failed, key=" + key, e); //noinspection ResultOfMethodCallIgnored @@ -140,6 +151,76 @@ public final class DiskCache { } } + /** + * 启动 / 低内存时调用:删除已硬过期的条目,并强制容量上限。 + * 必须在工作线程调用。 + */ + @WorkerThread + public void cleanup() { + deleteExpired(); + enforceCapacity(); + } + + /** + * 删除所有已硬过期的缓存文件(解析失败/损坏的也一并淘汰)。 + */ + private void deleteExpired() { + File[] files = mCacheDir.listFiles(); + if (files == null) { + return; + } + long now = System.currentTimeMillis(); + for (File f : files) { + if (f.isFile() && isFileExpired(f, now)) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + } + } + } + + /** + * 仅解析头部的 saveTime/hardTtl 判断硬过期,避免反序列化整个 value。 + */ + private boolean isFileExpired(@NonNull File file, long now) { + try (Reader reader = new BufferedReader( + new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) { + JsonObject root = JsonParser.parseReader(reader).getAsJsonObject(); + long saveTime = root.get(KEY_SAVE_TIME).getAsLong(); + long hardTtl = root.get(KEY_HARD_TTL).getAsLong(); + return now - saveTime > hardTtl; + } catch (Exception e) { + // 解析失败视为损坏,直接淘汰 + return true; + } + } + + /** + * 超出 {@link #MAX_ENTRIES} 时,按文件修改时间(最旧优先)淘汰多余条目。 + */ + private void enforceCapacity() { + File[] files = mCacheDir.listFiles(); + if (files == null || files.length <= MAX_ENTRIES) { + return; + } + List sorted = new ArrayList<>(Arrays.asList(files)); + sorted.sort(Comparator.comparingLong(File::lastModified)); + int toRemove = sorted.size() - MAX_ENTRIES; + for (int i = 0; i < toRemove; i++) { + //noinspection ResultOfMethodCallIgnored + sorted.get(i).delete(); + } + } + + /** + * 写入后轻量检查:仅在可能超限时才真正排序淘汰,避免每次 put 都遍历目录。 + */ + private void maybeEnforceCapacity() { + File[] files = mCacheDir.listFiles(); + if (files != null && files.length > MAX_ENTRIES) { + enforceCapacity(); + } + } + private File fileFor(@NonNull String key) { return new File(mCacheDir, md5(key) + ".json"); }