manga-subsampling-plan.md 44 KB

分页漫画区域解码与下载共用文件链路

状态:实施中,分页已接入文件与区域解码适配器,尚未完成真机验收。2026-09-26 对照仓库源码、Coil 3.6.3、 Telephoto 0.19.0 与本机 Komikku 校正。进度与尺寸修复已提交为 9f6f87872; 存储切片 dea63568d 接入有效性检查、原子发布及等待者安全的文件锁; 当前切片接入分页 File 请求、读取租约与在线文件字节预算。

目标:分页缩放时按需读取原图细节;阅读与后续漫画下载共用同一份有效文件, 已有文件不重复获取,已保留的章节断网可读。 “清晰”以源图分辨率为上限,不承诺放大超过原图仍有新增细节; “复用”不等于不再解码,preview、瓦片和重开后的解码仍是正常工作。

1. 当前事实与方案差异

  • MangaReaderScreen 的请求保留完整原始 URL 身份,共用显式解码尺寸。 分页预取、展示与背景提色携带 MangaImageFileOwner;CoverInterceptor 在首次解码前 经 BookHelp 获取原图并把请求改为 File,返回结果的 request.data 也是 File。 条漫也复用原图 File 获取链路,AsyncImage 绘制有界预览;长图和放大后不足显示宽度的 静态图由视口区域解码块补充细节,不再把整图预览作为最终高清结果。 条漫按窗口宽度与侧边留白解码;分页适应高度按高度解码,拉伸同时满足宽高, 原始尺寸保留原图,旋转适配保守覆盖交换后的窗口尺寸。
  • 旧 CoverFetcher 的网络流或内存 Buffer 无法提供区域解码所需的文件模型。 分页现在绕过该路径,Telephoto 可从 File 的 DISK/MEMORY_CACHE 结果建立区域解码源。 MangaZoomableImageSource 保留其格式判断,并为实际瓦片源补上独立读取租约; EInk 明确使用 Coil painter,避免未变换原图瓦片覆盖二值化预览。
  • 不能仅凭相同内存键断言随机命中低分辨率图片。Coil MemoryCacheService 会验证尺寸、采样状态和 precision;稳定目标减少布局引起的差异,但不提供缩放细节。
  • MangaChapterPageLoader 目前只加载章节文本和图片 URL,未向阅读页提供缓存图片文件。
  • BookHelp.getImage(book, src) 的路径是 externalFiles/book_cache/<bookFolderName>/images/<md5Encode16(src)>.<suffix>。 flowImages 与下载使用同一章节基地址解析图片地址,应该沿用完整解析后原始 src (包含书源选项)作为身份,不能用带临时 token 的最终网络 URL 替代。
  • isImageExist 仍只看 exists,保留旧 UI/队列的低成本提示语义,不是下载完成证明。 首个存储切片让 saveImage 在文件锁内检查文件格式/尺寸,字节与流写入都先写临时文件、 校验后原子替换;不再发布新坏图或降级为直接复制到可见目标。 历史文件目前只检查结构,不具备完整性/书源版本的持久有效记录。
  • saveImage 的锁现在按实际书籍文件路径建立,引用数包括等待者,最后一个退出才移除。 当前是串行获取:成功后的等待者复用文件;首位取消时下一位接手获取。 尚未实现独立于任一订阅者的共享任务,也未接入阅读 Fetcher;不能宣称完整跨消费者去重已完成。
  • clearInvalidCache 在应用启动及主页面清理时执行;clearComicCache 按阅读章节窗口删除图片。 现有规则不识别显式下载、活动阅读和瓦片解码中的文件。
  • MangaSettings.autoOfflineCache 已存在,不要新增同名设置。当前主要控制章节预取, 不是图片已持久化或章节可离线的完成证明。

2. 区域解码的依赖依据

Telephoto 0.19.0 的 Coil3ImageSource.kt 在成功结果为 BitmapImage 时,尝试:

  1. 用 result.diskCacheKey 打开 Coil DiskCache snapshot;
  2. 或在 dataSource == DISK || MEMORY_CACHE 时,把 结果请求的 data 映射为本地 URI, 构造文件/content URI 区域解码源;内存命中时还检查文件存在。

之后还要通过 canBeSubSampled,否则回退普通 painter。拿不到源时会重试一次请求, 第二次关闭内存读取;自定义 Fetcher 不会因设置 diskCachePolicy 自动接上 Coil 磁盘缓存。 EngineInterceptor 只从文件型 ImageSource 提取 diskCacheKey,任意 Buffer 不会自动写入磁盘。

