如何从零开始写一款书籍阅读器

一款书籍阅读器,需要以下功能才能说的上比较完整:

  1. 文字页面展示,即书页;
  2. 页面之间的跳转动画,即翻页动作;
  3. 能够在每一页上记录阅读进度,即书签;
  4. 能够自由选择文字并标注,即笔记;
  5. 能够设置一些属性,如屏幕亮度,字体大小,主体颜色等,即个性化设置。

书籍阅读器

这篇文章带来的就是如何打造这么一款阅读器。(由于整体代码量比较大,所以我只能说说我的实现思路再加上部分的核心代码来说明,不会有太多的代码展示。)

翻页动作——搭建整个阅读器的框架

在阅读器上的翻页动作无外乎仿真和平移这两种动画,翻页时需要准备两张页面,一张是当前页,另一张是需要翻转的下一页。翻页的过程就是对这两个页面的剪辑。

这里就不赘述翻页的原理了(仿真翻页可以由贝塞尔曲线计算坐标绘制实现,平移翻页则是简单坐标平移变化),这里提供一些参考链接。
实现书籍翻页效果
Github上的PageFlip库

现在要做的就是将翻页动作与 View 结合起来,我们新建一个 PageAnimController 内部实现翻页动画和动画切换,同时设置 PageCarver 来监听翻页动作,目的是为了能够让 view 检测到翻页动作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface PageCarver {

void drawPage(Canvas canvas, int index);//绘制页内容
Integer requestPrePage();//请求翻到上一页
Integer requestNextPage();//请求翻到下一页
void requestInvalidate();//刷新界面
Integer getCurrentPageIndex();//获取当前页

/**
* 开始动画的回调
*
* @param isCancel 是否是取消动画
*/

void onStartAnim(boolean isCancel);

/**
* 结束动画的回调
*
* @param isCancel 是否是取消动画
*/

void onStopAnim(boolean isCancel);
}

新建 BaseReaderView 作为阅读器的基础视图,两者结合以便控制阅读器的翻页效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public abstract class BaseReaderView extends View implements PageAnimController.PageCarver{

/**
* 将View的绘制事件传送给 PageAnimController 实现动画绘制过程中
* @param canvas
* @return
*/

@Override
protected void onDraw(Canvas canvas) {
if (pageAnimController == null || !pageAnimController.dispatchDrawPage(canvas, this)) {
drawPage(canvas, currentPageIndex);
}
}

/**
* 将View的触摸事件传送给 PageAnimController 以便实现翻页动画
* @param event
* @return
*/

@Override
public boolean onTouchEvent(MotionEvent event) {
pageAnimController.dispatchTouchEvent(event, this);
return true;
}
}

但是在翻页动画中是需要无数次的调用 drawPage 来绘制界面的,为了减少界面计算的开支必须要有一个 Bitmap 缓存来降低消耗。复用时可以直接使用已经生成的bitmap.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* <p>
* 页面快照,用来存储阅读器每一页的内容
*
* @author cpacm 2017/10/9
*/


public class PageSnapshot {
private int pageIndex;
private Bitmap mBitmap;
private Canvas mCanvas;

public Canvas beginRecording(int width, int height) {
if (mBitmap == null) {
mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_4444);
mCanvas = new Canvas(mBitmap);
} else {
mCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
}
return mCanvas;
}

public void draw(Canvas canvas) {
if (null != mBitmap) {
canvas.drawBitmap(mBitmap, 0, 0, null);
}
}

public void destroy() {
if (mBitmap != null && !mBitmap.isRecycled()) {
mBitmap.recycle();
mBitmap = null;
}
}
}

基础模型如下图所示:
页面切换模型

现在我们来总结一下,这一部分我们搭建了阅读器最基础的框架,包括
(1) 翻页动画与阅读器视图的结合,能够确保在View中正确监听翻页动作,
保证整个翻页动作的准确性。
(2) 利用 Bitmap 缓存优化绘图流程,保证翻页动画的流畅性。而后包括文字,图片等元素的显示都是绘制在这个 Bitmap 上的。

书页——组合模式,保证阅读器高度可定制化

阅读器模块图

