首页(书架)此前是纯命令式拼出来的,而整个客户端没有任何路由:URL 从头到尾不变, 返回键、刷新、分享、收藏一概不成立。 ## 路由 `ui/router.ts` 把 fragment 解析成 route,shell 渲染 router 报上来的那个: #/shelf 书架 #/book/<id> 阅读器 #/library/<路径> 书库里的目录 用 fragment 而不是路径,是因为同一份产物要被两个宿主加载,而两个都服务不了任意路径: Android 从 appassets.androidplatform.net 加载(那里没有服务端),H5 由 reader 服务端 托管(没有客户端路由回落)。路径式路由会在 vite dev 里正常、在两个生产宿主里 404。 返回走应用自己的屏幕轨迹,不走 history.back():应用里的「返回」是「上一个屏幕」, 而浏览器历史里可能有登录表单或别的站点。轨迹为空时(新标签页里粘贴的链接)替换成父级, 而不是压入——否则那个链接会永远压在书架后面。 走进书库子目录用 replace:书库是一个带面包屑的屏幕,走目录是同一个屏幕改自己的参数。 压栈的话,读者走过四个文件夹再按返回会逐层退出来。 带着链接进来而尚未登录时,route 存在 App.pending 里等在登录页后面,登录后直接落到 目标屏幕,而不是落到一个他从未见过的书架。 Android 外壳的返回手势不需要改动:它看的是 WebView 的 canGoBack(),而路由现在真的 往 WebView 历史里写——之前那段注释描述的行为,从这次起才成立。 ## 首页 - 先看到书,再看到控件:标题和封面在最上面,搜索在后,排序与数量贴在它们作用的那一节上。 旧版把搜索框、数量、四个排序 chip 依次排在封面之前,三行控件才轮到第一本书。 - 排序栏是唯一吸顶的东西,它承载的是读者会滚回去改的两件事。 - 空状态给一条出路(打开书库管理 / 刷新 / 清除搜索),而不是一句「书库还是空的」。 - 冷启动画骨架屏,且按当前密度画——密度不对的骨架屏会让读者看到布局动两次。 - 封面缺图时把书名画成封面,而不是一个灰色方块。 ## 顺手修掉的 5 个真缺陷 1. mountUI 在 unmount 之后仍会渲染。屏幕是异步加载的,离开后那条迟到的响应会把 整棵树画进一个刚被清空的容器;书架迟到的响应会把自己一屏封面画进书库管理器。 2. --safe-left/-right/-top/-bottom 从未在生效的样式表里定义过,只有一份没人 import 的 死样式表定义过。未定义的自定义属性会让整个 calc() 失效并静默丢弃声明。 3. 封面在 flex 列里没有显式宽度,aspect-ratio 盒子因此收缩到内容宽度:只有书名的封面 塌成 42px、有图的占满卡片,整排看起来参差不齐。 4. 阅读列在宽屏上被拉伸到 9 列 45px 的封面。现在内容列封顶 68rem,封面随断点变大。 5. 删掉 web/src/ui/styles.css——414 行从未被引用的旧样式,以及 .book-highlight 等 已经没有任何代码产出的规则。 ## 验证 - web 全量 284 项测试通过(新增 router 19 项 + 链接契约 4 项)。 - typecheck / build / check-shell.sh / build-web-assets.sh 均通过。 - 真浏览器实测:深链直开、刷新不丢、返回离开屏幕、登录后落回目标、书库走目录、 空搜索、粘性工具栏、深浅色、390px 与 1440px 两档宽度。 - 体积:251KB → 252KB(gzip 79.1KB)。
Android 外壳
状态:契约已定义,实现未开始。
这个目录目前只有设计契约,没有可编译的代码。原因很直接:本次开发环境没有 JDK 与 Android SDK,写一个编译不过、跑不起来的 APK 只是往仓库里堆未经验证 的代码。宁可把边界写清楚,等有构建环境时一次写对。
下面是已经定死、并且已被 H5 侧验证过的部分。
分工:谁渲染什么
用户的问题原话是:「Android 端 epub 书籍可以使用 H5 渲染,但是其他类型的
书籍需要使用原生来渲染,提升性能。」 这条已经落进代码
(web/src/ui/reader-view.ts 的 capabilities()),外壳只需要认这个契约:
kind |
渲染方 | 为什么 |
|---|---|---|
reflowable(epub) |
WebView | 设备上只有 WebView 实现了 CSS 多栏、内嵌字体、竖排、振假名。原生文本栈要把这些重做一遍,只会更差。 |
text(txt) |
WebView | 同上,且它只是纯文本,没有原生收益。 |
paged(cbz / 漫画目录 / 单图) |
原生 Pager | 一页是一个 JPEG。原生 ImageView 走硬件解码并复用 bitmap;WebView 每页一个合成层并在主线程解码。真正的原因是内存:300MB 的画册在 WebView 里是中端机 OOM,在原生里没事。 |
document(pdf) |
系统查看器 | 系统已经有一个了。在 WebView 里套 iframe 只是把同样的事做两遍。 |
API 已经提供了路由所需的全部信息,不需要外壳自己嗅探扩展名或 UA:
GET /api/v1/books/:id/manifest
→ { content: { kind: "reflowable" | "paged" | "text" | "document" | "single-image", ... },
kind: "reflowable" | ..., // 与 content.kind 相同,冗余保留给旧客户端
files: [ { rel_path, size, missing } ] }
kind 同时出现在 content.kind 和顶层,两边永远是同一个值。外壳读哪个都行,
但不要改用扩展名或 UA 去猜——kind 就是那个唯一开关。
files 对 paged 的书是逐页下载的路径清单:漫画目录没有单一的书文件,
外壳按这份清单取页。清单同时是 /books/:id/file?path= 的契约——
manifest 列出的路径就是这个端点会服务的路径,多一条也没有。压缩包里的页面按压缩包
自己的路径列出,包内页码走 /assets?ref=page:卷:页,不是文件路径。
H5 侧在没有外壳时的降级路径也已实测通过(paged → 内置翻页器、
document → iframe),所以外壳缺失或被停用不会让 App 白屏。
桥接契约
外壳在 WebView 加载 assets 之前,往 window 上写一个对象
(web/src/bridge.ts 是唯一的定义处):
window.readerBridge = {
serverUrl: 'http://192.168.1.10:8080', // file:// 下没有 origin,必须给
platform: 'android',
deviceName: 'Pixel 8', // 写进进度,多设备同步时能看出是哪台
onBookOpened(bookId) {}, // 外壳接管输入(硬件键、原生翻页)
onTurnPage(turn) {}, // 外壳把翻页手势交回给 H5
cacheBook(bookId) {}, // 请求离线缓存
};
三条约定:
- 外壳只写这一个对象,不 eval 字符串。 同进程,走对象比走
addJavascriptInterface的字符串协议简单,也不会因为转义出错。 platform: 'android'是唯一的路由开关。 不嗅探 UA —— UA 是猜的, 桥是事实。- H5 不碰 refresh token。 令牌由外壳的加密存储管理;WebView 的
localStorage里放长期凭证,任何被注入的脚本都能读。
web/src/bridge.ts 已实现读取侧,并且无桥时正常工作(纯 H5 部署即如此)。
离线缓存
服务端已经把「按书缓存」所需的接口准备好了,外壳侧直接可用:
GET /books/:id/content— 整本书文件流式下载,带ETag与Range。 书内容按哈希不可变,所以缓存永不失效,断点续传也是免费的。- 目录型书籍(漫画目录)没有单一文件,返回
400 DIRECTORY_BOOK; 这类书按items?group=N逐卷取图。
进度与笔记走 GET /sync?since=<serverTime> 增量拉取。服务端是唯一真相源,
冲突是 last-writer-wins —— 一台设备属于一个读者,多设备之间不存在「编辑
冲突」,只存在「某台设备落后了」。离线期间本地记录,恢复后合并。
分发
AGPL 免费、无收入,所以不走应用商店:直接发 APK。
- 侧载 APK 到 GitHub Releases / CNB 制品库
- 没有审核周期,没有 Apple 的 99 美元
- iOS 按设计文档的判断等有赞助再说,本目录不涉及
开工前需要的东西
- JDK 17+ 与 Android SDK(
ANDROID_HOME) - AGP + Kotlin 版本对齐(建议 AGP 8.x / Kotlin 2.x)
web/dist的产物打进app/src/main/assets/www/—— 第一步是把web/的构建产物复制进去,不是在 Android 侧重写渲染- 一个可连的服务端实例(
docker compose up即可)
第 3 条是这个仓库的结构性约束:外壳不重写排版。排版的唯一实现在
web/,两条实现只会让「最小覆盖 CSS」的策略分叉。