翰阅 HanYue
跨平台优雅 EPUB 阅读器 — 当前 v0.0.3,Android only,渲染 + 翻页 + 工具栏雏形已完成。
翰阅的目标是做一个美的阅读器。对标 Apple Books — 干净、克制、读者打开就想读。后期走 KMM / Compose Multiplatform 扩展到 iOS 和桌面。
项目纯 Kotlin + Jetpack Compose + Material3,EPUB 解析为手写纯 Kotlin 解析器(~200 行),渲染使用 WebView + CSS columns 横向分页。不引入 Hilt 等重型框架,DI 手动管理。
当前状态
v0.0.3 已实现功能
| 功能 | 状态 | 说明 |
|---|---|---|
| 书架 — 网格视图 | 完成 | 导入后封面 + 书名展示 |
| 书架 — SAF 导入 | 完成 | 系统文件选择器 → sandbox 拷贝 → 入库 |
| 书架 — 空状态 | 完成 | 内置示例书 + 引导导入 |
| 阅读器 — CSS 分页 | 完成 | CSS columns 横向分栏 |
| 阅读器 — 翻页 | 完成 | 点击左右区域、translateX 动画 |
| 阅读器 — 章节切换 | 完成 | 自动衔接上下章 |
| 阅读器 — 三形态响应式 | 完成 | 手机/折叠屏/平板 |
| 阅读位置记忆 | 完成 | 章节 + 页号,三处保存 |
| 工具栏 — 章节面板 | 完成 | TOC 跳转 |
| 工具栏 — 主题面板 | 完成 | 白/复古纸/夜,CSS 变量热更 |
| 工具栏 — 进度面板 | 占位 | UI 骨架已建 |
| 工具栏 — 排版面板 | 占位 | UI 骨架已建 |
| 点中间唤出工具栏 | 完成 | 点 reader 区域收回 |
| 内置示例书 | 完成 | 诗经选,随 APK 打包 |
项目结构
按未来能拆 commonMain / androidMain 的方式分包:
分层原则
data/epub、data/library、data/model是纯 Kotlin,只用标准库 + KMP 安全 APIui/reader/EpubWebView.kt是 Android 特有 — iOS 阶段会对应到WKWebView- DI 手动 ServiceLocator,v0.0.x 不引 Hilt
data 层详解
data/model
Book.kt — 书的基本信息:
@Serializable
data class Book(
val id: String,
val title: String,
val author: String?,
val coverFile: String?, // filesDir 下的封面缓存路径
val addedAt: Long,
)
ReaderSettings.kt — 阅读设置:
@Serializable
data class ReaderSettings(
val theme: ReaderTheme = ReaderTheme.Sepia, // 默认复古纸
val fontSize: Int = 18,
val lineHeight: Float = 1.75f,
val letterSpacing: Float = 0f,
)
data/library — 持久化
三个 Repository 各自管理一个 JSON 文件在 filesDir:
| Repository | 文件 | 用途 |
|---|---|---|
BookRepository | bookshelf.json | 书架书列表 CRUD |
PositionRepository | positions.json | 阅读位置(bookId → spineIndex + pageIndex) |
SettingsRepository | settings.json | 阅读设置(主题、字体等) |
说"不引 Hilt"不是偷懒:v0.0.1 就三个 JSON 文件、两个协程操作,ServiceLocator 单例 < 30 行,换了 Hilt 只会多出 200 行 annotation。
ui 层详解
MainActivity.kt
入口 Activity。setContent 中用 calculateCurrentWindowSizeClass() 计算 WindowSizeClass,通过 LocalWindowSize 传给全局。不锁方向。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val windowSize = calculateCurrentWindowSizeClass()
CompositionLocalProvider(LocalWindowSize provides windowSize) {
HanYueApp()
}
}
}
}
HanYueApp.kt
NavHost 根路由,定义两个目的地:
sealed class Route(val route: String) {
data object Bookshelf : Route("bookshelf")
data object Reader : Route("reader/{bookId}") {
fun create(bookId: String) = "reader/$bookId"
}
}
三形态响应式
LocalWindowSize 是 CompositionLocal<WindowSizeClass>,三档 Compact / Medium / Expanded。阅读器在 Expanded 下启用双页对开(TODO),手机保持单页。折叠屏的 FoldingFeature 通过 Jetpack WindowManager 监听。
EPUB 格式简介
EPUB 本质是一个 ZIP 包,里面是 HTML + CSS + 图片。标准结构:
book.epub
├── META-INF/
│ └── container.xml ← 指向 OPF 文件路径
└── OEBPS/
├── content.opf ← 元数据 + manifest + spine
├── chapter1.xhtml
├── chapter2.xhtml
├── styles/
│ └── book.css
└── images/
└── cover.jpg
解析流程:container.xml → 找到 .opf → 解析 metadata(书名/作者/封面)→ manifest(所有资源)→ spine(阅读顺序)→ 按 spine 顺序加载每个章节 HTML → 注入自定义 CSS/JS → 交给 WebView 渲染。
EpubParser — 纯 Kotlin 解析器
三个公开方法:
| 方法 | 用途 |
|---|---|
parsePackage(epubFile) | 解析 OPF,返回 EpubPackage(含 metadata / manifest / spine) |
loadChapterHtml(epubFile, pkg, item) | 从 ZIP 中读取某章节的原始 HTML |
parseToc(epubFile, pkg) | 从 nav 文件中解析 TOC,返回 List<TocEntry> |
关键细节
- 解析方式:
XmlPullParser+ZipFile,纯标准库无外部依赖 - TOC 解析:从 manifest 里找
properties="nav"的 nav 文件 → 正则提取<nav epub:type="toc">下的<a href>→ 映射到 spine 索引 - 资源路径解析:EPUB 内路径是相对 OPF 目录的,
resolveZipPath()和resolvePath()处理相对路径和..回溯 - 边界处理:无 TOC 返回空列表,无 linear spine 抛出异常,meta cover 优先于 cover-image property
EPUB 数据模型
data class EpubPackage(
val opfDir: String,
val metadata: EpubMetadata,
val manifest: Map<String, ManifestItem>,
val spine: List<SpineItem>,
) {
val linearSpine: List<ManifestItem> // 只返回 linear=true 的章节
}
data class EpubMetadata(
val title: String,
val author: String?,
val coverManifestId: String?,
)
data class ManifestItem(
val id: String,
val href: String,
val mediaType: String,
val properties: String? = null,
)
data class SpineItem(
val idref: String,
val linear: Boolean = true,
)
data class TocEntry(
val title: String,
val spineIndex: Int,
)
linearSpine 是阅读器遍历章节的依据,跳过 linear="no" 的 non-linear 条目。
ReaderScreen — 阅读器 UI
阅读器的 Compose 入口。核心状态:
data class ReaderContent(
val webView: WebView?,
val totalPages: Int,
val currentPage: Int,
)
val state: StateFlow<ReaderState> // Loading / BookNotFound / Error / Loaded
val pendingPosition: StateFlow<PagePosition> // First / Last / At(pageIndex)
val settings: StateFlow<ReaderSettings>
UI 结构(从底层到顶层):
- 全屏
EpubWebView— 渲染章节内容 - 透明点击叠加层 — 三区:左1/3 ← 前翻 | 中1/3 → 唤出工具栏 | 右1/3 → 后翻
- 顶栏
AnimatedVisibility— 返回按钮 + 书名 - 底栏
BottomToolbar— 4 个面板入口 - 右下角页码 —
"currentPage + 1 / totalPages" - 可滑出面板 —
PanelHost承载章节/进度/主题/排版面板
内容渲染 ← → 点击叠加层
翻页 / 唤出 ↓ 工具栏
4面板 ↓ PanelHost
滑出面板
EpubWebView — WebView 封装
核心做三件事:
1. 内容注入
composeChapterHtml() 将原始的 EPUB 章节 HTML 加工后注入 WebView:
原始 HTML → 注入 viewport meta
→ 注入 __hyInitial 位置提示(first/last/page:N)
→ 注入 <style> 样式表(ReaderCss.stylesheet)
→ 注入 <script> 分页 JS(ReaderCss.script)
→ 用 <body> 内容包进 #hy-page > #hy-content
→ loadDataWithBaseURL(baseUrl, 加工后HTML, ...)
2. 资源拦截
自定义 WebViewClient.shouldInterceptRequest(),拦截 epub.local 域的请求,从 ZIP 中取图片 / CSS 等资源返回。
override fun shouldInterceptRequest(view, request): WebResourceResponse? {
val uri = request.url
if (uri.host != EPUB_HOST) return null // 只拦截 epub.local
val path = uri.path.removePrefix("/")
val bytes = resourceProvider(path) // 从 ZIP 取
return WebResourceResponse(guessMime(path), "utf-8", bytes.inputStream())
}
3. 热更新
两个更新入口:
hySetTheme(bg, fg, muted, accent, rule)— 只改 CSS 变量,不触发重排hySetTypography(fontSize, lineHeight, letterSpacing)— 改 CSS 变量 + 强制重跑分页布局
CSS Columns 分页引擎
这是阅读器的技术核心——不依赖任何第三方 JS 分页库,纯 CSS + 少量 JS 实现。
原理
fixed 全屏 包含 #hy-content
width = n × 100vw
column-count: n
column-fill: auto
每个"页"就是一列,列的宽度 = 视口宽度,列高 = 视口高度。
分页计算
// 1. 测量视口
pageWidth = viewport.getBoundingClientRect().width
pageHeight = viewport.getBoundingClientRect().height
// 2. 内容高度
contentExtent = content.scrollHeight - paddingY
perColumn = pageHeight - paddingY // 每列可用高度
// 3. 页数
n = Math.max(1, Math.ceil((contentExtent - lineHeight) / perColumn))
// 4. 设置列数
content.style.width = (n * pageWidth) + 'px'
content.style.columnCount = n
翻页
// 翻到第 n 页
transform: translateX(-n × pageWidth)
// CSS Transition 提供 220ms 滑动动画
transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1)
JS 全局 API
| 方法 | 用途 |
|---|---|
hyTotalPages() | 返回总页数 |
hyCurrentPage() | 返回当前页索引 |
hyGoToPage(n, animated) | 跳转到第 n 页 |
hySetTheme(...) | 切换主题 |
hySetTypography(...) | 更新排版并重排 |
初始化引导
WebView 加载完成后,bootstrap() 以 50ms 间隔轮询直到布局完成(最多 60 次 = 3 秒)。布局完成后调用 bridge.onLayout(totalPages) 通知 Kotlin。
为什么不用 JS 分页库?
CSS columns 的方案在中等长度章节(< 200 屏)上性能优于 JS 分页,因为它完全由浏览器的原生布局引擎处理。对于 800+ 屏的超大章节,首屏计算会慢一些,但阅读场景下章节通常不会这么长。如果需要,可以在代码中看到有一个「scrollHeight vs pageWidth」的兜底校验逻辑。
ReaderCss — 样式表
包含两个静态成员:
stylesheet(settings)
生成完整的 CSS 字符串。核心布局:
#hy-page {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
overflow: hidden; touch-action: none;
}
#hy-content {
column-gap: 0;
column-fill: auto;
padding: 36px 0; // 上下内边距
opacity: 0; // 初始隐藏
transition: transform 220ms cubic-bezier(...);
}
排版相关:
- 字体栈:
-apple-system, "PingFang SC", "Hiragino Sans GB", "Source Han Sans SC", "Noto Sans CJK SC", "Microsoft YaHei", system-ui, sans-serif - 正文
p首行缩进 2em,边距 0 0 0.85em - 标题
h1-h6取消缩进,设置break-after: avoid防止列尾孤行 - 代码、表格、引用块等都有相应样式
script
包含了上述完整的分页引擎 JS,IIFE 形式注入。
ReaderViewModel — 状态管理
boot() 初始化流程:
→ 取 Book → EpubParser
→ 解析 OPF → PositionRepository
→ 读取存 → loadChapter
→ 加载首章 → emit Loaded
章节切换:
nextChapter(currentPage)— 先保存当前位置,设 pendingPosition = First,加载 spineIndex + 1previousChapter(currentPage)— 保存位置,设 pendingPosition = Last,加载 spineIndex - 1goToChapter(spineIndex, currentPage)— 保存位置,设 pendingPosition = First,跳转指定章
位置保存三时机:
Lifecycle.Event.ON_STOP— 切到后台- 章节切换时 —
nextChapter/previousChapter/goToChapter DisposableEffect.onDispose— Composable 销毁
主题系统
三种主题定义在 ReaderTheme 枚举:
| 属性 | Light (白) | Sepia (复古纸) | Dark (夜) |
|---|---|---|---|
bg | #FFFFFF | #FAF7F2 | #1A1815 |
fg | #1F1F1F | #2B2B2B | #E8E4DC |
mutedFg | #6E6E6E | #6B6258 | #A89E91 |
accent | #4A6FB8 | #8B6F47 | #C9A87C |
rule | #E0E0E0 | #D6CAB8 | #4A443C |
chrome | #F2F2F2 | #F0EAE0 | #252320 |
主题切换流程
// Compose 层
User 点击主题 → setTheme(theme)
→ settingsRepository.update { it.copy(theme = theme) }
→ settings StateFlow 更新
→ EpubWebView update {} 检测到变化
→ applySettingsChange()
→ webView.evaluateJavascript("hySetTheme(bg,fg,muted,accent,rule)")
// JS 层
hySetTheme = function(bg, fg, muted, accent, rule) {
document.documentElement.style.setProperty('--hy-bg', bg);
// ... 其他变量
// 无需重排,纯视觉变化
}
排版参数
三个可调参数:
| 参数 | 默认值 | 范围 | CSS 变量 |
|---|---|---|---|
fontSize | 18px | — | --hy-font-size |
lineHeight | 1.75 | — | --hy-line-height |
letterSpacing | 0em | — | --hy-letter-spacing |
排版修改后需要重新分页(因为行高/字号变化会影响每页行数)。JS 中 hySetTypography() 更新 CSS 变量后将 configured = false 并调用 bootstrap() 重新计算。
hySetTypography = function(fontSize, lineHeight, letterSpacing) {
// 更新 CSS 变量
s.setProperty('--hy-font-size', fontSize + 'px');
s.setProperty('--hy-line-height', String(lineHeight));
s.setProperty('--hy-letter-spacing', letterSpacing + 'em');
// 触发布局重算
configured = false;
setTimeout(bootstrap, 0);
}
书架 — BookRepository
BookRepository 管理 bookshelf.json,提供:
class BookRepository(private val filesDir: File) {
suspend fun list(): List<Book> // 获取所有书
suspend fun get(id: String): Book? // 按 ID 查
suspend fun add(book: Book) // 加书
suspend fun remove(id: String) // 删除
fun bookFile(book: Book): File // EPUB 文件路径
fun coverFile(book: Book): File // 封面文件路径
}
所有操作通过 kotlinx-serialization 读写 JSON 文件,Mutex 保证并发安全。
导入流程
通过 Android SAF(Storage Access Framework)的 ACTION_OPEN_DOCUMENT 让用户选择 EPUB 文件:
选择 EPUB → 拷贝到
filesDir → 解析 OPF
取 metadata → 提取封面
缓存图片 → 持久化到
bookshelf.json
EpubImporter.kt 封装了完整流程,返回 Book 对象。封面提取用 EpubParser.extractCover()。
版本路线图
| 版本 | 状态 | 内容 |
|---|---|---|
| v0.0.1 | ✅ | 书架(导入、网格、点击进入)+ 渲染翻页 + 三形态响应式 + 内置示例书 |
| v0.0.2 | ✅ | 阅读位置记忆(章节 + 页号,filesDir/positions.json,三处保存) |
| v0.0.3 | ✅ | 下栏工具栏骨架 + 章节面板 + 主题面板(CSS 变量热更);进度与排版面板占位;点中间唤出,点 reader 收回 |
| v0.0.4 | 🔨 | 进度条 + 字号/行间距/字间距功能化 |
| v0.0.5 | 📋 | CJK 精细排版(标点挤压、行尾约束、首行缩进策略) |
| v0.1.0 | 📋 | 第一个用得起的版本:搜索、书签 |
| v0.x | 📋 | 字典查词、笔记 |
| v1.0 | 📋 | Android 自我满意 |
KMM 展望
跨平台路线(不在 v0.0.x 范围内):
- 把
data/整层迁到commonMain,必要时把文件 IO 抽到expect/actual - 把
ui/迁到commonMain(Compose Multiplatform) - iOS WebView 适配:
actual class EpubWebView用UIViewController包WKWebView - 桌面:用
compose-webview-multiplatform或类似方案
当前代码结构已为 KMM 做好准备:
data/下的 EpubParser、BookRepository 是纯 Kotlin,无 Android 依赖- 仅有
ui/reader/EpubWebView.kt是 Android 特有,iOS 阶段替换为 WKWebView 实现
设计原则摘录
反原则(看着静读天下不要犯的错)
- 不要丑。
- 不要堆设置。每加一个设置项前问:这个值的默认我能不能选好?
- 不要让用户为基础排版买单。字体回退、中英混排、行尾约束,这些是工具应该解决的。
- 不要 Material You 抢戏。要可控的品牌色,不让动态取色破坏纸张质感。
代码规范
- 不写无意义注释。well-named 标识符是文档。注释只解释为什么,不解释做什么。
- 不为未来抽象。三个相似函数 < 一个不成熟的抽象。
- 不加防御性代码。内部代码可信。验证只在边界。
- UI 修改必须真机/模拟器验证。
常用命令
./gradlew :app:assembleDebug # 构建 debug
./gradlew :app:installDebug # 装到连接的设备/模拟器
./gradlew :app:compileDebugKotlin # 只编译 Kotlin,快速检查
adb shell am start \
-n com.gaohuiyu.hanyue/.MainActivity # 启动
翰阅 HanYue · 当前版本 v0.0.3 · 最后更新 2026.06