因此 ImageRequest.data = File(path) 配合默认 FileUriFetcher,可以自动走区域解码, 无需手写瓦片组件。这里复用的文件必须是完成书源解密但未应用灰度/EInk 等展示变换的原图。

来源:本机相应版本 sources.jar; Telephoto 0.19.0 Coil3ImageSource。

Komikku 可以参考什么

对照仓库 D:/Project/komikku 的下列实际调用链(只读参考,不修改该工程):

  • HttpPageLoader.internalLoadPage:优先查 ChapterCache,miss 才请求网络; putImageToCache 完成后才设置 page.stream 与 Ready。加载顺序可借鉴:完整文件在先,渲染在后。
  • HttpPageLoader 的 PriorityBlockingQueue:当前页/重试优先于普通预取,线程数有限。 可借鉴调度语义,不照搬独立的 readerThreads 设置与全局协程管理。
  • ChapterCache:保存章节 page list 和原图,DiskLruCache editor commit 后才发布。 在线缓存具有明确字节容量,支持重开、识别已淘汰图片并重新排队。
  • Downloader.getOrDownloadImage:已有下载优先,否则使用 ChapterCache 字节,最后才联网。 copyImageFromCache 把缓存复制到下载目录并删缓存文件;因此这是获取复用,不是同一文件存储, 也不能把“使用了缓存”当作无复制、无额外磁盘占用的证明。
  • ReaderPageImageView:静态图使用 SubsamplingScaleImageView,动画图单独渲染, 设置 onReady/onImageLoadError 再更新阅读器状态。可参考就绪与解码失败的阶段划分。

结论:plan 的文件优先/区域解码方向与成熟阅读器一致;“阅读与下载永远共用同一份文件” 是本项目自己的增强目标,Komikku 没有替我们证明它。不能照搬其在线 LRU 清理来删除保留下载, 也不在本项目引入 SSIV、Voyager、SQLDelight 或其 source extension 架构。

3. 推荐决策:一份原图,独立保留策略

所有分页阅读都先取得可用于随机读取的文件,再交给 ZoomableAsyncImage。 自动离线开关不控制是否生成解码文件,只控制章节预取与长期保留。 关闭开关仍应清晰缩放,不能退回现在的 URL/Buffer 整图路径。

沿用 BookHelp 图片路径,文件内容为存储真相;增加最小的章节索引与保留记录,管理用途和有效性。 阅读、自动离线和显式下载使用同一获取入口、同一去重机制、同一文件,不复制到第二个 Coil 缓存。 不要为此新建 Gradle 模块或通用存储框架。

用途 获取方式 保留与淘汰
普通在线阅读 当前页优先,有限预取;完成后发布本地文件 可淘汰,受阅读缓存预算限制
自动离线 同一入口补齐配置范围内章节及元数据 受明确的自动离线保留窗口约束
用户显式下载 同一入口补齐指定章节 持久保留,不受阅读 LRU 或阅读位置窗口删除
活动渲染/获取 持有文件使用凭证 使用期间不可删除,不等于永久保留

同一张图可关联多个章节与用途。取消一个用途不能删除仍被其他用途保留的文件。 下载成功只是保留用途升级,不再获取或再复制已有有效图片。

BookHelp 外部专属目录通常不被系统作为 cache 回收,但可能因应用清数据、卸载、 外部存储不可用或应用自己的清理而失效,不能写成“只有卸载才会删除”。

4. 先补文件契约,再改阅读模型

4.1 统一获取入口

在现有 Android 数据层 manga owner 内形成获取能力;读者是漫画会话,下载者是现有图片下载调用链。 ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或 File。 仅在接入真实调用者的同一切片增加窄契约,不先生成无调用方接口。

一次获取的身份至少包含书籍存储身份、书源身份与完整原始 src;最终网络 URL 只是请求结果。 落盘保持现有路径兼容,迁移时确认书籍改名/来源切换与 updateCacheFolder 的语义。 如果书源变化可能改变解密结果,必须使旧有效记录失效,而不是只靠路径存在。

获取步骤:

  1. 在同一身份的获取协调器中检查有效文件;有效才复用。
  2. 命中历史文件但没有有效记录时,读取格式/尺寸并校验后采纳。无效文件进入失败/重试流程, 不因 exists 判成功。对需完整性证明的旧文件无法仅凭尺寸判断截断,应保守重取或进一步校验。
  3. miss 时以原始 src 调用 AnalyzeUrl,沿用 header、cookie、书源限流、WiFi 限制和 JS 解密语义。 解密必须使用与 BookHelp 下载一致的参数,不同时引入第二套规则实现。
  4. 在目标目录写唯一 .tmp 文件,完成读取与解密后校验,再以原子替换发布最终文件。 不把临时文件交给 Coil;不允许退回向可见最终路径逐字节复制。
  5. 写入失败、取消、解密失败或校验失败,清临时文件并返回错误,不覆盖原有有效文件。 遇到平台不支持原子发布时明确失败或采用经过验证的安全发布方式,不静默降低保证。
  6. 成功后更新有效记录、章节关联与保留用途,通知等待者取得同一文件。

