فهرست منبع

perf: 书架首屏封面预热

HapeLee 1 ماه پیش
والد
کامیت
5d8d677b48

+ 3 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -301,6 +301,7 @@ import io.legado.app.ui.login.SourceLoginViewModel
 import io.legado.app.ui.main.MainNavRouteTracker
 import io.legado.app.ui.main.MainNavRouteTracker
 import io.legado.app.ui.main.MainRouteSearchContent
 import io.legado.app.ui.main.MainRouteSearchContent
 import io.legado.app.ui.main.MainViewModel
 import io.legado.app.ui.main.MainViewModel
+import io.legado.app.ui.main.bookshelf.BookshelfCoverPreloader
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
 import io.legado.app.ui.main.bookshelf.autoGroup.AiAutoGroupViewModel
 import io.legado.app.ui.main.bookshelf.autoGroup.AiAutoGroupViewModel
 import io.legado.app.ui.main.explore.ExploreViewModel
 import io.legado.app.ui.main.explore.ExploreViewModel
@@ -555,6 +556,8 @@ val appModule = module {
             .crossfade(true)
             .crossfade(true)
             .build()
             .build()
     }
     }
+    // 书架首屏封面预热:依赖上面的 ImageLoader 单例,进程内复用已缓存封面
+    singleOf(::BookshelfCoverPreloader)
 
 
     viewModelOf(::DictRuleViewModel)
     viewModelOf(::DictRuleViewModel)
     viewModelOf(::ImportDictRuleViewModel)
     viewModelOf(::ImportDictRuleViewModel)

+ 10 - 0
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -64,6 +64,7 @@ import io.legado.app.ui.book.read.ReadBookRouteHost
 import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.ui.book.readaloud.ReadAloudShellHost
 import io.legado.app.ui.book.readaloud.ReadAloudShellHost
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
+import io.legado.app.ui.main.bookshelf.BookshelfCoverPreloader
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.welcome.WelcomeActivity
 import io.legado.app.ui.welcome.WelcomeActivity
 import io.legado.app.ui.widget.components.privacy.PrivateAppStartGate
 import io.legado.app.ui.widget.components.privacy.PrivateAppStartGate
@@ -293,6 +294,7 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
     }
     }
 
 
     private val viewModel by viewModel<MainViewModel>()
     private val viewModel by viewModel<MainViewModel>()
+    private val bookshelfCoverPreloader by inject<BookshelfCoverPreloader>()
     private val otherSettingsGateway by inject<OtherSettingsGateway>()
     private val otherSettingsGateway by inject<OtherSettingsGateway>()
     private val mangaSettingsGateway by inject<MangaSettingsGateway>()
     private val mangaSettingsGateway by inject<MangaSettingsGateway>()
     private val backupSettingsGateway by inject<BackupSettingsGateway>()
     private val backupSettingsGateway by inject<BackupSettingsGateway>()
@@ -347,6 +349,14 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
                 checkUpdateOnStart()
                 checkUpdateOnStart()
             }
             }
         }
         }