一般来说,阅读器获取数据都是一章一章来的,不管是从网络上还是本地。而获取过来的数据阅读器要进行分页才能展示。如上图所示,书页展示由 PageElement 模块负责,该模块接收从 BookReaderView 传入的章节数据,然后再经底下的4个模块计算来分页。
分页模块

  • PageElement,分页模块:功能包括将传入的章节数据分成数个 PageData (生成的 PageData 个数即为该章节页数,PageData 记录了每一页开头文字在章节的位置,同时包含该页面HeaderData, LineData,HeadrDataFooterData 数据等。各个 Data 里面记录了相应的文字信息,可以快速的定位到章节内容中。);绘制页面;缓存章节数据以便无缝切换章节。
  • HeaderElement,页头部分:显示章节的标题;绘制每一页的头部。
  • LineElement,文字行部分:测量一行文字需要的字数;测量行高;绘制行文字;绘制笔记内容;测量每一个字在屏幕中的位置,用于笔记功能;
  • ImageElement,图片部分:测量图片的宽高;绘制图片。
  • FooterElement,页尾部分:绘制每一页的页尾,包括进度,时间和电量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//摘自 PageElement 的 onDraw 方法
@Override
public void draw(Canvas canvas) {
int index = drawPageIndex - startPageIndex;
if (index < 0 || index >= pages.size()) return;
BookPageData bookPageData = pages.get(index);
int offsetX = bookSettingParams.paddingLeft;
int offsetY = bookSettingParams.paddingTop;
if (bookPageData == null) return;
canvas.drawColor(bookSettingParams.getBgColor());
bookHeaderElement.setChapterTitle(bookPageData.getChapterName());
bookHeaderElement.setX(offsetX);
bookHeaderElement.setY(offsetY);
if (bookPageData.isChapterFirstPage()) {
bookHeaderElement.drawFirstPage(canvas);
} else {
bookHeaderElement.draw(canvas);
}

bookFooterElement.setProgress(bookPageData.getPageIndex(), bookPageData.getPageNums());
bookFooterElement.setX(offsetX);
bookFooterElement.setY(offsetY + getHeight() - bookFooterElement.getHeight());
bookFooterElement.draw(canvas);

for (int i = 0; i < bookPageData.getDataList().size(); i++) {
BookData bookData = bookPageData.getDataList().get(i);
if (bookData instanceof BookLineData) {
BookLineData bookLineData = (BookLineData) bookData;
bookLineElement.setLineText(bookLineData.getContent());
bookLineElement.setX(bookLineData.getPosition().x);
bookLineElement.setY(bookLineData.getPosition().y);
bookLineElement.drawWithDigests(canvas, bookLineData, bookReaderView.getCurrentDigests(index));
//bookLineElement.draw(canvas);
} else if (bookData instanceof BookImageData) {
BookImageData bookImageData = (BookImageData) bookData;
bookImageElement.setX(bookImageData.getPosition().x);
bookImageElement.setY(bookImageData.getPosition().y);
bookImageElement.syncDrawWithinBitmap(canvas, bookImageData, bookReaderView.getCacheBitmap(drawPageIndex));
}
}
}

将书页分成几部分组合起来可以有效的减少代码的耦合,而且可以自由的控制每一部分的修改,添加和移除。比如当以后我想要加个批注的功能,可以再添加一个新的 Element ,再复写其测量方法和绘制方法,就可以很方便的使用了。

总结一下:
(1) PageElement 利用各个 Element 模块将章节数据进行测量分页,每一页 PageData 记录着 LineData,ImageData,HeaderDataFooterData信息。绘图时需要将各个信息填入 Element
(2) 绘图时调用 PageElement 的 draw 方法,其 draw 方法再调用 各个 Element 的 draw 方法以完成整个绘图流程。

另外还需要提到的一点是阅读器内部维护了一个书页的队列,该队列缓存了由三个章节数据转化而来的书页列表。比如说你正在阅读第六章,那么队列里面缓存的就是第五章,第六章和第七章的数据,这样就能实现上下章翻页的无缝切换而不需要在翻至下一章时因为等待新的章节数据加载而中断整个阅读体验。