“有效”至少要求文件非空、格式受支持、头部尺寸有效,且本次完整读取成功; 已知 Content-Length 应匹配,解密后重新校验。服务端未知长度不伪造百分比。 格式校验通过也不保证每台设备都能区域解码,设备解码失败属于渲染降级流程。 失败冷却/坏图诊断与有效文件分开,不能把失败占位放进下载完成判定。

4.2 并发、取消与文件生命周期

  • 使用有明确生命周期的同键协调器;锁条目不能在等待者仍可能使用时被删除并重建。 首版可用有界生命周期的协调 owner,之后再按引用计数回收,禁止裸 remove(key)。
  • 同时阅读与下载同图,只做一次网络获取/解密/写入。等待者取消只释放自己的订阅; 仍有下载/阅读消费者时不能取消共用任务。最后一个消费者离开后的取消规则写入契约测试。
  • 不增加跨图片的全局获取/解密槽位;遵循书源自身的限流配置,同文件获取仍互斥。 阅读预取限制在当前窗口,不把章节下载并发限制叠加到可见页。
  • 渲染取得文件使用凭证,从交给 Telephoto 起到其解码源关闭为止保护该文件; 不能只在预览 Coil 请求结束时释放。优先保护整个活动章节,避免与可见页、预取页生命周期竞争。
  • 清理、重试、有效记录变更与发布共享协调规则;主线程不做 exists/格式校验或磁盘删除。
  • 进度关联原始请求身份,并区分书籍/书源/尝试次数,过期尝试的完成与失败不能覆盖新尝试。 当前全局 URL 进度只是现有展示兼容机制,未来共享获取任务应自己发布有身份的状态。

4.3 章节与保留记录

在同一书籍目录保存版本化、原子更新的最小索引(具体模型以契约测试为准): 章节 URL/index、章节文本文件、预期图片完整 src 列表、有效文件关联、保留用途和最后访问时间。 不在元数据里保存最终带 token URL 或认证 header 明文副本;完整身份需要持久化时评估敏感选项。

下载完成 = 章节列表/正文足以重建阅读页 + 所有预期图片有效且被保留。 单个图片下载完、预取请求成功或 UI Ready 均不能单独标为整章可离线。 索引不代替真实文件;打开时发现缺失应降级为未完成/可重试,并修正下载状态。 索引损坏时重建可重建部分,不清空整本书原图,更不能无声删除已有下载。

旧图片缺少用途记录时,不能直接当阅读缓存淘汰;先保守保留,再由明确迁移规则或用户清理分类。 新策略必须接管 clearComicCache、clearInvalidCache 与改名/移除书籍清理。 当前删除路径已协调读写租约,章节窗口不会清理预算管理的在线文件; updateCacheFolder 同时检查源/目标目录的使用登记,忙时延迟迁移,释放租约后在 IO 上重试。 连续改名的待迁移依赖也保留;这不是持久化迁移队列,进程中断后的旧目录恢复仍需后续索引支持。 迁移仍沿用既有 rename 语义,目标目录已存在时不会合并;需要稳定目录身份/迁移索引解决 活动阅读期间改名后,新路径暂未迁移而产生的缓存 miss。

5. 阅读渲染接法

  1. 当前切片沿用 Coil 的展示/有限预取调度,在平台拦截器中取得文件; 不为适配 File 而向共享章节模型新增本地路径。后续需要严格的当前页优先和共享任务取消语义时, 再把调度上移到会话数据层;当前只按当前页、后续页、前页的顺序提交请求。 不向共享领域契约暴露 File、Uri 或 Context。现有 Android-only 渲染标识可在 adapter/UI 层承载。
  2. 加载时显示下载进度/占位;成功后一次性发布已就绪文件,构建 .data(File(path)) 请求。 不默认先走 URL 图片请求再切 File,避免两条获取链路与重复 preview 解码。
  3. Telephoto 负责 preview、瓦片、缩放和平移。保留项目点击、旋转、拆页、灰度与九宫格行为。
  4. 首版 preview 保持当前保守显示尺寸。确认瓦片路径与首次清晰过渡正常后,才降低 preview 尺寸。 ContentScale.None 必须单独验证:缩小 preview 不应改变原图固有尺寸或初始缩放。
  5. 内存键包含渲染模式、变换参数和文件内容版本/重试代次;尺寸复用服从 Coil 校验。 不能让已修复文件继续命中旧坏图/旧变换结果。自动背景提色也改用同一个文件。
  6. 条漫暂保留 AsyncImage 整页渲染,避免重引入页边界细线;可复用同一文件获取层, 不在本次切片重写条漫全局缩放或阅读器手势。

