阅读器的进度显示与估计


对电子书阅读器来说,提示当前阅读进度是一项很自然的功能,习惯用电脑的人都常看到进度条 (progress bar) 和滚动条 (scroll bar),如右图是 [Textus](http://www.jjgod.org/projects/textus) 中使用的右侧滚动条。

然而在实际实现中,进度计算是一件伤脑筋的事情,比如 Textus 的实现其实很简单:在打开文件时读入整个文件,然后整个交给 [Core Text](http://en.wikipedia.org/wiki/Core_Text) 去排版,将排版后的结果分解为行 (`CTLineRef`) 记录下来,并将所有行总的高度设置为整个文本视图的高度,这样,每当滚动视图 (`NSScrollView`) 移动到某个位置时,重绘函数 (`-drawRect:`) 被调用到,我们根据该位置来判断应该绘制从第几行到第几行的内容,再调用 Core Text 把这些行画出来。

这么做看似很简单直接,结果也很容易保证正确,带来的问题是,每次用户修改设置 (比如调整字体大小、窗口尺寸) 时,就得把整个文件重新排版一遍,即使此时我们只需要看到**当前一页**的内容。为什么这种方法这么低效,我还一直使用它呢?因为这个实现严格依赖滚动视图给出的位置来判断当前阅读进度,所以总的高度估计必须非常精确,不然随便滚动一下就可能出现错位,而一次算给出整个高度的方法最准确,不容易出错。

在 iTextus,也就是 iPad 版本的文本阅读器中,我打算换一种方法,主要的原因是:

* 大家在手持设备中都不喜欢用进度条,因为频繁滑动比较烦人
* 分页方式实现起来比较简单高效,而 iPad 的性能有限

其实从文件载入到显示出来,90% 的时间花费在排版上 (对于 Core Text 程序,就是 `CTFramesetterCreateWithAttributedString` 这一步),剩下 5% 用来读取文件,5% 用来绘制排版结果。所以在 iPad 上我们尽可能的减少排版时间,最简单的方法就是把这个时间均摊到每页上,这样每页的排版时间就几乎可以忽略不计了。另一方面,这样也能减少程序的内存占用,在内存紧张的时候可以简单地回收几个页面的排版数据,而不必清空所有的。

既然采用分页方式,进度显示就很灵活了,我们先看看常见的 iPhone 上电子书阅读器是怎么做的。


[Stanza](http://www.lexcycle.com/) 在全屏阅读状态时,除了页面底部用不同颜色显示已读和未读进度比例之外,没有任何其他的提示,这样看起来非常简洁。

触碰页面中部之后 Stanza 会出现一个更详细的界面,包括上方的导航栏提供了书名和作者,中间提示了章节、页数和进度的百分比,下方工具栏还提供了直接通过拖拉跳转页面的功能。

[GoodReader](http://www.goodiware.com/goodreader.html) 在全屏阅读时干脆什么都不显示,触碰页面中部之后的界面和 Stanza 差不多,不同之处是把页面和跳转进度条放到了左边。


[Eucalyptus](http://eucalyptusapp.com/) 采用的是模仿真实书籍的方式,全屏时在页面上方显示书名和页码。

在触碰页面中部之后,Eucalyptus 显示的界面和 Stanza 差不多,更简单一点,中间没有放置任何 HUD 控件,页码放在了下方,而且干脆也不提供任何设置功能了。


[Classics](http://www.classicsapp.com/) 的方式更有趣一些:它没有单独的信息界面,导航栏是一直保持在页面上部的,书名在导航栏上,然而它改写了导航栏,在上面通过颜色区分显示阅读过和尚未阅读的进度比。下方则用浅色显示章节名称和页码。

其他几个阅读器主要是考虑 iPhone 界面空间非常宝贵,导航栏和状态栏没必要随时保持在页面上,把它们在用户要求时单独显示出来反而不容易干扰阅读,而 Classics 这种做法也是可以理解的,因为它复用了导航栏,算是节省了一些空间,这样实现也简单一点。

此外,Stanza 和 GoodReader 都支持横屏显示,而 Eucalyptus 和 Classics 都不支持。

目前我在 iTextus 中实现的是类似 Stanza 的进度提示方式,如下图所示,因为这是最容易实现的,至于最后是否应该选择这样的方式,我还没有决定,也欢迎你的意见。

与进度显示紧密相关的是进度的估计,因为我们既然不能一次排版完所有内容,就必须有个合适的方法估计当前阅读内容的总页数,目前我采用的一个简单的方法如下:

1. 因为我们一次读取整个文件的内容,所以一开始我们就知道当前文件的总长度 (字符数量)
2. 但是每页可能有不同数量的字符 (比如有的页面段落、换行较多,有的则较密集),可是我们假设平均数量是一个稳定值
3. 总的估计页数 = 文件总的字符数量 / 当前页面平均字符数得到
4. 一开始只排版了一个页面,那当前的页面平均字符数就是第一页的字符数,此后每排版一个新的页面,我们可以让这个平均值更精确一点

经过实际使用的尝试,对于较长的文件 (比如文本长度 100KB 以上的),这个页数估计是非常精确的,就算偶尔出现震荡也不会超过一两页,不会影响用户对进度的体验。

前不久 [Instapaper](http://www.instapaper.com/) 的开发者 [Marco](http://www.marco.org/) 提出了一个新的想法:根据时间跟踪和估计进度,大致构思如下:

1. 从用户打开一个新的文件开始,记录用户阅读该文件的总时间
2. 用户的平均阅读速度 = 该文件总阅读时间 / 当前已阅读的页数 (或者字数?)
3. 根据总的页数或者字数和用户的平均阅读速度推算要读完这本书要花费的总时间
4. 可以在界面中显示从现在开始,如果不间断,将在什么时刻读完这本书

这毫无疑问是一个比较有意思的想法,其实游戏和播放器都经常采用这样的方式 (时间记录和完成时间估计),但用在书籍、文章阅读时会起到积极的作用吗?会不会有些副作用?不惯怎么说,应该是值得尝试的。

Author: Jiang Jiang

A software engineer from China, working on some OS for a fruit company. Interested in typography and science fiction.

9 thoughts on “阅读器的进度显示与估计”

  1. 是不是可以考虑扔掉进度这个概念?使读者不知道内容一共有多少,如果没看完可以一直滚动,直到不能滚动为止。
    个人感觉进度的存在会或多或少影响到人的阅读行为,有目录和书签就可以了。

  2. Stanza在项目列表中的右侧,利用一个饼图来显示当前阅读进度。其好处是用户肯定有时钟指针的概念,一眼就能识别出进度的大约值(比数字和进度条的识别速度应该都要快)。这和看书页厚度一样,都是一种模糊处理。这种模糊化信息表达方式似乎也是一种思路

  3. 我到不觉得Marco那个观点很新颖,很像Readmore这个app做的事情。不过readmore是关于纸质书的就是了

  4. textus是基于core text的,但iphone 中core text好像没有公开,很想知道itextus是用的什么?

  5. 谢谢您的及时回复,倒是我晚了。我看了您的iTextus代码,有个问题想请教您。但您在代码中对于文本采用的是同一个textAttributes。我是新手,有个问题请您看看:
    我希望在一页文本中个别词或短句显示成其它字体或背景,比如一行的中间可能两个词字体不一样,如何快速确定前面的文本已经占用了多少宽度?如 ” hello jjgod,hello iTextus”,我想把iTextus用不同的字体。我用sizeWithFont计算每个单词的显示大小,然后拼接起来,如果发现iTextus字号非常大(不够一行宽度),则换行显示,但对于较多的文本,这种方式效率极低。
    如果按照core text的办法,为不同字体要求的文本使用不同的textAttributes,如何快速确定前面的文本显示到了哪个位置,我才能接着显示另一个文本?因为我发现core text确定的是一个rect,对于最后一行的实际宽度没法获取。
    不知道描述清楚没有,谢谢!

  6. post 不能编辑,唉
    CTLineGetTypographicBounds似乎可以获得已经显示的宽度,我正在准备写点代码测试一下。但依然很麻烦,因为我必须确定一行的开始显示的位置,而API显示的frame都是Rect,代码必须分好行后交给core text显示?效率估计也不高

  7. @suga: Textus 里的代码可以参考。一般用 CTFrameGetLineOrigins() 来获得一个 frame 里每行的 origin。分行显示不是问题,因为 CTFrameDraw() 的内部实现肯定也是逐行遍历的。

Leave a Reply

Your email address will not be published. Required fields are marked *