- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
22 KiB
Android EPUB 阅读器实施方案
Context
当前 ReadViewSDK 是一个功能完整的 iOS EPUB 阅读器框架(Swift + UIKit),支持 EPUB 2/3 解析、三种渲染模式(重排/固定布局/网页交互)、全文搜索、高亮/书签/批注、分页管理等。目标是基于现有 iOS 版本的架构和 JS 桥接协议,实现一套 Android 版本的 EPUB 阅读器。
核心策略:JS 桥接层(epub-bridge.js、WeReadApi.js 等)直接复用,Swift 逻辑层用 Kotlin 重写,UIKit UI 层用 Android 原生 View 重写。
第一阶段:项目基础设施(预计 1 周)
1.1 创建 Android 项目
ReadViewSDK-Android/
├── app/ # Demo 应用
├── reader-core/ # Kotlin 核心库(解析、模型、搜索)
├── reader-jsbridge/ # WebView JS 桥接层
├── reader-ui/ # Android UI 组件(工具栏、搜索面板、设置等)
├── reader-view/ # 翻页容器(ViewPager2 封装)
└── build-logic/ # Gradle convention plugins
- 语言:Kotlin
- 最低 API:24(Android 7.0)
- 目标 API:34
- 构建工具:Gradle + Kotlin DSL
- 依赖注入:手动(与 iOS 的
RDEPUBReaderDependencies模式一致)
1.2 Gradle 模块划分
| 模块 | 对应 iOS 层 | 职责 |
|---|---|---|
reader-core |
EPUBCore/ + EPUBTextRendering/ | 解析、模型、搜索引擎、CSS 生成、分页算法 |
reader-jsbridge |
RDEPUBJavaScriptBridge + JS Resources | JS 文件管理、消息收发、脚本生成 |
reader-ui |
EPUBUI/ | 工具栏、搜索面板、设置面板、目录列表、高亮管理 |
reader-view |
ReaderView/ | 翻页容器(ViewPager2 + PagerSnapHelper) |
1.3 公共依赖
// reader-core
implementation("net.lingala.zip4j:zip4j:2.11.5") // 替代 ZIPFoundation
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
implementation("com.google.code.gson:gson:2.11.0") // 替代 Codable
implementation("androidx.webkit:webkit:1.10.0")
// reader-ui
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.viewpager2:viewpager2:1.0.0")
// reader-view
implementation("androidx.viewpager2:viewpager2:1.0.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
第二阶段:Core 层 — EPUB 解析与模型(预计 2 周)
2.1 数据模型迁移
将 iOS 的 Codable struct 迁移为 Kotlin data class:
| iOS 文件 | Android 文件 | 关键类型 |
|---|---|---|
RDEPUBModels.swift |
EpubModels.kt |
EpubLayout, EpubMetadata, ManifestItem, SpineItem, TableOfContentsItem |
RDEPUBReadingLocationModels.swift |
ReadingLocationModels.kt |
ReadingLocation, Viewport, ReadingContext |
RDEPUBAnnotationModels.swift |
AnnotationModels.kt |
Selection, Highlight, Bookmark, Annotation |
RDEPUBPaginationModels.swift |
PaginationModels.kt |
EpubPage, ChapterInfo, FixedSpread |
RDEPUBSearchModels.swift |
SearchModels.kt |
SearchMatch, SearchResult, SearchState, SearchPresentation |
RDEPUBPreferences.swift |
ReaderPreferences.kt |
ReadingPreferences(字体、行高、颜色、分栏) |
RDEPUBRenderRequest.swift |
RenderRequest.kt |
ReflowableRenderRequest, FixedRenderRequest |
RDEPUBReaderConfiguration.swift |
ReaderConfiguration.kt |
完整阅读器配置 |
RDEPUBReaderTheme.swift |
ReaderTheme.kt |
6 种预设主题 |
2.2 EPUB 解析器迁移
| iOS 文件 | Android 文件 | 说明 |
|---|---|---|
RDEPUBParser.swift |
EpubParser.kt |
主解析入口 |
RDEPUBParser+Archive.swift |
EpubParser+Archive.kt |
zip4j 解压(替代 ZIPFoundation) |
RDEPUBParser+Package.swift |
EpubParser+Package.kt |
OPF XML SAX 解析 |
RDEPUBParser+TOC.swift |
EpubParser+TOC.kt |
NCX/Nav Document 解析 |
RDEPUBParser+Resources.swift |
EpubParser+Resources.kt |
HTML/资源文件读取 |
RDEPUBParser+ReadingProfile.swift |
EpubParser+ReadingProfile.kt |
阅读模式判断 |
RDEPUBPublication.swift |
EpubPublication.kt |
Facade 门面 |
RDEPUBResourceResolver.swift |
ResourceResolver.kt |
URL 规范化 |
XML 解析方案:iOS 使用 XMLParser(SAX),Android 使用 org.xml.sax.XMLReader(同为 SAX),解析逻辑可逐行对照翻译。
2.3 搜索引擎迁移
| iOS 文件 | Android 文件 |
|---|---|
RDEPUBSearchEngine.swift |
EpubSearchEngine.kt |
RDEPUBTextSearchEngine.swift |
TextSearchEngine.kt |
搜索引擎核心逻辑(遍历 spine → 读取 HTML → 解析为纯文本 → NSString.range 搜索 → 生成 match)可直接翻译为 Kotlin String.indexOf 循环。
2.4 CSS 生成与样式
| iOS 文件 | Android 文件 |
|---|---|
RDEPUBStyleSheetBuilder.swift |
StyleSheetBuilder.kt |
RDEPUBFixedLayoutTemplate.swift |
FixedLayoutTemplate.kt |
CSS 生成逻辑完全平台无关,直接翻译即可。
2.5 分页算法
| iOS 文件 | Android 文件 | 说明 |
|---|---|---|
RDEPUBPaginator.swift |
EpubPaginator.kt |
离屏 WebView 分页计算 |
RDEPUBReadingSession.swift |
ReadingSession.kt |
状态机 + 页面管理 |
关键决策:Android 版统一使用 WebView 渲染,不实现 EPUBTextRendering 层(DTCoreText 的 Android 等价物过于复杂且收益低)。这意味着:
- 不迁移
EPUBTextRendering/整个目录(18 个文件) - 不迁移
EPUBUI/TextPage/目录(7 个文件) - Android 版的所有内容(包括重排模式)都通过 WebView + CSS column 分页实现
- 这与 iOS 的
webInteractive路径一致,已验证可行
第三阶段:JS 桥接层(预计 1 周)
3.1 JS 文件直接复用
将以下文件直接复制到 Android assets/ 目录:
reader-jsbridge/src/main/assets/js/
├── epub-bridge.js # 直接复用
├── WeReadApi.js # 直接复用
├── cssInjector.js # 直接复用
├── rangy-core.js # 直接复用
└── rangy-serializer.js # 直接复用
3.2 WKWebView → Android WebView 桥接映射
| iOS 机制 | Android 等价物 |
|---|---|
WKScriptMessageHandler.userContentController(_:didReceive:) |
@JavascriptInterface 方法 |
window.webkit.messageHandlers.{name}.postMessage() |
修改 JS 端调用 AndroidBridge.{method}() |
WKWebView.evaluateJavaScript() |
WebView.evaluateJavascript() |
WKURLSchemeHandler |
WebViewClient.shouldInterceptRequest() |
WKUserScript(页面加载前注入) |
WebView.addJavascriptInterface() + WebViewClient.onPageStarted() |
3.3 Android Bridge 实现
// EpubBridge.kt — 对应 iOS RDEPUBJavaScriptBridge
class EpubBridge(private val callback: BridgeCallback) {
@JavascriptInterface
fun onProgressionChanged(json: String) { callback.onProgressionChanged(json) }
@JavascriptInterface
fun onSelectionChanged(json: String) { callback.onSelectionChanged(json) }
@JavascriptInterface
fun onInternalLink(href: String) { callback.onInternalLink(href) }
@JavascriptInterface
fun onExternalLink(url: String) { callback.onExternalLink(url) }
@JavascriptInterface
fun onJSError(message: String) { callback.onJSError(message) }
@JavascriptInterface
fun onFixedLayoutReady() { callback.onFixedLayoutReady() }
}
JS 端修改:需要将 window.webkit.messageHandlers.xxx.postMessage(data) 替换为 window.AndroidBridge.xxx(JSON.stringify(data))。可以通过在注入时做字符串替换,或维护一个 Android 版本的 bridge JS 文件。
3.4 WebView 资源拦截
对应 iOS 的 ss-reader://book/ 自定义协议:
// EpubSchemeHandler.kt
class EpubSchemeHandler(private val publication: EpubPublication) : WebViewClient() {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
val url = request.url.toString()
if (url.startsWith("ss-reader://book/")) {
val path = url.removePrefix("ss-reader://book/")
val data = publication.readResource(path)
val mimeType = getMimeType(path)
return WebResourceResponse(mimeType, "UTF-8", data.byteInputStream())
}
return null
}
}
3.5 JavaScriptBridge 脚本生成
对应 iOS 的 RDEPUBJavaScriptBridge 中各种 *Script() 方法,生成 evaluateJavascript() 调用的 JS 代码字符串。这些方法的核心是拼接 JSON 参数 + 调用 WeReadApi.* 方法,逻辑完全相同。
第四阶段:翻页容器(预计 1.5 周)
4.1 PagerView 实现
对应 iOS RDReaderView(UICollectionView + UIPageViewController):
// PagerView.kt — 对应 iOS RDReaderView
class PagerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val viewPager: ViewPager2
private val adapter: PageAdapter
enum class DisplayMode { HORIZONTAL, VERTICAL }
var displayMode = DisplayMode.HORIZONTAL
set(value) { /* 切换 orientation */ }
fun reloadData() { adapter.notifyDataSetChanged() }
fun scrollToPage(index: Int, animated: Boolean) { /* ... */ }
}
PageAdapter:使用 RecyclerView.Adapter,每个 item 是一个 FrameLayout,内部放 WebView。
4.2 点击区域检测
对应 iOS RDReaderTapRegionHandler:
// TapRegionHandler.kt
class TapRegionHandler {
enum class Region { LEFT, CENTER, RIGHT }
fun resolve(x: Float, viewWidth: Float): Region {
return when {
x < viewWidth / 3 -> Region.LEFT
x > viewWidth * 2 / 3 -> Region.RIGHT
else -> Region.CENTER
}
}
}
4.3 预加载控制
对应 iOS RDReaderPreloadController:
使用 ViewPager2.offscreenPageLimit 控制预加载页面数量(默认 1-2 页)。
第五阶段:UI 组件(预计 2 周)
5.1 阅读器主控制器
对应 iOS RDEPUBReaderController:
// EpubReaderFragment.kt — Fragment 优于 Activity,方便宿主嵌入
class EpubReaderFragment : Fragment() {
// 对应 iOS 的各 Coordinator
private lateinit var runtime: ReaderRuntime
private lateinit var pagerView: PagerView
private lateinit var topBar: TopToolBar
private lateinit var bottomBar: BottomToolBar
private lateinit var searchPanel: SearchPanelView
// Public API
fun openBook(epubUrl: Uri, configuration: ReaderConfiguration = ReaderConfiguration.default)
fun goTo(location: ReadingLocation)
fun goToPageNumber(pageNumber: Int)
fun search(keyword: String)
fun addHighlight(selection: Selection, color: Int, note: String?)
fun addBookmark(note: String?)
// ... 其余 public API
}
5.2 Coordinator 架构迁移
保持 iOS 的 Coordinator 模式,每个 Coordinator 作为独立的 Kotlin 类:
| iOS Coordinator | Android 类 | 职责 |
|---|---|---|
RDEPUBReaderRuntime |
ReaderRuntime |
Facade,协调所有子 Coordinator |
RDEPUBReaderLoadCoordinator |
LoadCoordinator |
EPUB 文件解析 + Publication 初始化 |
RDEPUBReaderPaginationCoordinator |
PaginationCoordinator |
分页计算 |
RDEPUBReaderLocationCoordinator |
LocationCoordinator |
阅读位置管理 + 持久化 |
RDEPUBReaderChromeCoordinator |
ChromeCoordinator |
工具栏状态同步 |
RDEPUBReaderAnnotationCoordinator |
AnnotationCoordinator |
高亮/书签/批注 CRUD |
RDEPUBReaderSearchCoordinator |
SearchCoordinator |
全文搜索协调 |
RDEPUBReaderViewportMonitor |
ViewportMonitor |
屏幕旋转/尺寸变化检测 |
5.3 WebView 内容视图
对应 iOS RDEPUBWebView + RDEPUBWebContentView:
// EpubWebView.kt — 封装 Android WebView
class EpubWebView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : WebView(context, attrs) {
private val bridge = EpubBridge(/* ... */)
fun setup() {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(bridge, "AndroidBridge")
webViewClient = EpubSchemeHandler(publication)
webChromeClient = WebChromeClient()
}
fun loadChapter(spineIndex: Int, preferences: ReadingPreferences) {
// 生成 HTML 模板 + 注入 CSS + 加载资源
}
fun evaluateScript(script: String, callback: ((String?) -> Unit)? = null) {
evaluateJavascript(script) { result -> callback?.invoke(result) }
}
}
5.4 顶部工具栏
对应 iOS RDEPUBReaderTopToolView:
<!-- top_tool_bar.xml -->
<LinearLayout>
<ImageButton android:id="@+id/btnBack" />
<TextView android:id="@+id/tvTitle" weight="1" />
<ImageButton android:id="@+id/btnSearch" />
<ImageButton android:id="@+id/btnBookmark" />
</LinearLayout>
5.5 底部工具栏
对应 iOS RDEPUBReaderBottomToolView:
<!-- bottom_tool_bar.xml -->
<LinearLayout>
<ImageButton android:id="@+id/btnTOC" /> <!-- 目录 -->
<ImageButton android:id="@+id/btnBookmarks" /> <!-- 书签列表 -->
<ImageButton android:id="@+id/btnHighlights" /> <!-- 高亮列表 -->
<ImageButton android:id="@+id/btnAddHighlight" /> <!-- 添加高亮 -->
<ImageButton android:id="@+id/btnSettings" /> <!-- 设置 -->
</LinearLayout>
5.6 搜索面板
对应 iOS RDEPUBReaderSearchBarView:
// SearchPanelView.kt
class SearchPanelView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val searchField: EditText
private val cancelButton: Button
private val resultsList: RecyclerView // 分组结果列表
private val emptyStateLabel: TextView
fun updateResults(sections: List<SearchSection>, keyword: String, currentMatchIndex: Int?)
fun showNoResults()
fun showSearching()
}
5.7 设置面板
对应 iOS RDEPUBReaderSettingsViewController:
使用 BottomSheetDialogFragment 实现底部弹出设置面板:
- 亮度调节(SeekBar)
- 字体大小(+/- 按钮)
- 字体选择
- 行间距
- 主题切换(6 种预设)
- 翻页模式切换
5.8 目录列表
对应 iOS RDEPUBReaderChapterListController:
使用 BottomSheetDialogFragment + RecyclerView 实现目录列表,支持章节标题显示和点击跳转。
第六阶段:持久化与状态管理(预计 0.5 周)
6.1 持久化实现
对应 iOS RDEPUBReaderPersistence:
// ReaderPersistence.kt
interface ReaderPersistence {
fun loadLocation(bookId: String): ReadingLocation?
fun saveLocation(bookId: String, location: ReadingLocation)
fun loadBookmarks(bookId: String): List<Bookmark>
fun saveBookmarks(bookId: String, bookmarks: List<Bookmark>)
fun loadHighlights(bookId: String): List<Highlight>
fun saveHighlights(bookId: String, highlights: List<Highlight>)
fun loadSettings(): ReaderSettings
fun saveSettings(settings: ReaderSettings)
}
// SharedPreferences 实现
class SharedPrefsPersistence(context: Context) : ReaderPersistence {
// 使用 Gson 序列化/反序列化
}
6.2 状态管理
使用 Kotlin StateFlow 替代 iOS 的手动状态同步:
// ReaderState.kt
data class ReaderUiState(
val isLoading: Boolean = true,
val currentPage: Int = 0,
val totalPages: Int = 0,
val title: String = "",
val isToolbarVisible: Boolean = false,
val isSearchVisible: Boolean = false,
val bookmarks: List<Bookmark> = emptyList(),
val highlights: List<Highlight> = emptyList(),
val searchState: SearchState? = null,
)
class ReaderStateManager {
private val _uiState = MutableStateFlow(ReaderUiState())
val uiState: StateFlow<ReaderUiState> = _uiState.asStateFlow()
}
第七阶段:主题与外观(预计 0.5 周)
7.1 主题系统
对应 iOS RDEPUBReaderTheme:
// ReaderTheme.kt
data class ReaderTheme(
val name: String,
val contentBackgroundColor: Int, // Color int
val textColor: Int,
val toolbarBackgroundColor: Int,
val toolbarForegroundColor: Int,
val isDark: Boolean
) {
companion object {
val LIGHT = ReaderTheme("浅色", Color.WHITE, Color.BLACK, ...)
val DARK = ReaderTheme("深色", Color.rgb(0x1A, 0x1A, 0x1A), Color.WHITE, ...)
val YELLOW = ReaderTheme("纸质", Color.rgb(0xF5, 0xF0, 0xE0), Color.BLACK, ...)
// ... 共 6 种
}
}
7.2 状态栏适配
根据主题动态设置状态栏颜色(Window.statusBarColor)和图标颜色(WindowInsetsController.isAppearanceLightStatusBars)。
第八阶段:测试与 Demo(预计 1 周)
8.1 单元测试
EpubParserTest— EPUB 解析(container.xml、OPF、TOC)SearchEngineTest— 搜索引擎StyleSheetBuilderTest— CSS 生成ResourceResolverTest— URL 解析LocationCoordinatorTest— 位置管理
8.2 集成测试
EpubReaderIntegrationTest— 完整阅读流程(打开 → 分页 → 翻页 → 搜索 → 高亮)
8.3 Demo App
对应 iOS ReadViewDemo:
// MainActivity.kt
class MainActivity : AppCompatActivity() {
private fun openBook() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/epub+zip"
}
startActivityForResult(intent, REQUEST_OPEN_BOOK)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_OPEN_BOOK && resultCode == RESULT_OK) {
val uri = data?.data ?: return
val fragment = EpubReaderFragment.newInstance(uri)
// 嵌入 Fragment
}
}
}
8.4 UI 自动化测试
对应 iOS 的 XCUIApplication 测试(24 个测试文件):
- 使用 Espresso + UiAutomator
- 覆盖:目录交互、页码导航、书签管理、高亮管理、搜索、设置效果、工具栏状态等
实施时间估算
| 阶段 | 内容 | 预计工时 |
|---|---|---|
| 1 | 项目基础设施 | 1 周 |
| 2 | Core 层(解析、模型、搜索、CSS) | 2 周 |
| 3 | JS 桥接层 | 1 周 |
| 4 | 翻页容器 | 1.5 周 |
| 5 | UI 组件 | 2 周 |
| 6 | 持久化与状态管理 | 0.5 周 |
| 7 | 主题与外观 | 0.5 周 |
| 8 | 测试与 Demo | 1 周 |
| 合计 | 约 9.5 周 |
关键技术决策
-
统一 WebView 渲染:不实现 DTCoreText 等效层,所有内容通过 WebView + CSS column 分页。简化实现,与 iOS 的 webInteractive 路径一致。
-
Coordinator 模式保留:与 iOS 架构保持一致,便于两端逻辑对照和维护。
-
JS Bridge 微调:Android 的
@JavascriptInterface与 iOS 的WKScriptMessageHandler机制不同,JS 端需要适配(用AndroidBridge.*替代window.webkit.messageHandlers.*)。 -
不使用 Kotlin Multiplatform:当前阶段直接用 Kotlin 重写,避免 KMP 的额外复杂度。未来如果需要共享逻辑层,可以逐步迁移。
-
ViewPager2 替代 PageViewController:Android 无 page curl 效果,使用 ViewPager2 + 自定义 PageTransformer 实现翻页动画(可选深度效果或滑动效果)。
文件映射索引(iOS → Android)
核心映射
| iOS Swift 文件 | Android Kotlin 文件 | 行数参考 |
|---|---|---|
RDEPUBParser.swift + extensions |
EpubParser.kt + extensions |
~600 |
RDEPUBModels.swift |
EpubModels.kt |
~200 |
RDEPUBPublication.swift |
EpubPublication.kt |
~150 |
RDEPUBJavaScriptBridge.swift |
EpubJavaScriptBridge.kt |
~400 |
RDEPUBStyleSheetBuilder.swift |
StyleSheetBuilder.kt |
~300 |
RDEPUBSearchEngine.swift |
EpubSearchEngine.kt |
~150 |
RDEPUBPaginator.swift |
EpubPaginator.kt |
~200 |
RDEPUBReadingSession.swift |
ReadingSession.kt |
~250 |
RDEPUBReaderController.swift + extensions |
EpubReaderFragment.kt + extensions |
~800 |
RDEPUBReaderContext.swift |
ReaderContext.kt |
~150 |
RDEPUBReaderRuntime.swift |
ReaderRuntime.kt |
~200 |
| 各 Coordinator 文件 | 对应 *Coordinator.kt |
各 ~100-200 |
RDEPUBWebView.swift + extensions |
EpubWebView.kt + extensions |
~500 |
RDReaderView.swift + extensions |
PagerView.kt + extensions |
~400 |
RDEPUBReaderSearchBarView.swift |
SearchPanelView.kt |
~500 |
RDEPUBReaderTopToolView.swift |
TopToolBar.kt |
~150 |
RDEPUBReaderBottomToolView.swift |
BottomToolBar.kt |
~200 |
RDEPUBReaderSettingsViewController.swift |
SettingsSheetFragment.kt |
~300 |
直接复用(不需翻译)
| 文件 | 说明 |
|---|---|
epub-bridge.js |
JS 核心桥接 |
WeReadApi.js |
JS API 门面 |
cssInjector.js |
CSS 注入工具 |
rangy-core.js |
Range 操作库 |
rangy-serializer.js |
Range 序列化 |
不迁移(Android 不需要)
| iOS 文件/目录 | 原因 |
|---|---|
EPUBTextRendering/(18 个文件) |
DTCoreText/CoreText 无 Android 等价物,统一用 WebView |
EPUBUI/TextPage/(7 个文件) |
原生文字渲染层,Android 不需要 |
RDEPUBTextContentView.swift |
同上 |
RDEPUBTextPageRenderView.swift |
同上 |
RDEPUBWebDecorationOverlayView.swift |
Android WebView 的高亮由 JS 直接处理,不需要原生 overlay |