格式、变换与失败降级

  • 灰度现在统一在最终渲染 ColorFilter 中处理(参考 Komikku 的容器绘制滤镜), 不再对 Coil preview 单独做灰度 bitmap 变换;预览、原图瓦片、动画与整图回退使用相同矩阵。 通道/透明度滤镜继续在灰度之后应用,EInk 仍优先走自己的二值化变换。 内存缓存键升级到 v2,防止沿用旧版已经灰度变换的 preview。文件始终保存原图。
  • EInk 首版使用明确的整图渲染分支:由 Coil 生成二值化 painter,配合现有缩放状态/手势, 不把变换后的 preview 交给会读取未变换原图的自动瓦片路径。 仅继续使用 ZoomableAsyncImage(File) 或设置 diskCachePolicy 不能关闭该区域解码路径。 此模式暂不承诺区域解码清晰度;之后验证等价渲染滤镜再统一,不能悄悄失去二值化。
  • GIF/SVG/不支持区域解码的格式保留整图或动画行为,不因命名后缀误判原图格式。
  • 原图格式嗅探优先于 URL 后缀;FileUriFetcher 的 MIME 可能来自后缀,需覆盖伪后缀样本, 必要时只对漫画本地文件提供窄 fetcher,但仍确保结果请求 data 是可映射的本地文件。
  • 区域解码失败应记录并对当前文件版本降级至整图,不立即删下载或无限重新联网。 Telephoto 0.19.0 的公开 ZoomableImage 未暴露 SubSamplingImageErrorReporter, 也没有对应 CompositionLocal;不能假定直接调用它已经接入错误回退。 现在在 SubSamplingImageSource.decoder() 返回的 factory/decoder 边界捕获初始化和瓦片异常, 发出一次请求 owner 的降级状态,保留预览直到 Coil 整图 painter 接管;取消照常传播。 错误适配与在线文件租约独立:本地目录、CBZ 解压文件及 content URI 同样包装原 source, 在线原图额外获取瓦片租约。无租约不再跳过错误恢复,原 source 的格式/URI 能力与关闭语义保留。 ImageRegionDecoder 在 0.19.0 虽处于 internal 包,声明为 public;没有复制库渲染代码, 但升级依赖时必须复核此适配点。降级绑定当前请求 owner,重试建立新 owner 后允许再次区域解码; 尚未实现跨请求的文件版本失败记忆。
  • 低存储、外部存储不可用显式报错并允许重试;不得回退到巨型原图加载并冒充清晰模式成功。

6. 容量与清理

  • 当前为可淘汰阅读文件设置 1000 MiB 字节预算(参考原版 Legado 的缓存容量),最终值需按典型漫画与低存储设备验证。 用 images/.online/<原图文件名> 登记用途,marker 的修改时间记录访问时间; 避免用图片后缀判断用途,也不保存 URL/token/header 副本。新获取文件在发布前登记, 历史未分类文件保守保留;现有章节下载复用时删除 marker,升级为预算外保留文件。 这是可淘汰文件的软预算:活动请求/瓦片暂不删除,释放租约后在 IO 上合并清理请求并再次淘汰。 不是整本书缓存的硬上限,不能靠超预算删除正在使用的文件。 该预算不包含用户显式保留下载;下载界面单独显示占用。
  • 淘汰只针对无显式保留、无自动离线保留、无活动使用的文件,以访问时间排序。 共享图片的所有章节关联都解除后才可删除。不能按整本目录 LRU 一删了之。
  • 超大单图可临时超出预算以供当前阅读,结束后清理;磁盘不足则获取失败。 容量检查与失败路径不得删除仍被读取的文件。
  • imageRetainNum 是现有章节窗口语义,不改名为字节上限。用于自动离线时需说明保留范围, 不能影响显式下载或替代字节预算。
  • “清 HTTP 缓存”只清 HTTP 数据;“清阅读缓存”只清可淘汰文件; “删除下载/清全部书籍数据”才解除显式保留。活动文件延迟删除。
  • 新获取入口不再为同一原图另写 Coil DiskCache;现有 OkHttp manga_cache 仅作过渡。 是否还能利用旧 HTTP 缓存、如何避免新网络获取的双份落盘,必须按实际 AnalyzeUrl 客户端验证, 不凭修改一个容量数字宣称已消除双份存储。