+
+        // 书架封面预热:必须早于书架首帧发起。卡片请求带了 placeholderMemoryCacheKey,
+        // 内存缓存里已有同一键时 Coil 会在真实加载之前就把缓存图交给 target,于是进入书架
+        // 的第一帧就是封面,而不是"灰底 → 稍后出现"。独立协程,不阻塞上面的启动关键路径;
+        // 预热失败对 UI 无影响(卡片自己的请求会照常决定成功/错误态)。
+        lifecycleScope.launch {
+            runCatching { bookshelfCoverPreloader.preloadCurrentGroupFirstScreen() }
+        }
     }
     }
 
 
     /**
     /**

+ 288 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfCoverPreloader.kt

@@ -0,0 +1,288 @@
+package io.legado.app.ui.main.bookshelf
+
+import android.app.Application
+import android.content.res.Configuration
+import coil3.ImageLoader
+import coil3.memory.MemoryCache
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.repository.BookGroupRepository
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.BookshelfRepository
+import io.legado.app.domain.gateway.BookshelfSettingsGateway
+import io.legado.app.domain.gateway.CoverSettingsGateway
+import io.legado.app.domain.gateway.PrivateContentGateway
+import io.legado.app.domain.model.isPrivateBook
+import io.legado.app.domain.model.settings.BookshelfSettings
+import io.legado.app.ui.widget.components.image.cover.bookshelfCoverMemoryCacheKey
+import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest
+import io.legado.app.ui.widget.components.image.cover.isDefaultCoverPath
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.coroutines.withContext
+import kotlin.math.roundToInt
+
+/** 预热上限:再多的书也不在首屏,留着让书架自己按需加载。 */
+private const val MAX_PRELOAD_BOOKS = 24
+
+/** 列表模式一屏只能看到几本,没必要按网格的列数铺开。 */
+private const val LIST_PRELOAD_BOOKS = 10
+
+/** 按列数 × 预估可见行数收敛预热数量(按高屏手机 3 列 × 5 行估)。 */
+private const val ESTIMATED_VISIBLE_ROWS = 5
+
+/** 预热并发上限:它是启动期后台任务,不能把解码线程和启动关键路径全部占满。 */
+private const val PRELOAD_CONCURRENCY = 4
+
+/** 卡片封面四周的内边距([io.legado.app.ui.main.bookshelf.BookItem] 里的 `padding(4.dp)`)。 */
+private const val COVER_ITEM_PADDING_DP = 4
+
+/**
+ * 待预热的书:只保留书卡那种“确实有真实封面地址”的书。
+ */
+internal data class CoverPreloadCandidate(
+    val bookUrl: String,
+    val coverPath: String,
+    val sourceOrigin: String?,
+)
+
+/**
+ * 一次预热请求的全部参数。
+ *
+ * [memoryCacheKey] 必须与书架卡片请求里的 memoryCacheKey 完全一致,否则预热写进内存缓存
+ * 的是另一个条目,卡片照样要重新读盘解码。
+ */
+internal data class CoverPreloadTarget(
+    val memoryCacheKey: String,
+    val bookUrl: String,
+    val coverPath: String,
+    val sourceOrigin: String?,
+    val widthPx: Int,
+    val heightPx: Int,
+)
+
+/** 卡片只加载真实封面地址;空地址与“默认封面”都不产生请求。 */
+internal fun BookShelfItem.toCoverPreloadCandidate(): CoverPreloadCandidate? {
+    val path = getDisplayCover()
+    if (isDefaultCoverPath(path)) return null
+    return CoverPreloadCandidate(
+        bookUrl = bookUrl,
+        coverPath = path!!,
+        sourceOrigin = origin,
+    )
+}
+
+/**
+ * 计算首屏预热目标(纯函数,便于单测)。
+ *
+ * 尺寸按卡片布局反推:外层列宽 [coverWidthDp](受书架设置控制)减去左右各 4dp 内边距,
+ * 高宽比固定 5:7。尺寸准不准只影响“卡片是否要再解一次码”——内存缓存命中时 Coil 会先把
+ * 缓存图交给 target,所以尺寸偏差不会让封面晚出现,只会多一次解码。
+ */
+internal fun buildCoverPreloadTargets(
+    groupId: Long,
+    candidates: List<CoverPreloadCandidate>,
+    coverWidthDp: Int,
+    density: Float,
+    limit: Int,
+): List<CoverPreloadTarget> {
+    val outerPx = (coverWidthDp * density).roundToInt()
+    val paddingPx = (COVER_ITEM_PADDING_DP * density).roundToInt()
+    val widthPx = (outerPx - 2 * paddingPx).coerceAtLeast(1)
+    val heightPx = (widthPx * 7f / 5f).roundToInt()
+    return candidates.take(limit).map { candidate ->
+        CoverPreloadTarget(
+            memoryCacheKey = bookshelfCoverMemoryCacheKey(
+                groupId = groupId,
+                bookUrl = candidate.bookUrl,
+                coverPath = candidate.coverPath,
+            ),
+            bookUrl = candidate.bookUrl,
+            coverPath = candidate.coverPath,
+            sourceOrigin = candidate.sourceOrigin,
+            widthPx = widthPx,
+            heightPx = heightPx,
+        )
+    }
+}
+
+/**
+ * 书架首屏封面预热。
+ *
+ * 存在的理由:进程刚起来时 Coil 内存缓存是空的,书架卡片必须先把封面文件读出来解码完才能
+ * 画第一张图,于是进入书架时先看到一片灰底、封面随后才出现。卡片请求本来就带了
+ * `placeholderMemoryCacheKey`,只要内存缓存里已经有同一个键,Coil 会在发起真实加载之前
+ * 就把缓存图交给 target——所以把“读盘 + 解码”提前到书架首帧之前,卡片第一帧就是封面。
+ *
+ * 触发点在书架组合之前([io.legado.app.ui.main.MainActivity.onCreate]),因此这里自己按
+ * 书架设置查出当前分组首屏的书;顺序复用 [BookshelfRepository.sortBooks],与书架列表同一份
+ * 排序实现,避免两套排序漂移导致预热到别的书上。
+ */
+class BookshelfCoverPreloader(
+    private val application: Application,
+    private val imageLoader: ImageLoader,
+    private val bookRepository: BookRepository,
+    private val bookGroupRepository: BookGroupRepository,
+    private val bookshelfRepository: BookshelfRepository,
+    private val bookshelfSettingsGateway: BookshelfSettingsGateway,
+    private val coverSettingsGateway: CoverSettingsGateway,
+    private val privateContentGateway: PrivateContentGateway,
+) {
+
+    /**
+     * 本进程已经预热过的“分组 + 排序 + 封面尺寸”签名。
+     *
+     * Activity 因主题/语言重建会再走一遍 onCreate,没有它就会重复预热同一批封面。
+     */
+    @Volatile
+    private var warmedSignature: String? = null
+
+    /**
+     * 预热当前书架分组首屏封面。
+     *
+     * @return 实际发起的预热请求数;不需要预热时返回 0
+     */
+    suspend fun preloadCurrentGroupFirstScreen(): Int {
+        val settings = bookshelfSettingsGateway.currentSettings
+        val coverSettings = coverSettingsGateway.currentSettings
+        // 全局启用默认封面时卡片根本不加载真实封面,预热只会白耗一次解码与流量
+        if (coverSettings.useDefaultCover) return 0
+        // 文件夹模式首屏是分组卡片(每格 4 张预览封面,内存键与分组页不同),不在本次范围
+        if (settings.bookGroupStyle == 2) return 0
+
+        val groupId = settings.saveTabPosition
+        val layout = resolveFirstScreenLayout(settings)
+        val signature = listOf(
+            groupId,
+            settings.bookshelfSort,
+            settings.bookshelfSortOrder,
+            layout.grid,
+            layout.coverWidthDp,
+        ).joinToString("|")
+        if (signature == warmedSignature) return 0
+
+        return withContext(Dispatchers.IO) {
+            val targets = buildTargets(groupId, settings, layout)
+            if (targets.isEmpty()) return@withContext 0
+            val pending = targets.filterNot { isAlreadyCached(it.memoryCacheKey) }
+            if (pending.isEmpty()) {
+                // 内存里已经有了,等价于预热完成
+                warmedSignature = signature
+                return@withContext 0
+            }
+            val semaphore = Semaphore(PRELOAD_CONCURRENCY)
+            coroutineScope {
+                pending.forEach { target ->
+                    launch {
+                        semaphore.withPermit {
+                            try {
+                                enqueueTarget(target, coverSettings.loadOnlyOnWifi)
+                            } catch (e: CancellationException) {
+                                throw e
+                            } catch (e: Exception) {
+                                // 预热失败与卡片无关:卡片自己的请求会照常重试并决定占位/错误态
+                            }
+                        }
+                    }
+                }
+            }
+            // 全部结束后才记账:中途被取消(例如 Activity 重建)不算预热过,
+            // 新 Activity 的 onCreate 应该能重新发起,而不是被这个标记拦住。
+            warmedSignature = signature
+            pending.size
+        }
+    }
+
+    private suspend fun buildTargets(
+        groupId: Long,
+        settings: BookshelfSettings,
+        layout: FirstScreenLayout,
+    ): List<CoverPreloadTarget> {
+        // 排序输入与书架完全一致:分组取 flowShow(书架的 groupsFlow),
+        // 私密掩码取 flowAll(书架的 allGroupsFlow),两边都用同一个纯函数判定
+        val showGroups = bookGroupRepository.flowShow().first()
+        val allGroups = bookGroupRepository.flowAll().first()
+        val group: BookGroup? = showGroups.find { it.groupId == groupId }
+        val privateBookUrls = privateContentGateway.flowPrivateBookUrls().first()
+        val privateGroupMask = allGroups.fold(0L) { acc, it ->
+            if (it.groupId > 0 && it.isPrivate) acc or it.groupId else acc
+        }
+
+        val candidates = bookshelfRepository.sortBooks(
+            list = bookRepository.flowBookShelfByGroup(groupId).first(),
+            group = group,
+            sort = settings.bookshelfSort,
+            sortOrder = settings.bookshelfSortOrder,
+        ).asSequence()
+            // 锁定态的卡片用的是另一套内存键,预热清晰封面既无用也只是白解码
+            .filterNot { isPrivateBook(it.bookUrl, it.group, privateBookUrls, privateGroupMask) }
+            .mapNotNull { it.toCoverPreloadCandidate() }
+            .toList()
+
+        return buildCoverPreloadTargets(
+            groupId = groupId,
+            candidates = candidates,
+            coverWidthDp = layout.coverWidthDp,
+            density = application.resources.displayMetrics.density,
+            limit = layout.limit,
+        )
+    }
+
+    private fun isAlreadyCached(memoryCacheKey: String): Boolean =
+        imageLoader.memoryCache?.get(MemoryCache.Key(memoryCacheKey))?.image != null
+
+    private suspend fun enqueueTarget(target: CoverPreloadTarget, loadOnlyOnWifi: Boolean) {
+        val request = buildCoverImageRequest(
+            context = application,
+            data = target.coverPath,
+            sourceOrigin = target.sourceOrigin,
+            loadOnlyWifi = loadOnlyOnWifi,
+            // 预热结果只进内存缓存,不经过任何 target,过渡动画没有意义
+            crossfade = false,
+            memoryCacheKey = target.memoryCacheKey,
+            bookUrl = target.bookUrl,
+            preferCache = true,
+        ) {
+            size(target.widthPx, target.heightPx)
+        }
+        imageLoader.execute(request)
+    }
+
+    private data class FirstScreenLayout(
+        val grid: Boolean,
+        val coverWidthDp: Int,
+        val limit: Int,
+    )
+
+    private fun resolveFirstScreenLayout(settings: BookshelfSettings): FirstScreenLayout {
+        val landscape = application.resources.configuration.orientation ==
+                Configuration.ORIENTATION_LANDSCAPE
+        val layoutMode = if (landscape) {
+            settings.bookshelfLayoutModeLandscape
+        } else {
+            settings.bookshelfLayoutModePortrait
+        }
+        val grid = layoutMode != 0
+        val columns = if (!grid) {
+            1
+        } else if (landscape) {
+            settings.bookshelfLayoutGridLandscape
+        } else {
+            settings.bookshelfLayoutGridPortrait
+        }
+        val limit = if (grid) {
+            (columns.coerceAtLeast(1) * ESTIMATED_VISIBLE_ROWS).coerceIn(1, MAX_PRELOAD_BOOKS)
+        } else {
+            LIST_PRELOAD_BOOKS
+        }
+        return FirstScreenLayout(
+            grid = grid,
+            coverWidthDp = if (grid) settings.bookshelfGridCoverWidth else settings.bookshelfListCoverWidth,
+            limit = limit,
+        )
+    }
+}