1
2
3
4
5
6
7
8
9
10
/**
* <p>
* 章节缓存构成方案如下:
* | -6,-5,-4,-3,-2,-1,0 | 1,2,3,4,5,6,7,8,9 | 10,11,12,13,14,15 | = pages
* | cacheChapter1 | cacheChapter2 | cacheChapter3 |
* startPageIndex = pageIndex:-6 endPageIndex = pageIndex:16
* currentChapterStartIndex => pageIndex:1 => pages[7]
* currentChapterEndIndex => pageIndex:10 => pages[16]
* </p>
*/

书签,笔记——记录阅读进度

书签

书签的本质就是记录当前页的第一个文字在整章文本的位置,然后再加上书籍的id,章节的id(或序号)就能准确定位。

笔记

要记录笔记就需要文字选择器来选择文字,这个时候就需要知道每一个字在当前的坐标位置(之前用 LineElement 测量文字时已经生成每个文字的位置)。

为了达到上图的效果,就必须要处理在当前页的触摸事件:

文字选择流程

有些细节的处理没有放到流程中,但大致意思是能明白的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// TextSelectorElement 上的触摸分发方法
public boolean dispatchTouchEvent(final MotionEvent ev) {
int key = ev.getAction();
currentTouchPoint.set(ev.getX(), ev.getY());
switch (key) {
case MotionEvent.ACTION_DOWN:
isPressInvalid = false;
hasConsume = true;
isDown = true;
mTouchDownPoint.set(ev.getX(), ev.getY());
// 该方法中会记录isBookDigestDown的值
checkIsPressDigests(ev.getX(), ev.getY());
//判断是否处于选择模式
if (!isSelect) {
if (isBookDigestDown == 0) {
postLongClickPerform(0);//提交长按时间
}
} else {
// 判断是否触摸到选择光标上,若是则可以拖动光标移动
checkCurrentMoveCursor(ev);
}
break;
case MotionEvent.ACTION_MOVE:
float move = PointF.length(ev.getX() - mTouchDownPoint.x, ev.getY() - mTouchDownPoint.y);
if (move > moveSlop) {
isPressInvalid = true;
}
if (isPressInvalid) {
removeLongPressPerform();
if (isSelect) {
// 关闭弹窗(包括笔记编辑框等)
onCloseView();
// 移动光标
onMove(ev);
} else {
//未处于选择模式下,相当于一个普通的点击事件
onPress(ev);
}
}
break;
case MotionEvent.ACTION_UP:
hasConsume = false;
removeLongPressPerform();
if (isSelect) {
// -1 表示为未触摸到光标
if (moveCursor == -1) {
// 取消选择模式
setSelect(false);
hasConsume = true;
} else {
//停止移动时,会打开笔记生成弹框
onOpenDigestsView();
}
moveCursor = -1;
} else {
if (isBookDigestDown == 1) {
onOpenNoteView();
hasConsume = true;
} else if (isBookDigestDown == 2) {
onOpenEditView();
hasConsume = true;
} else {
// 模拟成一个普通的点击事件,会取消当前的选择模式
onPress(ev);
}
}
invalidate();
break;
case MotionEvent.ACTION_CANCEL:
hasConsume = false;
removeLongPressPerform();
break;
default:
break;
}
// 判断选择器是否消耗了当前事件
return hasConsume || isSelect;
}

当然,笔记也要记录当前选择的书籍id,章节id(或序号),文字在章节中的位置这些信息,方便定点跳转。

设置——为阅读器添砖加瓦

阅读器设置界面

阅读器的设置一般包括:界面亮度的调整,字体大小的调整,上下章的跳转,书籍目录笔记和书签的展示,翻页动画的更改,日夜主题的更改。当一些设置需要阅读器能够在参数变化时及时响应,就得需要在设置变化时能及时更新 BookReaderView 下的各个 Element 模块。
这里我是通过一个辅助类贯穿整个阅读器来帮助更新各个模块,该类记录了阅读器内部所有可设置的属性,当各个模块被通知需要更新时重新从该类中读取参数并设置(比如画笔的颜色,页面的间距,字体的大小等)。

1
2
3
4
5
6
7
8
9
10
11
12
// 摘自 PageElement 下的设置属性变化方法
// BookSettingParams 即为记录阅读器设置属性的辅助类
@Override
public void update(ReaderSettingParams params) {
bookSettingParams = (BookSettingParams) params;
bookHeaderElement.update(bookSettingParams);
bookFooterElement.update(bookSettingParams);
bookLineElement.update(bookSettingParams);
bookImageElement.update(bookSettingParams);

initPageElement();
}