7. 实施切片与门禁

每个切片可独立验证;不要一次同时更换网络、数据库、DI、导航和 UI。

A. 文件获取契约与历史兼容

先写测试,再修正 BookHelp 发布与有效性语义;接入现有 saveImage/saveImages 真实调用方。 测试:取消/IO 失败无半文件、坏文件可重试、同图并发一次获取、等待者取消、锁回收竞争、 原始 src/headers/解密参数兼容、历史文件采纳、失败批次不标下载完成。

首个已实现子切片:BookImageFileStore 由 BookHelp 实际使用,负责非空/格式校验、 同目录临时写入、原子发布与按文件串行获取;下载完成检查改为实际图片有效性检查。 hasImageContent 不再边检查边删除坏图,避免检查后新文件已发布却被旧检查删除的竞争。 图片及目录清理与写入登记原子协调:存在写入者或排队等待者时跳过清理, 章节窗口清理不删除写入中的临时文件;同步 writeImage 也登记写入保护。 目前是写入保护,不代表已经保护未来瓦片解码器的读取生命周期。 此时还没有引入阅读自动落盘,所以不会增加无限增长的在线阅读存储。

仍未完成:持久有效记录/源版本失效、完整传输长度记录、共享任务订阅取消语义、 完整离线清单。读取保护、预算与目录迁移协调由 B 补齐;用途保护由 C 补齐。 格式/尺寸检查不是整图所有像素可解码的证明; 后续渲染错误必须保持可重试,不能因已有文件无条件标 Ready。

首个子切片的验证:BookImageFileStoreTest 10 项、BookHelpImageFileTest 在 API 26/35 各 5 项 native graphics 测试、BookHelpImageCacheCompleteTest 1 项,共 21 项通过。 compileAppDebugKotlin、verifyConfigArchitecture、lintAppDebug 与 git diff --check 通过; lint 仍有现有的 118 个警告、13 个提示,本切片没有放宽 baseline。 覆盖并发、等待者取消、传输失败、发布前取消、坏数据不覆盖原图、PNG/SVG、 历史坏文件重试、已有有效文件不解析网络 URL、写入与清理竞争。 校验通过显式关闭的输入流读取图片头部。 这些测试不替代真实外部存储、认证书源 JS 解密、断网章节恢复和瓦片生命周期的验证。

B. 漫画会话到本地文件渲染

接入当前页与有限预取,替换 URL 图片请求与自动背景提色;接 File 模型、错误降级、 EInk 整图分支及活动文件保护。首版保持 preview 尺寸,不先做 1/4 优化。 这一切片启用在线落盘时必须同时提供可淘汰预算与安全清理,不能先交付无上限版本。

当前已接入分页展示/预取/背景提色的 File 请求、1000 MiB 预算、请求租约与瓦片独立租约、 EInk painter 分支;未降低 preview 尺寸,也未向共享领域泄漏 File。 原图被预算淘汰后,即使预览还在内存缓存,拦截器也先恢复原图并发出真实获取事件, 让之前 Ready 的页面恢复 Loading;有效原图命中不强制降级 Ready。 解析器按书籍/渲染请求 owner 重建,重试代次进入内存键,避免新请求接着使用旧 owner 的瓦片源。 格式回退沿用 Telephoto/Coil;区域解码初始化/瓦片错误现已自动降级,源/文件版本失效仍未完成。 自动离线仍沿用既有章节窗口;显式下载由 C 的用途索引保护,不参与窗口淘汰。

新增验证覆盖在线预算的重启恢复/LRU/历史保护、活动瓦片阻止清理、请求销毁后迟到获取释放、 在线文件升级章节保留以及 Coil DISK/MEMORY_CACHE 两种结果的 File 身份。 本切片编译、架构门禁与 69 项相关测试通过:存储 14 项、BookHelp 在 API 26/35 各 7 项、 Coil 集成在 API 26/35 各 1 项、owner 2 项、下载完成 1 项、交互 31 项、尺寸 5 项。 lintAppDebug 通过,仍为现有 118 个警告与 13 个提示;未放宽 baseline。 Windows Robolectric 的 API 35 ImageDecoder 文件 JNI 返回 "Only supported on Android"; 集成测试指定真实 BitmapFactoryDecoder,生产解码器配置不变。 这些测试尚不证明真实 Compose 页面已出现瓦片或达到 full-quality,需要设备验收。

