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个模块计算来分页。

分页模块

  1. PageElement,分页模块:功能包括将传入的章节数据分成数个 PageData (生成的 PageData 个数即为该章节页数,PageData 记录了每一页开头文字在章节的位置,同时包含该页面HeaderData, LineData,HeadrData 和 FooterData 数据等。各个 Data 里面记录了相应的文字信息,可以快速的定位到章节内容中。);绘制页面;缓存章节数据以便无缝切换章节。
  2. HeaderElement,页头部分:显示章节的标题;绘制每一页的头部。
  3. LineElement,文字行部分:测量一行文字需要的字数;测量行高;绘制行文字;绘制笔记内容;测量每一个字在屏幕中的位置,用于笔记功能;
  4. ImageElement,图片部分:测量图片的宽高;绘制图片。
  5. 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,HeaderData和FooterData信息。绘图时需要将各个信息填入 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的绘图知识等,这篇文章也只是给大家提个思路而已。如果有问题或者新的想法欢迎交流!

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • android阅读器长按选择文字功能实现代码

    前言: 有时候我们需要实现长按选择文字功能,比如阅读器一般都有这个功能,有时候某个自定义控件上可能就有这种需求,如何实现呢?正好最近还算闲,想完善一下自己写的那个轻量级的txt文件阅读器(比如这个长按选择文字的功能就想加进去).于是花了两三天时间,实现了这个功能,效果还是不错的. 首先先看看效果图吧: 授人以鱼不如授人以渔,下面具体实现原理的教程. 1.实现原理 原理其实也不难,简单总结就是:绘制文字时把显示的文字的坐标记录下来(记录文字的左上右上左下右下四个点坐标),作用就是为了计算滑动范围.

  • Android编程实现小说阅读器滑动效果的方法

    本文实例讲述了Android编程实现小说阅读器滑动效果的方法.分享给大家供大家参考,具体如下: 看过小说都知道小说阅读器翻页有好多种效果,比如仿真翻页,滑动翻页,等等.由于某种原因,突然想写一个简单点的滑动翻页效果.在这里写出来也没有什么意图,希望大家可以根据这个效果举一反三,写出其他的效果.图就不上了. 下面是代码:大家理解onTouch事件即可 package com.example.testscroll.view; import android.content.Context; impor

  • android仿新闻阅读器菜单弹出效果实例(附源码DEMO下载)

    开发中碰到问题之后实现的,觉得可能有的开发者用的到或则希望独立成一个小功能DEMO,所以就放出来这么一个DEMO. 原本觉得是最后完成后发网站客户端的,可是这样体现不出一个功能一个功能的分析实现效果,而且周期时间长,所以就完成一部分,发一部分,敬请谅解. 下面的菜单弹出效果在很多的新闻阅读器上都有,比如今日头条.360新闻等. 其实这个实现起来很简单,看其效果,其实就是一个PopupWindow,之后设定相应postion的按钮点击属性,之后获取按钮的位置,给它设置动画显示消失就可以出现了. 下

  • Android 如何从零开始写一款书籍阅读器的示例

    一款书籍阅读器,需要以下功能才能说的上比较完整: 文字页面展示,即书页: 页面之间的跳转动画,即翻页动作: 能够在每一页上记录阅读进度,即书签: 能够自由选择文字并标注,即笔记: 能够设置一些属性,如屏幕亮度,字体大小,主体颜色等,即个性化设置. 书籍阅读器 这篇文章带来的就是如何打造这么一款阅读器.(由于整体代码量比较大,所以我只能说说我的实现思路再加上部分的核心代码来说明,不会有太多的代码展示.) 翻页动作--搭建整个阅读器的框架 在阅读器上的翻页动作无外乎仿真和平移这两种动画,翻页时需要准

  • JavaScript+Node.js写一款markdown解析器

    目录 1. 准备工作 2. 处理图片&超链接 3. 处理blockquote 4. 处理标题 5. 处理字体 6. 处理代码块 7. 处理列表 8. 处理表格 9. 调用方法 1. 准备工作 首先编写getHtml函数,传入markdown文本字符串,这里使用fs读取markdown文件内容,返回值是转换过后的字符串. const fs = require('fs'); const source = fs.readFileSync('./test.md', 'utf-8'); const get

  • Qt 使用Poppler实现pdf阅读器的示例代码

    开发环境 Qt5.5.1.Qt Creator 3.5.1 Qt实现pdf阅读器和MFC实现pdf阅读器,其实原理都是差不多的. 需要用到Poppler开源库,下载地址如下 https://poppler.freedesktop.org/ 如果只是要在window的gcc下运行的话,可以下载已经编译好的库 https://sourceforge.net/projects/poppler-win32/ 注意:这个是MinGW版本的Qt,也就是运行在GCC环境下的库,里面只包含 *.dll 和 *.

  • 手写一个@Valid字段校验器的示例代码

    上次给大家讲述了 Springboot 中的 @Valid 注解 和 @Validated 注解的详细用法: 详解Spring中@Valid和@Validated注解用法 当我们用上面这两个注解的时候,需要首先在对应的字段上打上规则注解,类似如下. @Data public class Employee { /** 姓名 */ @NotBlank(message = "请输入名称") @Length(message = "名称不能超过个 {max} 字符", max

  • 用python给自己做一款小说阅读器过程详解

    前言 前一段时间书荒的时候,在喜马拉雅APP发现一个主播播讲的小说-大王饶命.听起来感觉很好笑,挺有意思的,但是只有前200张是免费的,后面就要收费.一章两毛钱,本来是想要买一下,发现说的进度比较慢而且整本书要1300多张,算了一下,需要200大洋才行,而且等他说完,还不知道要到什么时候去. 所以就找文字版的来读,文字版又有它的缺点,你必须手眼联动才行.如果要忙别的事情,但是又抑制不住想看的冲动,就很纠结了.在网上找了一圈,没有其他的音频.而且以前用的那些有阅读功能的软件,比如微信阅读.追书神器

  • python生成器/yield协程/gevent写简单的图片下载器功能示例

    本文实例讲述了python生成器/yield协程/gevent写简单的图片下载器功能.分享给大家供大家参考,具体如下: 1.生成器: '''第二种生成器''' # 函数只有有yield存在就是生成器 def test(i): while True: i += 1 res = yield i print(res) i += 1 return res def main(): t = test(1) # 创建生成器对象 print(next(t)) # next第一次执行从上到下,yield是终点 p

  • 使用AngularJS制作一个简单的RSS阅读器的教程

    简介 几年前,我用C#写了一个RSS阅读器,但是我想如果把它做成一个SPA(单页应用)效果会更好. Angular使一些事情变得简单,RSS阅读器就是其中之一. 我也用Twitter Bootstrap(做UI)实现了RSS阅读器,调试页面样式是最难的地方之一...可能是因为我不擅长css的原因. 背景 我有一些自己喜欢的网站( CodeProject, Dr.Dobb's Journal, ComputerWorld, Inc. Magazine). 然而,我发现其中很多网站都有烦人的广告.风

随机推荐