Readium-2,基于WebView的开源电子书项目介绍

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的布局部分就能有一个很直观的了解了:

<!-- activity_r2_viewpager.xml -->
<androidx.constraintlayout.widget.ConstraintLayout>
    <org.readium.r2.navigator.pager.R2ViewPager />
</androidx.constraintlayout.widget.ConstraintLayout>

<!-- viewpager_fragment_epub.xml -->
<androidx.constraintlayout.widget.ConstraintLayout>
    <org.readium.r2.navigator.R2WebView/>
</androidx.constraintlayout.widget.ConstraintLayout>

通过布局可以看出,R2的阅读部分很简单,就是由多个WebView所组成的ViewPager,每一个WebView负责加载一个章节的内容,因为epub格式中每个章节的内容很类似于html,所以进行一些预处理就可以直接在WebView中展示了。而章节内的翻页与内容跳转的逻辑上的操作则交由WebView中的css与js部分来进行处理,而章节间的切换的部分则是交给了ViewPager。

对于WebView中操作的具体实现原理感兴趣的朋友可以翻阅项目中的css与js文件,这里就不再展开了。

那么以上就是对于Readium-2这个电子书项目的简单介绍了,希望能有更多人了解到这个项目,也给有类似需求的开发者带来一些帮助。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,544评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,430评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,764评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,193评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,216评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,182评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,063评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,917评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,329评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,543评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,722评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,425评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,019评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,671评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,825评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,729评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,614评论 2 353

推荐阅读更多精彩内容