Pārlūkot izejas kodu

feat: 漫画之问题

HapeLee 1 mēnesi atpakaļ
vecāks
revīzija
2897f9af3e
25 mainītis faili ar 1513 papildinājumiem un 208 dzēšanām
  1. 38 43
      app/src/main/java/io/legado/app/help/book/BookHelp.kt
  2. 22 9
      app/src/main/java/io/legado/app/help/book/BookImageFileStore.kt
  3. 3 0
      app/src/main/java/io/legado/app/help/coil/CoverExtras.kt
  4. 54 11
      app/src/main/java/io/legado/app/help/coil/CoverInterceptor.kt
  5. 69 0
      app/src/main/java/io/legado/app/help/coil/MangaImageDimensions.kt
  6. 33 2
      app/src/main/java/io/legado/app/help/coil/MangaImageFileOwner.kt
  7. 97 0
      app/src/main/java/io/legado/app/help/coil/MangaPrefetchRequests.kt
  8. 66 0
      app/src/main/java/io/legado/app/help/coil/MangaPreviewCache.kt
  9. 10 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaPageSpread.kt
  10. 26 3
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt
  11. 59 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderInteraction.kt
  12. 240 95
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt
  13. 24 32
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt
  14. 9 5
      app/src/main/java/io/legado/app/ui/book/manga/MangaSettingsPanel.kt
  15. 1 1
      app/src/main/res/values-zh-rCN/strings.xml
  16. 1 1
      app/src/main/res/values/strings.xml
  17. 24 0
      app/src/test/java/io/legado/app/help/book/BookImageFileStoreTest.kt
  18. 195 2
      app/src/test/java/io/legado/app/help/coil/MangaFileRequestTest.kt
  19. 71 0
      app/src/test/java/io/legado/app/help/coil/MangaImageDimensionsTest.kt
  20. 37 0
      app/src/test/java/io/legado/app/help/coil/MangaImageFileOwnerTest.kt
  21. 76 0
      app/src/test/java/io/legado/app/help/coil/MangaPrefetchDataTest.kt
  22. 127 0
      app/src/test/java/io/legado/app/help/coil/MangaPrefetchRequestsTest.kt
  23. 60 0
      app/src/test/java/io/legado/app/ui/book/manga/MangaPageLoadStateTest.kt
  24. 87 0
      app/src/test/java/io/legado/app/ui/book/manga/MangaReaderInteractionTest.kt
  25. 84 4
      docs/dev/manga-subsampling-plan.md

+ 38 - 43
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -37,10 +37,9 @@ import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.isWifiConnect
 import io.legado.app.utils.onEachParallel
 import io.legado.app.utils.postEvent
-import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.launch
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.currentCoroutineContext
@@ -50,21 +49,21 @@ import kotlinx.coroutines.flow.asFlow
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.Semaphore
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
 import org.apache.commons.text.similarity.JaccardSimilarity
 import org.koin.core.context.GlobalContext
 import splitties.init.appCtx
+import java.io.Closeable
 import java.io.File
 import java.io.FileNotFoundException
 import java.io.FileOutputStream
 import java.io.IOException
-import java.io.Closeable
 import java.io.InputStream
-import java.util.concurrent.atomic.AtomicBoolean
 import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
 import java.util.zip.ZipFile
 import kotlin.math.abs
 import kotlin.math.max