C. 离线保留与后续显式下载

接章节索引/完成判定、现有 autoOfflineCache 和受保护清理,验证断网重建; 后续下载 UI/队列只补齐同一文件并升级保留用途。历史未分类文件不纳入新增在线预算; 现有章节窗口清理语义继续保留,无法从旧文件本身推断此前是否显式下载。 旧文件再次经过显式下载队列时会无网络补登记索引,之后受显式用途保护。

当前 C 的最小实现:每张原图在 images/.downloads/<原图文件名> 有版本化 Properties 索引, 原子记录 chapter.<index>.<章节 URL 哈希>=explicit。索引只保存引用身份,不复制 src/token/header。 多个章节共享一张原图时追加引用;失败下载也保留已登记用途,但文件缺失/无效时绝不判定完成。 Manual/Batch 队列请求保留显式用途,ReadPreload 不降级已有用途;暂停、优先级与失败重试保留用途。 已有图片命中也会补登记索引,不因 hasImageContent 提前跳过升级;执行中的预加载收到显式请求, 完成回调会再次复核用途并补登记。用途复核和完成提交与入队共用模型锁; 仍在执行的同章再次请求保留在队列,范围游标经过执行中章节时转为待复核索引, 执行中请求不算可启动章节,避免调度循环空转。正文与真实有效原图仍决定章节完成,索引不能代替文件。 在线预算与章节窗口均保护显式用途;整本缓存的主动清理仍可删除下载,读取租约继续保护活动文件。 索引损坏时原图仍受保护,完成判定为 false,可通过显式重试补齐引用。 这是原图→章节用途索引,尚非完整章节下载清单:稳定目录身份、源版本失效、下载取消后主动取消保留、 逐章删除/共享引用回收与独立下载列表恢复留待后续;现有章节正文/数据库继续负责重建阅读页。

本轮验证:13 个相关测试类、104 项测试全部通过(包含 API 26/35 native graphics 文件链路测试), 覆盖区域解码初始化/瓦片失败与取消、本地无在线租约 source 的降级/关闭、在线生产适配器的瓦片租约、 灰度矩阵与通道滤镜组合/EInk 优先级、索引重启/损坏恢复/共享引用、显式用途升级不联网、 保留文件不占在线预算、执行中 Single/Range 请求的保留与可启动判断、既有阅读交互。 复用审查阶段的编译产物队列复现程序,执行中的同章显式请求现可在预加载完成后再次出队。实际执行 :app:testAppDebugUnitTest (按上述测试类过滤)、verifyConfigArchitecture、lintAppDebug,并完成 Kotlin 编译与 git diff --check;lint 仍为原有 118 个警告、13 个提示,未修改 baseline。 区域错误使用可控 decoder 注入验证,灰度验证矩阵对原图像素的结果与滤镜顺序; 真机原生瓦片/CBZ/content URI 失败、实际灰度绘制、回退后的缩放体验和完整断网重建仍未验收。

D. 优化与真机验收

对照原版 Legado 的 downloadOnly() 预取,远端预取只准备原图, 不生成预览位图、不执行 EInk 转换、不发送页面 Ready。分页和条漫在线页均进入共享原图文件链路, 同图展示、预取、下载通过文件 mutex 合并成功获取;条漫预览由 AsyncImage 绘制, 高清区域块直接绘制到 Canvas,不添加整页缩放图层,避免页边界细线。 开启预取时,普通分页额外保留前后各两屏的真实渲染;原尺寸模式不额外保留,避免提前解码完整大图。 条漫显示预热改用 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/尺寸索引恢复和损坏重建、 解码前尺寸发布、清空内存后的预览磁盘复用及损坏预览回退;布局锚点仅验证选择/偏移,未做设备重测时序测试。 加载遮罩依赖实际渲染状态,尚未运行设备绘制时序测试;真机首次显示/放大清晰耗时、 相邻屏内存、条漫长图/页边界、网络并发与加密书源峰值内存仍待测量。

条漫的 MangaWebtoonTiles 保留 Coil 默认 4096 上限的整图预览,仅为实际视口及前后一行 准备原图区域块;每块解码约 1024 像素,额外读取一圈采样像素并用共享浮点边界裁剪, 避免分块之间透底。透明区域先恢复阅读背景,再绘制原图块,避免与预览重复叠画改变颜色。 缩放后的实际布局宽度决定采样,离开窗口即释放位图引用。 预览 PNG 命中时从 MangaImageFileOwner 借原图与独立租约,不把预览文件当区域解码源; 墨水屏使用原始展示请求的变换配置处理各块,灰度/通道滤镜仍在绘制时应用。 格式或区域解码失败保留预览并记录错误,取消不降级、不发布迟到结果。 条漫区域解码现在由项目自己的 Android 平台适配器管理,使用公开的 BitmapRegionDecoder; 原先访问 Telephoto 内部参数和 painter getter 的 Java 桥接已删除。

