TXT分页
小说阅读
TextView字数
小说分页
Textview显示字数
关于使用TextView阅读TXT分页的探讨,之所以这么个标题,是因为最终实现的效果也不是100%精确分页(精确分页就是说,不管怎么上一页下一页,每页的字符都保持不变,不会出现上一页少一行多一行少字符多字符之类的情况),所以不敢妄称实现,只是一些心得。做的时候发现同样问题的人从古至今非常多,但是没发现简单的实现,因此发出来,希望可以共同探讨一下,至于100%精确分页,还得请诸位实现了的大佬指点。
首先明确一点,这里说的100%精确分页,是建立在用多少读取多少,不是使用大量内存来读取、分割、保存、中转等,至于为什么这么苛刻,还是解释一下吧,最初这个需求是为了给一款石器时代的墨水屏电纸书用的,系统为Android2.x,内存少的可怜,加载多了可能就会死机,因此我只能尽量节省内存。而且这个古董不支持触摸,就算支持触摸,墨水屏显示滚动效果很不好,刷新效率低,滚动的话估计会糊成一片,所以只能翻页,刷新整个页面效果多少还好点。因此,最简单的方式就是TextView加载一段内容显示。
当然,除了TextView之外,你可以自绘实现,只是这样感觉比较麻烦,没有对这个进行深入的研究,所以不在本文的讨论之内。
本身TextView显示一段文本,非常简单,加载TXT,无非也是显示其中一段文本,但是,要做到分页控制,才会发现这个很难实现。搜索资料时发现有人说TextView是最复杂的View,因为涉及的东西太多了,比如左右顺序,换行控制等等。是不是最复杂的我不知道,但是感觉它提供的API还是太少。
目前这些能见到的100%精确分页的,都搞的非常非常麻烦,甚至还有搬出来数据库的……我只是想TextView加载一个片段而已,怎么就这么复杂呢?
其实还是怪TextView没有提供几个重要的API,比如,这个TextView最多能显示多少个字符,比如这个TextView当前可见的字符有多少个。如果有了这俩API,那剩下的就很好办了。
为什么我会纠结这个TextView最多显示多少个字符呢?
两个原因,1是内存很紧张,加载多了可能就死机;2是所找到的代码资料里,每次加载最多多少个字符,全都是写死的,比如给个2000,肯定够用了,但是实际上只显示几百个字符,我加载那么多干啥,地主家有余粮,可我这不够啊。或者给个小一点的值,但是改变字体大小后,显示的字符数量又比设定值要多,会留白怎么办?因此我必须要知道TextView最多能显示的字符数量,尽量减少无用的内存占用,还得不能不够显示的。另外就是,可能有人说,直接算好了这个设备的屏幕能显示多少字符,然后也写死算了……代码里硬编码一些这样的值,是不规范的,我个人也非常不喜欢,因为明显感觉有逻辑漏洞,万一代码放到其他设备上呢?
为什么我需要TextView当前可见的字符有多少个呢?
因为要分页啊!我不知道当前显示了多少个字符,就不能定位到底阅读到哪里,翻页就没法实现了。总不能只显示一页吧。
没有这俩API,只能自己想办法造了。自己造,思路是有,但是真的去做,发现非常困难,为什么呢?比如获取最多能显示多少个字符,纯中文,好处理,View宽度÷每个字符宽度,可以得到一行多少个字,然后×多少行。但是当全英文或者中英混排的时候就不行了,因为英文字母、标点宽度都是不同的!只能退而求其次,假设全是英文字符“a”,然后算出来这个TextView最多能显示多少个“a”。为什么用“a”呢?因为“a”的宽度比较适中吧,可能有人问,为什么不用标点什么的呢?这是因为我个人感觉,就算用“a”拿到的最大显示数量,也远比实际显示出来的字符数量要多的多,当然,你要是高兴或者内存足够,就想用啥用啥。
开始处理最大显示数量的问题,找了一圈,发现有个办法可以实现:
获取单个字符的宽度高度,然后用View的面积÷单个字符的面积=可显示的字符数量
因此可以写成:
/**
* 获取大概最多的可显示的数量
* 只是个大概的数量,不能当真。为了减少每次读取好几千个字符的尴尬。是根据控件大小计算得来的,
* 真实显示情况肯定比这个少,因为还有行距什么的。
*
* @return 数量
*/
private int getMaxTotalWordsCouldShow(boolean chinese) {
String text = chinese ? "测" : "a";
Paint paint = getPaint();
paint.setTextSize(getTextSize());
int textWidth = (int) paint.measureText(text.toString());
Rect rect = new Rect();
paint.getTextBounds(text, 0, text.length(), rect);
int w = rect.width();
int h = rect.height();
int width = textWidth < w ? textWidth : w;
int wSpace = (getWidth() == 0 ? getMeasuredWidth() : getWidth()) - getPaddingLeft() - getPaddingRight();
int hSpace = (getHeight() == 0 ? getMeasuredHeight() : getHeight()) - getPaddingTop() - getPaddingBottom();
int textSpace = width * h;
int showSpace = wSpace * hSpace;
return showSpace / textSpace;
}
上面的方法是放到继承自TextView的自定义View中用的。
第一个问题算是处理完了,虽然不够精确完美,但是至少算是解决了问题。
然后第二个问题:当前页面显示的字符数量
通过搜索发现:
/** * 获取当前页总字数 */ public int getCurrentTotalCharCount() { try { return getLayout().getLineEnd(getCurrentTotalLineCount()); } catch (Exception ignore) { } return 0; } /** * 获取当前页总行数 */ public int getCurrentTotalLineCount() { try { Layout layout = getLayout(); int topOfLastLine = getHeight() - getPaddingTop() - getPaddingBottom() - getLineHeight(); return layout.getLineForVertical(topOfLastLine); } catch (Exception ignore) { } return 0; }
这个可以返回页面总字数,但是不知道是否足够精确。貌似也找不到更精确的方法了。需要注意的是这俩方法只能在渲染完毕之后调用。
接下来处理第三个问题:分页
先说说分页是怎么分:一大段文本,阅读到当前页,阅读的位置应该是当前页面左上角第一个字符;下一页比较好理解,就是当前页左上角第一个字符+当前页一共显示了多少个字符;上一页就比较麻烦了,应该是当前页左上角第一个字符 减去 上一页一共可以看到多少个字符才能定位到上一页的第一个字符的位置。这是个难点哦,我找到的demo里,只是实现了下一页,上一页估计原作者也没想到该怎么办,所以没有提供代码,也没提怎么处理。解决这个问题,其实我是用了个小技巧:从当前页左上角第一个字符的位置(已知)然后往前读取x个字符(TextView最多能显示的字符数量),然后将读取出来的字符翻转,就是尾巴变成头,然后赋到TextView里,就能拿到当前页显示了多少个字符了,对吧?但是这时候显示出来的字符,都是倒序的,因为我们前面做了翻转,所以这时候要再次把第一次读取出来的字符取指定长度,这时候就是上一页的真正的我们要的内容了,可以赋到TextView了,阅读的定位指针,就可以真正改变了。但是要注意的是,这期间给TextView两次赋值,会造成两次刷新,因此我在第一次赋值,也就是赋翻转之后的内容的时候,给TextView设置了setWillNotDraw(true),来屏蔽更新(虽然我不确定是不是真的有效,真的没有刷新),然后第二次赋值的时候才允许Draw。至此,第三个问题应该也处理完了。
虽然上面的做法理论上讲得通,实际做也可以,但是你会发现,不断的上一页下一页之后,页面行数会有误差,也就是不够精确,为什么呢?我没有详细验证,我觉得可能是1比如一个汉字,占两个字节,读取的时候刚好读了一半(可能看到乱码?),然后这种误差不断累积等;2统计当前页面显示了多少字符的方法所返回的结果与读取文件时字符的计算误差,比如统计字符的方法返回1个字符,但是读取文件时可能2个字节(我只是举例哈,具体的我也没验证,比如换行符、空格,到底是读取了几个字节或者统计成几个字符,我也不确定。欢迎大佬们验证)诸如此类,这些误差可能累计下来就导致分页不精确了。
好啦,原理上就是这些了。代码没多少,简单放一下吧。
自定义TextView,用来显示阅读的:
/** * Copyright (C), 2000-2019 * * @date 2019-12-08 19:09 * History: * <author> <time> <version> <desc> * 2019-12-08 19:09 1 描述(简述该类的作用目的等) */ public class ReadingTextView extends TextView { public ReadingTextView(Context context) { super(context); } public ReadingTextView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } public ReadingTextView(Context context, AttributeSet attrs) { super(context, attrs); } private int maxChineseWordsCount = 0; private int maxEnglishWordsCount = 0; /** * 获取大概最多的可显示的数量 * 只是个大概的数量,不能当真。为了减少每次读取好几千个字符的尴尬。是根据控件大小计算得来的, * 真实显示情况肯定比这个少,因为还有行距什么的。 * * @return 数量 */ private int getMaxTotalWordsCouldShow(boolean chinese) { String text = chinese ? "测" : "a"; Paint paint = getPaint(); paint.setTextSize(getTextSize()); int textWidth = (int) paint.measureText(text.toString()); Rect rect = new Rect(); paint.getTextBounds(text, 0, text.length(), rect); int w = rect.width(); int h = rect.height(); int width = textWidth < w ? textWidth : w; int wSpace = (getWidth() == 0 ? getMeasuredWidth() : getWidth()) - getPaddingLeft() - getPaddingRight(); int hSpace = (getHeight() == 0 ? getMeasuredHeight() : getHeight()) - getPaddingTop() - getPaddingBottom(); int textSpace = width * h; int showSpace = wSpace * hSpace; return showSpace / textSpace; } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); resize(); maxChineseWordsCount = getMaxTotalWordsCouldShow(true); maxEnglishWordsCount = getMaxTotalWordsCouldShow(false); } public int getMaxChineseWordsCount() { return maxChineseWordsCount; } public int getMaxEnglishWordsCount() { return maxEnglishWordsCount; } /** * 大小改变时,重新设置内容,返回的是有多少个字没有被显示。 * * @return 去掉的字数 */ public int resize() { CharSequence oldContent = getText(); CharSequence newContent = oldContent.subSequence(0, getCurrentTotalCharCount()); setText(newContent); return oldContent.length() - newContent.length(); } /** * 获取当前页总字数 */ public int getCurrentTotalCharCount() { try { return getLayout().getLineEnd(getCurrentTotalLineCount()); } catch (Exception ignore) { } return 0; } /** * 获取当前页总行数 */ public int getCurrentTotalLineCount() { try { Layout layout = getLayout(); int topOfLastLine = getHeight() - getPaddingTop() - getPaddingBottom() - getLineHeight(); return layout.getLineForVertical(topOfLastLine); } catch (Exception ignore) { } return 0; } }
然后是外部调用的时候,上一页下一页:
private void reloadCurrentPage() { findViewById(R.id.reading_bottom_loading_iv).setVisibility(View.VISIBLE); long readAt = mConfig.getReadingPosition(); if (readAt < mConfig.getTotalWordsCount()) { ProjectUtils.readFile(mFileNovel, mCurrentTextEncoding, getLoadMaxWordsCount(), readAt, false, new MyFileReadCallback() { @Override public void onFileRead(boolean isSuccess, int errorCode, String desc, String content) { if (isSuccess) { reading_main_rtv.setText(content); //不会改变指针,无需处理 updateReadingProcess(); } else { showToast(content); } loading.setVisibility(View.GONE); findViewById(R.id.reading_bottom_loading_iv).setVisibility(View.GONE); } }); } }//重新读取当前页 private void loadNextPage() { Log.d(TAG, "loadNextPage: ===========最多中文:" + reading_main_rtv.getMaxChineseWordsCount()); Log.d(TAG, "loadNextPage: ===========最多英文:" + reading_main_rtv.getMaxEnglishWordsCount()); 下一页的起始位置应当是当前页左上角!!!因为一旦改变字体,右下角的位置是会改变的! long readAt = mConfig.getReadingPosition(); int showCount = reading_main_rtv.getCurrentTotalCharCount(); final long startAt = readAt + showCount; if (startAt < mConfig.getTotalWordsCount()) { findViewById(R.id.reading_bottom_loading_iv).setVisibility(View.VISIBLE); ProjectUtils.readFile(mFileNovel, mCurrentTextEncoding, getLoadMaxWordsCount(), startAt, false, new MyFileReadCallback() { @Override public void onFileRead(boolean isSuccess, int errorCode, String desc, String content) { if (isSuccess) { reading_main_rtv.setText(content); mConfig.setReadingPosition(startAt); updateAndSaveConfig(); updateReadingProcess(); } else { showToast(content); } loading.setVisibility(View.GONE); findViewById(R.id.reading_bottom_loading_iv).setVisibility(View.GONE); } }); }else{ showToast("已到达最后一页!"); } }//下一页 private void loadPreviousPage() { //上一页:readAt是当前页左上角第一个字符的位置,上一页应该是该位置开始 //然后减去要读取的长度(获得到skip的位置,但是不能为负数) //然后查询长度不能超过skip。读出来剩余的字符,但是这些字符远大于当前页面可显示的字符 //所以下面又进行了一个字符串翻转的骚操作,是为了拿到从后往前可显示的字符数。 //所以要两次setText,但是不能让第一次的产生绘制,所以setWillNotDraw屏蔽一下。 //至此,解决所有问题。 final long readAt = mConfig.getReadingPosition(); if (readAt > 0) { findViewById(R.id.reading_bottom_loading_iv).setVisibility(View.VISIBLE); ProjectUtils.readFile(mFileNovel, mCurrentTextEncoding, getLoadMaxWordsCount(), readAt, true, new MyFileReadCallback() { @Override public void onFileRead(boolean isSuccess, int errorCode, String desc, String content) { if (isSuccess) { //获取到内容后进行翻转,然后赋值,用于拿到该页面能显示的字符数量,然后再切割原始字符 //可能会有些字符的偏差,具体原因没深入研究。 String reverse = ProjectUtils.reverse(content); reading_main_rtv.setWillNotDraw(true);//禁止更新 reading_main_rtv.setText(reverse);//翻转后的,用来拿到数量 int showCount = reading_main_rtv.getCurrentTotalCharCount(); content = content.substring(content.length() - showCount);//切割能显示的数量 reading_main_rtv.setText(content); reading_main_rtv.setWillNotDraw(false);//可以更新 if (readAt - showCount < 0) {//更新指针位置 mConfig.setReadingPosition(0); } else { mConfig.setReadingPosition(readAt - showCount); } updateAndSaveConfig(); updateReadingProcess(); } else { showToast(content); } loading.setVisibility(View.GONE); findViewById(R.id.reading_bottom_loading_iv).setVisibility(View.GONE); } }); }else{ showToast("已到达第一页!"); } }//上一页
只要有了阅读的位置指针,读取文件就好办了,有好多种方式,这里就不写了。
其实我挺懒的写文章的。。。
有更好的方式,欢迎指点,如果有纰漏,也欢迎斧正。
参考资料:
https://my.oschina.net/gotax/blog/136860
https://blog.youkuaiyun.com/f409031mn/article/details/88778108
https://blog.youkuaiyun.com/jdsjlzx/article/details/84958289
https://blog.youkuaiyun.com/gaoanchen/article/details/50437111 里面的评论在讨论分页的问题
还有这个:
https://blog.youkuaiyun.com/knock/article/details/5436177 这个感觉很有意思,为指定设备写的固件,按理说知道每页显示多少字符,计算分页应该容易多了,但是回想以前用MP3、电子词典看电子书什么的,确实很慢。。不知道那些古董代码逻辑是怎么写的。
还有些找不到了。。。就这样吧