@@ -80,10 +79,8 @@ object BookHelp {
     private const val cacheFolderName = "book_cache"
     private const val cacheImageFolderName = "images"
     private const val cacheEpubFolderName = "epub"
-    private val imageFiles = BookImageFileStore(::checkImage)
-    private val imageDownloadSlots = Semaphore(2)
-    private val imageDecodeSlots = Semaphore(1)
-    private const val ONLINE_IMAGE_BUDGET = 256L * 1024 * 1024
+    private val imageFiles = BookImageFileStore(validate = ::checkImage)
+    private const val ONLINE_IMAGE_BUDGET = 1000L * 1024 * 1024
     private val imageCleanupScope = CoroutineScope(SupervisorJob() + IO)
     private val imageCleanupRequests = Channel<Unit>(Channel.CONFLATED)
     private val pendingImageCacheMoves = ConcurrentHashMap<File, File>()
@@ -94,6 +91,11 @@ object BookHelp {
         imageCleanupScope.launch {
             for (request in imageCleanupRequests) {
                 try {
+                    // 合并一批预取/预览/瓦片租约变化,避免每次释放都重新遍历缓存。
+                    kotlinx.coroutines.delay(250)
+                    while (imageCleanupRequests.tryReceive().isSuccess) {
+                        // 当前批次处理全部已到达的请求。
+                    }
                     pendingImageCacheMoves.forEach { (source, target) ->
                         if (source.exists() && imageFiles.moveIfIdle(source, target, FileUtils::move)) {
                             pendingImageCacheMoves.remove(source, target)
@@ -103,7 +105,9 @@ object BookHelp {
                         }
                     }
                     imageFiles.trimOnlineCache(File(cachePath), ONLINE_IMAGE_BUDGET)
-                } catch (error: IOException) {
+                } catch (error: kotlinx.coroutines.CancellationException) {
+                    throw error
+                } catch (error: Exception) {
                     AppLog.put("在线漫画缓存清理失败", error)
                 }
             }
@@ -432,42 +436,33 @@ object BookHelp {
                 }
                 onDownload()
                 val context = currentCoroutineContext()
-                imageDownloadSlots.acquire()
-                try {
-                    val analyzeUrl = AnalyzeUrl(src, source = bookSource, coroutineContext = context)
-                    if (onlineOnly && loadOnlyWifi && !appCtx.isWifiConnect &&
-                        !analyzeUrl.urlNoQuery.startsWith("data:", true)
-                    ) throw IOException("WiFi not available, loadOnlyWifi enabled")
-                    // 在首次发布前登记。历史未分类文件仍保守保留,下载则升级为保留文件。
-                    if (!onlineOnly) imageFiles.setOnline(image, false)
-                    else if (!image.exists()) imageFiles.setOnline(image, true)
-                    if (ImageUtils.skipDecode(bookSource, isCover = false)) {
-                        imageInputStream(analyzeUrl, src, onlineOnly).use { input ->
+                val analyzeUrl = AnalyzeUrl(src, source = bookSource, coroutineContext = context)
+                if (onlineOnly && loadOnlyWifi && !appCtx.isWifiConnect &&
+                    !analyzeUrl.urlNoQuery.startsWith("data:", true)
+                ) throw IOException("WiFi not available, loadOnlyWifi enabled")
+                // 同文件获取互斥,跨图片并发只遵循书源自身的限流配置。
+                if (!onlineOnly) imageFiles.setOnline(image, false)
+                else if (!image.exists()) imageFiles.setOnline(image, true)
+                if (ImageUtils.skipDecode(bookSource, isCover = false)) {
+                    imageInputStream(analyzeUrl, src, onlineOnly).use { input ->
+                        imageFiles.write(image, input) { context.ensureActive() }
+                    }
+                    true
+                } else {
+                    val bytes =
+                        imageInputStream(analyzeUrl, src, onlineOnly).use(InputStream::readBytes)
+                    val decoded = runScriptWithContext {
+                        ImageUtils.decode(src, bytes, isCover = false, bookSource, book)
+                    }
+                    if (decoded == null) {
+                        AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载失败 解码为空")
+                        false
+                    } else {
+                        decoded.inputStream().use { input ->
                             imageFiles.write(image, input) { context.ensureActive() }
                         }
                         true
-                    } else {
-                        imageDecodeSlots.acquire()
-                        try {
-                            val bytes = imageInputStream(analyzeUrl, src, onlineOnly).use(InputStream::readBytes)
-                            val decoded = runScriptWithContext {
-                                ImageUtils.decode(src, bytes, isCover = false, bookSource, book)
-                            }
-                            if (decoded == null) {
-                                AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载失败 解码为空")
-                                false
-                            } else {
-                                decoded.inputStream().use { input ->
-                                    imageFiles.write(image, input) { context.ensureActive() }
-                                }
-                                true
-                            }
-                        } finally {
-                            imageDecodeSlots.release()
-                        }
                     }
-                } finally {
-                    imageDownloadSlots.release()
                 }
             }
         } catch (e: Exception) {
@@ -501,7 +496,7 @@ object BookHelp {
             if (!saveImage(bookSource, book, src, onlineOnly = true, loadOnlyWifi = loadOnlyWifi, onDownload = onDownload)) {
                 throw IOException("图片获取失败")
             }
-            imageFiles.trimOnlineCache(File(cachePath), ONLINE_IMAGE_BUDGET)
+            imageCleanupRequests.trySend(Unit)
             return file to lease
         } catch (error: Throwable) {
             lease.close()

+ 22 - 9
app/src/main/java/io/legado/app/help/book/BookImageFileStore.kt

@@ -2,18 +2,21 @@ package io.legado.app.help.book
 
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
+import java.io.Closeable
 import java.io.File
 import java.io.FileOutputStream
 import java.io.IOException
 import java.io.InputStream
-import java.io.Closeable
 import java.nio.file.Files
 import java.nio.file.StandardCopyOption.ATOMIC_MOVE
 import java.nio.file.StandardCopyOption.REPLACE_EXISTING
 import java.util.concurrent.atomic.AtomicBoolean
 
 /** BookHelp 图片文件的发布边界;临时文件永远不作为可读图片暴露。 */
-internal class BookImageFileStore(private val validate: (File) -> Boolean) {
+internal class BookImageFileStore(
+    private val readAccessTime: (File) -> Long = File::lastModified,
+    private val validate: (File) -> Boolean,
+) {
     companion object {
         const val ONLINE_MARKER_DIRECTORY = ".online"
         const val DOWNLOAD_INDEX_DIRECTORY = ".downloads"
@@ -23,6 +26,13 @@ internal class BookImageFileStore(private val validate: (File) -> Boolean) {
         var users = 0
     }
 
+    private data class OnlineCandidate(
+        val file: File,
+        val marker: File,
+        val accessTime: Long,
+        val size: Long
+    )
+
     private val entries = mutableMapOf<String, Entry>()
     private val evictionLock = Any()
 
@@ -107,17 +117,20 @@ internal class BookImageFileStore(private val validate: (File) -> Boolean) {
         val candidates = root.walkTopDown().filter {
             it.isFile && it.parentFile?.name == ONLINE_MARKER_DIRECTORY
         }
-            .map { marker -> File(requireNotNull(marker.parentFile).parentFile, marker.name) to marker }
-            .filter { (file, _) -> !isExplicitDownload(file) }
-            .toList().sortedBy { (_, marker) -> marker.lastModified() }
-        var total = candidates.sumOf { (file, _) -> file.length() }
-        for ((file, marker) in candidates) {
+            .map { marker ->
+                val file = File(requireNotNull(marker.parentFile).parentFile, marker.name)
+                // touch/下载可以并发修改文件;比较器只能读取本次枚举的固定值。
+                OnlineCandidate(file, marker, readAccessTime(marker), file.length())
+            }
+            .filter { !isExplicitDownload(it.file) }
+            .toList().sortedBy { it.accessTime }
+        var total = candidates.sumOf { it.size }
+        for ((file, marker, _, size) in candidates) {
             if (!marker.isFile || isExplicitDownload(file)) {
-                total -= file.length()
+                total -= size
                 continue
             }
             if (total <= maxBytes && file.exists()) continue
-            val size = file.length()
             if (deleteIfIdle(file) {
                     // 下载可能已在枚举之后升级保留用途,必须在删除前复核。
                     if (!marker.isFile || isExplicitDownload(file)) false

+ 3 - 0
app/src/main/java/io/legado/app/help/coil/CoverExtras.kt

@@ -30,6 +30,9 @@ object CoverExtras {
     /** 分页原图请求的生命周期;存在时先获取 BookHelp 文件,再交给 Coil 解码。 */
     val MangaFileOwner = Extras.Key<MangaImageFileOwner?>(null)
     val MangaFileTransferStarted = Extras.Key<(() -> Unit)?>(null)
+    val MangaAspectRatio = Extras.Key<((Float) -> Unit)?>(null)
+    val MangaWebtoon = Extras.Key(false)
+    val MangaDataPrefetch = Extras.Key(false)
 
     /**
      * CoverInterceptor 在把 request.data 改写为最终解析 URL 之前,把书架/详情页

+ 54 - 11
app/src/main/java/io/legado/app/help/coil/CoverInterceptor.kt

@@ -1,14 +1,21 @@
 package io.legado.app.help.coil
 
+import coil3.BitmapImage
+import coil3.decode.DataSource
 import coil3.intercept.Interceptor
 import coil3.request.CachePolicy
-import coil3.request.ImageResult
+import coil3.request.ErrorResult
 import coil3.request.ImageRequest
-import io.legado.app.help.source.SourceHelp
+import coil3.request.ImageResult
+import coil3.request.SuccessResult
+import coil3.request.allowHardware
+import coil3.request.crossfade
+import coil3.request.transformations
 import io.legado.app.data.appDb
 import io.legado.app.help.book.BookHelp
-import io.legado.app.utils.isAbsUrl
+import io.legado.app.help.source.SourceHelp
 import io.legado.app.model.analyzeRule.AnalyzeUrl
+import io.legado.app.utils.isAbsUrl
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import java.io.Closeable
@@ -54,19 +61,55 @@ class CoverInterceptor(
         val data = request.data
 
         val fileOwner = request.extras[CoverExtras.MangaFileOwner]
-        // 本地漫画与 content/file URI 保持现有平台加载器;仅在线页采用共享原图链路。
+        // 分页与条漫在线页复用同一原图;本地漫画与 content/file URI 保持平台加载器。
         if (fileOwner != null && data is String &&
             (data.isAbsUrl() || data.startsWith("data:", true)) &&
             !data.startsWith("file:", true) && !data.startsWith("content:", true)
         ) {
-            val file = withContext(Dispatchers.IO) {
-                val (file, lease) = acquireMangaFile(request, data)
-                fileOwner.attach(file, lease)
-                file
+            val (file, version, ratio) = withContext(Dispatchers.IO) {
+                val acquired = fileOwner.acquire(data) { acquireMangaFile(request, data) }
+                Triple(
+                    acquired,
+                    "${acquired.length()}:${acquired.lastModified()}",
+                    MangaImageDimensions.read(acquired)
+                )
+            }
+            fileOwner.setAspectRatio(ratio)
+            ratio?.let { request.extras[CoverExtras.MangaAspectRatio]?.invoke(it) }
+            val memoryKey = "${request.memoryCacheKey ?: data}:original:$version"
+            val webtoon =
+                request.extras[CoverExtras.MangaWebtoon] == true && request.extras[CoverExtras.MangaDataPrefetch] != true
+            val previewKey = if (webtoon) MangaPreviewCache.key(memoryKey, request) else ""
+            val snapshot =
+                if (webtoon) withContext(Dispatchers.IO) { MangaPreviewCache.open(previewKey) } else null
+
+            fun localRequest(dataFile: File, cachedPreview: Boolean) = request.newBuilder()
+                .data(dataFile).memoryCacheKey(memoryKey)
+                .placeholderMemoryCacheKey(memoryKey).diskCachePolicy(CachePolicy.DISABLED)
+                .apply {
+                    if (webtoon) allowHardware(false)
+                    if (cachedPreview) transformations(emptyList())
+                }.build()
+
+            var usedPreview = snapshot != null
+            var result = try {
+                chain.withRequest(localRequest(snapshot?.data?.toFile() ?: file, usedPreview))
+                    .proceed()
+            } finally {
+                if (snapshot != null) withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { snapshot.close() }
+            }
+            if (usedPreview && result is ErrorResult) {
+                withContext(Dispatchers.IO) { MangaPreviewCache.remove(previewKey) }
+                usedPreview = false
+                result = chain.withRequest(localRequest(file, false)).proceed()
+            }
+            if (webtoon && !usedPreview && result is SuccessResult && result.image is BitmapImage) {
+                MangaPreviewCache.save(previewKey, (result.image as BitmapImage).bitmap)
             }
-            return chain.withRequest(
-                request.newBuilder().data(file).diskCachePolicy(CachePolicy.DISABLED).build()
-            ).proceed()
+            // Telephoto 会对内存命中也重新淡入;已准备的预览直接显示,避免滑动时露出底色。
+            return if (result is SuccessResult && result.dataSource == DataSource.MEMORY_CACHE) {
+                result.copy(request = result.request.newBuilder().crossfade(false).build())
+            } else result
         }
 
         if (data is String && data.isNotBlank()) {

+ 69 - 0
app/src/main/java/io/legado/app/help/coil/MangaImageDimensions.kt

@@ -0,0 +1,69 @@
+package io.legado.app.help.coil
+
+import android.graphics.BitmapFactory
+import androidx.exifinterface.media.ExifInterface
+import io.legado.app.utils.SvgUtils
+import java.io.File
+import java.io.IOException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption.ATOMIC_MOVE
+import java.nio.file.StandardCopyOption.REPLACE_EXISTING
+import java.util.Properties
+
+/** 原图头部尺寸独立于预览降采样;可重建的索引不影响图片有效性和下载完成判定。 */
+internal object MangaImageDimensions {
+    fun read(file: File): Float? {
+        val length = file.length()
+        val modified = file.lastModified()
+        val index = File(File(file.parentFile, ".dimensions"), file.name)
+        try {
+            val saved = Properties().apply { index.inputStream().use(::load) }
+            if (saved.getProperty("version") == "1" && saved.getProperty("length") == length.toString() &&
+                saved.getProperty("modified") == modified.toString()
+            ) {
+                saved.getProperty("ratio")?.toFloatOrNull()?.takeIf { it.isFinite() && it > 0 }
+                    ?.let { return it }
+            }
+        } catch (_: IOException) { /* 未建立索引或缓存已被清理。 */
+        } catch (_: IllegalArgumentException) { /* 损坏索引重新建立。 */
+        }
+        val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+        file.inputStream().use { BitmapFactory.decodeStream(it, null, bounds) }
+        var ratio = if (bounds.outWidth > 0 && bounds.outHeight > 0) {
+            bounds.outWidth.toFloat() / bounds.outHeight
+        } else file.inputStream().use { input ->
+            SvgUtils.getSize(input)?.takeIf { it.width > 0 && it.height > 0 }
+                ?.let { (it.width / it.height).toFloat() }
+        } ?: return null
+        try {
+            val orientation =
+                ExifInterface(file.path).getAttributeInt(ExifInterface.TAG_ORIENTATION, 1)
+            if (orientation in listOf(
+                    ExifInterface.ORIENTATION_TRANSPOSE, ExifInterface.ORIENTATION_ROTATE_90,
+                    ExifInterface.ORIENTATION_TRANSVERSE, ExifInterface.ORIENTATION_ROTATE_270
+                )
+            ) ratio = 1f / ratio
+        } catch (_: IOException) { /* 非 EXIF 格式按头部尺寸。 */
+        }
+        if (!ratio.isFinite() || ratio <= 0) return null
+        try {
+            index.parentFile!!.mkdirs()
+            val temp = File.createTempFile("dimensions", ".tmp", index.parentFile)
+            try {
+                Properties().apply {
+                    setProperty("version", "1")
+                    setProperty("length", length.toString()); setProperty(
+                    "modified",
+                    modified.toString()
+                )
+                    setProperty("ratio", ratio.toString())
+                }.let { props -> temp.outputStream().use { props.store(it, null) } }
+                Files.move(temp.toPath(), index.toPath(), ATOMIC_MOVE, REPLACE_EXISTING)
+            } finally {
+                temp.delete()
+            }
+        } catch (_: IOException) { /* 尺寸缓存写入失败仍可正常显示原图。 */
+        }
+        return ratio
+    }
+}

+ 33 - 2
app/src/main/java/io/legado/app/help/coil/MangaImageFileOwner.kt

@@ -1,11 +1,13 @@
 package io.legado.app.help.coil
 
-import androidx.compose.runtime.Stable
 import androidx.compose.runtime.RememberObserver
-import io.legado.app.help.book.BookHelp
+import androidx.compose.runtime.Stable
 import io.legado.app.constant.AppLog
+import io.legado.app.help.book.BookHelp
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import java.io.Closeable
 import java.io.File
 import kotlin.coroutines.cancellation.CancellationException
@@ -26,6 +28,33 @@ class MangaImageFileOwner : Closeable, RememberObserver {
     private var closed = false
     private var file: File? = null
     private var lease: Closeable? = null
+    private val acquisition = Mutex()
+    private var requestData: String? = null
+    private var fileLength = 0L
+    private var fileModified = 0L
+    @Volatile
+    private var originalRatio: Float? = null
+    internal fun aspectRatio(): Float? = originalRatio
+    internal fun setAspectRatio(ratio: Float?) {
+        originalRatio = ratio
+    }
+
+    /** 同一显示请求的预览、背景取色与回退共享已校验的文件,避免反复查库和获取。 */
+    internal suspend fun acquire(data: String, fetch: suspend () -> Pair<File, Closeable>): File =
+        acquisition.withLock {
+            val cached = synchronized(this) {
+                if (closed) throw CancellationException("Image request disposed")
+                file?.takeIf {
+                    requestData == data && it.isFile && it.length() == fileLength &&
+                            it.lastModified() == fileModified
+                }
+            }
+            cached ?: fetch().let { (file, lease) ->
+                attach(file, lease)
+                synchronized(this) { requestData = data }
+                file
+            }
+        }
 
     @Synchronized
     fun attach(file: File, lease: Closeable) {
@@ -42,6 +71,8 @@ class MangaImageFileOwner : Closeable, RememberObserver {
             this.file = file
             this.lease = lease
         }
+        fileLength = file.length()
+        fileModified = file.lastModified()
     }
 
     /** 瓦片具有独立租约;释放 Compose 请求不提前释放解码器正在使用的文件。 */

+ 97 - 0
app/src/main/java/io/legado/app/help/coil/MangaPrefetchRequests.kt

@@ -0,0 +1,97 @@
+package io.legado.app.help.coil
+
+import coil3.ColorImage
+import coil3.decode.DecodeResult
+import coil3.decode.Decoder
+import coil3.request.CachePolicy
+import coil3.request.ImageRequest
+import coil3.request.transformations
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import okio.Buffer
+import java.io.Closeable
+
+/** 当前章持续准备原图,视口变化只重排等待项;两个后台任务不限制可见页/显式下载。主线程调用。 */
+internal class MangaPrefetchRequests(private val scope: CoroutineScope) : Closeable {
+    private var wanted = emptyList<Pair<String, Int>>()
+    private var prepare: (suspend (Pair<String, Int>) -> Unit)? = null
+    private val settled = mutableSetOf<Pair<String, Int>>()
+    private val active = mutableMapOf<Pair<String, Int>, Job>()
+    private var closed = false
+
+    fun update(keys: List<Pair<String, Int>>, start: suspend (Pair<String, Int>) -> Unit) {
+        if (closed) return
+        wanted = keys.distinct()
+        prepare = start
+        val keep = wanted.toSet()
+        settled.retainAll(keep)
+        active.keys.filterNot(keep::contains).forEach { active.remove(it)?.cancel() }
+        next()
+    }
+
+    private fun next() {
+        val start = prepare ?: return
+        while (!closed && active.size < 2) {
+            val key = wanted.firstOrNull { it !in settled && it !in active } ?: return
+            lateinit var job: Job
+            job = scope.launch(start = CoroutineStart.LAZY) {
+                try {
+                    start(key)
+                    if (active[key] === job) settled += key
+                } catch (error: CancellationException) {
+                    throw error
+                } catch (_: Exception) {
+                    // 失败不阻塞后续页,也不循环重试;显式重试代次或重新进入章节会重新准备。
+                    if (active[key] === job) settled += key
+                } finally {
+                    if (active[key] === job) {
+                        active.remove(key); next()
+                    }
+                }
+            }
+            active[key] = job
+            job.start()
+        }
+    }
+
+    override fun close() {
+        closed = true
+        val jobs = active.values.toList()
+        active.clear()
+        jobs.forEach(Job::cancel)
+        settled.clear()
+        wanted = emptyList()
+        prepare = null
+    }
+}
+
+/** 与 Glide downloadOnly 一样只准备数据;结果不进入内存缓存,也不通知页面 Ready。 */
+internal fun ImageRequest.asMangaPrefetch(): ImageRequest = newBuilder()
+    .apply { extras[CoverExtras.MangaDataPrefetch] = true }
+    .listener(null)
+    .target(null)
+    .memoryCachePolicy(CachePolicy.DISABLED)
+    .transformations(emptyList())
+    .decoderFactory { result, options, _ ->
+        Decoder {
+            // 带 owner 的在线原图已经完整下载和校验,无需再读整个文件。
+            // 不走原图链路的流必须读到 EOF 才能完成缓存写入。
+            if (options.extras[CoverExtras.MangaFileOwner] == null) {
+                val source = result.source.source()
+                val buffer = Buffer()
+                val context = currentCoroutineContext()
+                while (true) {
+                    context.ensureActive()
+                    if (source.read(buffer, 16 * 1024L) == -1L) break
+                    buffer.clear()
+                }
+            }
+            DecodeResult(ColorImage(0), isSampled = false)
+        }
+    }
+    .build()

+ 66 - 0
app/src/main/java/io/legado/app/help/coil/MangaPreviewCache.kt

@@ -0,0 +1,66 @@
+package io.legado.app.help.coil
+
+import android.graphics.Bitmap
+import coil3.disk.DiskCache
+import coil3.request.ImageRequest
+import coil3.request.transformations
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import okio.Path.Companion.toOkioPath
+import splitties.init.appCtx
+import java.io.File
+
+/** 条漫的可再生成、无损预览缓存,不参与下载保留或分页区域解码。 */
+internal object MangaPreviewCache {
+    private val cache by lazy {
+        DiskCache.Builder().directory(File(appCtx.cacheDir, "manga_previews").toOkioPath())
+            .maxSizeBytes(256L * 1024 * 1024).build()
+    }
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+    private val encoding = Semaphore(1)
+
+    fun open(key: String): DiskCache.Snapshot? = try {
+        cache.openSnapshot(key)
+    } catch (_: Exception) {
+        null
+    }
+
+    fun remove(key: String) {
+        try {
+            cache.remove(key)
+        } catch (_: Exception) {
+        }
+    }
+
+    suspend fun key(memoryKey: String, request: ImageRequest): String =
+        "manga-preview-v1:$memoryKey:${request.sizeResolver.size()}:${request.scale}:" +
+                request.transformations.joinToString { it.cacheKey }
+
+    // 仅后台编码限为一个,忙时跳过可再生成的缓存;不限制网络或可见页解码,不排队持有大量长图。
+    fun save(key: String, bitmap: Bitmap) {
+        if (!encoding.tryAcquire()) return
+        scope.launch {
+            try {
+                cache.openSnapshot(key)?.use { return@launch }
+                if (bitmap.isRecycled) return@launch
+                val editor = cache.openEditor(key) ?: return@launch
+                try {
+                    editor.data.toFile().outputStream().use {
+                        check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it))
+                    }
+                    editor.metadata.toFile().writeText("manga-preview-v1")
+                    editor.commit()
+                } catch (_: Exception) {
+                    editor.abort()
+                }
+            } catch (_: Exception) {
+                // 预览可重建;磁盘不足或缓存目录被清理不影响原图展示。
+            } finally {
+                encoding.release()
+            }
+        }
+    }
+}

+ 10 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaPageSpread.kt

@@ -21,6 +21,16 @@ internal data class MangaPageSlot(
 
 internal enum class MangaPageSlice { FULL, LEFT, RIGHT }
 
+/** 尺寸重排优先保留完整 spread 身份,包括同页的左右半页;身份失效才按原图页定位。 */
+internal fun mangaSpreadReconcileTarget(
+    spreads: List<MangaPageSpread>,
+    itemIndex: Int,
+    previousKey: String?
+): Int {
+    val retained = spreads.indexOfFirst { it.key == previousKey }
+    return if (retained >= 0) retained else spreads.indexOfFirst { itemIndex in it }
+}
+
 internal fun buildMangaSpreads(
     items: List<MangaReaderItemUi>,
     doublePage: Boolean,

+ 26 - 3
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt

@@ -241,9 +241,12 @@ sealed interface MangaReaderIntent {
     data class UpdateMenuPaletteStyle(val value: String) : MangaReaderIntent
     data class UpdateClickAction(val index: Int, val action: Int) : MangaReaderIntent
     data class RetryChapter(val chapterIndex: Int) : MangaReaderIntent
-    data class PageLoadStarted(val key: String, val force: Boolean = false) : MangaReaderIntent
-    data class PageLoadSucceeded(val key: String) : MangaReaderIntent
-    data class PageLoadFailed(val key: String, val message: String?) : MangaReaderIntent
+    data class PageLoadStarted(val requestId: MangaPageRequestId, val force: Boolean = false) :
+        MangaReaderIntent
+
+    data class PageLoadSucceeded(val requestId: MangaPageRequestId) : MangaReaderIntent
+    data class PageLoadFailed(val requestId: MangaPageRequestId, val message: String?) :
+        MangaReaderIntent
     data class RetryPage(val key: String) : MangaReaderIntent
     data class RetryFailedPagesInChapter(val chapterIndex: Int) : MangaReaderIntent
     data class PageStep(val direction: Int) : MangaReaderIntent
@@ -372,3 +375,23 @@ sealed interface MangaReaderEffect {
     data class ShareImage(val filePath: String) : MangaReaderEffect
     data class CopyImage(val filePath: String) : MangaReaderEffect
 }
+
+/** 加载回调必须同时匹配书籍、页面与重试代次。 */
+@Stable
+data class MangaPageRequestId(val bookUrl: String, val key: String, val retryRevision: Int)
+
+internal val MangaReaderItemUi.Page.requestId: MangaPageRequestId
+    get() = MangaPageRequestId(bookUrl, key, retryRevision)
+
+internal fun MangaReaderItemUi.Page.reduceImageLoad(
+    requestId: MangaPageRequestId,
+    next: MangaPageLoadState,
+    force: Boolean = false,
+): MangaReaderItemUi.Page {
+    if (this.requestId != requestId) return this
+    if (!force && next is MangaPageLoadState.Loading && loadState == MangaPageLoadState.Ready) return this
+    val reduced = if (next is MangaPageLoadState.Loading) {
+        next.copy(progress = (loadState as? MangaPageLoadState.Loading)?.progress ?: next.progress)
+    } else next
+    return if (loadState == reduced) this else copy(loadState = reduced)
+}

+ 59 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderInteraction.kt

@@ -152,3 +152,62 @@ internal fun shouldForceMangaChapterPosition(
 ): Boolean =
     !hasPages || isLoading || currentBookUrl != targetBookUrl ||
         pendingExplicitChapterIndex == targetChapterIndex
+
+/** 条漫预取跟随滑动中的可见页,不提前提交业务阅读进度。 */
+internal fun mangaImagePrefetchIndex(scrollMode: Int, current: Int, visible: Int?): Int =
+    if (scrollMode == io.legado.app.ui.book.manga.config.MangaScrollMode.WEBTOON ||
+        scrollMode == io.legado.app.ui.book.manga.config.MangaScrollMode.WEBTOON_WITH_GAP
+    ) visible ?: current
+    else current
+
+/** 列表可同时含前后章节;只准备实际当前章,当前页及附近页先执行,剩余页持续排队。 */
+internal fun mangaChapterPrefetchPages(
+    items: List<MangaReaderItemUi>, current: Int, fallbackChapter: Int,
+): List<MangaReaderItemUi.Page> {
+    val visible = items.getOrNull(current) as? MangaReaderItemUi.Page
+    val chapter = visible?.chapterIndex ?: fallbackChapter
+    val pages =
+        items.filterIsInstance<MangaReaderItemUi.Page>().filter { it.chapterIndex == chapter }
+    val anchor = visible?.pageIndex ?: pages.firstOrNull()?.pageIndex ?: return emptyList()
+    return pages.sortedWith(compareBy<MangaReaderItemUi.Page> { kotlin.math.abs(it.pageIndex - anchor) }
+        .thenBy { if (it.pageIndex >= anchor) 0 else 1 })
+}
+
+/** 高度改变前保持视口中已有图片的位置;未知占位没有可保持的图片内容。 */
+internal fun mangaWebtoonResizeAnchor(
+    visible: List<Triple<String, Int, Int>>,
+    center: Int,
+    knownSizes: Set<String>,
+): Pair<String, Int>? {
+    val candidates = visible.filter { it.first in knownSizes }
+    val anchor = candidates.firstOrNull { center >= it.second && center < it.second + it.third }
+        ?: candidates.minByOrNull { kotlin.math.abs(it.second + it.third / 2 - center) }
+        ?: return null
+    return anchor.first to -anchor.second
+}
+
+/** 滑动期间冻结可见项高度;离屏项仍可准备尺寸,停手后一次重测并恢复锚点。 */
+internal class MangaWebtoonResizeQueue {
+    private val pending = mutableMapOf<String, () -> Unit>()
+
+    fun update(
+        key: String,
+        scrolling: Boolean,
+        visible: Boolean,
+        resize: () -> Unit,
+        apply: (() -> Unit) -> Unit
+    ) {
+        if (scrolling && visible) {
+            pending[key] = resize
+        } else {
+            pending.remove(key)
+            apply(resize)
+        }
+    }
+
+    fun flush(validKeys: Set<String>, apply: (() -> Unit) -> Unit) {
+        val changes = pending.filterKeys { it in validKeys }.values.toList()
+        pending.clear()
+        if (changes.isNotEmpty()) apply { changes.forEach { it() } }
+    }
+}

+ 240 - 95
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -4,6 +4,7 @@ import android.widget.Toast
 import androidx.activity.compose.BackHandler
 import androidx.compose.animation.core.LinearEasing
 import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.background
 import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.gestures.awaitEachGesture
@@ -26,6 +27,7 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.requiredWidth
 import androidx.compose.foundation.layout.widthIn
 import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.VerticalPager
@@ -71,6 +73,7 @@ import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.constrainHeight
 import androidx.compose.ui.unit.constrainWidth
@@ -80,7 +83,6 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.repeatOnLifecycle
 import coil3.ImageLoader
 import coil3.compose.AsyncImage
-import coil3.request.CachePolicy
 import coil3.request.ImageRequest
 import coil3.request.allowHardware
 import coil3.request.crossfade
@@ -89,14 +91,16 @@ import coil3.size.Dimension
 import coil3.size.Scale
 import coil3.size.Size
 import coil3.toBitmap
-import io.legado.app.help.coil.MangaImageFileOwner
-import io.legado.app.help.coil.rememberMangaZoomableImageSource
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.hazeSource
 import io.legado.app.R
 import io.legado.app.help.coil.CoverExtras
+import io.legado.app.help.coil.MangaImageFileOwner
+import io.legado.app.help.coil.MangaPrefetchRequests
+import io.legado.app.help.coil.asMangaPrefetch
+import io.legado.app.help.coil.rememberMangaZoomableImageSource
 import io.legado.app.ui.book.manga.config.MangaDoublePageMode
 import io.legado.app.ui.book.manga.config.MangaPageScaleType
 import io.legado.app.ui.book.manga.config.MangaScrollMode
@@ -124,6 +128,7 @@ import kotlin.math.roundToInt
 
 private val LocalReaderViewportSize = staticCompositionLocalOf { IntSize.Zero }
 private val LocalReaderViewportOrigin = staticCompositionLocalOf { Offset.Zero }
+private val LocalMangaRatioUpdate = staticCompositionLocalOf<((String, Float) -> Unit)?> { null }
 private val LocalMangaAspectRatios = staticCompositionLocalOf<MutableMap<String, Float>> {
     mutableMapOf()
 }
@@ -174,8 +179,20 @@ fun MangaReaderScreen(
     BackHandler { onIntent(MangaReaderIntent.BackPressed) }
     var viewportSize by remember { mutableStateOf(IntSize.Zero) }
     var viewportOrigin by remember { mutableStateOf(Offset.Zero) }
-    val aspectRatios = remember { mutableStateMapOf<String, Float>() }
-    val automaticBackgrounds = remember { mutableStateMapOf<String, MangaPageEdgeColors>() }
+    val aspectRatios = remember(state.bookUrl) { mutableStateMapOf<String, Float>() }
+    var applyPageResize by remember(state.bookUrl) {
+        mutableStateOf<((String, () -> Unit) -> Unit)?>(
+            null
+        )
+    }
+    val updateAspectRatio: (String, Float) -> Unit = { key, ratio ->
+        if (ratio.isFinite() && ratio > 0 && aspectRatios[key] != ratio) {
+            val resize = { aspectRatios[key] = ratio; Unit }
+            applyPageResize?.invoke(key, resize) ?: resize()
+        }
+    }
+    val automaticBackgrounds =
+        remember(state.bookUrl) { mutableStateMapOf<String, MangaPageEdgeColors>() }
     val currentPageKey = state.pages.getOrNull(state.currentItemIndex)?.key
     val readerBackground = state.settings.backgroundColor.copy(alpha = 1f)
     val currentPageColor = automaticBackgrounds[currentPageKey]?.top
@@ -202,13 +219,6 @@ fun MangaReaderScreen(
 
     val context = LocalContext.current
     val lifecycleOwner = LocalLifecycleOwner.current
-    val pageDecodeSize = mangaPageDecodeSize(
-        paged = state.settings.scrollMode != MangaScrollMode.WEBTOON &&
-                state.settings.scrollMode != MangaScrollMode.WEBTOON_WITH_GAP,
-        sidePaddingPercent = state.settings.sidePaddingPercent,
-        pageScaleType = state.settings.pageScaleType,
-        widePageMode = state.settings.widePageMode,
-    )
     val pendingMessage = state.pendingMessages.firstOrNull()
     LaunchedEffect(pendingMessage?.id, context, lifecycleOwner) {
         val message = pendingMessage ?: return@LaunchedEffect
@@ -221,59 +231,12 @@ fun MangaReaderScreen(
             onIntent(MangaReaderIntent.MessageShown(message.id))
         }
     }
-    val pagePrefetchRevision = state.pages.filterIsInstance<MangaReaderItemUi.Page>()
-        .map { it.key to it.retryRevision }
-    DisposableEffect(
-        state.currentItemIndex,
-        pagePrefetchRevision,
-        state.settings.preDownloadCount,
-        state.settings.sourceOrigin,
-        state.settings.scrollMode,
-        state.settings.enableEInk,
-        state.settings.enableGray,
-        pageDecodeSize,
-    ) {
-        val ahead = state.settings.preDownloadCount.coerceIn(0, 10)
-        val current = state.currentItemIndex
-        val prioritizedIndices = buildList {
-            add(current)
-            addAll((current + 1..current + ahead).filter { it in state.pages.indices })
-            addAll((current - 1 downTo current - 2).filter { it in state.pages.indices })
-        }.distinct()
-        val requests = if (ahead == 0) emptyList() else prioritizedIndices
-            .mapNotNull(state.pages::getOrNull)
-            .filterIsInstance<MangaReaderItemUi.Page>()
-            .filterNot { it.loadState == MangaPageLoadState.Ready }
-            .map { page ->
-                val owner = if (state.settings.scrollMode != MangaScrollMode.WEBTOON &&
-                    state.settings.scrollMode != MangaScrollMode.WEBTOON_WITH_GAP
-                ) MangaImageFileOwner() else null
-                imageLoader.enqueue(
-                    page.imageRequest(
-                        settings = state.settings,
-                        context = context,
-                        decodeSize = pageDecodeSize,
-                        fileOwner = owner,
-                        onFileTransfer = { onIntent(MangaReaderIntent.PageLoadStarted(page.key, force = true)) },
-                        onAspectRatio = { aspectRatios[page.key] = it },
-                        onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
-                        onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
-                        onError = { message ->
-                            onIntent(MangaReaderIntent.PageLoadFailed(page.key, message))
-                        },
-                    ).newBuilder()
-                        // Keep prefetched pages available to the reader request. Disabling this
-                        // caused a slider jump to decode the same image again from scratch.
-                        .memoryCachePolicy(CachePolicy.ENABLED)
-                        .build()
-                ) to owner
-            }
-        onDispose { requests.forEach { (request, owner) -> request.dispose(); owner?.close() } }
-    }
+    MangaChapterImagePrefetch(state, imageLoader, updateAspectRatio)
     CompositionLocalProvider(
         LocalReaderViewportSize provides viewportSize,
         LocalReaderViewportOrigin provides viewportOrigin,
         LocalMangaAspectRatios provides aspectRatios,
+        LocalMangaRatioUpdate provides updateAspectRatio,
         LocalMangaBackgroundColors provides automaticBackgrounds,
     ) {
         val menuBackdrop = rememberLayerBackdrop()
@@ -301,7 +264,7 @@ fun MangaReaderScreen(
                     MangaScrollMode.PAGE_LEFT_TO_RIGHT,
                     MangaScrollMode.PAGE_RIGHT_TO_LEFT -> HorizontalMangaPager(state, onIntent, imageLoader)
                     MangaScrollMode.PAGE_TOP_TO_BOTTOM -> VerticalMangaPager(state, onIntent, imageLoader)
-                    else -> WebtoonMangaList(state, onIntent, imageLoader)
+                    else -> WebtoonMangaList(state, onIntent, imageLoader) { applyPageResize = it }
                 }
             }
 
@@ -395,12 +358,89 @@ private fun MangaReaderText.resolve(context: android.content.Context): String =
 }
 
 @Composable
+private fun MangaChapterImagePrefetch(
+    state: MangaReaderUiState,
+    imageLoader: ImageLoader,
+    updateAspectRatio: (String, Float) -> Unit,
+) {
+    val context = LocalContext.current
+    val pageDecodeSize = mangaPageDecodeSize(
+        paged = state.settings.scrollMode != MangaScrollMode.WEBTOON &&
+                state.settings.scrollMode != MangaScrollMode.WEBTOON_WITH_GAP,
+        sidePaddingPercent = state.settings.sidePaddingPercent,
+        pageScaleType = state.settings.pageScaleType,
+        widePageMode = state.settings.widePageMode,
+    )
+    val pagePrefetchRevision = state.pages.filterIsInstance<MangaReaderItemUi.Page>()
+        .map { it.key to it.retryRevision }
+    val prefetchItemIndex = mangaImagePrefetchIndex(
+        state.settings.scrollMode,
+        state.currentItemIndex,
+        state.footerItemIndex
+    )
+    val prefetchScope = rememberCoroutineScope()
+    val prefetch = remember(
+        imageLoader,
+        state.bookUrl,
+        state.settings.sourceOrigin,
+        state.settings.scrollMode
+    ) {
+        MangaPrefetchRequests(prefetchScope)
+    }
+    DisposableEffect(prefetch) { onDispose(prefetch::close) }
+    LaunchedEffect(
+        prefetch,
+        prefetchItemIndex,
+        pagePrefetchRevision,
+        state.settings.preDownloadCount,
+        state.chapterIndex
+    ) {
+        val pages = if (state.settings.preDownloadCount <= 0) emptyMap() else
+            mangaChapterPrefetchPages(state.pages, prefetchItemIndex, state.chapterIndex)
+                .associateBy { it.key to it.retryRevision }
+        prefetch.update(pages.keys.toList()) { key ->
+            val page = pages.getValue(key)
+            val owner = MangaImageFileOwner()
+            try {
+                val result = imageLoader.execute(
+                    page.imageRequest(
+                        settings = state.settings,
+                        context = context,
+                        decodeSize = pageDecodeSize,
+                        fileOwner = owner,
+                        onAspectRatio = { updateAspectRatio(page.key, it) },
+                    ).asMangaPrefetch()
+                )
+                if (result is coil3.request.ErrorResult) throw result.throwable
+            } finally {
+                // 完成后不持有整章租约或位图;原图受阅读缓存预算管理。
+                owner.close()
+            }
+        }
+    }
+}
+
+@Composable
+@OptIn(ExperimentalFoundationApi::class)
 private fun WebtoonMangaList(
     state: MangaReaderUiState,
     onIntent: (MangaReaderIntent) -> Unit,
     imageLoader: ImageLoader,
+    onResizeHandlerChanged: (((String, () -> Unit) -> Unit)?) -> Unit,
 ) {
-    val listState = rememberLazyListState(initialFirstVisibleItemIndex = state.currentItemIndex)
+    val prefetchEnabled by rememberUpdatedState(state.settings.preDownloadCount > 0)
+    // 按像素距离而非页数预组合;窗口跟随列表真实滚动方向,不经过 ViewModel/重组往返。
+    val cacheWindow = remember {
+        object : LazyLayoutCacheWindow {
+            override fun Density.calculateAheadWindow(viewport: Int): Int =
+                if (prefetchEnabled) viewport * 4 else 0
+
+            override fun Density.calculateBehindWindow(viewport: Int): Int =
+                if (prefetchEnabled) viewport * 2 else 0
+        }
+    }
+    val listState =
+        rememberLazyListState(cacheWindow, initialFirstVisibleItemIndex = state.currentItemIndex)
     // 单张图片的加载状态会更新整个 UiState.pages。可见页监听不能因此重启:重启会
     // 丢失 distinctUntilChanged 的上一条记录,并把 LazyColumn 尚未完成重测时保留的
     // 旧视口重新上报为当前页,进而使章节/页码跳回去。
@@ -411,6 +451,50 @@ private fun WebtoonMangaList(
     var lastWebtoonTapPosition by remember { mutableStateOf(Offset.Unspecified) }
     val viewportSize = LocalReaderViewportSize.current
     val aspectRatios = LocalMangaAspectRatios.current
+    val resizeQueue = remember(state.bookUrl) { MangaWebtoonResizeQueue() }
+    val applyResize: (() -> Unit) -> Unit = { resize ->
+        val layout = listState.layoutInfo
+        val anchor = if (listState.isScrollInProgress || latestReaderState.scrollRequest != null ||
+            latestReaderState.bookUrl != state.bookUrl
+        ) null else {
+            val loadedKeys = layout.visibleItemsInfo.mapNotNull { info ->
+                (latestReaderState.pages.getOrNull(info.index) as? MangaReaderItemUi.Page)
+                    ?.takeIf { it.key == info.key && it.loadState == MangaPageLoadState.Ready && it.key in aspectRatios }
+                    ?.key
+            }.toSet()
+            mangaWebtoonResizeAnchor(
+                layout.visibleItemsInfo.map { Triple(it.key.toString(), it.offset, it.size) },
+                (layout.viewportStartOffset + layout.viewportEndOffset) / 2,
+                loadedKeys,
+            )
+        }
+        resize()
+        anchor?.let { (key, offset) ->
+            val index = latestReaderState.pages.indexOfFirst { it.key == key }
+            if (index >= 0) listState.requestScrollToItem(index, offset)
+        }
+    }
+    val latestApplyResize by rememberUpdatedState(applyResize)
+    DisposableEffect(listState, state.bookUrl, resizeQueue) {
+        onResizeHandlerChanged { key, resize ->
+            val visible = listState.layoutInfo.visibleItemsInfo.any { it.key == key }
+            val index = latestReaderState.pages.indexOfFirst { it.key == key }
+            val apply =
+                if (visible || index in 0 until listState.firstVisibleItemIndex) latestApplyResize
+                else { change: () -> Unit -> change() }
+            // 整章远端页的尺寸不会移动当前图片,不为它们反复发起滚动位置请求。
+            resizeQueue.update(key, listState.isScrollInProgress, visible, resize, apply)
+        }
+        onDispose { onResizeHandlerChanged(null) }
+    }
+    LaunchedEffect(listState, state.bookUrl, resizeQueue) {
+        snapshotFlow { listState.isScrollInProgress }.collect { scrolling ->
+            if (!scrolling) resizeQueue.flush(
+                latestReaderState.pages.map { it.key }.toSet(),
+                latestApplyResize
+            )
+        }
+    }
     var zoom by remember { mutableFloatStateOf(1f) }
     var pan by remember { mutableStateOf(Offset.Zero) }
     val fraction = 1f - state.settings.sidePaddingPercent.coerceIn(0, 45) * 2f / 100f
@@ -756,11 +840,13 @@ private fun HorizontalMangaPager(
     val aspectRatios = LocalMangaAspectRatios.current
     val useDoublePage = isDoublePageActive(state.settings.doublePageMode, viewport)
     val aspectRatioSnapshot = aspectRatios.toMap()
-    val spreads = remember(state.pages, useDoublePage, aspectRatioSnapshot, state.settings) {
+    var spreadRatios by remember(state.bookUrl) { mutableStateOf(aspectRatioSnapshot) }
+    var pendingSpreadKey by remember(state.bookUrl) { mutableStateOf<String?>(null) }
+    val spreads = remember(state.pages, useDoublePage, spreadRatios, state.settings) {
         buildMangaSpreads(
             state.pages,
             useDoublePage,
-            aspectRatioSnapshot,
+            spreadRatios,
             coverSingle = state.settings.doublePageCoverSingle,
             shiftPairing = state.settings.doublePageShift,
             splitWidePages = state.settings.widePageMode == MangaWidePageMode.SPLIT,
@@ -774,28 +860,48 @@ private fun HorizontalMangaPager(
         initialPage = initialSpread,
         pageCount = { spreads.size.coerceAtLeast(1) },
     )
-    var spreadLayoutReady by remember { mutableStateOf(false) }
+    val latestReaderState by rememberUpdatedState(state)
+    val latestSpreads by rememberUpdatedState(spreads)
+    LaunchedEffect(pagerState, aspectRatioSnapshot) {
+        // 后台整章尺寸到达不能中断 fling;布局重排保留拆分页的 LEFT/RIGHT 身份。
+        snapshotFlow { pagerState.isScrollInProgress }.first { !it }
+        if (spreadRatios != aspectRatioSnapshot) {
+            pendingSpreadKey = latestSpreads.getOrNull(pagerState.currentPage)?.key
+            spreadRatios = aspectRatioSnapshot
+        }
+    }
+    var spreadLayoutReady by remember(state.bookUrl) { mutableStateOf(false) }
+    var reconciledSpreads by remember(state.bookUrl) { mutableStateOf(emptyList<MangaPageSpread>()) }
     LaunchedEffect(spreads, state.currentItemIndex) {
+        snapshotFlow { pagerState.isScrollInProgress }.first { !it }
         spreadLayoutReady = false
-        val target = spreads.indexOfFirst { state.currentItemIndex in it }
+        val target = mangaSpreadReconcileTarget(
+            spreads, state.currentItemIndex,
+            pendingSpreadKey.takeIf { state.scrollRequest == null })
+        pendingSpreadKey = null
         if (target >= 0 && target != pagerState.currentPage) pagerState.scrollToPage(target)
+        reconciledSpreads = spreads
         spreadLayoutReady = true
     }
-    LaunchedEffect(pagerState, spreads, state.pages, state.navigationId) {
+    LaunchedEffect(pagerState, state.bookUrl, state.navigationId) {
         snapshotFlow {
-            val spread = spreads.getOrNull(pagerState.currentPage)
+            val reader = latestReaderState
+            val spread = latestSpreads.getOrNull(pagerState.currentPage)
             val indices = spread?.itemIndices.orEmpty()
-            val itemIndex = state.scrollRequest?.itemIndex?.takeIf { it in indices }
+            val itemIndex = reader.scrollRequest?.itemIndex?.takeIf { it in indices }
                 ?: indices.firstOrNull()
-            if (!spreadLayoutReady) null
+            // 新布局提交与 Pager 按 key 重测之间,不能把旧页码映射到新 spread 上报。
+            if (!spreadLayoutReady || latestSpreads != reconciledSpreads) null
             // 滚动中不切章:随 fling 越过章节边界时延迟到落定再上报,避免窗口重建期误切。
             // 把 isScrollInProgress 并入快照,滚动结束会产生新值从而重发聚焦页。
             else Triple(pagerState.isScrollInProgress, indices, itemIndex)
         }.distinctUntilChanged()
             .collect { entry ->
                 val (isScrolling, indices, itemIndex) = entry ?: return@collect
+                val reader = latestReaderState
+                if (reader.navigationId != state.navigationId) return@collect
                 itemIndex?.let { index ->
-                    when (val item = state.pages.getOrNull(index)) {
+                    when (val item = reader.pages.getOrNull(index)) {
                         is MangaReaderItemUi.Page -> if (isScrolling) {
                             onIntent(MangaReaderIntent.FooterItemChanged(index, state.navigationId))
                         } else {
@@ -805,8 +911,8 @@ private fun HorizontalMangaPager(
                                     firstItemIndex = indices.first(),
                                     lastItemIndex = indices.last(),
                                     currentChapterVisible = indices.any { visibleIndex ->
-                                        (state.pages.getOrNull(visibleIndex) as? MangaReaderItemUi.Page)
-                                            ?.chapterIndex == state.chapterIndex
+                                        (reader.pages.getOrNull(visibleIndex) as? MangaReaderItemUi.Page)
+                                            ?.chapterIndex == reader.chapterIndex
                                     },
                                     navigationId = state.navigationId,
                                 )
@@ -834,6 +940,9 @@ private fun HorizontalMangaPager(
     }
     HorizontalPager(
         state = pagerState,
+        beyondViewportPageCount = if (state.settings.preDownloadCount > 0 &&
+            state.settings.pageScaleType != MangaPageScaleType.ORIGINAL
+        ) 2 else 0,
         key = { spreads.getOrNull(it)?.key ?: "empty:$it" },
         reverseLayout = state.settings.scrollMode == MangaScrollMode.PAGE_RIGHT_TO_LEFT,
         modifier = Modifier
@@ -888,7 +997,6 @@ private fun HorizontalMangaPager(
             },
     ) { page ->
         val slots = spreads.getOrNull(page)?.slots.orEmpty()
-        val indices = slots.map(MangaPageSlot::itemIndex)
         val reverseSpread =
             (state.settings.scrollMode == MangaScrollMode.PAGE_RIGHT_TO_LEFT) xor
                     state.settings.doublePageInvert
@@ -1053,13 +1161,17 @@ private fun VerticalMangaPager(
         initialPage = state.currentItemIndex,
         pageCount = { state.pages.size.coerceAtLeast(1) },
     )
-    LaunchedEffect(pagerState, state.pages, state.navigationId) {
+    val latestReaderState by rememberUpdatedState(state)
+    LaunchedEffect(pagerState, state.bookUrl, state.navigationId) {
         snapshotFlow {
-            val item = state.pages.getOrNull(pagerState.currentPage)
+            val item = latestReaderState.pages.getOrNull(pagerState.currentPage)
             // 滚动中不切章:把 isScrollInProgress 并入快照,滚动结束产生新值从而重发聚焦页。
-            Triple(pagerState.isScrollInProgress, pagerState.currentPage, item)
+            Triple(pagerState.isScrollInProgress, pagerState.currentPage, item?.key)
         }.distinctUntilChanged()
-            .collect { (isScrolling, page, item) ->
+            .collect { (isScrolling, page, key) ->
+                val reader = latestReaderState
+                if (reader.navigationId != state.navigationId) return@collect
+                val item = reader.pages.getOrNull(page)?.takeIf { it.key == key }
                 when (item) {
                     is MangaReaderItemUi.Page -> if (isScrolling) {
                         onIntent(MangaReaderIntent.FooterItemChanged(page, state.navigationId))
@@ -1067,7 +1179,7 @@ private fun VerticalMangaPager(
                         onIntent(
                             MangaReaderIntent.VisibleItemChanged(
                                 itemIndex = page,
-                                currentChapterVisible = item.chapterIndex == state.chapterIndex,
+                                currentChapterVisible = item.chapterIndex == reader.chapterIndex,
                                 navigationId = state.navigationId,
                             )
                         )
@@ -1088,7 +1200,14 @@ private fun VerticalMangaPager(
             else pagerState.scrollToPage(it.itemIndex)
         }
     }
-    VerticalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
+    VerticalPager(
+        state = pagerState,
+        key = { state.pages.getOrNull(it)?.key ?: "empty:$it" },
+        beyondViewportPageCount = if (state.settings.preDownloadCount > 0 &&
+            state.settings.pageScaleType != MangaPageScaleType.ORIGINAL
+        ) 2 else 0,
+        modifier = Modifier.fillMaxSize(),
+    ) { page ->
         state.pages.getOrNull(page)?.let {
             MangaReaderItem(it, state.settings, onIntent, imageLoader, Modifier.fillMaxSize(), true)
         }
@@ -1244,6 +1363,7 @@ private fun MangaPageImage(
     val viewportSize = LocalReaderViewportSize.current
     val viewportOrigin = LocalReaderViewportOrigin.current
     val aspectRatios = LocalMangaAspectRatios.current
+    val updateRatio = LocalMangaRatioUpdate.current
     val automaticBackgrounds = LocalMangaBackgroundColors.current
     val context = LocalContext.current
     val fallbackHeight = LocalConfiguration.current.screenHeightDp.dp
@@ -1283,18 +1403,26 @@ private fun MangaPageImage(
         )
     }
     val fileOwner = remember(imagePipelineKey) { MangaImageFileOwner() }
+    var webtoonImageDisplayed by remember(imagePipelineKey) { mutableStateOf(false) }
     val request = remember(imagePipelineKey) {
         page.imageRequest(
             settings = settings,
             context = context,
             decodeSize = decodeSize,
-            fileOwner = fileOwner.takeIf { paged },
-            onFileTransfer = { onIntent(MangaReaderIntent.PageLoadStarted(page.key, force = true)) },
-            onAspectRatio = { ratio -> aspectRatios[page.key] = ratio },
-            onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
-            onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
+            fileOwner = fileOwner,
+            onFileTransfer = {
+                onIntent(
+                    MangaReaderIntent.PageLoadStarted(
+                        page.requestId,
+                        force = true
+                    )
+                )
+            },
+            onAspectRatio = { ratio -> updateRatio?.invoke(page.key, ratio) },
+            onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.requestId)) },
+            onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.requestId)) },
             onError = { message ->
-                onIntent(MangaReaderIntent.PageLoadFailed(page.key, message))
+                onIntent(MangaReaderIntent.PageLoadFailed(page.requestId, message))
             },
         )
     }
@@ -1322,7 +1450,7 @@ private fun MangaPageImage(
                     sourceOrigin = settings.sourceOrigin,
                     fallbackColor = settings.backgroundColor,
                     aspectRatio = imageRatio,
-                    fileOwner = fileOwner.takeIf { paged },
+                    fileOwner = fileOwner,
                 ) { colors ->
                     automaticBackgrounds[page.key] = colors
                 },
@@ -1420,6 +1548,7 @@ private fun MangaPageImage(
             )
             MangaImageLoadOverlay(
                 page.loadState,
+                zoomableImageState.isImageDisplayed || zoomableImageState.isPlaceholderDisplayed,
                 { onIntent(MangaReaderIntent.RetryPage(page.key)) },
                 { onIntent(MangaReaderIntent.RetryFailedPagesInChapter(page.chapterIndex)) },
             )
@@ -1439,9 +1568,13 @@ private fun MangaPageImage(
             contentScale = ContentScale.FillWidth,
             colorFilter = mangaColorFilter(settings),
             modifier = Modifier.fillMaxSize(),
+            onLoading = { webtoonImageDisplayed = it.painter != null },
+            onSuccess = { webtoonImageDisplayed = true },
+            onError = { webtoonImageDisplayed = false },
         )
         MangaImageLoadOverlay(
             page.loadState,
+            webtoonImageDisplayed,
             { onIntent(MangaReaderIntent.RetryPage(page.key)) },
             { onIntent(MangaReaderIntent.RetryFailedPagesInChapter(page.chapterIndex)) },
         )
@@ -1451,10 +1584,20 @@ private fun MangaPageImage(
 @Composable
 private fun MangaImageLoadOverlay(
     loadState: MangaPageLoadState,
+    imageDisplayed: Boolean,
     onRetry: () -> Unit,
     onRetryChapter: () -> Unit,
 ) {
-    if (loadState is MangaPageLoadState.Ready) return
+    // 原图获取、预览解码与实际绘制不同步。已有图片时不叠加转圈,失败仍可重试。
+    var showLoading by remember { mutableStateOf(false) }
+    LaunchedEffect(imageDisplayed, loadState is MangaPageLoadState.Failed) {
+        showLoading = false
+        if (!imageDisplayed && loadState !is MangaPageLoadState.Failed) {
+            delay(150)
+            showLoading = true
+        }
+    }
+    if (loadState !is MangaPageLoadState.Failed && (imageDisplayed || !showLoading)) return
     if (loadState is MangaPageLoadState.Failed) {
         // 失败态保留深色底便于重试按钮可读;加载/排队态不再整页压黑罩,
         // 避免已解码淡入的图被一层「阴影」盖住(#2082)。
@@ -1584,15 +1727,13 @@ private fun MangaReaderItemUi.Page.imageRequest(
     return ImageRequest.Builder(context)
         .data(imageUrl)
         .allowHardware(true)
-        // 稳定解码目标避免条漫首次占位高度参与降采样,也让预取与展示的尺寸一致。
-        // Coil 仍会检查缓存位图是否满足目标尺寸;分页放大清晰度需要另外接入区域解码。
+        // 稳定解码目标避免条漫首次占位高度参与降采样。
+        // Coil 检查缓存预览是否满足目标尺寸;分页放大清晰度由原图区域解码补足。
         .size(decodeSize)
         // STRETCH 的宽、高都必须满足,否则 FIT 降采样后会在另一个轴上被放大。
         // 只有一个轴有目标值时(适应宽/高、条漫),Coil 按该轴计算缩放。
         .scale(Scale.FILL)
-        // The preload request has no view-size resolver while the displayed request does. A
-        // shared key lets the displayed request reuse it immediately, then crossfade only if a
-        // better-sized decode is needed.
+        // 展示、背景取色与回退共享稳定身份;仅数据预取不读写位图缓存。
         .memoryCacheKey(memoryCacheKey)
         .placeholderMemoryCacheKey(memoryCacheKey)
         .apply {
@@ -1601,6 +1742,9 @@ private fun MangaReaderItemUi.Page.imageRequest(
             extras[CoverExtras.MangaBookUrl] = bookUrl
             extras[CoverExtras.MangaFileOwner] = fileOwner
             extras[CoverExtras.MangaFileTransferStarted] = onFileTransfer
+            extras[CoverExtras.MangaAspectRatio] = onAspectRatio
+            extras[CoverExtras.MangaWebtoon] = settings.scrollMode == MangaScrollMode.WEBTOON ||
+                    settings.scrollMode == MangaScrollMode.WEBTOON_WITH_GAP
         }
         .apply {
             if (settings.enableEInk) transformations(MangaEInkTransformation(settings.eInkThreshold))
@@ -1610,7 +1754,8 @@ private fun MangaReaderItemUi.Page.imageRequest(
             onError(result.throwable.localizedMessage)
         }, onSuccess = { _, result ->
             val image = result.image
-            if (image.width > 0 && image.height > 0) {
+            // 在线页使用原图/EXIF 尺寸,不用降采样后的近似比例反复改变布局。
+            if (fileOwner?.aspectRatio() == null && image.width > 0 && image.height > 0) {
                 onAspectRatio(image.width.toFloat() / image.height)
             }
             onSuccess()

+ 24 - 32
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt

@@ -274,15 +274,15 @@ class MangaReaderViewModel(
             is MangaReaderIntent.RetryChapter -> executeSession(
                 MangaSessionCommand.RetryChapter(intent.chapterIndex)
             )
-            is MangaReaderIntent.PageLoadStarted -> markPageLoading(intent.key, intent.force)
+            is MangaReaderIntent.PageLoadStarted -> markPageLoading(intent.requestId, intent.force)
 
             is MangaReaderIntent.PageLoadSucceeded -> updatePageLoadState(
-                intent.key,
+                intent.requestId,
                 MangaPageLoadState.Ready,
             )
 
             is MangaReaderIntent.PageLoadFailed -> updatePageLoadState(
-                intent.key,
+                intent.requestId,
                 MangaPageLoadState.Failed(intent.message),
             )
 
@@ -1229,24 +1229,11 @@ class MangaReaderViewModel(
     }
 
     /**
-     * 置为 Loading,但保留已有百分比:预取请求与展示请求的 onStart 会先后到达,
+     * 置为 Loading,但保留已有百分比:同图的多个展示请求 onStart 会先后到达,
      * 直接塞 [MangaPageLoadState.Loading] 会把已经走到的进度清回“不确定”。
      */
-    private fun markPageLoading(key: String, force: Boolean = false) {
-        _uiState.update { state ->
-            val index = state.pages.indexOfFirst { it.key == key }
-            val page =
-                state.pages.getOrNull(index) as? MangaReaderItemUi.Page ?: return@update state
-            if (!force && page.loadState == MangaPageLoadState.Ready) return@update state
-            val progress = (page.loadState as? MangaPageLoadState.Loading)?.progress
-            val next = MangaPageLoadState.Loading(progress)
-            if (page.loadState == next) return@update state
-            state.copy(
-                pages = state.pages.mapIndexed { itemIndex, item ->
-                    if (itemIndex == index) page.copy(loadState = next) else item
-                }.toImmutableList(),
-            )
-        }
+    private fun markPageLoading(requestId: MangaPageRequestId, force: Boolean = false) {
+        updatePageLoadState(requestId, MangaPageLoadState.Loading(), force)
     }
 
     /**
@@ -1255,8 +1242,13 @@ class MangaReaderViewModel(
      */
     private fun applyPageProgress(imageUrl: String, percentage: Int) {
         _uiState.update { state ->
-            val from = state.currentItemIndex - PAGE_PROGRESS_WINDOW
-            val to = state.currentItemIndex + PAGE_PROGRESS_WINDOW
+            val anchor = mangaImagePrefetchIndex(
+                state.settings.scrollMode,
+                state.currentItemIndex,
+                state.footerItemIndex
+            )
+            val from = anchor - PAGE_PROGRESS_WINDOW
+            val to = anchor + PAGE_PROGRESS_WINDOW
             var changed = false
             val pages = state.pages.mapIndexed { itemIndex, item ->
                 if (itemIndex < from || itemIndex > to) {
@@ -1276,20 +1268,20 @@ class MangaReaderViewModel(
         }
     }
 
-    private fun updatePageLoadState(key: String, loadState: MangaPageLoadState) {
+    private fun updatePageLoadState(
+        requestId: MangaPageRequestId,
+        loadState: MangaPageLoadState,
+        force: Boolean = false,
+    ) {
         _uiState.update { state ->
-            val index = state.pages.indexOfFirst { it.key == key }
+            val index = state.pages.indexOfFirst { it.key == requestId.key }
             val page =
                 state.pages.getOrNull(index) as? MangaReaderItemUi.Page ?: return@update state
-            // 已就绪的页不被重新入队/预取触发 onStart 而降级回 Loading,避免已显示的图被
-            // 遮罩/转圈闪一下;重试走 retryPage 显式置回 Queued,不受此限制。
-            if (page.loadState == MangaPageLoadState.Ready) return@update state
-            if (page.loadState == loadState) return@update state
-            state.copy(
-                pages = state.pages.mapIndexed { itemIndex, item ->
-                    if (itemIndex == index) page.copy(loadState = loadState) else item
-                }.toImmutableList(),
-            )
+            val next = page.reduceImageLoad(requestId, loadState, force)
+            if (next === page) return@update state
+            state.copy(pages = state.pages.mapIndexed { itemIndex, item ->
+                if (itemIndex == index) next else item
+            }.toImmutableList())
         }
     }
 

+ 9 - 5
app/src/main/java/io/legado/app/ui/book/manga/MangaSettingsPanel.kt

@@ -459,12 +459,16 @@ private fun ReaderSettingsContent(
             )
         }
     }
-    SettingSlider(
-        stringResource(R.string.manga_reader_preload_pages),
-        settings.preDownloadCount,
-        0..10
+    SettingSwitch(
+        stringResource(R.string.manga_reader_preload_chapter),
+        settings.preDownloadCount > 0,
     ) {
-        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.PRE_DOWNLOAD, it))
+        onIntent(
+            MangaReaderIntent.UpdateSetting(
+                MangaReaderSettingKey.PRE_DOWNLOAD,
+                if (it) 10 else 0
+            )
+        )
     }
     SettingSwitch(
         stringResource(R.string.manga_reader_auto_offline_cache),

+ 1 - 1
app/src/main/res/values-zh-rCN/strings.xml

@@ -3285,7 +3285,7 @@
     <string name="manga_reader_right_to_left">从右到左</string>
     <string name="manga_reader_top_to_bottom">从上到下</string>
     <string name="manga_reader_side_padding">条漫侧边留白</string>
-    <string name="manga_reader_preload_pages">预加载页数</string>
+    <string name="manga_reader_preload_chapter">预加载当前章图片</string>
     <string name="manga_reader_red">红</string>
     <string name="manga_reader_green">绿</string>
     <string name="manga_reader_blue">蓝</string>

+ 1 - 1
app/src/main/res/values/strings.xml

@@ -3289,7 +3289,7 @@
     <string name="manga_reader_right_to_left">Right to left</string>
     <string name="manga_reader_top_to_bottom">Top to bottom</string>
     <string name="manga_reader_side_padding">Webtoon side padding</string>
-    <string name="manga_reader_preload_pages">Preload pages</string>
+    <string name="manga_reader_preload_chapter">Preload current chapter images</string>
     <string name="manga_reader_red">Red</string>
     <string name="manga_reader_green">Green</string>
     <string name="manga_reader_blue">Blue</string>

+ 24 - 0
app/src/test/java/io/legado/app/help/book/BookImageFileStoreTest.kt

@@ -96,6 +96,30 @@ class BookImageFileStoreTest {
         assertEquals(1, moves)
     }
 
+    @Test
+    fun `eviction snapshots access times once even while markers are touched during sorting`() =
+        withDirectory { root ->
+            val files =
+                (0..79).map { index -> root.resolve("page$index.jpg").apply { writeBytes(image) } }
+            files.forEach { store.setOnline(it, true) }
+            val reads = mutableMapOf<String, Int>()
+            val concurrentStore = BookImageFileStore(readAccessTime = { marker ->
+                val count = reads.getOrDefault(marker.name, 0) + 1
+                reads[marker.name] = count
+                // 旧实现比较器反复读文件属性,本检查能确定性暴露该问题。
+                check(count == 1) { "Access time read again during sorting" }
+                val snapshot = marker.name.removePrefix("page").removeSuffix(".jpg").toLong()
+                marker.setLastModified(100_000L - snapshot)
+                snapshot
+            }) { true }
+            concurrentStore.trimOnlineCache(root, image.size * 3L)
+            assertEquals(80, reads.size)
+            assertTrue(reads.values.all { it == 1 })
+            assertEquals(
+                files.takeLast(3).map { it.name },
+                files.filter { it.exists() }.map { it.name })
+        }
+
     @Test
     fun `online budget evicts least recently used files but preserves history and retained files`() = withDirectory { root ->
         val old = root.resolve("old.jpg").apply { writeBytes(image) }

+ 195 - 2
app/src/test/java/io/legado/app/help/coil/MangaFileRequestTest.kt

@@ -3,16 +3,21 @@ package io.legado.app.help.coil
 import android.app.Application
 import android.graphics.Bitmap
 import android.util.Base64
+import coil3.ColorImage
 import coil3.ImageLoader
-import coil3.decode.DataSource
 import coil3.decode.BitmapFactoryDecoder
+import coil3.decode.DataSource
+import coil3.request.ErrorResult
 import coil3.request.ImageRequest
 import coil3.request.SuccessResult
-import coil3.request.ErrorResult
 import coil3.request.allowHardware
+import coil3.request.crossfade
+import coil3.request.crossfadeMillis
 import io.legado.app.data.entities.Book
 import io.legado.app.help.book.BookHelp
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
 import me.saket.telephoto.subsamplingimage.SubSamplingImageSource
 import okio.Path.Companion.toOkioPath
 import org.junit.Assert.assertEquals
@@ -39,6 +44,194 @@ class MangaFileRequestTest {
         RuntimeEnvironment.getApplication().injectAsAppCtx()
     }
 
+    @Test
+    fun `webtoon reuses persisted preview after memory eviction and corrupt preview falls back to original`() =
+        runBlocking {
+            val src = "https://invalid.example/persisted-preview.png"
+            val raw = BookHelp.getImage(book, src)
+            val bitmap = Bitmap.createBitmap(80, 120, Bitmap.Config.ARGB_8888)
+            try {
+                bitmap.eraseColor(android.graphics.Color.RED)
+                val bytes = ByteArrayOutputStream().use {
+                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, it); it.toByteArray()
+                }
+                BookHelp.writeImage(book, src, bytes)
+            } finally {
+                bitmap.recycle()
+            }
+            val owner = MangaImageFileOwner()
+            val loader = ImageLoader.Builder(RuntimeEnvironment.getApplication()).components {
+                add(BitmapFactoryDecoder.Factory())
+                add(CoverInterceptor { _, data -> BookHelp.acquireReadingImage(null, book, data) })
+            }.build()
+            var ratio: Float? = null
+            var failures = 0
+            try {
+                val request =
+                    ImageRequest.Builder(RuntimeEnvironment.getApplication()).data(src).size(20, 30)
+                        .memoryCacheKey("persisted-webtoon").apply {
+                            extras[CoverExtras.MangaFileOwner] = owner
+                            extras[CoverExtras.MangaWebtoon] = true
+                            extras[CoverExtras.MangaAspectRatio] = { ratio = it }
+                        }.listener(onError = { _, _ -> failures++ }).build()
+                val first = loader.execute(request)
+                if (first is ErrorResult) throw first.throwable
+                assertEquals(80f / 120f, ratio!!, 0f)
+                val key = MangaPreviewCache.key(first.request.memoryCacheKey!!, request)
+                val preview = withTimeout(5_000) {
+                    var snapshot = MangaPreviewCache.open(key)
+                    while (snapshot == null) {
+                        delay(10); snapshot = MangaPreviewCache.open(key)
+                    }
+                    snapshot
+                }
+                val previewFile = preview.data.toFile()
+                preview.close()
+                loader.memoryCache!!.clear()
+                val reused = loader.execute(request)
+                if (reused is ErrorResult) throw reused.throwable
+                assertEquals(previewFile, reused.request.data)
+                assertEquals(
+                    android.graphics.Color.RED,
+                    (reused.image as coil3.BitmapImage).bitmap.getPixel(0, 0)
+                )
+                previewFile.writeText("corrupted preview")
+                loader.memoryCache!!.clear()
+                val recovered = loader.execute(request)
+                if (recovered is ErrorResult) throw recovered.throwable
+                assertEquals(raw, recovered.request.data)
+                assertEquals(0, failures)
+                withTimeout(5_000) {
+                    while (true) {
+                        val repaired = MangaPreviewCache.open(key)
+                        if (repaired != null) {
+                            repaired.close(); break
+                        }
+                        delay(10)
+                    }
+                }
+            } finally {
+                owner.close(); loader.shutdown(); BookHelp.clearCache(book)
+            }
+        }
+
+    @Test
+    fun `adjacent preview is reused on entering page and replaced original invalidates bitmap cache`() =
+        runBlocking {
+            val src = "https://invalid.example/warmed-page.png"
+            val file = BookHelp.getImage(book, src)
+            fun writePng(color: Int) {
+                val bitmap = Bitmap.createBitmap(40, 60, Bitmap.Config.ARGB_8888)
+                try {
+                    bitmap.eraseColor(color)
+                    val output = ByteArrayOutputStream()
+                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
+                    BookHelp.writeImage(book, src, output.toByteArray())
+                } finally {
+                    bitmap.recycle()
+                }
+            }
+
+            val previewOwner = MangaImageFileOwner()
+            val visibleOwner = MangaImageFileOwner()
+            var transfers = 0
+            val loader = ImageLoader.Builder(RuntimeEnvironment.getApplication()).components {
+                add(BitmapFactoryDecoder.Factory())
+                add(CoverInterceptor { _, data ->
+                    BookHelp.acquireReadingImage(null, book, data, onDownload = { transfers++ })
+                })
+            }.build()
+            try {
+                writePng(android.graphics.Color.RED)
+                val request = ImageRequest.Builder(RuntimeEnvironment.getApplication())
+                    .data(src).size(20, 30).allowHardware(false).memoryCacheKey("adjacent-page")
+                    .crossfade(200)
+                    .apply { extras[CoverExtras.MangaFileOwner] = previewOwner }.build()
+                val preview = loader.execute(request)
+                if (preview is ErrorResult) throw preview.throwable
+                val visible = request.newBuilder().apply {
+                    extras[CoverExtras.MangaFileOwner] = visibleOwner
+                }.build()
+                val reused = loader.execute(visible)
+                if (reused is ErrorResult) throw reused.throwable
+                assertEquals(DataSource.MEMORY_CACHE, (reused as SuccessResult).dataSource)
+                assertEquals(0, reused.request.crossfadeMillis)
+                assertEquals(200, (preview as SuccessResult).request.crossfadeMillis)
+                val oldModified = file.lastModified()
+                writePng(android.graphics.Color.BLUE)
+                assertTrue(file.setLastModified(oldModified + 2_000))
+                val replaced = loader.execute(visible)
+                if (replaced is ErrorResult) throw replaced.throwable
+                assertEquals(DataSource.DISK, (replaced as SuccessResult).dataSource)
+                assertEquals(
+                    android.graphics.Color.BLUE,
+                    (replaced.image as coil3.BitmapImage).bitmap.getPixel(0, 0)
+                )
+                assertEquals(0, transfers)
+            } finally {
+                previewOwner.close()
+                visibleOwner.close()
+                loader.shutdown()
+                BookHelp.clearCache(book)
+            }
+        }
+
+    @Test
+    fun `file prefetch does not decode or notify Ready and visible request gets a real bitmap`() =
+        runBlocking {
+            val bitmap = Bitmap.createBitmap(40, 60, Bitmap.Config.ARGB_8888)
+            val bytes = try {
+                ByteArrayOutputStream().use {
+                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
+                    it.toByteArray()
+                }
+            } finally {
+                bitmap.recycle()
+            }
+            val src = "data:image/png;base64,${Base64.encodeToString(bytes, Base64.NO_WRAP)}"
+            val owner = MangaImageFileOwner()
+            val displayOwner = MangaImageFileOwner()
+            var acquisitions = 0
+            var ready = 0
+            var originalRatio: Float? = null
+            val loader = ImageLoader.Builder(RuntimeEnvironment.getApplication()).components {
+                add(BitmapFactoryDecoder.Factory())
+                add(CoverInterceptor { _, data ->
+                    acquisitions++
+                    BookHelp.acquireReadingImage(null, book, data)
+                })
+            }.build()
+            try {
+                val request = ImageRequest.Builder(RuntimeEnvironment.getApplication())
+                    .data(src).size(20, 30).allowHardware(false)
+                    .apply {
+                        extras[CoverExtras.MangaFileOwner] = owner
+                        extras[CoverExtras.MangaAspectRatio] = { originalRatio = it }
+                    }
+                    .listener(onSuccess = { _, _ -> ready++ }).build()
+                val preload = loader.execute(request.asMangaPrefetch())
+                if (preload is ErrorResult) throw preload.throwable
+                assertTrue(preload.image is ColorImage)
+                assertEquals(0, ready)
+                assertEquals(40f / 60f, originalRatio!!, 0f)
+                val displayed = loader.execute(request.newBuilder().apply {
+                    extras[CoverExtras.MangaFileOwner] = displayOwner
+                }.build())
+                if (displayed is ErrorResult) throw displayed.throwable
+                assertFalse(displayed.image is ColorImage)
+                assertEquals(DataSource.DISK, (displayed as SuccessResult).dataSource)
+                assertEquals(1, ready)
+                loader.execute(request)
+                // 同 owner 的后续预览直接使用已持有的有效原图,无需重新查库/获取。
+                assertEquals(2, acquisitions)
+            } finally {
+                owner.close()
+                displayOwner.close()
+                loader.shutdown()
+                BookHelp.clearCache(book)
+            }
+        }
+
     @Test
     fun `Coil reports local original even when preview is reused from memory`() = runBlocking {
         val bitmap = Bitmap.createBitmap(40, 60, Bitmap.Config.ARGB_8888)

+ 71 - 0
app/src/test/java/io/legado/app/help/coil/MangaImageDimensionsTest.kt

@@ -0,0 +1,71 @@
+package io.legado.app.help.coil
+
+import android.app.Application
+import android.graphics.Bitmap
+import androidx.exifinterface.media.ExifInterface
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import java.io.File
+import java.nio.file.Files
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [26, 35])
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class MangaImageDimensionsTest {
+    private fun write(
+        file: File,
+        width: Int,
+        height: Int,
+        format: Bitmap.CompressFormat = Bitmap.CompressFormat.PNG
+    ) {
+        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+        try {
+            file.outputStream().use { assertTrue(bitmap.compress(format, 100, it)) }
+        } finally {
+            bitmap.recycle()
+        }
+    }
+
+    @Test
+    fun `dimensions survive reopen and invalid index or replaced original is rebuilt`() {
+        val root = Files.createTempDirectory("manga-dimensions").toFile()
+        val file = File(root, "page.png")
+        try {
+            write(file, 40, 60)
+            assertEquals(40f / 60f, MangaImageDimensions.read(file)!!, 0f)
+            val index = File(root, ".dimensions/page.png")
+            assertTrue(index.isFile)
+            assertEquals(40f / 60f, MangaImageDimensions.read(file)!!, 0f)
+            index.writeText("corrupt\\u????")
+            assertEquals(40f / 60f, MangaImageDimensions.read(file)!!, 0f)
+            write(file, 80, 40)
+            assertEquals(2f, MangaImageDimensions.read(file)!!, 0f)
+        } finally {
+            root.deleteRecursively()
+        }
+    }
+
+    @Test
+    fun `dimensions account for EXIF rotation before layout`() {
+        val root = Files.createTempDirectory("manga-exif").toFile()
+        val file = File(root, "page.jpg")
+        try {
+            write(file, 40, 60, Bitmap.CompressFormat.JPEG)
+            ExifInterface(file.path).apply {
+                setAttribute(
+                    ExifInterface.TAG_ORIENTATION,
+                    ExifInterface.ORIENTATION_ROTATE_90.toString()
+                )
+                saveAttributes()
+            }
+            assertEquals(60f / 40f, MangaImageDimensions.read(file)!!, 0f)
+        } finally {
+            root.deleteRecursively()
+        }
+    }
+}

+ 37 - 0
app/src/test/java/io/legado/app/help/coil/MangaImageFileOwnerTest.kt

@@ -1,5 +1,6 @@
 package io.legado.app.help.coil
 
+import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Test
 import java.io.Closeable
@@ -7,6 +8,42 @@ import java.io.File
 import kotlin.coroutines.cancellation.CancellationException
 
 class MangaImageFileOwnerTest {
+    @Test
+    fun `preview and background share acquisition but changed or missing file is reacquired`() =
+        runBlocking {
+            val owner = MangaImageFileOwner()
+            val file = File.createTempFile("manga-owner", ".png")
+            var acquisitions = 0
+            var closes = 0
+            val fetch: suspend () -> Pair<File, Closeable> = {
+                acquisitions++
+                file.writeText("original")
+                file to Closeable { closes++ }
+            }
+            try {
+                owner.acquire("page", fetch)
+                owner.acquire("page", fetch)
+                assertEquals(1, acquisitions)
+                file.writeText("changed size")
+                owner.acquire("page", fetch)
+                assertEquals(2, acquisitions)
+                file.delete()
+                owner.acquire("page", fetch)
+                assertEquals(3, acquisitions)
+                owner.close()
+                assertEquals(3, closes)
+                try {
+                    owner.acquire("page", fetch)
+                    error("Disposed owner accepted image")
+                } catch (_: CancellationException) {
+                    assertEquals(3, acquisitions)
+                }
+            } finally {
+                owner.close()
+                file.delete()
+            }
+        }
+
     @Test
     fun `late acquisition after disposal closes its lease`() {
         val owner = MangaImageFileOwner()

+ 76 - 0
app/src/test/java/io/legado/app/help/coil/MangaPrefetchDataTest.kt

@@ -0,0 +1,76 @@
+package io.legado.app.help.coil
+
+import android.app.Application
+import coil3.ColorImage
+import coil3.ImageLoader
+import coil3.decode.DataSource
+import coil3.decode.ImageSource
+import coil3.fetch.Fetcher
+import coil3.fetch.SourceFetchResult
+import coil3.request.ErrorResult
+import coil3.request.ImageRequest
+import coil3.request.SuccessResult
+import kotlinx.coroutines.runBlocking
+import okio.Buffer
+import okio.ForwardingSource
+import okio.buffer
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [26, 35])
+class MangaPrefetchDataTest {
+    private class StreamPage
+
+    @Test
+    fun `stream prefetch consumes to EOF without decoding caching a bitmap or calling display listener`() =
+        runBlocking {
+            val size = 70_000L
+            var consumed = 0L
+            var eof = false
+            var closed = false
+            var ready = 0
+            val stream = object : ForwardingSource(Buffer().write(ByteArray(size.toInt()))) {
+                override fun read(sink: Buffer, byteCount: Long): Long =
+                    super.read(sink, byteCount).also {
+                        if (it == -1L) eof = true else consumed += it
+                    }
+
+                override fun close() {
+                    closed = true; super.close()
+                }
+            }.buffer()
+            val loader = ImageLoader.Builder(RuntimeEnvironment.getApplication()).build()
+            try {
+                val request = ImageRequest.Builder(RuntimeEnvironment.getApplication())
+                    .data(StreamPage()).size(20, 30)
+                    .fetcherFactory<StreamPage> { _, options, _ ->
+                        Fetcher {
+                            SourceFetchResult(
+                                ImageSource(stream, options.fileSystem),
+                                null,
+                                DataSource.NETWORK
+                            )
+                        }
+                    }
+                    .listener(onSuccess = { _, _ -> ready++ })
+                    .build().asMangaPrefetch()
+                val result = loader.execute(request)
+                if (result is ErrorResult) throw result.throwable
+                assertTrue(result.image is ColorImage)
+                assertNull((result as SuccessResult).memoryCacheKey)
+                assertEquals(size, consumed)
+                assertTrue(eof)
+                assertTrue(closed)
+                assertEquals(0, ready)
+            } finally {
+                loader.shutdown()
+            }
+        }
+}

+ 127 - 0
app/src/test/java/io/legado/app/help/coil/MangaPrefetchRequestsTest.kt

@@ -0,0 +1,127 @@
+package io.legado.app.help.coil
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MangaPrefetchRequestsTest {
+    @Test
+    fun `immediate cancellation can finish all active jobs without starting pending work`() =
+        runTest {
+            val queue = MangaPrefetchRequests(
+                CoroutineScope(
+                    coroutineContext + UnconfinedTestDispatcher(testScheduler)
+                )
+            )
+            var starts = 0
+            var closes = 0
+            queue.update((0..2).map { "p$it" to 0 }) {
+                starts++
+                try {
+                    awaitCancellation()
+                } finally {
+                    closes++
+                }
+            }
+            assertEquals(2, starts)
+            queue.close()
+            assertEquals(2, closes)
+            assertEquals(2, starts)
+        }
+
+    @Test
+    fun `whole chapter advances with two background tasks and viewport reprioritizes only pending pages`() =
+        runTest {
+            val queue = MangaPrefetchRequests(this)
+            val keys = (0..5).map { "p$it" to 0 }
+            val started = mutableListOf<Pair<String, Int>>()
+            val gates = keys.associateWith { CompletableDeferred<Unit>() }
+            val prepare: suspend (Pair<String, Int>) -> Unit =
+                { key -> started += key; gates.getValue(key).await() }
+            queue.update(keys, prepare)
+            runCurrent()
+            assertEquals(keys.take(2), started)
+            queue.update(keys.reversed(), prepare)
+            runCurrent()
+            assertEquals(keys.take(2), started)
+            gates.getValue(keys[0]).complete(Unit)
+            runCurrent()
+            assertEquals(listOf(keys[0], keys[1], keys[5]), started)
+            gates.values.forEach { it.complete(Unit) }
+            runCurrent()
+            assertEquals(listOf(keys[0], keys[1], keys[5], keys[4], keys[3], keys[2]), started)
+            queue.update(keys, prepare)
+            runCurrent()
+            assertEquals(6, started.size)
+            queue.close()
+        }
+
+    @Test
+    fun `chapter exit cancels active work and failure does not block later pages or spin`() =
+        runTest {
+            val queue = MangaPrefetchRequests(this)
+            val cancelled = mutableListOf<String>()
+            queue.update(listOf("old1" to 0, "old2" to 0)) { key ->
+                try {
+                    awaitCancellation()
+                } finally {
+                    cancelled += key.first
+                }
+            }
+            runCurrent()
+            val started = mutableListOf<Pair<String, Int>>()
+            val prepare: suspend (Pair<String, Int>) -> Unit = { key ->
+                started += key
+                if (key.first == "failed") error("network failure")
+            }
+            val next = listOf("failed" to 0, "next" to 0, "last" to 0)
+            queue.update(next, prepare)
+            runCurrent()
+            assertEquals(listOf("old1", "old2"), cancelled)
+            assertEquals(next, started)
+            queue.update(next, prepare)
+            runCurrent()
+            assertEquals(next, started)
+            queue.update(listOf("failed" to 1, "next" to 0, "last" to 0), prepare)
+            runCurrent()
+            assertEquals(next + ("failed" to 1), started)
+            queue.close()
+            queue.update(listOf("unused" to 0), prepare)
+            runCurrent()
+            assertEquals(4, started.size)
+        }
+
+    @Test
+    fun `disabling prefetch releases active work and reenabling prepares again`() = runTest {
+        val queue = MangaPrefetchRequests(this)
+        var starts = 0
+        var closes = 0
+        val prepare: suspend (Pair<String, Int>) -> Unit = {
+            starts++
+            try {
+                awaitCancellation()
+            } finally {
+                closes++
+            }
+        }
+        queue.update(listOf("page" to 0), prepare)
+        runCurrent()
+        queue.update(emptyList(), prepare)
+        runCurrent()
+        assertEquals(1, closes)
+        queue.update(listOf("page" to 0), prepare)
+        runCurrent()
+        assertEquals(2, starts)
+        queue.close()
+        queue.close()
+        runCurrent()
+        assertEquals(2, closes)
+    }
+}

+ 60 - 0
app/src/test/java/io/legado/app/ui/book/manga/MangaPageLoadStateTest.kt

@@ -0,0 +1,60 @@
+package io.legado.app.ui.book.manga
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Test
+
+class MangaPageLoadStateTest {
+    private val page = MangaReaderItemUi.Page(
+        key = "page", imageUrl = "url", bookUrl = "book", chapterIndex = 0, chapterCount = 1,
+        pageIndex = 0, pageCount = 1, chapterName = "chapter",
+    )
+
+    @Test
+    fun `retry ignores late start success and failure of previous request`() {
+        val retry = page.copy(retryRevision = 1)
+        for (event in listOf(
+            MangaPageLoadState.Loading(100), MangaPageLoadState.Ready,
+            MangaPageLoadState.Failed("old error")
+        )) {
+            assertSame(retry, retry.reduceImageLoad(page.requestId, event, force = true))
+        }
+        assertEquals(
+            MangaPageLoadState.Ready,
+            retry.reduceImageLoad(retry.requestId, MangaPageLoadState.Ready).loadState
+        )
+    }
+
+    @Test
+    fun `book switch cannot accept another book callback with same page key`() {
+        val switched = page.copy(bookUrl = "another-book")
+        assertSame(switched, switched.reduceImageLoad(page.requestId, MangaPageLoadState.Ready))
+    }
+
+    @Test
+    fun `cached Ready is preserved but missing original can force Loading without erasing progress`() {
+        val ready = page.reduceImageLoad(page.requestId, MangaPageLoadState.Ready)
+        assertSame(ready, ready.reduceImageLoad(page.requestId, MangaPageLoadState.Loading()))
+        assertEquals(
+            MangaPageLoadState.Failed("decode failed"),
+            ready.reduceImageLoad(
+                page.requestId,
+                MangaPageLoadState.Failed("decode failed")
+            ).loadState
+        )
+        val fetching =
+            ready.reduceImageLoad(page.requestId, MangaPageLoadState.Loading(50), force = true)
+        assertEquals(MangaPageLoadState.Loading(50), fetching.loadState)
+        assertEquals(
+            MangaPageLoadState.Loading(50),
+            fetching.reduceImageLoad(page.requestId, MangaPageLoadState.Loading()).loadState
+        )
+        assertEquals(
+            MangaPageLoadState.Failed("current failure"),
+            fetching.reduceImageLoad(
+                page.requestId,
+                MangaPageLoadState.Failed("current failure")
+            ).loadState
+        )
+    }
+}

+ 87 - 0
app/src/test/java/io/legado/app/ui/book/manga/MangaReaderInteractionTest.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.book.manga
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.unit.IntSize
 import io.legado.app.ui.book.manga.config.MangaDoublePageMode
+import io.legado.app.ui.book.manga.config.MangaScrollMode
 import io.legado.app.ui.book.manga.config.MangaZoomStartPosition
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
@@ -11,6 +12,92 @@ import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class MangaReaderInteractionTest {
+    @Test
+    fun `dimension regrouping preserves the second half of a split wide page`() {
+        val items = listOf(page(0), page(1), page(2))
+        val old = buildMangaSpreads(items, false, mapOf("p0" to 2f), splitWidePages = true)
+        val right = old.first { it.slots.single().slice == MangaPageSlice.RIGHT }
+        val changed =
+            buildMangaSpreads(items, false, mapOf("p0" to 2f, "p2" to 2f), splitWidePages = true)
+        val target = mangaSpreadReconcileTarget(changed, 0, right.key)
+        assertEquals(right.key, changed[target].key)
+        assertEquals(MangaPageSlice.RIGHT, changed[target].slots.single().slice)
+    }
+
+    @Test
+    fun `removed double page identity falls back to the current original page`() {
+        val items = listOf(page(0), page(1), page(2))
+        val old = buildMangaSpreads(items, true)
+        val changed = buildMangaSpreads(items, true, mapOf("p0" to 2f))
+        val target = mangaSpreadReconcileTarget(changed, 1, old[0].key)
+        assertTrue(1 in changed[target])
+        assertEquals(-1, mangaSpreadReconcileTarget(changed, 99, null))
+    }
+
+    @Test
+    fun `chapter prefetch includes distant pages but excludes adjoining chapters and prioritizes viewport`() {
+        val pages = listOf(page(0, 0)) + (0..24).map { page(it, 1) } + page(0, 2)
+        val ordered = mangaChapterPrefetchPages(pages, 13, 0)
+        assertEquals(25, ordered.size)
+        assertTrue(ordered.all { it.chapterIndex == 1 })
+        assertEquals(listOf(12, 13, 11, 14, 10), ordered.take(5).map { it.pageIndex })
+        assertEquals((0..24).toSet(), ordered.map { it.pageIndex }.toSet())
+    }
+
+    @Test
+    fun `chapter boundary falls back to committed chapter without preloading another chapter`() {
+        val items = listOf(MangaReaderItemUi.ChapterEdge("edge", "loading"), page(0, 0), page(0, 1))
+        assertEquals(listOf(1), mangaChapterPrefetchPages(items, 0, 1).map { it.chapterIndex })
+        assertEquals(emptyList<MangaReaderItemUi.Page>(), mangaChapterPrefetchPages(items, 0, 2))
+    }
+
+    @Test
+    fun `visible height changes wait for fling to finish while offscreen dimensions prepare immediately`() {
+        val queue = MangaWebtoonResizeQueue()
+        val changes = mutableListOf<String>()
+        var measures = 0
+        val apply: (() -> Unit) -> Unit = { measures++; it() }
+        queue.update("visible", true, true, { changes += "stale" }, apply)
+        queue.update("visible", true, true, { changes += "latest" }, apply)
+        queue.update("removed", true, true, { changes += "removed" }, apply)
+        queue.update("offscreen", true, false, { changes += "offscreen" }, apply)
+        assertEquals(listOf("offscreen"), changes)
+        queue.flush(setOf("visible", "offscreen"), apply)
+        assertEquals(listOf("offscreen", "latest"), changes)
+        assertEquals(2, measures)
+        queue.flush(setOf("visible"), apply)
+        assertEquals(2, measures)
+    }
+
+    @Test
+    fun `a later immediate resize supersedes a pending resize for the same page`() {
+        val queue = MangaWebtoonResizeQueue()
+        var value = 0
+        val apply: (() -> Unit) -> Unit = { it() }
+        queue.update("page", true, true, { value = 1 }, apply)
+        queue.update("page", false, true, { value = 2 }, apply)
+        queue.flush(setOf("page"), apply)
+        assertEquals(2, value)
+    }
+
+    @Test
+    fun `webtoon prefetch follows visible position before reading progress is committed`() {
+        assertEquals(12, mangaImagePrefetchIndex(MangaScrollMode.WEBTOON, 2, 12))
+        assertEquals(12, mangaImagePrefetchIndex(MangaScrollMode.WEBTOON_WITH_GAP, 2, 12))
+        assertEquals(2, mangaImagePrefetchIndex(MangaScrollMode.PAGE_TOP_TO_BOTTOM, 2, 12))
+        assertEquals(2, mangaImagePrefetchIndex(MangaScrollMode.WEBTOON, 2, null))
+    }
+
+    @Test
+    fun `resize preserves loaded image at viewport center instead of unknown placeholder above it`() {
+        val visible = listOf(Triple("placeholder", -100, 400), Triple("loaded", 300, 500))
+        assertEquals("loaded" to -300, mangaWebtoonResizeAnchor(visible, 400, setOf("loaded")))
+        assertEquals(null, mangaWebtoonResizeAnchor(visible, 400, emptySet()))
+        assertEquals(
+            "loaded" to 100,
+            mangaWebtoonResizeAnchor(listOf(Triple("loaded", -100, 900)), 400, setOf("loaded"))
+        )
+    }
 
     @Test
     fun `explicit chapter placeholder stays at target and exposes retry after failure`() {

+ 84 - 4
docs/dev/manga-subsampling-plan.md

@@ -15,7 +15,7 @@
 - `MangaReaderScreen` 的请求保留完整原始 URL 身份,共用显式解码尺寸。
   分页预取、展示与背景提色携带 `MangaImageFileOwner`;`CoverInterceptor` 在首次解码前
   经 BookHelp 获取原图并把请求改为 File,返回结果的 request.data 也是 File。
-  条漫继续使用旧 URL/AsyncImage 路径。
+  条漫也复用原图 File 获取链路,继续使用 AsyncImage 整页绘制。
   条漫按窗口宽度与侧边留白解码;分页适应高度按高度解码,拉伸同时满足宽高,
   原始尺寸保留原图,旋转适配保守覆盖交换后的窗口尺寸。
 - 旧 `CoverFetcher` 的网络流或内存 Buffer 无法提供区域解码所需的文件模型。
@@ -138,7 +138,8 @@ ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或
   首版可用有界生命周期的协调 owner,之后再按引用计数回收,禁止裸 `remove(key)`。
 - 同时阅读与下载同图,只做一次网络获取/解密/写入。等待者取消只释放自己的订阅;
   仍有下载/阅读消费者时不能取消共用任务。最后一个消费者离开后的取消规则写入契约测试。
-- 复用现有 imageDownloadSlots / imageDecodeSlots 限流,不让 Coil 预取旁路再开一组无限任务。
+- 不增加跨图片的全局获取/解密槽位;遵循书源自身的限流配置,同文件获取仍互斥。
+  阅读预取限制在当前窗口,不把章节下载并发限制叠加到可见页。
 - 渲染取得文件使用凭证,从交给 Telephoto 起到其解码源关闭为止保护该文件;
   不能只在预览 Coil 请求结束时释放。优先保护整个活动章节,避免与可见页、预取页生命周期竞争。
 - 清理、重试、有效记录变更与发布共享协调规则;主线程不做 exists/格式校验或磁盘删除。
@@ -207,7 +208,7 @@ ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或
 
 ## 6. 容量与清理
 
-- 当前为可淘汰阅读文件设置 256 MiB 字节预算,最终值需按典型漫画与低存储设备验证。
+- 当前为可淘汰阅读文件设置 1000 MiB 字节预算(参考原版 Legado 的缓存容量),最终值需按典型漫画与低存储设备验证。
   用 `images/.online/<原图文件名>` 登记用途,marker 的修改时间记录访问时间;
   避免用图片后缀判断用途,也不保存 URL/token/header 副本。新获取文件在发布前登记,
   历史未分类文件保守保留;现有章节下载复用时删除 marker,升级为预算外保留文件。
@@ -264,7 +265,7 @@ lint 仍有现有的 118 个警告、13 个提示,本切片没有放宽 baseli
 EInk 整图分支及活动文件保护。首版保持 preview 尺寸,不先做 1/4 优化。
 这一切片启用在线落盘时必须同时提供可淘汰预算与安全清理,不能先交付无上限版本。
 
-当前已接入分页展示/预取/背景提色的 File 请求、256 MiB 预算、请求租约与瓦片独立租约、
+当前已接入分页展示/预取/背景提色的 File 请求、1000 MiB 预算、请求租约与瓦片独立租约、
 EInk painter 分支;未降低 preview 尺寸,也未向共享领域泄漏 File。
 原图被预算淘汰后,即使预览还在内存缓存,拦截器也先恢复原图并发出真实获取事件,
 让之前 Ready 的页面恢复 Loading;有效原图命中不强制降级 Ready。
@@ -313,6 +314,85 @@ Manual/Batch 队列请求保留显式用途,ReadPreload 不降级已有用途
 
 ### D. 优化与真机验收
 
+对照原版 Legado 的 `downloadOnly()` 预取,远端预取只准备原图,
+不生成预览位图、不执行 EInk 转换、不发送页面 Ready。分页和条漫在线页均进入共享原图文件链路,
+同图展示、预取、下载通过文件 mutex 合并成功获取;条漫继续由 AsyncImage 整页绘制,避免页边界细线。
+开启预取时,普通分页额外保留前后各两屏的真实渲染;原尺寸模式不额外保留,避免提前解码完整大图。
+条漫显示预热改用 Foundation 1.12.1 的 LazyLayoutCacheWindow:滚动方向前方四个视口、
+后方两个视口,按实际测量高度决定准备/保留哪些项,不以图片张数封顶。
+预组合项使用同一个 AsyncImage 请求和租约,移除重复的手工预览解码队列;
+原图纯数据预取持续准备实际当前整章,不再以十张封顶;显示缓存窗口仍按视口范围控制。
+设置改为“预加载当前章图片”开关,原先非零页数继续视为开启,关闭时取消整章准备并将额外显示缓存窗口设为零。
+条漫的窗口锚点和进度过滤跟随滑动中的可见页(footerItemIndex),不等停手提交阅读进度;
+显示窗口由 LazyColumn 直接随滚动方向更新,不经过 footerItemIndex/业务状态往返;
+预组合和预测量由框架按帧调度,图片请求仍异步执行,不增加网络或原图获取的全局槽位。
+原图获取后即读取头部/EXIF 宽高比,保存到 images/.dimensions 的版本化可重建索引,
+索引匹配原图长度/修改时间,损坏或原图替换时重建;布局不再被降采样后的近似比例反复改写。
+尺寸发布早于位图解码,纯数据预取也发布尺寸。静止时重测保持视口中已有图片的 key/像素位置;
+手势或惯性滚动期间,可见项的尺寸变更暂存,停手后合并一次重测;
+离屏项仍立即更新尺寸,以便进入视口前完成准备。停手后只保留仍存在的页键和最新尺寸,
+同页后续即时更新会覆盖旧暂存。滚动或显式导航期间不强制 requestScrollToItem,避免取消手势或覆盖导航。
+冷加载越过预取范围时,可见图片可能暂时保持占位高度,停手才切换真实高度;不承诺网络/解码必能赶上任意速度。
+本地/content 路径仍沿用平台加载器与解码后尺寸回调,尚未接入这个尺寸索引。
+原图链路的内存预览命中关闭本次淡入,避免 Telephoto 对缓存图重复淡入时露出底色;
+首次获取/解码仍遵循淡入设置。
+条漫另有 256 MiB 可再生成的无损 PNG 预览磁盘缓存,键包含原图版本、渲染身份、目标尺寸/缩放、
+变换 cacheKey。内存淘汰后可从预览文件恢复,不必每次重新降采样原图;损坏预览自动移除并回退原图。
+编码在后台进行,只保留一个编码任务,忙时跳过保存以免排队持有大量长图;不阻塞可见页首显。
+只有最终 BitmapImage 保存,动画 Drawable 不转为静态图;缓存命中不重复应用 EInk 等变换。
+预览不参与下载完成/显式保留,不代替分页区域解码原图;访问前仍取得原图租约和有效性检查。
+原图发布仍保持 fsync/校验/原子替换,未为提速降低下载共用文件的可靠性。
+仅数据预取的临时结果不进入位图内存缓存;没有原图 owner 的流仍须读取到 EOF 并关闭。
+整章原图准备有两个独立后台任务,当前页及邻近页优先,剩余页持续排队。
+同章滑动只重排等待项,不取消在途任务;切章、切书、关闭预取或退出时取消旧任务。
+每页完成即释放租约,只记录页键/重试代次,避免把整章变成不可淘汰文件或持有整章位图。
+失败跳过继续后续页,不自动循环重试;显式重试通过新代次重新准备,重新进入章节也重新检查。
+后台任务数量仅约束这个整章准备队列,不限制可见页、显式下载或全局网络并发。
+列表可能含前后章节,但准备只选可见页所属章;边界项使用当前业务章节,未加载章节不隐式拉取。
+当前仍按文件 mutex 合并成功获取,尚不是订阅计数式共享传输任务:
+切章/退出等获取取消时,等待展示/下载可能重试未完成的传输,不能宣称这一取消边界已解决。
+跨图片获取/解密的额外全局槽位已经移除,书源限流和同文件互斥保留。
+预算扫描改为后台合并清理,展示获取不等待全目录枚举;活动租约仍保护超预算原图。
+清理排序先快照每个候选的访问时间和大小,比较器不读取会被并发 touch 修改的文件属性,
+避免 TimSort 的比较契约异常;删除前仍复核在线标记、显式下载用途和活动租约。
+后台清理记录普通异常并继续接收请求,协程取消正常传播。此修复通过 BookImageFileStoreTest、
+BookHelpImageFileTest 共 34 项测试、Kotlin 编译、架构检查及 diff 检查;未在报错设备重现或重跑 lint。
+同一个渲染 owner 的预览/取色/降级复用已获取文件,长度或修改时间变化/文件缺失时重新获取;
+跨 owner 仍验证原图,不用未经校验的快速命中替代正确性检查。
+位图缓存键附带原图长度/修改时间,文件替换不会沿用旧预览;同时间同长度的外部修改尚不检测。
+回调携带书籍、页键、重试代次;旧重试/其他书籍的完成、失败和强制 Loading 被丢弃。
+已 Ready 的当前请求出现真实解码失败时仍进入可重试失败态。
+加载指示器延迟 150 ms 显示,并结合分页实际图/占位预览、条漫 painter 的显示状态隐藏,
+不把传输完成或纯数据预取完成当成显示完成;快速缓存命中不会立即闪转圈。
+原图/尺寸索引/预览磁盘缓存改动此前通过 Kotlin 编译、架构检查和 100 项相关测试
+(包括 API 26/35 文件及流预取);当时 lint 通过,仍有现有 118 个警告、13 个提示,未放宽 baseline。
+本次切换 LazyColumn 缓存窗口并延后可见项重测后,运行 `:app:testAppDebugUnitTest`
+(过滤 MangaReaderInteractionTest、MangaFileRequestTest、MangaPrefetchRequestsTest,共 45 项),
+Kotlin 编译、45 项测试、`verifyConfigArchitecture` 和 `git diff --check` 均通过;本次未重跑 lint。
+随后改为整章原图准备,运行 `:app:testAppDebugUnitTest`(过滤 MangaReaderInteractionTest、
+MangaPrefetch*Test、MangaFileRequestTest、MangaImageDimensionsTest,共 55 项)、
+`:app:assembleAppDebug`、`verifyConfigArchitecture` 和 `git diff --check`,均通过;未重跑 lint。
+新增覆盖超过十页的整章选择、相邻章节过滤、等待任务重排/在途保留、自动推进至章尾、
+切章/禁用取消、失败继续且不循环重试、显式重试代次。整章准备完成后仍受阅读缓存预算淘汰,
+不是显式下载保留;超预算章节不承诺所有原图同时留存。真实书源整章网络耗时和快速滑动体验待设备验证。
+分页复核补齐竖向 Pager 的稳定页键,并让横/竖可见页监听使用最新状态而不随加载进度重启。
+横向双页/宽页拆分的尺寸重排等待滚动结束,优先保留 spread 键(包含 LEFT/RIGHT 半页身份),
+组合失效时回落到当前原图页;显式导航仍按目标原图页执行。
+重排后的 spread 列表与 Pager 位置尚未校正一致时暂停可见页上报,避免旧页码写入新布局的阅读进度。
+整章预取组合逻辑独立为私有 MangaChapterImagePrefetch,背景色缓存随书籍隔离。
+本次 `:app:testAppDebugUnitTest`(过滤 *Manga*Test、BookImageFileStoreTest、BookHelpImageFileTest)
+共 131 项通过,Kotlin 编译、架构检查和 diff 检查通过。Pager 重排时序仅检查源码及纯函数,
+真机横/竖快速翻页、拆页右半页重排、缩放/旋转与低内存行为仍待验收。
+最终源码重新运行同一组 131 项测试、`:app:assembleAppDebug`、`verifyConfigArchitecture`、
+`:app:lintAppDebug` 均通过;lint 仍为现有 118 个警告、13 个提示,未修改 baseline。
+测试覆盖重叠窗口保留/退出/重试、EOF 与流关闭、预取不污染位图缓存或发送 Ready、
+同 owner 复用与文件变化重新获取、相邻预览内存命中/文件替换后解码、旧代次与跨书籍回调过滤,
+以及原图存储、下载保留、既有阅读交互和本地区域错误降级。
+新测试覆盖滚动中的窗口锚点、可见尺寸延后/离屏尺寸即时更新/批量重测/过期页和尺寸过滤、EXIF/尺寸索引恢复和损坏重建、
+解码前尺寸发布、清空内存后的预览磁盘复用及损坏预览回退;布局锚点仅验证选择/偏移,未做设备重测时序测试。
+加载遮罩依赖实际渲染状态,尚未运行设备绘制时序测试;真机首次显示/放大清晰耗时、
+相邻屏内存、条漫长图/页边界、网络并发与加密书源峰值内存仍待测量。
+
 确认支持格式下 `subSamplingState != null` 且最终达到 full-quality 状态,
 再比较 preview 尺寸、内存、首次清晰时间及是否闪烁。不把正常瓦片解码计为多余完整请求。