翰阅 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.1 完成 ✅ v0.0.2 完成 ✅ v0.0.3 完成 🔨 v0.0.4 进行中

v0.0.3 已实现功能

功能状态说明
书架 — 网格视图完成导入后封面 + 书名展示
书架 — SAF 导入完成系统文件选择器 → sandbox 拷贝 → 入库
书架 — 空状态完成内置示例书 + 引导导入
阅读器 — CSS 分页完成CSS columns 横向分栏
阅读器 — 翻页完成点击左右区域、translateX 动画
阅读器 — 章节切换完成自动衔接上下章
阅读器 — 三形态响应式完成手机/折叠屏/平板
阅读位置记忆完成章节 + 页号,三处保存
工具栏 — 章节面板完成TOC 跳转
工具栏 — 主题面板完成白/复古纸/夜,CSS 变量热更
工具栏 — 进度面板占位UI 骨架已建
工具栏 — 排版面板占位UI 骨架已建
点中间唤出工具栏完成点 reader 区域收回
内置示例书完成诗经选,随 APK 打包

项目结构

按未来能拆 commonMain / androidMain 的方式分包:

com.gaohuiyu.hanyue/
MainActivity.kt // 入口,setContent + LocalWindowSize
HanYueApp.kt // NavHost 根
data/ // 【未来 commonMain 候选】
model/ // Book.kt, ReaderSettings.kt
epub/ // 纯 Kotlin EPUB 解析
EpubParser.kt // 解析核心
EpubModels.kt // 数据模型
library/ // 持久化
BookRepository.kt
PositionRepository.kt
SettingsRepository.kt
import/
EpubImporter.kt // SAF → sandbox → 入库
ui/ // 【androidMain】
nav/ // type-safe 路由
layout/ // WindowSizeClass
bookshelf/ // 书架
reader/ // 阅读器
ReaderScreen.kt // 阅读器 UI
ReaderViewModel.kt // 状态管理
EpubWebView.kt // WebView 封装
ReaderCss.kt // CSS + JS 分页引擎
BottomToolbar.kt // 下栏工具栏
ReaderColors.kt // Compose 颜色桥接
theme/
di/
ServiceLocator.kt

分层原则

  • data/epubdata/librarydata/model 是纯 Kotlin,只用标准库 + KMP 安全 API
  • ui/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文件用途
BookRepositorybookshelf.json书架书列表 CRUD
PositionRepositorypositions.json阅读位置(bookId → spineIndex + pageIndex)
SettingsRepositorysettings.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"
    }
}

三形态响应式

LocalWindowSizeCompositionLocal<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 结构(从底层到顶层):

  1. 全屏 EpubWebView — 渲染章节内容
  2. 透明点击叠加层 — 三区:左1/3 ← 前翻 | 中1/3 → 唤出工具栏 | 右1/3 → 后翻
  3. 顶栏 AnimatedVisibility — 返回按钮 + 书名
  4. 底栏 BottomToolbar — 4 个面板入口
  5. 右下角页码 — "currentPage + 1 / totalPages"
  6. 可滑出面板 — PanelHost 承载章节/进度/主题/排版面板
EpubWebView
内容渲染
← → 点击叠加层
翻页 / 唤出
工具栏
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 实现。

原理

#hy-page
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() 初始化流程:

BookRepository
→ 取 Book
EpubParser
→ 解析 OPF
PositionRepository
→ 读取存
loadChapter
→ 加载首章
emit Loaded

章节切换

  • nextChapter(currentPage) — 先保存当前位置,设 pendingPosition = First,加载 spineIndex + 1
  • previousChapter(currentPage) — 保存位置,设 pendingPosition = Last,加载 spineIndex - 1
  • goToChapter(spineIndex, currentPage) — 保存位置,设 pendingPosition = First,跳转指定章

位置保存三时机

  1. Lifecycle.Event.ON_STOP — 切到后台
  2. 章节切换时 — nextChapter / previousChapter / goToChapter
  3. 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 变量
fontSize18px--hy-font-size
lineHeight1.75--hy-line-height
letterSpacing0em--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 文件:

SAF
选择 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 范围内):

  1. data/ 整层迁到 commonMain,必要时把文件 IO 抽到 expect/actual
  2. ui/ 迁到 commonMain(Compose Multiplatform)
  3. iOS WebView 适配:actual class EpubWebViewUIViewControllerWKWebView
  4. 桌面:用 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