+ 2 - 1
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -129,6 +129,7 @@ import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.divider.PillHeaderDivider
 import io.legado.app.ui.widget.components.divider.PillHeaderDivider
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.image.cover.bookshelfSharedCoverSourceId
 import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
 import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyVerticalGrid
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyVerticalGrid
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
@@ -1679,7 +1680,7 @@ fun BookshelfPage(
                 val isSelected = selectedBookUrls.contains(bookUi.book.bookUrl)
                 val isSelected = selectedBookUrls.contains(bookUi.book.bookUrl)
                 val sharedCoverKey = bookCoverSharedElementKey(
                 val sharedCoverKey = bookCoverSharedElementKey(
                     bookUi.book.bookUrl,
                     bookUi.book.bookUrl,
-                    "bookshelf:$sharedCoverGroupId"
+                    bookshelfSharedCoverSourceId(sharedCoverGroupId)
                 )
                 )
                 ReorderableItem(
                 ReorderableItem(
                     state = reorderableState,
                     state = reorderableState,

+ 18 - 7
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt

@@ -54,9 +54,20 @@ import org.koin.compose.koinInject
 import io.legado.app.model.BookCover as BookCoverModel
 import io.legado.app.model.BookCover as BookCoverModel
 
 
 private const val SharedCoverRadiusCacheMaxSize = 256
 private const val SharedCoverRadiusCacheMaxSize = 256
-private const val DefaultCoverPath = "use_default_cover"
+
+/** 用户在换封面页选的“默认封面”,与空地址一样表示这本书没有真实封面。 */
+internal const val DefaultCoverPath = "use_default_cover"
 private val sharedCoverRadiusCache = mutableStateMapOf<String, Dp>()
 private val sharedCoverRadiusCache = mutableStateMapOf<String, Dp>()
 
 
+/**
+ * 这本书是否有真实封面地址可加载。
+ *
+ * 与 [usesDefaultBookCover] 的区别:这里只看地址本身,不读 Compose 配置,
+ * 因此预热这类非组合场景也能复用同一判定。
+ */
+internal fun isDefaultCoverPath(path: String?): Boolean =
+    path.isNullOrBlank() || path == DefaultCoverPath
+
 /**
 /**
  * 封面在源页面的圆角缓存读取入口:封面离开源页面(Visible→Visible 定格)时写入,
  * 封面在源页面的圆角缓存读取入口:封面离开源页面(Visible→Visible 定格)时写入,
  * 阅读端 sharedBounds 的起始圆角由它提供,保证转场两端圆角衔接连续。
  * 阅读端 sharedBounds 的起始圆角由它提供,保证转场两端圆角衔接连续。
@@ -66,9 +77,7 @@ internal fun sharedCoverSourceRadius(sharedCoverKey: String?): Dp? =
 
 
 @Composable
 @Composable
 internal fun usesDefaultBookCover(path: String?): Boolean {
 internal fun usesDefaultBookCover(path: String?): Boolean {
-    return LocalAppUiConfiguration.current.cover.useDefaultCover ||
-            path.isNullOrBlank() ||
-            path == DefaultCoverPath
+    return LocalAppUiConfiguration.current.cover.useDefaultCover || isDefaultCoverPath(path)
 }
 }
 
 
 @OptIn(ExperimentalSharedTransitionApi::class)
 @OptIn(ExperimentalSharedTransitionApi::class)
@@ -189,9 +198,11 @@ fun BookCoverImage(
                     sourceOrigin = sourceOrigin,
                     sourceOrigin = sourceOrigin,
                     loadOnlyWifi = coverSettings.loadOnlyOnWifi,
                     loadOnlyWifi = coverSettings.loadOnlyOnWifi,
                     crossfade = showLoadingPlaceholder,
                     crossfade = showLoadingPlaceholder,
-                    memoryCacheKey = sharedCoverKey?.let {
-                        "$it:cover:${memoryCacheKey ?: finalPath}"
-                    } ?: memoryCacheKey ?: finalPath,
+                    memoryCacheKey = coverMemoryCacheKey(
+                        sharedCoverKey = sharedCoverKey,
+                        explicitKey = memoryCacheKey,
+                        path = finalPath,
+                    ),
                     bookUrl = bookUrl,
                     bookUrl = bookUrl,
                     preferCache = preferCache,
                     preferCache = preferCache,
                     configure = requestBuilder,
                     configure = requestBuilder,

+ 37 - 0
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoverMemoryKey.kt

@@ -0,0 +1,37 @@
+package io.legado.app.ui.widget.components.image.cover
+
+import io.legado.app.ui.main.bookCoverSharedElementKey
+
+/**
+ * 封面在 Coil 内存缓存中的键的唯一拼装入口。
+ *
+ * 键必须由卡片与预热共用同一个函数:书架卡片的请求一旦带上 [sharedCoverKey],缓存键就
+ * 变成「共享元素键:cover:封面地址」。预热若按别的公式拼键,写进内存缓存的就是另一个
+ * 条目,卡片依旧要重新读盘解码——表现就是进入书架时先闪一下灰底。
+ */
+internal fun coverMemoryCacheKey(
+    sharedCoverKey: String?,
+    explicitKey: String?,
+    path: String?,
+): String? = sharedCoverKey?.let { "$it:cover:${explicitKey ?: path}" } ?: explicitKey ?: path
+
+/**
+ * 书架分组页共享元素用的来源标识。
+ *
+ * 卡片([io.legado.app.ui.main.bookshelf.BookshelfScreen])与封面预热共用,避免同一段
+ * 字面量在两处各写一遍后漂移——漂移的结果是预热全部落空且很难发现。
+ */
+internal fun bookshelfSharedCoverSourceId(groupId: Long): String = "bookshelf:$groupId"
+
+/** 书架分组页卡片的封面内存缓存键(与卡片请求里的 memoryCacheKey 必须完全一致)。 */
+internal fun bookshelfCoverMemoryCacheKey(
+    groupId: Long,
+    bookUrl: String,
+    coverPath: String,
+): String = requireNotNull(
+    coverMemoryCacheKey(
+        sharedCoverKey = bookCoverSharedElementKey(bookUrl, bookshelfSharedCoverSourceId(groupId)),
+        explicitKey = null,
+        path = coverPath,
+    )
+)

+ 105 - 0
app/src/test/java/io/legado/app/ui/main/bookshelf/BookshelfCoverPreloadTest.kt

@@ -0,0 +1,105 @@
+package io.legado.app.ui.main.bookshelf
+
+import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.widget.components.image.cover.DefaultCoverPath
+import io.legado.app.ui.widget.components.image.cover.bookshelfCoverMemoryCacheKey
+import io.legado.app.ui.widget.components.image.cover.isDefaultCoverPath
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/**
+ * 预热只有在“内存缓存键与卡片请求完全一致”时才有意义——键一旦漂移,预热写进去的是
+ * 另一个条目,书架依旧要重新读盘解码(也就是入口处那片灰底)。这里把这条不变量钉住。
+ */
+class BookshelfCoverPreloadTest {
+
+    @Test
+    fun memoryKey_matchesShelfCardRequestKey() {
+        assertEquals(
+            "${bookCoverSharedElementKey("bookUrl", "bookshelf:7")}:cover:https://cover",
+            bookshelfCoverMemoryCacheKey(groupId = 7L, bookUrl = "bookUrl", coverPath = "https://cover"),
+        )
+    }
+
+    @Test
+    fun preloadTargets_carryCardKeySizeAndOrigin() {
+        val targets = buildCoverPreloadTargets(
+            groupId = 7L,
+            candidates = listOf(
+                CoverPreloadCandidate("b1", "https://c1", "origin"),
+                CoverPreloadCandidate("b2", "https://c2", null),
+            ),
+            coverWidthDp = 120,
+            density = 3f,
+            limit = 24,
+        )
+
+        assertEquals(2, targets.size)
+        assertEquals(
+            "${bookCoverSharedElementKey("b1", "bookshelf:7")}:cover:https://c1",
+            targets[0].memoryCacheKey,
+        )
+        // 120dp 列宽 - 左右各 4dp 内边距 = 112dp;3x 密度 → 336px;高宽比 5:7 → 470px
+        assertEquals(336, targets[0].widthPx)
+        assertEquals(470, targets[0].heightPx)
+        assertEquals("origin", targets[0].sourceOrigin)
+        assertEquals("b1", targets[0].bookUrl)
+    }
+
+    @Test
+    fun preloadTargets_respectFirstScreenLimit() {
+        val candidates = (1..30).map { CoverPreloadCandidate("b$it", "https://c$it", null) }
+
+        assertEquals(24, buildCoverPreloadTargets(7L, candidates, 120, 3f, limit = 24).size)
+        assertEquals(12, buildCoverPreloadTargets(7L, candidates, 120, 3f, limit = 12).size)
+    }
+
+    @Test
+    fun candidate_keepsOnlyBooksWithRealCoverAddress() {
+        assertNull(book("b", coverUrl = null).toCoverPreloadCandidate())
+        assertNull(book("b", coverUrl = "  ").toCoverPreloadCandidate())
+        assertNull(book("b", coverUrl = DefaultCoverPath).toCoverPreloadCandidate())
+
+        assertEquals("https://cover", book("b", coverUrl = "https://cover").toCoverPreloadCandidate()?.coverPath)
+        // 自定义封面优先于书源封面,与卡片一致
+        assertEquals(
+            "file://custom",
+            book("b", coverUrl = "https://cover", customCoverUrl = "file://custom")
+                .toCoverPreloadCandidate()?.coverPath,
+        )
+    }
+
+    @Test
+    fun defaultCoverPath_isRecognisedWithoutComposeConfig() {
+        assertEquals(true, isDefaultCoverPath(DefaultCoverPath))
+        assertEquals(true, isDefaultCoverPath(null))
+        assertEquals(false, isDefaultCoverPath("https://cover"))
+    }
+
+    private fun book(
+        bookUrl: String,
+        coverUrl: String?,
+        customCoverUrl: String? = null,
+        origin: String = "source",
+    ) = BookShelfItem(
+        bookUrl = bookUrl,
+        name = bookUrl,
+        author = "author",
+        origin = origin,
+        originName = "originName",
+        coverUrl = coverUrl,
+        customCoverUrl = customCoverUrl,
+        durChapterTitle = null,
+        durChapterTime = 0L,
+        durChapterPos = 0,
+        latestChapterTitle = null,
+        latestChapterTime = 0L,
+        lastCheckCount = 0,
+        totalChapterNum = 0,
+        durChapterIndex = 0,
+        type = 0,
+        group = 0L,
+        order = 0,
+    )
+}