新增测试复现长图加载成功后宽度被缩小,并覆盖有界视口块、缩放采样、窗口重叠复用/释放、 取消、分数缩放接缝、各块墨水屏变换及预览缓存命中后的原图租约。Windows Robolectric 的文件型 BitmapRegionDecoder JNI 导致测试进程退出,真实文件区域解码另放在 MangaWebtoonRegionInstrumentedTest;当前无连接设备,且设备测试编译受既有 HttpTtsTest 的 AppConfig.speechRatePlay 未解析引用阻塞,不能把原生解码或页面滚动算作已验收。 最终执行 :app:testAppDebugUnitTest(过滤 io.legado.app.help.coil.*Manga*Test 和 io.legado.app.ui.book.manga.*Test),91 项全部通过;Kotlin/Java 编译、 :app:assembleAppDebug、:app:lintAppDebug、verifyConfigArchitecture 与 git diff --check 均通过。lint 为 115 个警告、13 个提示,未放宽 baseline。

分页确认支持格式下 subSamplingState != null 且最终达到 full-quality 状态, 再比较 preview 尺寸、内存、首次清晰时间及是否闪烁。不把正常瓦片解码计为多余完整请求。

2026-10-01 模式切换回归:Telephoto 0.19 在分页请求中设置 maxBitmapSize(Size.ORIGINAL), 按宽度加载长图时会创建数万像素高的整图预览,解码成功不代表 Canvas 能绘制。 MangaFileRequestTest 用真实 Coil/拦截器链复现 128×12000 长图切换后生成高度 12000 的预览。 CoverInterceptor 统一把漫画整图预览限制在 4096×4096 内,涵盖在线、文件和 content 路径; 原图及区域解码源不变。回归测试覆盖同图反复条漫/分页切换和原图独立租约保留。 93 项相关单元测试、编译、打包、架构门禁与 git diff --check 通过; :app:lintAppDebug 通过,仍为 115 个警告、13 个提示,未修改 baseline。 真机切换后的绘制效果仍待验证。

区域解码平台边界(2026-10-01)

本切片只替换条漫解码器,不迁移 Gradle 模块或分页渲染器。新的 domain/reader/MangaRegionDecoder<Tile> 契约使用纯 Kotlin 的尺寸、矩形和采样值, 不引用 Android、Compose、Coil、Telephoto、文件或流;实际消费者将 Tile 绑定为 Android Bitmap。 调用以显示方向的原图坐标描述区域;关闭幂等且与原生解码互斥,已返回的块不因关闭而失效。 格式不支持/读取失败显式抛错,取消不发布结果,条漫宿主保留已显示的预览。

职责 当前依赖分类 当前归属 / 后续方向
解码契约、尺寸、区域、八种 EXIF 坐标映射 common-ready :app 的纯 Kotlin 候选;尚未进入 commonMain
Android file/content/resource/asset、文件租约、BitmapRegionDecoder platform-island MangaAndroidRegionSource / MangaAndroidRegionDecoder
视口瓦片选择、绘制、淡入、墨水屏变换 renderer-specific 现有 Android Compose;后续先分离几何模型再评估 CMP
分页 ZoomableImage / RecoveringRegionImageSource platform-island 保留现有 Telephoto 渲染与错误回退;仍需按库版本验证

Android 适配器读取 EXIF,反向映射区域后仅解码该块,再校正块方向;不生成完整长图。 API 26–30 使用旧版公开 newInstance 重载,31+ 使用新版重载。单个原生解码器串行工作, 符合当前瓦片串行调度;独立原图租约仍覆盖源与解码器生命周期。 测试包含八种方向的区域映射和像素、关闭与解码竞争、取消、无效输入及租约幂等关闭。 真实文件分块测试已切换到新适配器,但设备执行证据仍待补齐。

这不是已完成的 KMP/CMP 漫画阅读器:当前没有对应共享模块、Desktop/iOS 解码实现或非 Android 目标编译。后续按已选宿主建立真实共享模块,将纯几何与调度纳入 commonTest,平台宿主选择解码 实现与块类型;不把 Bitmap/Painter/Uri 塞进共享领域 API,也不为其他平台提供静默空实现。

