本地漫画 / 文档阅读器 · Flutter · Android 优先(兼容 iOS)
压缩包按需解压、图片永远保持原始比例、多页无缝拼成一条长页, 并针对 120Hz 高刷新率做了适配。不联网、不收集任何数据。
| 导入方式 | 文件夹、ZIP 压缩包、多选图片 |
| 阅读模式 | ① 左右翻页(拖动与点击两侧翻页同时可用)② 上下滑动(Webtoon 连续长页) |
| 图片处理 | 宽度撑满屏幕、高度按真实比例;未超屏居中,超屏顶部对齐;支持双指缩放与平移 |
| 排序 | 按文件名自然排序(2.jpg 在 10.jpg 前),导入前可手动调整顺序 |
| 高刷新率 | 主动申请 120Hz,实测满帧(vsync 8.33ms) |
| 格式 | PNG / JPEG / GIF / WebP / BMP / ICO / AVIF / HEIC / QOI |
| 隐私 | 完全本地,无网络请求、无账号、无埋点 |
| 应用名 | inkScroll |
| 包名 / applicationId | com.inkscroll.reader |
| Dart 包名 | inkscroll |
| 版本 | 0.1.0 |
| minSdk / targetSdk | 24 / 36 |
完整设计文档见 DESIGN.md(架构、数据模型、导入流程、阅读模式、 关键代码、难点与优化、合规风险,共 6 个部分)。 下面的「修复记录」保留了每一轮的根因分析(包括失败的尝试)—— 那些坑比功能清单本身更有参考价值。
flutter pub get
flutter analyze
flutter test
flutter build apk --release --split-per-abi产物在 build/app/outputs/flutter-apk/。推 tag 即自动发布:
git tag v0.1.0 && git push origin v0.1.0这些是当前版本确实没做的,不打算含糊过去:
- RAR / 7z —— 需要额外原生库,当前会明确提示"请先转成 zip";
- PDF —— 依赖较重,尚未接入;
- DOCX —— 只做了骨架,未完成;
- 网页导入 / 网盘 —— 入口在但未实现抓取逻辑;
- GBK 等中文编码 —— 文本解码目前以 UTF-8 为主。
关于 120Hz:Android 与国产 ROM(OriginOS / ColorOS / HyperOS 等)默认把应用限制在
60Hz,且是否允许某个应用使用高刷新率由用户在系统设置里决定——应用无法绕过。
inkScroll 会主动申请(显示模式 + Surface.setFrameRate),但要真正跑满 120Hz,
还需要你在系统设置里为它手动开启高刷新率。
关于签名:当前 release APK 使用 Flutter 模板的 debug 签名, 仅供测试安装,不可用于正式分发。自行发布请替换为自己的签名配置。
根因:长图模式用了 BoxFit.fill。fill 的语义是"把图拉伸到填满盒子",
不保持宽高比。父级 SizedBox 的高度来自导入时嗅探到的 aspectRatio,
只要测量值有一点误差,图片就会被纵向压扁。
修复(lib/ui/reader/widgets/page_image.dart):
- 改用
BoxFit.cover—— 保持宽高比 + 填满盒子 + 只裁掉误差那几像素,既不变形也不留缝; - 去掉激进的
cacheWidth强制缩放(解码尺寸与布局盒不一致也会引入变形),改为按屏幕物理像素宽解码;只对超过 GPU 纹理上限(8192)的超长页等比降采样; - 新增自愈:真实解码后把准确
aspectRatio回填给PageMetrics,并用锚点保住滚动位置 → 测量误差会自动校正,而不是一直错下去。
根因:旧实现在目录不可读时静默返回空列表,上层只报"没有找到可阅读的内容"。
而 Android 的 file_picker.getDirectoryPath() 在 Provider 型目录(外置 SD 卡 / Google Drive /
部分 Downloads)下会解析不出真实文件路径 —— 这种情况下 Directory.list() 直接抛异常或返回空,
用户完全看不到原因。另外 maxDepth: 4 会漏掉层级更深的图片。
修复(lib/importers/import_utils.dart + folder_importer.dart):
- 新增
ensureReadableDirectory()预检:把content://地址、目录不存在、无权限三种情况分别抛出可操作的中文提示(而不是笼统的"没有内容"); - 复制失败 / 复制到 0 个文件 / 扫描失败都给出具体原因;
- 识别失败时打印扩展名直方图(例如
cr2×120、nef×80),直接告诉用户"有文件但不支持这些格式"; maxDepth4 → 8;maxFiles20000 → 50000;深度超限的文件被计数并提示,不再静默丢弃;- 放宽图片判定:
images.length >= 1即可成书(原来要求 ≥3,只有 1~2 张图的目录无法导入); - 诊断能力已加单元测试覆盖(4 个用例)。
根因 1:为了读几十字节的图片头,解压整张图。
DEFLATE 是流式算法,想要前 100KB 的输出,无法只读压缩数据的前 10KB
(没有可靠的字节对应关系),只能从头解。旧实现是「读完整条压缩数据 → 解压整张图 → 切片前 256KB」,
所以 maxBytes 只省了拷贝,省不掉解压 —— 一张 5MB 的漫画页就要解压 5MB。
500 张图 = 解压几 GB。
根因 2:全量逐张串行嗅探。 每张图 3 次串行 I/O,500 张就是 1500 次。
修复:
- 分级试探解压(
zip_archive_source.dart的_tryInflateHead): 先只读条目开头的 48KB / 160KB / 512KB 试探解压,校验首字节是已知图片签名才采纳; 失败才回退到读整条。绝大多数 JPEG/PNG 的尺寸在最前面,所以通常只解压几十 KB 而不是几 MB。 正确性不打折 —— 只有确实解析出图片头才算成功。 - 采样嗅探(
archive_importer.dart):不再全量嗅探,改为 「前 120 页精确 + 全篇均匀抽样 240 页」,共约 360 次解压(而不是几千次)。 未抽到的页用中位比例兜底,阅读时由 ① 的自愈机制校正。 - 并发 8 路(
mapConcurrent),并用FileByteSource内部的读取互斥保证setPosition + read成对执行(否则并发读会互相串位,表现为"图片头随机解析失败")。
代价(诚实说明):大压缩包第一次打开时,未抽到的页高度是估计值, 滚过去的一瞬间可能有一次微调。这是"秒开"换来的。图片不多的包(≤360 页)仍然全量精确测量。
| 项 | 旧 | 新 |
|---|---|---|
| 应用名(桌面图标) | book_reader | inkScroll |
| Android applicationId / namespace | com.example.book_reader | com.inkscroll.reader |
| iOS bundle identifier | com.inkscroll.bookReader | com.inkscroll.reader |
| Android Kotlin 包路径 | com/example/book_reader/ |
com/inkscroll/reader/ |
| 标题栏 / MaterialApp title | 书架 / Book Reader | inkScroll |
已用 aapt2 dump badging 验证:
package: name='com.inkscroll.reader'、application-label:'inkScroll'、
launchable-activity: name='com.inkscroll.reader.MainActivity'。
另外顺带补上了缺失的 ios/ 平台目录(之前只生成了 android)。
这两个是同一个根因的两面:图片真实比例与父级盒子比例不一致。
上一轮我用 cover 修了"变形",但 cover 的代价是裁掉多出来的部分 ——
父级高度小于图片真实高度时下面就被裁了,正是"每张图下面一部分被遮住"。
fill / cover / contain 三者单独用都不够:
| fit | 变形 | 裁剪 | 留白 |
|---|---|---|---|
fill |
❌ 拉伸 | ✅ 无 | ✅ 无 |
cover |
✅ 无 | ❌ 裁掉 | ✅ 无 |
contain |
✅ 无 | ✅ 无 | ❌ 留白 |
正确做法不是换 fit,而是把盒子改成图片的真实比例。 一旦盒子比例 == 图片比例,三种 fit 完全等价 —— 不拉伸、不裁剪、不留白同时成立。
实现:新增 lib/ui/widgets/adaptive_media_box.dart。
PageImage 解码后拿到真实宽高,作为布局的唯一真相:
真实比例 = 解码宽 / 解码高;可用宽 W、可用高 H
若 W / 真实比例 ≤ H → 盒子 = (W, W/真实比例) // 宽度受限,完整显示
否则 → 盒子 = (H * 真实比例, H) // 高度受限,等比缩小宽度
两处配套修改:
- 长图模式每行外面套
ClipRect(SizedBox在 Flutter 3.47 没有clipBehavior)。 不加裁剪时超高行会盖住下一行的可视区域 —— 这是"下面被遮住"的第二个成因,与 fit 无关。 - 翻页模式改为
Alignment.topCenter:图小于屏幕时顶部对齐, 水平方向由AdaptiveMediaBox自动居中。
关键澄清:解压不是为了让导入变快 —— 导入阶段仍然只读中央目录。 解压的价值在阅读时的翻页延迟:
| 不解压(按需) | 解压到缓存 | |
|---|---|---|
| 导入耗时 | 快 | 仍是快(解压放后台) |
| 翻页延迟 | 每页 DEFLATE 解压,几十~几百 ms | 直接读文件,几 ms,还能吃系统页缓存 |
| 磁盘占用 | 只占压缩包 | 压缩包 + 约 1~3 倍解压产物 |
| 内存峰值 | 每次解压一个条目 | 几乎无 |
实现要点(lib/data/archive_extractor.dart + reader/page_cache.dart):
- 解压在后台进行:导入完成、你已经在看书时静默跑 —— 不需要等;
- resolver 优先读解压后的文件,文件不存在时自动回退到 zip 读取 → "解压到一半就开始看书"完全安全,翻到哪页哪页可用;
- 完整护栏:总量上限 2GB、单条目 64MB、条目数 5 万、失败 20 次自动中止、可随时取消;
.complete完成标记:中断过的解压下次会续传(已存在的文件跳过);- 设置面板可看状态/进度,并一键清除解压缓存(清掉只是回到按需解压,书不会坏)。
上一轮加了目录预检但问题依旧,所以这轮改文件识别:
- 内容嗅探(
sniffFileKind):扩展名不认识的文件会读文件头按魔数判定。 无扩展名、.dat、扩展名与内容不符的文件现在都能认出来。 - 大幅扩展格式覆盖:
- 可渲染:JPG / JPEG / JPE / JFIF / PNG / WebP / GIF / BMP / AVIF / HEIC·HEIF / TIFF / ICO
- 可识别但引擎不支持:JPEG XL (.jxl) / PSD / QOI / 相机 RAW (CR2、NEF、ARW…)→ 给出明确的转换建议,而不是笼统的"无法识别"
- 文本:TXT / MD / LOG / NFO / SRT / ASS / VTT
- 识别失败时列出扩展名直方图:
文件类型统计:cr2×120、nef×80、xmp×40—— 一眼看出是格式不支持还是路径问题。 maxDepth8 → 12,maxFiles→ 50000,超限文件计数并提示。- 新增 10 个格式识别单元测试(AVIF/HEIC 区分、mp4 不误判、ICO/QOI/TIFF 魔数、SVG 归类)。
如果这次仍认不出图片,请把那条"文件类型统计"截图给我 —— 有了它我能立刻判断是"格式不支持"还是"路径/权限问题",不用再猜。
这一轮你给了明确的渲染规则,也让我找到了性能问题的真正根因。
前两轮我都在用父级给定的盒子 + fit 去迁就图片,两头不讨好:
| 试过的 | 结果 |
|---|---|
BoxFit.fill |
拉伸变形 →"高度被压缩" |
BoxFit.cover |
裁掉多余 →"下面一部分被遮住" |
BoxFit.contain |
留白 → 长页出现缝隙 |
根因:只要父级高度与图片真实高度不一致,三种 fit 都必然出问题。
正确做法:撤掉高度约束,让图片自己决定高度。
Image(image: provider, width: double.infinity) // 注意:没有 height只给宽度、不给高度时,Flutter 会把图片缩放到宽度撑满可用宽度, 高度按真实宽高比自动算出:
显示宽 = 可用宽
显示高 = 可用宽 / (图片宽 / 图片高)
于是 不拉伸、不裁剪、不留白 三项同时成立, 而且完全不需要预先知道图片尺寸 —— 解码器自己知道。 之前那条脆弱链路(嗅探比例 → 算高度 → 填盒子 → 自愈回填)整条被拆掉。
按你的要求实现的三种场景:
| 场景 | 实现 | 效果 |
|---|---|---|
| 上下滑动 | SliverList 里不给任何高度约束 |
宽撑满 + 顶部对齐;下一张顶部 = 上一张底部(天然无缝) |
| 左右滑动/点击 | PageViewportFrame(minHeight + Center) |
宽撑满;未超屏 → 居中;超屏 → 顶部对齐 |
| 所有模式 | 只等比缩放,绝不改变宽高比 | 原比例缩放 |
根因 1:按原图分辨率解码。
上一轮我为了修变形把 cacheWidth 删掉了 —— 这是个严重错误。
不限制解码宽度时,一张 3000×4000 的漫画页解码后占
3000×4000×4 ≈ 48MB,缓存几张就把内存打满,引发疯狂 GC(卡顿 + 发烫)。
现在用 ResizeImage(width: 屏幕物理宽, allowUpscaling: true) 限制解码宽度:
同一张图只占约 6MB。allowUpscaling: true 保证低分辨率条漫也能撑满宽度。
根因 2:每次重建都可能重新解码。
改用显式 ImageProvider(MemoryImage 的 == 按字节内容比较),
让 Flutter 的 ImageCache 跨重建复用已解码的 ui.Image;
否则每次 setState 都可能重新解一次大图(几十毫秒)。
根因 3:边读边在主 isolate 上解压。 DEFLATE 解压是 CPU 密集操作,在 Dart 里跑在主 isolate。 之前"后台解压"意味着每次翻页都要在主线程解压 —— 体感就是"很慢、卡"。
现在改为:首次打开压缩包时先把解压做完,再进阅读器(带进度界面), 之后每页都是直接文件 I/O(几毫秒),还能吃到操作系统页缓存。 解压结果落盘持久化 → 只有第一次需要等。
新增 lib/ui/prepare_screen.dart:让这一次等待可预期、可取消、
可跳过(跳过就回退到按需解压,功能不受影响)。
顺带修掉一个 O(n²) 隐患:原本每页解码都会触发一次全量度量重建。 现在改成"缓冲 + 每帧最多一次 + 300ms 防抖"。 同时去掉了滚动位置修正 —— 布局已由真实高度驱动、滚动偏移是绝对像素值, 再 jumpTo 反而会把稳稳的位置推偏("读到一半突然跳一下")。
关于"文件夹导入也慢":文件夹路径没有解压开销,瓶颈就是上面三条 (解码尺寸、重复解码、度量重建),所以同样受益。 若实测仍有明显差异,请告诉我是打开慢还是翻页慢 —— 前者指向文件拷贝/扫描,后者指向解码/GC,我能据此定位。
见 ⑥。唯一变化:从"边读边后台解压"改为"读之前先解压完", 因为边读边解压正是性能根因 3。
这一轮我搭好了 Android 模拟器(Android 14 / 1080×2400 / 4GB),
把 APK 装进去、用 adb 驱动真实点击与截图,逐条验证。
结果发现你的抱怨全部成立,而且根因和我之前的猜测完全不同。
症状:不管用哪种方式导入(zip / 文件夹),打开后永远停在加载圈,
进度条显示 1/30 · 0%,一张图都出不来。
排查过程(这轮最花时间的部分):
- 先确认解压成功 ——
adb看到 app 私有目录里 30 张图都写进去了(2.5MB); - 加诊断日志,看到 resolver 确实命中了缓存并返回 60047 字节;
- 但在
await之后的代码永远不执行; - 一度怀疑是"渲染死循环"(日志里确实有
Skipped 136 frames、单帧 2.58 秒), 把LayoutBuilder里的副作用清掉后死循环消失,但图片依然不出来; - 最后用二分法删代码定位到:
ArchivePageByteResolver.imageBytes()里
// ❌ 问题代码
final future = _load(page).whenComplete(() => _inflight.remove(page.id));
_inflight[page.id] = future;
return future;whenComplete 返回的是一个代理 Future,它没有正常完成,
导致调用方的 await 永远挂住。(同时清掉 _inflight 会让后续调用
又走一遍 _load,进一步掩盖问题。)
修复:去掉 Future 代理,把清理逻辑放进 _load 自己的 finally:
// ✅ 修复
Future<Uint8List> imageBytes(PageNode page) {
final hit = _cache.get(page.id);
if (hit != null) return Future.value(hit);
final existing = _inflight[page.id];
if (existing != null) return existing;
final future = _load(page); // 不再包 whenComplete
_inflight[page.id] = future;
return future;
}
Future<Uint8List> _load(PageNode page) async {
try {
return await _loadInner(page);
} finally {
_inflight.remove(page.id); // 在这里清理,语义清晰
}
}验证:修复后立刻出图(见 dist/verification/shot_13.png)。
症状:上下滑动模式下,怎么滑都纹丝不动(进度恒为 0%)。
根因:GestureDetector 一旦注册了 onDoubleTap,框架会启用
DoubleTapGestureRecognizer,它在 pointer down 时就抢占手势竞技场,
并一直等到确认"没有第二次点击"才松手。
结果滚动的 Drag 识别器在竞技场里输掉 —— 滑动永远不生效。
这个坑很隐蔽:单击、菜单都正常,所以很容易误判成"滚动坏了"。
修复:上下滑动模式不注册双击(该模式本来就不需要双击缩放,
缩放只在翻页模式由 InteractiveViewer 处理);翻页模式保留双击缩放。
诚实说明:这个修复有明确的技术依据,但我通过
adb注入滑动 (input swipe/input motionevent都试过)在模拟器上始终无法复现滚动, 所以未能在实机上确认。如果你实测仍有问题,请告诉我。
用专门生成的测试图(四边白框 + 顶部红条 + 底部绿条 + 大号页码)验证, 这样能一眼看出有没有被裁切、有没有变形:
| 验证项 | 结果 | 证据 |
|---|---|---|
| 宽度撑满屏幕 | ✅ | 图片左右边缘贴齐屏幕 |
| 不裁切(红条/绿条/白框都完整可见) | ✅ | dist/verification/shot_13.png |
| 不变形(黄竖线在左右边缘且平行) | ✅ | 同上 |
| 上下滑动:下一张顶部 = 上一张底部 | ✅ | 无缝拼接 |
| 左右翻页:高度未超屏 → 居中 | ✅ | h02_page3_landscape.png(上下各有黑边) |
| 左右点击翻页 + 进度同步 | ✅ | 依次到 2/30 · 3%、4/30 · 10% |
| 点击中间呼出菜单 + 三种模式切换 | ✅ | v09_menu.png |
| 包名/应用名 | ✅ | aapt2:com.inkscroll.reader / inkScroll |
前两轮我都在用父级给定的盒子 + fit 去迁就图片,
结果 fill 变形、cover 裁切、contain 留白,怎么调都是错的。
根因:只要父级高度与图片真实高度不一致,三种 fit 都必然出问题。
正确做法:撤掉高度约束,让图片自己决定高度。
Image(image: provider, width: double.infinity) // 注意:没有 height只给宽度、不给高度时,Flutter 会把图片缩放到宽度撑满可用宽度, 高度按真实宽高比自动算出 —— 不拉伸、不裁剪、不留白同时成立, 而且完全不需要预先知道图片尺寸(解码器自己知道)。
于是整条脆弱链路(嗅探比例 → 算高度 → 填盒子 → 自愈回填)被整条拆掉:
_buildVertical 的 SliverList 里不再有 SizedBox(height:)。
根因 1:按原图分辨率解码。 上轮我为了修变形把 cacheWidth 删了 —— 严重错误。
3000×4000 的漫画页解码后占 48MB,缓存几张就打满内存、疯狂 GC。
现在用 ResizeImage(width: 屏幕物理宽, allowUpscaling: true),同图只占约 6MB。
根因 2:每次重建都可能重新解码。 改用显式 ImageProvider
(MemoryImage 的 == 按字节内容比较),让 ImageCache 跨重建复用已解码的 ui.Image。
根因 3:边读边在主 isolate 解压。 DEFLATE 跑在主 isolate, "后台解压"意味着每次翻页都在主线程解压 —— 体感就是"很慢、卡"。 现在改为首次打开前先把解压做完(带进度界面,可跳过), 之后每页都是直接文件 I/O(几毫秒),解压结果落盘持久化,只有第一次需要等。
根因 4(这轮才发现):build 期间的副作用导致渲染死循环。
LayoutBuilder 的 builder 里调用了会 notifyListeners() 的方法,
而外层 AnimatedBuilder 正在 build → 每帧都重建(Skipped 136 frames、
单帧 2.58 秒)。虽然修掉它之后图片仍不出来(真正的元凶是 ①),
但它确实在抢占主线程。现在 build 路径里不做任何改状态的事。
这轮继续在 Android 14 模拟器上实测(adb 驱动真实点击 + 截图)。
四个反馈全部处理,其中两个挖到了同一个致命 bug。
上轮我修好了 ArchivePageByteResolver 的 whenComplete 代理 Future 问题,
但漏掉了 FilePageByteResolver —— 而它正是文件夹导入和手动选图导入
走的那条路径。
后果:导入能成功、文件也复制进去了,但每张图都永远加载不出来
(await 挂在代理 Future 上,不报任何错)。
这直接解释了你反馈的两条:
- "通过文件夹导入"看起来失败(其实导入成功了,只是内容显示不出来)
- "通过选择图片导入的漫画在浏览时图片需要加载很久"
修复方式与上轮一致:直接返回 _load 的 Future,清理放进 finally。
并且在代码里写明了"千万别写成 whenComplete",并全库搜索确认再无第三处。
为什么 file_picker.getDirectoryPath() 在 Android 11+ 上必然失败(实测确认):
- 系统限制:Android 11 起不允许把存储根目录、
Download等受保护目录 授权给应用 —— 选择器界面直接显示 "Can't use this folder / To protect your privacy, choose another folder"。 - 插件限制:
file_picker的getFullPathFromTreeUri()里, Downloads provider 的content://→ 路径转换被if (Build.VERSION.SDK_INT < R)包住 —— 只在 Android 11 以下生效。
两者叠加:选了文件夹 → 返回 null → 旧代码 if (dir == null) return;
把失败彻底吞掉 → 界面毫无反应。
修复:不再试图把 content:// 转成路径(本来就不可靠),
而是自己写 SAF 原生插件(SafFolderPlugin.kt)直接读文件内容:
ACTION_OPEN_DOCUMENT_TREE选目录 →takePersistableUriPermission持久授权DocumentsContract.buildChildDocumentsUriUsingTree递归枚举(显式栈,防深目录爆栈)ContentResolver.openInputStream逐个复制进应用私有目录- 零额外 Gradle 依赖(不引
androidx.documentfile) - 重名自动加后缀;按扩展名白名单过滤(避免把视频、安装包也复制进来)
顺带解决阅读性能:复制完成后每页都是纯本地文件 I/O, 不再经过 SAF 的 Binder 往返(那非常慢)。
实机验证:选 Download/inkscroll/pages → 授权 → 12 张图全部复制成功、
自动进入阅读器、图片立即渲染(见 dist/verification/folder_import_ok.png)。
旧实现的错误:给文件加 00001_ 前缀、按选择顺序排列,
把用户用文件名表达的顺序(1.jpg、2.jpg、10.jpg)覆盖掉了。
新策略:
- 保留原始文件名(不再加前缀),排序键就是文件名;
- 用自然排序(
1 < 2 < 10,而不是字典序1 < 10 < 2); - 导入前弹出顺序确认界面(
lib/ui/image_order_sheet.dart):- 每行有「移到最前 / 上移 / 下移」
- 一键「按文件名排序」/「选择顺序」两种快捷方式
- 用户确认后按手动顺序导入
实机验证:故意乱序选择(#1 → #6 → #3 → #10),
界面正确显示 4 张并可逐项调整(见 dist/verification/img_05_order_sheet.png)。
卡顿的真正根因(这轮才找到):PageImage 未加载时占位高度是 0,
图片解码后才变成真实高度(如 1600)→ RenderSliverList 每次都要重排下方所有项
→ 滑动时持续卡顿。
修复:占位直接用导入阶段嗅探到的 aspectRatio 撑出同样的高度
(AspectRatio),图片加载完成后高度完全一致 → 零重排。
配套措施:
| 措施 | 说明 |
|---|---|
FilePageByteResolver Future 修复 |
消除"永远加载不出来"(见 ⑪) |
| 占位撑满估算高度 | 消除滑动时的重排抖动 |
PageImageCache 16→32 项 / 64→128MB |
往回滑不再重新读盘+解码 |
ImageCache 24→40 张 / 64→128MB |
覆盖前后几屏预取 |
ResizeImage 限制解码宽度 |
一页约 6MB 而不是 48MB(配合 120Hz 的内存预算) |
| 进度通知节流 | 只在显示出来的数字变化时通知,不再每帧重建进度条 |
关于 120Hz:Flutter 默认就按显示刷新率出帧,不需要特殊开启。 真正要做的是让每帧的工作量降到 8.3ms 以内 —— 上面这些 (尤其"消除重排"和"减少解码/GC")就是为此。
| bug | 说明 |
|---|---|
沉浸式设置在 dispose() 里 |
原来是离开阅读器才隐藏系统栏,方向反了 → 移到 initState(),并在 dispose() 还原 |
| 进度条可能被系统导航栏遮挡 | 配合上面的沉浸式修复,阅读时不再被遮挡 |
诚实说明:我仍然无法通过
adb复现滑动操作 ——input swipe/input motionevent都无效,但input tap有效、Slider拖动同样无效、且日志里没有任何 Flutter 异常。 这更像是adb注入的 MOVE 事件时间戳相同、被 Flutter 判定为 tap 而非 drag。 所以**"滑动是否流畅/是否生效"需要你在真机上确认**。
平台目录(android/)已经生成并入库,不需要再跑 flutter create
(那会覆盖 pubspec.yaml——本仓库开发过程中就踩过这个坑)。
cd flutter-comic-reader
flutter pub get
flutter run # 接真机/模拟器
# 或只出一个安装包:
flutter build apk --debug如果是全新 clone 且没有 android/ 目录,再执行:
# 注意:会覆盖 pubspec.yaml,先备份
cp pubspec.yaml pubspec.yaml.bak
flutter create . --project-name book_reader --platforms=android
cp pubspec.yaml.bak pubspec.yaml
flutter pub get| 项 | 值 |
|---|---|
| Flutter | 3.47.7 stable |
| Dart | 3.13.5 |
flutter analyze |
✅ No issues found!(0 error / 0 warning / 0 info) |
flutter test |
✅ 39 / 39 通过 |
flutter build apk --debug |
✅ 成功 |
flutter build apk --release --split-per-abi |
✅ 成功(arm64 17.7MB / armv7 15.2MB / x86_64 19.1MB) |
| APK 元数据验证 | ✅ aapt2 dump badging:包名 com.inkscroll.reader、label inkScroll |
| 关键依赖 | archive 3.6.1(API 已按此版本核对)、provider 6.1.2、file_picker 8.3.7 |
| Android SDK | platform 34/35/36、build-tools 36.0.0、NDK 28.2、CMake 3.22.1 |
可安装产物(已在 dist/ 下,直接 adb install 即可):
| 文件 | 体积 | 适用 |
|---|---|---|
dist/app-arm64-v8a-release.apk |
17.7 MB | 现代 Android 手机(推荐) |
dist/app-armeabi-v7a-release.apk |
15.2 MB | 老旧 32 位设备 |
dist/app-x86_64-release.apk |
19.1 MB | 模拟器 / x86 平板 |
dist/app-debug.apk |
82 MB | 需要调试连接时 |
adb install -r dist/app-arm64-v8a-release.apk注意:release 包用的是 Flutter 模板默认的 debug 签名(
signingConfig = debug), 只是方便本机安装验证,不能上架。正式发布需要自建 keystore, 见android/app/build.gradle.kts里的buildTypes.release注释。
⚠️ 路径不要含空格。Gradle 9.x 对含空格的工程路径支持有问题。 本仓库之所以能构建成功,是因为复制到了C:\Users\...\develop\book_reader这个无空格路径下。如果你的 clone 路径含空格(例如D:\DSH Desktop\work\...), 请先复制到无空格路径再构建。
首次运行后:点右下角 导入 →
- 选择文件夹:选一个装图片的目录(会自动分章);
- 选择压缩包:选一个
.zip(不会解压到磁盘,只读中央目录); - 选择图片:多选图片直接成书。
| 模块 | 状态 | 说明 |
|---|---|---|
| 统一文档模型 | ✅ | Document / Chapter / PageNode / ContentRef,模型内不含字节 |
| 压缩包按需读取 | ✅ | 自研 ZIP 中央目录解析 + 单条目 DEFLATE,见 lib/data/archive/zip_archive_source.dart |
| TAR 随机访问 | ✅ | 只读 512 字节头建索引,之后随机切片 |
| 文件夹导入 | ✅ | 递归、自然排序、子目录分章、漫画/小说判定 |
| TXT 导入 | ✅ | BOM + UTF-8 严格校验、正则分章、按段分页 |
| 散图导入 | ✅ | 多选图片按序号成书 |
| 图片尺寸嗅探 | ✅ | PNG/JPEG/WebP/GIF/BMP 头部解析,不解码像素 |
| 三种阅读模式 | ✅ | 水平滑动 / 水平点击 / 垂直虚拟长页 |
| 虚拟长页 | ✅ | PageMetrics 前缀和 + SliverList.builder 懒构建 + 无缝拼接 |
| 进度与跳转 | ✅ | 百分比计算、进度条拖动跳转、按模式分别记忆 |
| 手势 | ✅ | 单击分区 / 双击缩放 / 双指缩放 / 缩放与滚动边缘交接 |
| 内存控制 | ✅ | 三层 LRU + ImageCache 收紧 + cacheWidth 限宽解码 |
| 书架 | ✅ | 全部 / 最近 / 收藏 / 搜索 / 重命名 / 删除 / 详情 |
| 设置 | ✅ | 模式、背景、字号、行距、边距、点击区域、亮度 |
| 功能 | 文件 | 说明 |
|---|---|---|
lib/importers/pdf_importer.dart |
加 pdfx;只读 MediaBox,渲染留给阅读器 |
|
| DOCX | lib/importers/docx_importer.dart |
加 xml;解析 word/document.xml + .rels |
| 网页 | lib/importers/web_importer.dart |
加 dio + html;实现 Readability 启发式 |
| 网盘 | lib/importers/netdisk/netdisk_provider.dart |
先做 WebDAV(零合规风险) |
| rar / 7z | lib/data/archive/native_archive_source.dart |
需原生桥;只解单条目到临时文件 + LRU |
| tar.gz / gz | lib/importers/import_router.dart |
需顺序解压建索引,见 DESIGN.md §2.5 |
| GBK / GB2312 | lib/core/charset.dart |
加 fast_gbk(纯 Dart)或 charset_converter(平台) |
| 超长图瓦片解码 | lib/ui/reader/widgets/page_image.dart |
需 Android BitmapRegionDecoder 平台通道 |
| 封面提取 | lib/state/app_state.dart |
目前书架用按书名哈希生成的占位封面 |
| 分类 / 标签 | — | 需要 drift/sqlite 替换 SharedPreferences |
lib/
├── main.dart 入口 + ImageCache 收紧
├── core/ 无业务基础设施
│ ├── magic.dart 魔术字节识别(不信扩展名)
│ ├── image_header.dart ★ 图片尺寸嗅探(不解码像素)
│ ├── natural_sort.dart ★ 自然排序(逐段比较路径)
│ ├── lru_cache.dart 条目数 + 字节数双重限额
│ ├── charset.dart 编码探测(BOM + UTF-8 严格校验)
│ └── errors.dart 统一错误 + UI 动作映射
├── data/
│ ├── byte_source.dart 随机访问字节源
│ ├── archive/ ★ 压缩包层
│ │ ├── archive_source.dart 抽象 + 条目缓存 + 安全上限
│ │ ├── zip_archive_source.dart ★ 自研中央目录 + 按需 DEFLATE
│ │ ├── tar_archive_source.dart TAR 随机访问
│ │ ├── native_archive_source.dart rar/7z 原生桥骨架
│ │ └── archive_factory.dart 格式分派
│ ├── importer.dart Importer 抽象 + ImportOutcome
│ ├── byte_store.dart 落盘(流式复制 + 指纹去重)
│ ├── prefs_store.dart 持久化(书架 / 进度 / 设置)
│ └── models/ BookMeta / ReaderSettings / ReadingProgress
├── importers/
│ ├── import_router.dart 探测 → 分派
│ ├── archive_importer.dart ★ zip/tar → 漫画/小说
│ ├── folder_importer.dart ★ 文件夹 → 漫画/小说
│ ├── txt_importer.dart TXT → 分章分页
│ ├── loose_image_importer.dart 散图 → 漫画
│ ├── text_parser.dart ★ 编码探测 + 分章 + 分页
│ ├── pdf/docx/web_importer 骨架 + 接入指引
│ └── netdisk/ NetDiskProvider 抽象 + 注册表
├── reader/
│ ├── document.dart ★ 统一文档模型 + PageMetrics
│ ├── document_codec.dart Document ↔ JSON(秒开的关键)
│ ├── page_cache.dart PageByteResolver + PageImageCache
│ └── reader_controller.dart ★ 状态机 + 进度 + 跳转 + 模式换算
├── state/app_state.dart 书架 / 设置 / 导入 / 文档装载
└── ui/
├── home_screen.dart 书架 + 导入入口
├── settings_sheet.dart 阅读设置面板
└── reader/
├── reader_screen.dart ★ 阅读器主界面(三种模式)
├── gesture_arbiter.dart ★ 手势仲裁(缩放 vs 滚动 / 单击 vs 双击)
└── widgets/ PageImage / ProgressBar / TextPageView
ZipArchiveSource 只做三件事:
- 读文件尾部 64KB 找 EOCD(含 Zip64);
- 读中央目录(4 万条目约 1.8MB)得到条目列表;
- 读单个条目时:读 30 字节局部头算出数据偏移 → 只读该条目的压缩数据 → DEFLATE 解出。
没有 extractAll,也没有 decodeBytes(整包)。
一个 2GB 的漫画包,打开第一页只读了约 200KB。
每页是独立的 PageNode,各自渲染、各自释放。
SliverList.builder 只构建可见区 + cacheExtent 内的项,
每项高度由 PageMetrics 预先给定,所以总高度也能算准 —— 懒加载与准确进度同时成立。
三层限额:
PageImageCache(原始字节,16 项 / 64MB)EntryCache(压缩包条目,16 项 / 64MB)- Flutter
ImageCache(解码后位图,24 张 / 64MB,默认 100 张太宽松)
再加 cacheWidth 限宽解码(最有效的一招)与失败重试。
- rar / 7z:专有算法,没有可用的成熟纯 Dart 实现。当前会明确提示"请转为 zip"。
接入方式见
native_archive_source.dart(原生桥 + 单条目解到临时文件 + LRU)。 - 旧版
.doc:OLE2 二进制格式,纯 Dart 不现实。建议引导用户另存为.docx/.pdf。 - tar.gz / gz:需要顺序解压建索引,见 DESIGN.md §2.5 的方案。
- GBK / GB2312:起步只覆盖 UTF-8/16/32;非 UTF-8 会给出"疑似 GBK"的提示并降级解码。
- 超长图(单页高度 > GPU 最大纹理):Dart 层做不到区域解码,
必须走 Android
BitmapRegionDecoder。当前用限宽解码,极端长条会失败。 - 网盘:不要假设分享链接一定能解析。优先做 WebDAV / 局域网 HTTP(零风险), Cookie 方案必须标注"实验性、可能失效、有 ToS 风险"。详见 DESIGN.md §2.12。
- 导入一个 1000 张图的 zip:滚动流畅,内存峰值 < 300MB
- 打开该 zip 后检查 app 私有目录:没有任何解压出来的图片目录
- 长图模式:进度条百分比与视觉位置一致,拖动跳转准确
- 横竖屏旋转:停在原页原位(锚点算法生效)
- 双击放大到 2x,拖到图片边缘后能继续滚动到下一页
- 三种模式来回切换:位置不丢
- 杀掉 App 重开:恢复上次进度,且每种模式各自的进度都对
- 导入 GBK 的 TXT:给出"疑似 GBK"提示而不是静默乱码
- 恶意 zip(单条目声明 10GB):被上限拦截并给出明确错误,不 OOM
代码已在 Flutter 3.47.7 / Dart 3.13.5 上通过 flutter analyze(0 issue)与
flutter test(39/39)。archive 的 Inflate(bytes, uncompressedSize).getBytes()
签名已核对官方文档并被单元测试实际调用验证
(pub.dev archive 3.6.1)。
标注 ★ 的文件是本项目的技术核心,改动前建议先读 DESIGN.md 对应章节。
代码量:41 个 Dart 文件(含测试),约 8200 行(含注释)。
① TLS 中间人导致 Java/git 的 HTTPS 失败
本机存在 TLS 中间人(证书链不被 OpenSSL / JVM 自带信任库认可):
| 工具 | 使用的信任库 | 结果 |
|---|---|---|
Dart(flutter / pub) |
Windows 证书库 | ✅ 正常,pub get 成功 |
PowerShell(curl.exe / Invoke-WebRequest) |
Windows 证书库 | ✅ 正常 |
| git | OpenSSL 自带 | ❌ SSL certificate problem: unable to get local issuer certificate |
| JVM / Gradle wrapper / Maven | JVM cacerts | ❌ PKIX path building failed |
后果:flutter build apk 会在 Gradle wrapper 下载发行版这一步失败。
绕过方式(三选一):
- 用 PowerShell/curl 预下载 Gradle 发行版,解压到 wrapper 期望的目录
(本次采用;
gradle-wrapper.properties写的是-all, 实测放-bin发行版到同一 hash 目录 + 打上gradle-9.3.1-all.zip.ok即可用)。curl.exe -L -o "$env:TEMP\gradle.zip" ` https://mirrors.cloud.tencent.com/gradle/gradle-9.3.1-bin.zip # 解压到 ~/.gradle/wrapper/dists/gradle-9.3.1-all/<hash>/
- 把中间人代理的根证书导入 JVM 信任库:
keytool -importcert -trustcacerts -alias mitm \ -file mitm-ca.crt -keystore "$JAVA_HOME/lib/security/cacerts" - 给 JVM 加启动参数指向自定义 truststore:
-Djavax.net.ssl.trustStore=...
② 镜像源配置(已写入工程,国内网络直接受益)
| 用途 | 源 | 位置 |
|---|---|---|
| Gradle 发行版 | mirrors.cloud.tencent.com/gradle(实测 22 MB/s) |
手动下载(见上) |
| Maven 依赖(AGP/Kotlin/AndroidX) | maven.aliyun.com/repository/* |
android/build.gradle.kts、android/settings.gradle.kts |
| pub 包 | pub.dev 直连(清华 dart-pub 镜像返回 403,勿用) |
无需配置 |
| Flutter 引擎产物 | 直连 download.flutter.io(清华 flutter 镜像 403,勿用) |
无需配置 |
本机实测:
services.gradle.org只有 ~4 KB/s(10 分钟才 2.5MB), 腾讯云镜像 6 秒下完 130MB。Gradle 发行版一定要换源。
③ 插件 compileSdk 冲突
Dependency ':flutter_plugin_android_lifecycle' requires libraries and applications
that depend on it to compile against version 36 or later of the Android APIs.
:file_picker is currently compiled against android-34.
这不是本仓库代码的问题,而是 Flutter 插件各自声明 compileSdk 导致的常见冲突。
已在 android/build.gradle.kts 里统一抬高所有插件子工程的 compileSdk 到 36:
subprojects {
afterEvaluate {
val androidExt = project.extensions.findByName("android")
as? com.android.build.gradle.BaseExtension
if (androidExt != null) {
val current = androidExt.compileSdkVersion?.removePrefix("android-")?.toIntOrNull()
if (current == null || current < 36) androidExt.compileSdkVersion(36)
}
}
}抬高 compileSdk 只影响"能用哪些 API 编译",不影响 minSdk(安装兼容性)
与 targetSdk(运行时行为),所以是安全的。
④ flutter create . 会覆盖 pubspec.yaml
本仓库开发中已踩到。android/ 目录已入库,正常情况不需要再跑 flutter create。
需求里要求"支持加密压缩包提示输入密码"。本仓库的实现是:
- 读中央目录时通过 general purpose bit flag bit 0 检测加密;
- 没有密码 → 返回
NeedPassword,UI 弹密码框(这是正常返回值,不是异常); - 但纯 Dart 无法可靠解密 ZIP 的 ZipCrypto / AES,所以拿到密码后 会给出明确的可操作错误:"请先用 7-Zip / WinRAR 解压出未加密的 zip"。
这是刻意的诚实实现,不是遗漏:与其假装校验密码然后失败, 不如直接告诉用户当前能力边界。要真正支持,需要:
- ZipCrypto:自己实现流密码 + CRC 校验(约 200 行,纯 Dart 可行,但只覆盖老式加密);
- AES-256:需要 AE-1/AE-2 + PBKDF2-HMAC-SHA1 + HMAC-SHA1 校验(纯 Dart 可行但成本高),
或走平台原生(
zip4j/libzip)。



