给自己写了个小说阅读器,636 万字的书打开只要 0.16 秒
起因
手机上看小说,应用商店里的阅读器一个比一个过分:开屏广告、看书时底部飘横幅、翻两页弹个「限时特惠」。我就想安安静静看本书,怎么这么难。
索性自己写一个。Flutter 写的,仓库在这里:nbsheep/novel-reader(private)。支持 TXT / EPUB 导入,八种背景色、字号行距字体随便调、四种翻页模式(滚动/平移/覆盖/仿真)、进度记忆、书签、音量键翻页——这些是基本功,不展开。
这篇主要讲这次大更新里最花心思的两件事:长书的性能,和站内搜索下载顺带去广告。
长书卡顿:双层磁盘缓存
先说结论:636 万字的书(《太古神王》),首次打开全文分页 6.5 秒 → 33 毫秒,解析 2 秒 → 0.16 秒。
一开始的架构很朴素:打开书 → 解析整本 → TextPainter 全书分页 → 渲染。书小的时候毫无问题,直到我把一本六百万字的书扔进去,主线程直接 ANR,系统把 App 杀了。用户视角就是「改个字号点确定,闪退」。
修了三层:
1. 解析不碰主线程。 dart:ui 的 runOnPlatformThread(平台线程 isolate,有文本测量能力)跑全书分页,主线程零参与;失败再回退到时间片分块(每片 6ms 让出事件循环)。
2. 解析结果落盘。 解析一次的代价没理由付两次。解析结果按书缓存成 JSON(documents/cache/parse_v1_{id}.json),头部存源文件的 size + mtime 做校验,文件变了自己失效重解析。编解码丢在 compute isolate 里,主线程只管读。
3. 分页也落盘。 分页结果的 key 是 书id|字号|行距|字体|窗口尺寸,命中直接读。改字号第一次会重排一次(算完落盘),之后再点开就是毫秒级。
还有个体验细节:改字号触发重排时,旧分页继续显示,算完静默切换并用二分定位到当前阅读位置——而不是转圈等结果再跳回第一页。这个「不跳回第一页」比快 200 倍更让人觉得舒服。
中间还修过一个阴间 bug:删掉的书会「复活」。原因是各处持有的 BookLibrary 实例各留一份列表、各自整份写回,后写的把先写的删除覆盖了。改成 static 共享缓存 + 写串行队列,load 时顺手清掉文件已丢的条目,自愈。
站内搜索下载:WebView 过 Cloudflare
想在线搜书下载,源站是个盗版小说站(鬼书集)。它的搜索接口是 POST 表单,直接用 http 包请求会被 Cloudflare JS challenge 拦下来,返回 “Just a moment…”——App 里没有浏览器环境,过不了盾。
但目录页和正文页是普通 GET,不带盾。于是混合架构:
- 搜索:内嵌 WebView 打开网站,真浏览器环境自动过 CF。原生搜索框里输入书名,注入 JS 填进网站的
#schform表单然后f.submit(); - 下载:用户点进某本书的目录页后,Native 侧直接用 http 抓目录 + 遍历章节 ID 区间(这站的章节 ID 是连续的,目录页的
og:novel:latest_chapter_url给出末章 ID),并发 3 + 每请求随机延迟防 429,抓完现场打包 EPUB 入书架。
去广告,以及一场自己制造的乌龙
内嵌 WebView 打开盗版站,广告密度可以想象:Google 锚定横幅、全屏插页、伪装成「深入探索」的推广列表。注入了一段去广告脚本:
- 无条件删
iframe / ins / #google-anno-sa / .google-auto-placed / div[id^="aswift"]; - 外链
<a>(host 不含本站域名)连同只包着它的小容器一起爬升删除; - 几何规则删悬浮层:全屏遮罩、视口底部带链接的宽条。
这里有两个实测出来的坑:
坑一:Google 锚定广告的宿主 DIV 挂在 <html> 下面,<body> 之外。 只扫 body 永远删不掉它,必须从 documentElement 扫起。而且广告容器经常先插一个空壳、内容异步填充——图片加载不触发 childList 变动,MutationObserver 根本看不见。最后靠脚本内布 0.5s ~ 60s 十档延时补扫,配合 Dart 侧每 2 秒重新注入一次幂等脚本(window.__nzAdClean 标志防重复生效)兜底——因为 Cloudflare 的跳转链上 onPageFinished 有概率整个丢失,不能指望生命周期事件。
坑二是我自己挖的:去广告脚本把网站自己的搜索表单删了。 搜索突然永远失败,排查了半天导航代理、JS 桥、提交时机,最后用 JavaScriptChannel 探针把 document.getElementById('schform') 的结果推回来一看——f=NULL。搜索表单根本不在 DOM 里。
凶手就是第一版脚本里一条看起来人畜无害的规则:「position:fixed 且宽或高小于 10px 的元素一律删除」——本意是清 0x0 的广告占位壳。而网站自己的搜索栏平时是隐藏的固定定位元素,宽 0 高 0,完美命中。之前搜索偶尔能成功纯属时序侥幸。
修复方案也很直白:隐形元素根本不删(用户看不见的东西,删了只有风险没有收益),并且所有几何删除、外链爬升全部豁免含 form / input / textarea / button / select 的元素。改完之后搜索一次即中,三个页面实测零广告。
听书和其它
顺手把主流阅读器的标配补齐了:听书 TTS(逐块朗读、超长段落 3000 字切分连读、四档语速)、书内全文搜索(标题 + 正文,命中高亮)、章节追更检测(增量抓新章追加到 EPUB 末尾,不打乱已有进度)、自定义字体导入、自动阅读、屏幕常亮、点按分区翻页。
最后
整个项目最有意思的部分其实不是某个功能,而是「去广告脚本杀死了搜索功能」这种自食其果的瞬间——每个「看起来肯定没问题」的激进规则,都在等你下一次操作。防御性代码的最高原则大概就是:只删你确定的,放过的永远比误杀的好。
