前言
当我们想要开发一个电子书阅读器时,不可避免地会遇到 EPUB 格式。
其实,EPUB 并不复杂,只要掌握了 XML 解析和 HTML 处理,就能够写出一个高性能、轻量级的解析引擎。
我们先从最基础的解析工具开始。
XmlPullParser
EPUB 的核心元数据文件(.opf)和容器描述文件(.xml),本质上都是 XML。Android 官方推荐使用 XmlPullParser 来解析它们。
什么是 Pull 解析?
Pull 解析就是一行行往下读取,这种方式内存占用低。
与之相对的有 DOM 解析,它是一次性将整个文件读入内存,它的优点是快,但对内存极度不友好。
事件循环
XmlPullParser 的工作模式其实就是一个 while 循环,不断处理当前的事件类型:
START_DOCUMENT: 开始START_TAG: 读到了开始标签TEXT: 读到了标签里的文字END_TAG: 读到了结束标签END_DOCUMENT: 结束
实战
了解完以上内容,就可以开始解析一段 XML 了。
以下面这段 XML 为例:
xml
<student>
<name>张三</name>
<age>18</age>
</student>
作为
student_data.xml文件存放在app/src/main/assets目录下。
在 MainActivity 中使用 XmlPullParser 来解析:
kotlin
data class Student(var name: String = "", var age: Int = 0)
suspend fun parseStudentXml(inputStream: InputStream): Student? = withContext(Dispatchers.IO) { // 将耗时操作放在 IO 线程中
// 创建 Pull 解析器
val parser = Xml.newPullParser()
var student: Student? = null
try {
parser.setInput(inputStream, "UTF-8")
var eventType = parser.eventType
var currentName = ""
var currentAge = 0
Log.d("XmlDemo", "开始解析 XML 文档...")
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
// 获取标签名
when (parser.name) {
"student" -> {
Log.d("XmlDemo", "遇到标签 student")
}
// 获取标签内容, 并移到标签末位
"name" -> {
Log.d("XmlDemo", "遇到标签 name")
currentName = parser.nextText()
}
"age" -> {
Log.d("XmlDemo", "遇到标签 age")
currentAge = parser.nextText().toIntOrNull() ?: 0
}
}
}
}
// 获取下一个事件
eventType = parser.next()
}
student = Student(currentName, currentAge)
Log.d("XmlDemo", "解析成功: $student")
} catch (e: Exception) {
Log.e("XmlDemo", "解析失败", e)
} finally {
inputStream.close()
}
return@withContext student
}
// 调用示例
lifecycleScope.launch {
try {
val inputStream = assets.open("student_data.xml")
val student = parseStudentXml(inputStream)
} catch (e: IOException) {
e.printStackTrace()
}
}
运行结果:
ini
开始解析 XML 文档...
遇到标签 student
遇到标签 name
遇到标签 age
解析成功: Student(name=张三, age=18)
nextText() 陷阱
nextText() 会读取当前标签的内容,并移动到标签末尾 END_TAG。
-
如果标签内容是空的,那么它会返回空串 ""。
-
如果标签还嵌套了标签,那么它可能会抛出异常或导致数据丢失。
例如,我们将上述的 XML 内容改为:
xml
<student>
<name>张<br />三
</name>
<age>18</age>
</student>
此时运行程序,会报错:
less
解析失败 org.xmlpull.v1.XmlPullParserException: END_TAG expected (position:START_TAG (empty) <br>@2:18 in java.io.InputStreamReader@352bfed)
意思是说在期望找到一个结束标签时,却遇到了一个开始标签 <br。但我们认为的 <br /> 只是一个换行符,应该被当作文本内容进行处理。
这时,我们需要手动处理 TEXT 事件。
标签之间的换行符也是一个 TEXT 事件。
手动处理 TEXT 事件
手动处理 TEXT 事件时,为了解决 XML 嵌套导致的状态丢失问题,我们需要引入栈来维护状态。我们推荐使用 ArrayDeque 来替换 Stack,性能更好。
逻辑流程:
-
START_TAG:将当前标签名压入状态栈中。
-
TEXT:检查栈顶元素,如果是目标标签,就提取并拼接文本;如果是目标标签的子标签,根据业务逻辑判断是否需要提取。
-
END_TAG:弹出栈顶元素,回到最近一层的父标签的状态。
代码实现:基于栈的状态机
kotlin
suspend fun parseStudentXmlManual(inputStream: InputStream): Student? =
withContext(Dispatchers.IO) {
val parser = Xml.newPullParser()
var student: Student? = null
// 用 ArrayDeque 代替 Stack
val stateStack = ArrayDeque<String>()
try {
parser.setInput(inputStream, "UTF-8")
var eventType = parser.eventType
val sbName = StringBuilder()
var age = 0
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
val tagName = parser.name
// 存入新状态
stateStack.addLast(tagName)
}
XmlPullParser.TEXT -> {
// 获取当前文本内容
val text = parser.text
if (!text.isNullOrBlank()) {
// 查看栈顶,决定了当前文本属于谁
if (stateStack.isNotEmpty()) {
val currentTag = stateStack.last() // 获取当前所处的最近一层标签
when (currentTag) {
"name" -> sbName.append(text.trim())
"br" -> { /* 忽略 */
}
"age" -> age = text.trim().toIntOrNull() ?: 0
}
}
}
}
XmlPullParser.END_TAG -> {
// 恢复到上一个状态
if (stateStack.isNotEmpty()) {
stateStack.removeLast()
}
}
}
eventType = parser.next()
}
val name: String = sbName.toString()
student = Student(name, age)
Log.d("XmlDemo", "解析成功: $student")
} catch (e: Exception) {
Log.e("XmlDemo", "解析失败", e)
} finally {
inputStream.close()
}
return@withContext student
}
运行结果:
ini
解析成功: Student(name=张三, age=18)
Jsoup
EPUB 的正文内容其实就是网页(XHTML/HTML)。对于解析 HTML,我们不会去使用 XmlPullParser,因为它嵌套很复杂,并且标签常常不闭合。
这时,我们会使用 Jsoup 来解析。
它能将杂乱的 HTML 修正为标准的 DOM 树,并且有着 CSS 选择器:可以像写 CSS 样式一样查找元素。
CSS 选择器
提取数据非常容易:
-
选取所有一级标题:
doc.select("h1")。 -
选取所有带图片路径的 img 标签:
doc.select("img[src]")。
实战
假设要解析如下 HTML 文件,我们需要提取标题、作者和正文内容,去除掉正文中的广告。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Android 性能优化实战 - 技术周刊</title>
</head>
<body>
<header>
<div id="nav">首页 > Android > 性能优化</div>
</header>
<div class="container">
<article class="post">
<h1 class="post-title">Android 内存优化:从入门到精通</h1>
<div class="meta-info">
<span>作者:Android专家</span>
<span>阅读:1024</span>
</div>
<hr>
<div class="post-content">
<p>
在 Android 开发中,<b>内存泄漏</b>是导致应用卡顿的主要原因之一。
</p>
<div class="ad-banner">
<p>广告:点击领取高薪面试题库!</p>
</div>
<p>
使用 <a href="https://square.github.io/leakcanary/">LeakCanary</a> 工具,可以帮助我们快速定位 <i>Activity</i> 和 <i>Fragment</i> 的泄漏点。
</p>
<p>只有深入理解 GC 机制,才能写出健壮的代码。</p>
</div>
</article>
</div>
<footer>
<p>Copyright © 2022 技术博客</p>
</footer>
</body>
</html>
首先引入依赖:
kotlin
dependencies {
implementation("org.jsoup:jsoup:1.21.2")
}
代码如下:
kotlin
data class Blog(
val title: String,
val author: String,
val content: String,
)
suspend fun parseHtmlContent(
inputStream: InputStream
) = withContext(Dispatchers.IO) {
// 解析 HTML 为 Document 对象
val doc: Document = Jsoup.parse(inputStream, "UTF-8", "")
// 提取标题
val title = doc.select("h1.post-title").text()
// 提取作者
val author = doc.selectFirst("div.meta-info")?.text()
?.substringAfter("作者:")
?.substringBefore("阅读:")
?.trim()
?: "未知"
val contentDiv = doc.select("div.post-content")
// 先去除广告节点
contentDiv.select(".ad-banner").remove()
// 获取内容
val content = contentDiv.text()
return@withContext Blog(title, author, content)
}
// 使用示例
lifecycleScope.launch(Dispatchers.IO) {
try {
val inputStream = assets.open("blog.html")
val blog = parseHtmlContent(inputStream)
Log.d("EpubTest", "Blog: $blog")
} catch (e: IOException) {
e.printStackTrace()
}
}
运行结果:
ini
Blog: Blog(title=Android 内存优化:从入门到精通, author=Android专家, content=在 Android 开发中,内存泄漏是导致应用卡顿的主要原因之一。 使用 LeakCanary 工具,可以帮助我们快速定位 Activity 和 Fragment 的泄漏点。 只有深入理解 GC 机制,才能写出健壮的代码。)
DOM 遍历与节点操作
直接使用 text(),确实很方便,它能自动处理换行。但它也会将 <b>, <a> 等标签剥离,导致加粗、斜体等样式也都丢失了。
这时,我们可以手动遍历 DOM 树来手动处理特定标签,代码如下:
kotlin
suspend fun parseHtmlContent(
inputStream: InputStream
) = withContext(Dispatchers.IO) {
val doc: Document = Jsoup.parse(inputStream, "UTF-8", "")
val title = doc.select("h1.post-title").text()
// 提取作者
val author = doc.selectFirst("div.meta-info")?.text()
?.substringAfter("作者:")
?.substringBefore("阅读:")
?.trim()
?: "未知"
val sbContent = StringBuilder()
val contentDiv = doc.select("div.post-content").first()
// 移除广告节点
contentDiv?.select(".ad-banner")?.remove()
contentDiv?.let {
// 遍历容器的子节点
for (node in it.childNodes()) {
traverseNode(node, sbContent)
}
}
val content = sbContent.toString().trim()
return@withContext Blog(title, author, content)
}
/**
* 递归遍历节点树
*/
fun traverseNode(
node: Node,
sb: StringBuilder
) {
when (node) {
// 纯文本节点
is TextNode -> {
val text = node.text().trim()
if (text.isNotBlank()) {
sb.append(text)
}
}
// 元素节点
is Element -> {
when (node.tagName()) {
"p" -> { // 段落
sb.append("\n")
node.childNodes().forEach { traverseNode(it, sb) }
}
"div", "span" -> { // 容器
node.childNodes().forEach { traverseNode(it, sb) }
}
"b", "strong" -> {
sb.append("[加粗: ")
node.childNodes().forEach { traverseNode(it, sb) }
sb.append("]")
}
"i", "em" -> {
sb.append("[斜体: ")
node.childNodes().forEach { traverseNode(it, sb) }
sb.append("]")
}
"a" -> {
val href = node.attr("href")
sb.append("[链接($href): ")
node.childNodes().forEach { traverseNode(it, sb) }
sb.append("]")
}
else -> {}
}
}
}
}
解析出的内容:
css
在 Android 开发中,[加粗: 内存泄漏]是导致应用卡顿的主要原因之一。
使用[链接(https://square.github.io/leakcanary/): LeakCanary]工具,可以帮助我们快速定位[斜体: Activity]和[斜体: Fragment]的泄漏点。
只有深入理解 GC 机制,才能写出健壮的代码。
在实际开发中,我们会使用
SpannableStringBuilder来拼接结果,比如遇到<b>标签时应用StyleSpan(Typeface.BOLD),这样可以直接在 UI 组件中显示富文本效果。
EPUB 的组成
EPUB 电子书本质上就是一个 ZIP 压缩包,你可以将其后缀改为 .zip 并解压,查看其目录结构:
less
eBook/
├── META-INF/
│ └── container.xml (入口)
├── OEBPS/
│ ├── content.opf (核心)
│ ├── nav.xhtml (导航)
│ ├── toc.ncx (目录)
│ ├── Styles/ (样式)
│ ├── Text/ (正文)
│ └── Images/ (图片)
└── mimetype (身份标识)
关键文件:META-INF/container.xml
它的作用是告诉我们核心描述文件(.opf)的位置。
xml
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
我们只需使用 XmlPullParser 读取 full-path 属性,就能拿到文件路径 "OEBPS/content.opf"。
kotlin
suspend fun parseContainer(inputStream: InputStream): String? = withContext(Dispatchers.IO) {
val xmlPullParser = Xml.newPullParser()
xmlPullParser.setInput(inputStream, "UTF-8")
var eventType = xmlPullParser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
val tagName = xmlPullParser.name
if (tagName == "rootfile") {
val rootFilePath = xmlPullParser.getAttributeValue(null, "full-path")
Log.d("ContainerRootFilePath", "rootFilePath: $rootFilePath")
return@withContext rootFilePath
}
}
}
eventType = xmlPullParser.next()
}
return@withContext null
}
运行结果:
bash
rootFilePath: OEBPS/content.opf
关键文件:opf 文件
OPF 文件也是一个 XML,它存着书籍的重要信息:
<metadata>:元数据。书名、作者、标识符、简介、语言、简介、出版社、分类、封面等。<mainfest>:资源清单。书中的所有文件都要在此进行注册,每一项(item)都有着href、id、media-type。spine:书脊。它引用了资源清单里面的 id,定义了书的阅读顺序。
OPF 示例
xml
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title id="id">男女之间存在纯友情吗?(不,不存在!) 第一卷 Flag1.不然到三十岁还单身就跟我在一起吧?</dc:title>
<dc:creator id="id-2">七菜なな</dc:creator>
<dc:creator id="id-3">Parum</dc:creator>
<dc:identifier>calibre:19525</dc:identifier>
<dc:identifier>uuid:77842cae-a0d7-4144-9f4d-9ca1a73b2129</dc:identifier>
<dc:identifier id="BookId" opf:scheme="UUID">urn:uuid:52746820-f83f-11ee-bf6c-08bfb888ec3a</dc:identifier>
<dc:source>https://www.wenku8.cc/book/3103.htm</dc:source>
<dc:language>zh</dc:language>
<dc:contributor id="id-1">calibre (7.0.0) [https://calibre-ebook.com]</dc:contributor>
<dc:description>在某一所乡村国中,一对男女向彼此立下永恒友情的誓言。
朝着同一个梦想前进,成为命运共同体的两人之间──
关系并没有任何特别的发展,就这么度过了两年的岁月......!
至今都还没谈过初恋的 High 咖女子──犬冢日葵,
以及热爱花卉的植物男子──夏目悠宇,
就算升上高中二年级,还是一样在只有两人的园艺社中,平和地当着挚友。
然而这时,悠宇跟初恋对象重逢,使得两人之间的关系开始失控?
究竟「已经懂得恋慕之心」的日葵,能不能摆脱「理想挚友」的身份呢?</dc:description>
<dc:publisher>电击文库</dc:publisher>
<dc:subject>校园</dc:subject>
<dc:subject>青春</dc:subject>
<dc:subject>恋爱</dc:subject>
<dc:subject>后宫</dc:subject>
<dc:subject>欢乐向</dc:subject>
<opf:meta refines="#id" property="title-type">main</opf:meta>
<opf:meta refines="#id" property="file-as">男女之间存在纯友情吗?(不,不存在!) 第一卷 Flag1.不然到三十岁还单身就跟我在一起吧?</opf:meta>
<meta name="cover" content="img157909.jpg"/>
<opf:meta refines="#id-1" property="role" scheme="marc:relators">bkp</opf:meta>
<opf:meta refines="#id-2" property="role" scheme="marc:relators">aut</opf:meta>
<opf:meta refines="#id-2" property="file-as">七菜なな</opf:meta>
<opf:meta refines="#id-3" property="role" scheme="marc:relators">aut</opf:meta>
<opf:meta refines="#id-3" property="file-as">Parum</opf:meta>
<opf:meta property="belongs-to-collection" id="id-4">男女之间存在纯友情吗?(不,不存在!)</opf:meta>
<opf:meta refines="#id-4" property="collection-type">series</opf:meta>
<opf:meta refines="#id-4" property="group-position">1</opf:meta>
</metadata>
<manifest>
<item href="Text/Cover.xhtml" id="Cover.xhtml" media-type="application/xhtml+xml"/>
<item href="Text/chapter0.xhtml" id="chapter0.xhtml" media-type="application/xhtml+xml"/>
<item href="Text/chapter1.xhtml" id="chapter1.xhtml" media-type="application/xhtml+xml"/>
<item href="Text/Credits.xhtml" id="Credits.xhtml" media-type="application/xhtml+xml"/>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item href="toc.ncx" id="ncxuks" media-type="application/x-dtbncx+xml"/>
<item href="Styles/style.css" id="style.css" media-type="text/css"/>
<item href="Images/157909.jpg" id="img157909.jpg" media-type="image/jpeg"/>
<item href="Images/157910.jpg" id="img157910.jpg" media-type="image/jpeg"/>
</manifest>
<spine toc="ncxuks">
<itemref idref="Cover.xhtml"/>
<itemref idref="chapter0.xhtml"/>
<itemref idref="chapter1.xhtml"/>
<itemref idref="Credits.xhtml"/>
</spine>
</package>
定义数据类:
kotlin
/**
* 书籍元数据
*/
data class BookMetadata(
val title: String = "",
val authors: List<String> = emptyList(),
val description: String = "",
val language: String = "",
val cover: String = "",
val publisher: String = "",
val categories: List<String> = emptyList(),
)
/**
* 书籍目录顺序
*/
data class BookSpineItem(
val id: String,
val href: String,
val mediaType: String,
)
// 临时存储 manifest 数据
private data class ManifestItem(val href: String, val mediaType: String)
因为元数据封面和书脊都使用了 ID 引用,所以我们可以先解析 Manifest,构建出一个映射表,再来解析 Metadata 和 Spine,这样更加清晰。
kotlin
fun parseOpf(inputStream: InputStream): Pair<BookMetadata, List<BookSpineItem>> {
val parser = Xml.newPullParser()
parser.setInput(inputStream, "UTF-8")
// 开启命名空间处理,这样 parser.name 会返回 "title" 而不是 "dc:title",不包含 dc: 前缀
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
var eventType = parser.eventType
// 映射表:ID -> ManifestItem
val manifestMap = mutableMapOf<String, ManifestItem>()
// 书籍元数据
var title = ""
val authors = mutableListOf<String>()
var description = ""
var language = ""
var publisher = ""
val categories = mutableListOf<String>()
var coverId = "" // 封面 ID
// 脊骨列表:只存 ID 引用,稍后解析
val spineIdRefs = mutableListOf<String>()
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
val tagName = parser.name
when (tagName) {
// 处理元数据 (Metadata)
"title" -> title = safeNextText(parser)
"creator" -> authors.add(safeNextText(parser))
"description" -> description = safeNextText(parser)
"language" -> language = safeNextText(parser)
"publisher" -> publisher = safeNextText(parser)
"subject" -> categories.add(safeNextText(parser))
"meta" -> {
// 处理封面:<meta name="cover" content="img157909.jpg"/>
val nameAttr = parser.getAttributeValue(null, "name")
if (nameAttr == "cover") {
coverId = parser.getAttributeValue(null, "content")
}
}
// 处理清单 (Manifest)
"item" -> {
// <item id="..." href="..." media-type="..."/>
val id = parser.getAttributeValue(null, "id")
val href = parser.getAttributeValue(null, "href")
val mediaType = parser.getAttributeValue(null, "media-type")
if (id != null && href != null && mediaType != null) {
manifestMap[id] = ManifestItem(href, mediaType)
}
}
// --- 3. 处理脊骨 (Spine) ---
"itemref" -> {
// <itemref idref="..."/>
val idref = parser.getAttributeValue(null, "idref")
if (idref != null) {
spineIdRefs.add(idref)
}
}
}
}
}
eventType = parser.next()
}
// 解析封面路径:利用 coverId 从 manifestMap 查找真实路径
val finalCoverPath = coverId.let { id ->
manifestMap[id]?.href
} ?: ""
val metadata = BookMetadata(
title = title,
authors = authors,
description = description,
language = language,
cover = finalCoverPath, // 真实路径
publisher = publisher,
categories = categories
)
val bookSpineItems = spineIdRefs.mapNotNull { idref ->
val item = manifestMap[idref]
if (item != null) {
BookSpineItem(
id = idref,
href = item.href,
mediaType = item.mediaType
)
} else {
Log.w("OpfParser", "Spine item '$idref' not found in manifest.")
null
}
}
return Pair(metadata, bookSpineItems)
}
// 防止空标签导致异常
private fun safeNextText(parser: XmlPullParser): String {
var result = ""
if (parser.next() == XmlPullParser.TEXT) {
result = parser.text
parser.nextTag() // 移至 END_TAG
}
return result
}
测试:
kotlin
lifecycleScope.launch(Dispatchers.IO) {
try {
val inputStream = assets.open("OEBPS/content.opf")
val (metadata, spine) = parseOpf(inputStream)
println("=== 书籍信息 ===")
println("标题: ${metadata.title}")
println("作者: ${metadata.authors}")
println("封面路径: ${metadata.cover}")
println("简介: ${metadata.description.take(100)}...")
println("\n=== 章节列表 (Spine) ===")
spine.forEach { item ->
println("ID: ${item.id.padEnd(15)} | Type: ${item.mediaType.padEnd(25)} | Path: ${item.href}")
}
} catch (e: IOException) {
e.printStackTrace()
}
}
运行结果:
yaml
=== 书籍信息 ===
标题: 男女之间存在纯友情吗?(不,不存在!) 第一卷 Flag1.不然到三十岁还单身就跟我在一起吧?
作者: [七菜なな, Parum]
封面路径: Images/157909.jpg
简介: 在某一所乡村国中,一对男女向彼此立下永恒友情的誓言。
朝着同一个梦想前进,成为命运共同体的两人之间──
关系并没有任何特别的发展,就这么度过了两年的岁月......!
至今都还没谈过初恋的 High 咖女子─...
=== 章节列表 (Spine) ===
ID: Cover.xhtml | Type: application/xhtml+xml | Path: Text/Cover.xhtml
ID: chapter0.xhtml | Type: application/xhtml+xml | Path: Text/chapter0.xhtml
ID: chapter1.xhtml | Type: application/xhtml+xml | Path: Text/chapter1.xhtml
ID: chapter2.xhtml | Type: application/xhtml+xml | Path: Text/chapter2.xhtml
ID: chapter3.xhtml | Type: application/xhtml+xml | Path: Text/chapter3.xhtml
ID: chapter4.xhtml | Type: application/xhtml+xml | Path: Text/chapter4.xhtml
ID: chapter5.xhtml | Type: application/xhtml+xml | Path: Text/chapter5.xhtml
ID: chapter6.xhtml | Type: application/xhtml+xml | Path: Text/chapter6.xhtml
ID: chapter7.xhtml | Type: application/xhtml+xml | Path: Text/chapter7.xhtml
ID: Credits.xhtml | Type: application/xhtml+xml | Path: Text/Credits.xhtml
总结
关于解析的内容就到这里,我们来总结一下 EPUB 阅读器的解析流程:
- 获取 EPUB 电子书的 Uri。
- 使用
ZipInputStream读取这个压缩文件(节省空间)。 - 在 Zip 流中找到
META-INF/container.xml文件,解析出.opf文件的相对路径。 - 从 OPF 文件中解析出书籍元数据,以及阅读顺序(书籍目录)。
- 最后,在渲染内容时,根据路径找到内容文件,使用
Jsoup解析 HTML 即可。