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.csdn.net/f409031mn/article/details/88778108
https://blog.csdn.net/jdsjlzx/article/details/84958289
https://blog.csdn.net/gaoanchen/article/details/50437111 裏面的評論在討論分頁的問題
還有這個:
https://blog.csdn.net/knock/article/details/5436177 這個感覺很有意思,爲指定設備寫的固件,按理說知道每頁顯示多少字符,計算分頁應該容易多了,但是回想以前用MP3、電子詞典看電子書什麼的,確實很慢。。不知道那些古董代碼邏輯是怎麼寫的。
還有些找不到了。。。就這樣吧