Readium-2(简称R2)是一个由Readium基金会开发的,适用于Android与IOS平台的阅读器项目。与最同类的FBReader相比,最大的区别就是将电子书的解析与展示都交给了WebView来实现,并通过css与js来实现电子书的阅读效果。
支持特性
- 支持EPUB 2.x 与 3.x
- 支持Readium LCP
- 支持CBZ格式
- 自定义样式
- 夜间(深色)模式
- 支持翻页模式与滚动模式
- 电子书目录
- 支持OPDS 1.x 与 2.0
- 支持FXL格式
- 支持RTL模式
先贴上项目的地址:https://github.com/readium/r2-testapp-kotlin
首先,来大概介绍一下这个项目的优缺点与适用场景。
优点
- 将电子书的解析与展示都交给了浏览器完成,无需手动处理。
- 由于项目开发时间较新,而且原生部分使用kotlin进行开发,不会有FBReader等项目难以编译的问题。
- 项目中没有使用Native层的代码。
缺点
- 性能相较基于基于原生的项目执行效率上会差一些。
- 由于需要同时处理原生,JS,CSS的代码,可能会给开发和调试带来一定的麻烦。
- 由于加载机制的限制,部分全局功能(如获取全书总页数)难以实现。
- 在7.0或以下项目中展示效果会有问题。(这一点可以通过css的适配来解决)
注:该项目依然在持续进行更新,可能会在未来解决文中提到的部分问题,详情还是推荐关注该项目的Github主页。
适用场景
如果开发时间较为紧张,而且对于阅读模块的功能方面要求较为简单,对样式支持上的要求较高,又能够完成比较简单的js与css上的问题的话,Readium-2是一个较为不错的选择。
模块结构
代码分析
对于一个阅读器来说,最主要无非两个功能:对文件的解析与文本内容的展示。R2引入了NanoHttpd来直接在本地架设了一个轻量级的WebServer,然后将JS文件,CSS文件,字体文件与电子书文件等等都加载到这个WebServer中,再由WebServer将这些文件打包为一个完整的Web然后交由WebView展示出来。下面就以Epub格式的电子书为例,分别从这两个角度来看看R2在这两方面具体是如何处理的。
对文件的解析
首先,在onCreate方法中调用startServer方法启动本地服务器并加载部分基础js文件,之后由EpubParser类来解析container.xml文件与核心OPF文件
EpubParse.parse
override fun parse(fileAtPath: String, title: String): PubBox? {
//获取container.xml的输出流
val container = try {
generateContainerFrom(fileAtPath)
} catch (e: Exception) {
Timber.e(e, "Could not generate container")
return null
}
val data = try {
container.data(containerDotXmlPath)
} catch (e: Exception) {
Timber.e(e, "Missing File : META-INF/container.xml")
return null
}
//标记电子书格式为EPUB
container.rootFile.mimetype = mimetypeEpub
//通过解析container.xml文件获取核心OPF文件的路径
container.rootFile.rootFilePath = getRootFilePath(data)
val xmlParser = XmlParser()
val documentData = try {
container.data(container.rootFile.rootFilePath)
} catch (e: Exception) {
Timber.e(e, "Missing File : ${container.rootFile.rootFilePath}")
return null
}
//将核心OPF文件解析为XmlParser对象,即将所有的节点提取出来以便于之后的处理(OPF文件的结构与xml文件几乎一致)
xmlParser.parseXml(documentData.inputStream())
val epubVersion = xmlParser.root().attributes["version"]!!.toDouble()
//最后将核心OPF文件解析为Publication对象
val publication = opfParser.parseOpf(xmlParser, container.rootFile.rootFilePath, epubVersion)
?: return null
val drm = container.scanForDrm()
parseEncryption(container, publication, drm)
parseNavigationDocument(container, publication)
parseNcxDocument(container, publication)
/*
* This might need to be moved as it's not really about parsing the Epub
* but it sets values needed (in UserSettings & ContentFilter)
*/
setLayoutStyle(publication)
container.drm = drm
return PubBox(publication, container)
}
在上面的代码中,解析的逻辑上还是比较常规的,其中最关键的部分就是生成了Publication对象,其中包含了整本书的metadata与目录(即每一个目录节点与对应文件的映射关系)。
之后就是R2的重头戏,WebServer的初始化与启动。先来看看Server类的构造函数:
class Server(port: Int) : AbstractServer(port)
abstract class AbstractServer(private var port: Int) : RouterNanoHTTPD("127.0.0.1", port)
所以Server其实就是一个扩展过的RouterNanoHTTPD,限于篇幅,就不向RouterNanoHTTPD的源码进行深究了。在Server创建完成后,要将电子书的基本信息载入Server中:
Server.addEpub
fun addEpub(publication: Publication, container: Container, fileName: String, userPropertiesPath: String?) {
val fetcher = Fetcher(publication, container, userPropertiesPath, customResources)
//处理link中的额外字段
addLinks(publication, fileName)
publication.addSelfLink(fileName, URL("$BASE_URL:$port"))
//通过对应Handler将相应文件添加进本地服务器中
if (containsMediaOverlay) {
addRoute(fileName + MEDIA_OVERLAY_HANDLE, MediaOverlayHandler::class.java, fetcher)
}
addRoute(fileName + JSON_MANIFEST_HANDLE, ManifestHandler::class.java, fetcher)
addRoute(fileName + MANIFEST_HANDLE, ManifestHandler::class.java, fetcher)
addRoute(fileName + MANIFEST_ITEM_HANDLE, ResourceHandler::class.java, fetcher)
addRoute(JS_HANDLE, JSHandler::class.java, resources)
addRoute(CSS_HANDLE, CSSHandler::class.java, resources)
addRoute(FONT_HANDLE, FontHandler::class.java, fonts)
}
private fun addLinks(publication: Publication, filePath: String) {
containsMediaOverlay = false
//判断电子书是否支持多媒体内容(如音频,视频等)
for (link in publication.otherLinks) {
if (link.rel.contains("media-overlay")) {
containsMediaOverlay = true
link.href = link.href?.replace("port", "127.0.0.1:$listeningPort$filePath")
}
}
}
在上述代码中,值得关注的有addRoute(String url, Class> handler, Object... initParameter)方法与Fetcher对象的创建。addRoute方法将url与一个RouterNanoHTTPD.DefaultHandler的子类加入服务器中。之后在浏览器使用url访问本地服务器时,会调用Handler方法返回相应的数据。下面来看看Fetcher类的部分代码:
class Fetcher(var publication: Publication, var container: Container, private val userPropertiesPath: String?, customResources: Resources? = null) {
// …………
private fun getContentFilters(mimeType: String?, customResources: Resources? = null): ContentFilters {
return when (mimeType) {
//对epub文件内容进行预处理后
"application/epub+zip", "application/oebps-package+xml" -> ContentFiltersEpub(userPropertiesPath, customResources)
"application/vnd.comicbook+zip", "application/x-cbr" -> ContentFiltersCbz()
else -> throw Exception("Missing container or MIMEtype")
}
}
//ResourceHandler类中的get方法会通过调用该方法获取进行过预处理后的书籍内容的InputStream
fun dataStream(path: String): InputStream {
var inputStream = container.dataInputStream(path)
inputStream = contentFilters?.apply(inputStream, publication, container, path) ?: inputStream
return inputStream
}
在dataStream方法中会调用contentFilters对象中的apply方法对内容部分进行预处理,如添加css样式,引入js文件,引入字体文件等等。
到这里,对于epub文件与本地服务器的预处理就基本完成了,之后将会跳转到EpubActivity页面进行电子书的展示。
电子书的展示
在电子书阅读的部分,我相信直接来看EpubActivity的布局部分就能有一个很直观的了解了:
通过布局可以看出,R2的阅读部分很简单,就是由多个WebView所组成的ViewPager,每一个WebView负责加载一个章节的内容,因为epub格式中每个章节的内容很类似于html,所以进行一些预处理就可以直接在WebView中展示了。而章节内的翻页与内容跳转的逻辑上的操作则交由WebView中的css与js部分来进行处理,而章节间的切换的部分则是交给了ViewPager。
对于WebView中操作的具体实现原理感兴趣的朋友可以翻阅项目中的css与js文件,这里就不再展开了。
那么以上就是对于Readium-2这个电子书项目的简单介绍了,希望能有更多人了解到这个项目,也给有类似需求的开发者带来一些帮助。