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

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

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

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

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

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

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

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

 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 作为阅读器的基础视图,两者结合以便控制阅读器的翻页效果。

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.

/**
 * <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,页尾部分:绘制每一页的页尾,包括进度,时间和电量。
    //摘自 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 方法以完成整个绘图流程。

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

/**
 * <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 测量文字时已经生成每个文字的位置)。

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

文字选择流程

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

// 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 模块。
这里我是通过一个辅助类贯穿整个阅读器来帮助更新各个模块,该类记录了阅读器内部所有可设置的属性,当各个模块被通知需要更新时重新从该类中读取参数并设置(比如画笔的颜色,页面的间距,字体的大小等)。

// 摘自 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 记录了每个字符在屏幕中的位置),那剩下的就是怎么绘制的问题了。

/**
 * <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的绘图知识等,这篇文章也只是给大家提个思路而已。如果有问题或者新的想法欢迎交流!

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,598评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,644评论 4 59
  • 企划书前篇 初稿 消费 我们每一个人,从出生到死亡,都有一个共同的名字~消费者! ...
    小食後大掌柜阅读 419评论 0 0
  • 千山万水总关情,我怀着感恩和敬畏的祝愿来到。恳求天地的恩典,如同孩子渴望母亲的怀抱一样。我越来越明白情怀的重要,那...
    苏楠雮阅读 440评论 1 2
  • 苏格拉底说:没有反思的人生不值得过。而反思,是需要有层次的,就如同我们想开车去一个陌生的地方,我们先要查查地图,到...
    linwan888阅读 299评论 0 0