manga-subsampling-plan.md 23 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。 条漫继续使用旧 URL/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)。
  • 同时阅读与下载同图,只做一次网络获取/解密/写入。等待者取消只释放自己的订阅; 仍有下载/阅读消费者时不能取消共用任务。最后一个消费者离开后的取消规则写入契约测试。
  • 复用现有 imageDownloadSlots / imageDecodeSlots 限流,不让 Coil 预取旁路再开一组无限任务。
  • 渲染取得文件使用凭证,从交给 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,文件始终保存未应用展示变换的原图。
  • EInk 首版使用明确的整图渲染分支:由 Coil 生成二值化 painter,配合现有缩放状态/手势, 不把变换后的 preview 交给会读取未变换原图的自动瓦片路径。 仅继续使用 ZoomableAsyncImage(File) 或设置 diskCachePolicy 不能关闭该区域解码路径。 此模式暂不承诺区域解码清晰度;之后验证等价渲染滤镜再统一,不能悄悄失去二值化。
  • GIF/SVG/不支持区域解码的格式保留整图或动画行为,不因命名后缀误判原图格式。
  • 原图格式嗅探优先于 URL 后缀;FileUriFetcher 的 MIME 可能来自后缀,需覆盖伪后缀样本, 必要时只对漫画本地文件提供窄 fetcher,但仍确保结果请求 data 是可映射的本地文件。
  • 区域解码失败应记录并对当前文件版本降级至整图,不立即删下载或无限重新联网。 Telephoto 0.19.0 的公开 ZoomableImage 未暴露 SubSamplingImageErrorReporter, 也没有对应 CompositionLocal;不能假定直接调用它已经接入错误回退。 下一步需要通过公开 rememberSubSamplingImageState(errorReporter = ...) 的窄渲染适配器 或库的明确 API 扩展接入,该切片尚未解决区域解码初始化/瓦片失败的自动降级。
  • 低存储、外部存储不可用显式报错并允许重试;不得回退到巨型原图加载并冒充清晰模式成功。

6. 容量与清理

  • 当前为可淘汰阅读文件设置 256 MiB 字节预算,最终值需按典型漫画与低存储设备验证。 用 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 补齐。 格式/尺寸检查不是整图所有像素可解码的证明; 后续渲染错误必须保持可重试,不能因已有文件无条件标 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 请求、256 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/队列只补齐同一文件并升级保留用途。已有下载缓存从 A 开始保守保护, 不能等新 UI 做完才保护旧文件。

D. 优化与真机验收

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

必须覆盖:

  • 分页六种缩放、四种宽图模式、横竖窗口、双页/拆页、双击与九宫格。
  • 支持格式的原图细节放大对比、长图内存、渐进 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

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