Coil 3 继续承担整图预览、图片缓存和变换。它的 Decoder 扩展允许接入自定义解码器,但默认 图片解码并不提供按矩形读取原图的瓦片能力;将区域解码注册进 Coil 也不会消除平台实现。 本切片保留直接的区域解码契约,避免每块都走一遍整图请求链。

本切片实际运行 :app:testAppDebugUnitTest(同上两组过滤),102 项通过; :app:assembleAppDebug、verifyConfigArchitecture、git diff --check 通过。 :app:lintAppDebug 完成,115 个警告、13 个提示,baseline 不变。完整设备测试编译仍受 既有 HttpTtsTest 引用阻塞;临时 init script 将 AndroidTest.kotlin 源目录限定为 src/androidTest/java/io/legado/app/help/coil 后,:app:assembleAppDebugAndroidTest 通过。 测试 APK 包含长图区域、墨水屏和 JPEG EXIF 用例,不代表整个设备测试集通过。 按用户要求没有安装或执行真机测试;设备侧阅读效果、快速滚动和模式切换由用户复测。

条漫缩放定位(2026-10-01)

用户确认跳位发生在双击/双指缩放。条漫通过改变 LazyColumn 实际宽度缩放图片,但旧实现 保留缩放前的像素滚动偏移;长图中同一原图坐标会因此移出手势位置。 新增数学复现与缩放锚点测试:例如长图顶端位于 -10000、手势 Y=700,放大 2.5 倍却 保持旧偏移时,该内容点移动至 Y=16750。

现在按手势所在的已知尺寸图片计算 `新滚动偏移 = (手势Y - 原条目顶端) × 实测宽度倍率

  • 手势Y - 纵向平移`,与宽度变化在下一次重测中一起提交。双击使用点击位置,双指使用 手势中心;倍率使用实际测量宽度及取整后的图片宽度,避免连续手势和边距取整累计误差。 固定高度章节项/未知占位不应用图片缩放公式;缩放锚点未完成测量时,高度更新不覆盖它。 双击放大也修正横向平移,在允许的平移边界内保持点击处内容。

:app:testAppDebugUnitTest --tests 'io.legado.app.ui.book.manga.*Test' 的 56 项测试通过, 覆盖缩放定位、往返缩放、纵向平移与章节间隔;编译、打包、架构门禁和文本检查通过。 真机双击、双指手势及边界钳制效果由用户复测,不把数学测试等同于设备验收。

Coil 整图对照版本(2026-10-01)

对照试验期间,MangaReaderScreen 的 MANGA_REGION_DECODING_ENABLED 为 false: 条漫仅使用 Coil AsyncImage;分页强制使用 Coil painter,Telephoto 只保留手势/绘制宿主, 不启用其区域解码。条漫解码实现当时保留用于对比后恢复,不参与对照版加载路径。 整图继续保留 4096×4096 位图尺寸上限,原图文件缓存、预览缓存及缩放定位修复保持现状。 这用于比较无区域解码时的显示效果;超长图清晰度可能下降,放大不会补载高清瓦片。 本切片以编译、APK 打包、架构门禁与 git diff --check 验证;视觉效果由用户复测。

用户完成对照后要求恢复分块版本:已删除临时开关,恢复条漫高清瓦片与分页区域解码。 分页墨水屏模式仍保留原有整图变换路径。预览尺寸上限和缩放定位修复保持现状。

必须覆盖:

  • 分页六种缩放、四种宽图模式、横竖窗口、双页/拆页、双击与九宫格。
  • 支持格式的原图细节放大对比、长图内存、渐进 JPEG、伪后缀、GIF/SVG、灰度与 EInk。
  • 开关自动离线后仍能区域解码;重开有效文件无网络请求,显式下载与阅读共用同一文件。
  • 应用重启断网、章节元数据缺失、动态 token/header、清理与瓦片并发、低存储、下载保留。
  • 弱网百分比、未知长度转圈、解密/解码阶段保持 Loading,陈旧完成事件不覆盖新请求。

按修改运行真实任务:

.\gradlew.bat :app:compileAppDebugKotlin
.\gradlew.bat :app:testAppDebugUnitTest --tests <受影响测试类>
.\gradlew.bat verifyConfigArchitecture lintAppDebug --continue --no-configuration-cache
# 涉及资源、Manifest 或打包时
.\gradlew.bat :app:assembleAppDebug
git diff --check

不承诺尚未运行的真机、性能与离线兼容验证;不以“文件已经落盘”作为区域解码和下载完成证明。