语音朗读——为阅读器添加辅助功能

语音朗读

此处的语音朗读使用的是讯飞的TTS引擎。如何使用引入TTS我这里就不具体描述了,重要的是在TTS的 onSpeakProgress(int progress, int beginPos, int endPos) 方法中可以获取当前句子的朗读进度。

当我们传入一章文字时,TTS会自动帮助我们分段(会以,。等标点符号切割整篇文字),然后按段落来进行朗读。上面 progress 代表该段落在整篇文字的进度,beginPos 代表该段落的起始字符在整篇文字的位置,endPos 代表该段落的末尾字符在整篇文字的位置。

既然能够知道朗读的位置,那就能知道朗读时文字在屏幕的位置了(之前有说过 LineData 记录了每个字符在屏幕中的位置),那剩下的就是怎么绘制的问题了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
/**
* <p>
* 听书tts播放模组
*
* @author cpacm 2017/12/13
*/


public class BookSpeechElement extends ResElement implements SynthesizerListener {

// .... 省略部分代码

// 从每一页数据 PageData 中的 LineData 列表中获取要绘制的区域
private void updateDrawRect(int startPos, int endPos) {
if (endPos <= offsetPosition || endPos == this.endPos) return;
this.endPos = endPos;
this.tempPos = startPos;
int s = this.startPos + startPos + bookPageData.getStartPos() - offsetPosition;
int e = this.startPos + endPos + bookPageData.getStartPos() - offsetPosition;
drawRect.clear();
for (BookLineData line : lineData) {
if (line.startPos > e || line.endPos <= s) continue;
if (line.startPos <= s && line.endPos <= e) {
Rect startRect = line.getCharArea().get(s);
Rect endRect = line.getCharArea().get(line.endPos - 1);
Rect rect = new Rect(startRect.left, startRect.top, endRect.right, endRect.bottom);
drawRect.add(rect);
}
if (line.startPos > s && line.endPos <= e) {
Rect startRect = line.getCharArea().get(line.startPos);
Rect endRect = line.getCharArea().get(line.endPos - 1);
Rect rect = new Rect(startRect.left, startRect.top, endRect.right, endRect.bottom);
drawRect.add(rect);
}
if (line.startPos > s && line.endPos > e) {
Rect startRect = line.getCharArea().get(line.startPos);
Rect endRect = line.getCharArea().get(e);
Rect rect = new Rect(startRect.left, startRect.top, endRect.right, endRect.bottom);
drawRect.add(rect);
}
if (line.startPos <= s && line.endPos > e) {
Rect startRect = line.getCharArea().get(s);
Rect endRect = line.getCharArea().get(e);
Rect rect = new Rect(startRect.left, startRect.top, endRect.right, endRect.bottom);
drawRect.add(rect);
}
}
// 刷新当前书页
bookReaderView.flashCurrentPageSnapshot();
}


@Override
public void draw(Canvas canvas) {
if (!isSpeaking()) return;
for (Rect rect : drawRect) {
canvas.drawLine(rect.left, rect.bottom, rect.right, rect.bottom, paint);
}
}

@Override
public void destroy() {
exitTts();
}

/*################## 语音合成的回调 ###################*/
@Override
public void onSpeakBegin() {}

@Override
public void onBufferProgress(int progress, int beginPos, int endPos, String info) { }

@Override
public void onSpeakPaused() {}

@Override
public void onSpeakResumed() {}

@Override
public void onSpeakProgress(int progress, int beginPos, int endPos) {
// 根据朗读的进度更新UI
updateDrawRect(beginPos, endPos);
}

@Override
public void onCompleted(SpeechError speechError) {}

@Override
public void onEvent(int i, int i1, int i2, Bundle bundle) {}
}

总结

首先声明一点,整篇文章只是阐述了我自己从零开始做书籍阅读器时一些思路和使用的一些技巧,并没有覆盖到阅读器的各个角落。如果你想要自己实现一款阅读器,那你必须要有扎实的基础知识,比如View的绘制流程和事件分发流程,Canvas的绘图知识等,这篇文章也只是给大家提个思路而已。如果有问题或者新的想法欢迎交流!