開源電子書專案FBReader初探(五)
先來回顧一下上一節最後說到的點,新角色FBReaderApp呼叫了openBookInternal方法:
private synchronized void openBookInternal(final Book book, Bookmark bookmark, boolean force) { //忽略部分程式碼.. Model = BookModel.createModel(book, plugin); Collection.saveBook(book); ZLTextHyphenator.Instance().load(book.getLanguage()); BookTextView.setModel(Model.getTextModel()); //忽略部分程式碼.. } 複製程式碼
一、BookModle生成過程中,都有哪些“不為人知的祕密”
上一篇,我們已經分析過,在BookModel.createModel生成BookModel時,針對於epub格式的檔案來說,最終會呼叫NativeFormatPlugin的readModelNative:
private native int readModelNative(BookModel model, String cacheDir); 複製程式碼
這裡有兩個引數BookModel和cacheDir,我們先來看看BookModel是怎麼生成的:
public static BookModel createModel(Book book, FormatPlugin plugin) throws BookReadingException { if (plugin instanceof BuiltinFormatPlugin) { final BookModel model = new BookModel(book); ((BuiltinFormatPlugin)plugin).readModel(model); return model; } //忽略部分程式碼.. } 複製程式碼
直接new BookModel,並且將book裝入。再來看看cacheDir:
String tempDirectory = SystemInfo.tempDirectory(); 複製程式碼
這個SystemInfo上一篇我們已經分析過,其實現為Paths.systemInfo(context)。是用來獲取一些路徑地址的。那麼這裡傳入的路徑是什麼?debug看一下:

傳入了一個路徑給native,取名cache,看來navtive在解析電子書時會生成快取?暫時把這個疑問放一邊,去看一下BookModel這個類:

有好多方法都是灰色的,證明在java程式碼中沒有地方呼叫這些程式碼,細看一下,這些都是一些set賦值操作,不免想到是否在native進行解析時會呼叫呢?經過debug後發現,的確在navtive解析電子書時,會呼叫這些操作賦值許多資料,這也解釋了上一篇最後關於BookModel解析前後內容存在差別的原因。這裡有三個方法,值得我們去關注一下:
1.initInternalHyperlinks——生成BookModel對應的儲存管理CachedCharStorage
public void initInternalHyperlinks(String directoryName, String fileExtension, int blocksNumber) { myInternalHyperlinks = new CachedCharStorage(directoryName, fileExtension, blocksNumber); } CachedCharStorage.class public CachedCharStorage(String directoryName, String fileExtension, int blocksNumber) { myDirectoryName = directoryName + '/'; myFileExtension = '.' + fileExtension; myArray.addAll(Collections.nCopies(blocksNumber, new WeakReference<char[]>(null))); } 複製程式碼
引數名稱很清楚,檔案目錄、副檔名和blocksNumber。CachedCharStorage在構建時,會根據傳入的blocksNumber建立一個大小為blocksNumber集合,其它的暫時看來還不清楚有什麼用。debug看一下initInternalHyperlinks被呼叫時具體引數傳遞情況:

很明顯了,這個檔案路徑跟我們之前傳遞進去的路徑是一個路徑,副檔名是nlinks。看來native不只是解析,還會在解析的過程中生成快取檔案,而且快取檔案的存放地址就是我們傳入的地址。
2.createTextModel——初始化核心類ZLTextPlainModel
public ZLTextModel createTextModel( String id, String language, int paragraphsNumber, int[] entryIndices, int[] entryOffsets, int[] paragraphLenghts, int[] textSizes, byte[] paragraphKinds, String directoryName, String fileExtension, int blocksNumber ) { return new ZLTextPlainModel( id, language, paragraphsNumber, entryIndices, entryOffsets, paragraphLenghts, textSizes, paragraphKinds, directoryName, fileExtension, blocksNumber, myImageMap, FontManager ); } ZLTextPlainModel.class public ZLTextPlainModel( String id, String language, int paragraphsNumber, int[] entryIndices, int[] entryOffsets, int[] paragraphLengths, int[] textSizes, byte[] paragraphKinds, String directoryName, String fileExtension, int blocksNumber, Map<String,ZLImage> imageMap, FontManager fontManager ) { myId = id; myLanguage = language; myParagraphsNumber = paragraphsNumber; myStartEntryIndices = entryIndices; myStartEntryOffsets = entryOffsets; myParagraphLengths = paragraphLengths; myTextSizes = textSizes; myParagraphKinds = paragraphKinds; myStorage = new CachedCharStorage(directoryName, fileExtension, blocksNumber); myImageMap = imageMap; myFontManager = fontManager; } 複製程式碼
這個引數個數就很多了,而且有些引數並不能看出來是做什麼的。但是不難發現這裡也有這麼三個引數:directoryName,fileExtension,blocksNumber。那麼這三個引數實際值又是什麼呢?還得需要debug看一下:

地址還是我們傳入的地址,但是這裡檔案型別變成了ncache,而且blocksNumber是12,我們知道CachedCharStorage會對應的建立一個長度為12的集合。
3.呼叫BookModel的setBookTextModel,將2建立的ZLTextPlainModel賦值給BookModel
public void setBookTextModel(ZLTextModel model) { myBookTextModel = model; } 複製程式碼
這裡debug可以知道,將第二步建立的ZLTextPlainModel賦值給了BookModel。
回到FBReaderApp的openBookInternal方法,我們將斷點放在BookModel.createModel之後的Collection.saveBook(book),當斷點到這裡時,進入手機,我們去看一下剛才路徑下面是否有我們之前猜測的native生成的快取檔案:

果然!這裡有.ncache和.nlinke檔案,而且個數分別為12和1。跟blocksNumber大小一致。
大膽的猜測一下,這個ncache是不是在native解析內容時,每達到一定大小(圖中128K)就會切分出來一個快取檔案,然後根據某些條件去讀取對應的快取檔案中的內容?
二、獲取頁面對應Bitmap並繪製到cavas上
在之前檢視FBReader的佈局檔案時,我們知道,其頁面中只有一個控制元件——ZLAndroidWidget。既然要看繪製,那不多說直入onDraw:
@Override protected void onDraw(final Canvas canvas) { final Context context = getContext(); if (context instanceof FBReader) { //喚醒螢幕 ((FBReader)context).createWakeLock(); } else { System.err.println("A surprise: view's context is not an FBReader"); } super.onDraw(canvas); //final int w = getWidth(); //final int h = getMainAreaHeight(); myBitmapManager.setSize(getWidth(), getMainAreaHeight()); if (getAnimationProvider().inProgress()) { onDrawInScrolling(canvas); } else { onDrawStatic(canvas); ZLApplication.Instance().onRepaintFinished(); } } 複製程式碼
這裡引出了myBitmapManager,看一下它是什麼,在哪定義的:

原來是BitmapManagerImpl,那就看一下setSize,是做啥了:
private final int SIZE = 2; private final Bitmap[] myBitmaps = new Bitmap[SIZE]; void setSize(int w, int h) { if (myWidth != w || myHeight != h) { myWidth = w; myHeight = h; for (int i = 0; i < SIZE; ++i) { myBitmaps[i] = null; myIndexes[i] = null; } System.gc(); System.gc(); System.gc(); } } 複製程式碼
很簡單,判斷、賦值和清空bitmap集合。之前傳遞過來的引數第一個是getWidth即為當前控制元件的寬度,但是第二個引數缺不是getHeight而是getMainAreaHeight:
private int getMainAreaHeight() { final ZLView.FooterArea footer = ZLApplication.Instance().getCurrentView().getFooterArea(); return footer != null ? getHeight() - footer.getHeight() : getHeight(); } 複製程式碼
這裡資訊量比較大,我們分開來一個一個的看:
1.ZLApplication.Instance() 在FBReader的onCreate中我們已經分析過,是FBReaderApp例項
2.getCurrentView(),經過追溯能夠知道實際為FBView物件。
public final ZLView getCurrentView() { return myView; } 賦值方法 protected final void setView(ZLView view) { if (view != null) { myView = view; //忽略部分程式碼... } } FBReaderApp.class public FBReaderApp(SystemInfo systemInfo, final IBookCollection<Book> collection) { super(systemInfo); //忽略部分程式碼... BookTextView = new FBView(this); } private synchronized void openBookInternal(final Book book, Bookmark bookmark, boolean force) { //忽略部分程式碼... setView(BookTextView); //忽略部分程式碼... } 複製程式碼
3.getFooterArea:
FBView.class @Override public Footer getFooterArea() { //根據ViewOptions中定義的footer型別,建立相應的footer switch (myViewOptions.ScrollbarType.getValue()) { case SCROLLBAR_SHOW_AS_FOOTER: if (!(myFooter instanceof FooterNewStyle)) { if (myFooter != null) { myReader.removeTimerTask(myFooter.UpdateTask); } myFooter = new FooterNewStyle(); myReader.addTimerTask(myFooter.UpdateTask, 15000); } break; case SCROLLBAR_SHOW_AS_FOOTER_OLD_STYLE: if (!(myFooter instanceof FooterOldStyle)) { if (myFooter != null) { myReader.removeTimerTask(myFooter.UpdateTask); } myFooter = new FooterOldStyle(); myReader.addTimerTask(myFooter.UpdateTask, 15000); } break; default: if (myFooter != null) { myReader.removeTimerTask(myFooter.UpdateTask); myFooter = null; } break; } return myFooter; } private abstract class Footer implements FooterArea { //忽略部分程式碼... public int getHeight() { //返回ViewOptions中設定的footer高度 return myViewOptions.FooterHeight.getValue(); } //忽略部分程式碼... } 複製程式碼
經過上面三步的分析,可以得出的結論是getMainAreaHeight方法獲取到的高度是ZLAndroidWidget的高度減去Footer的高度。那麼也就是說BitmapManager在建立bitmap時,的最大高度為去掉Footer區域後的高度:
public Bitmap getBitmap(ZLView.PageIndex index) { //忽略部分程式碼... myBitmaps[iIndex] = Bitmap.createBitmap(myWidth, myHeight, Bitmap.Config.RGB_565); //忽略部分程式碼... } 複製程式碼
我們再回到onDraw中,可以看到其中有一個判斷:
if (getAnimationProvider().inProgress()) { onDrawInScrolling(canvas); } else { onDrawStatic(canvas); ZLApplication.Instance().onRepaintFinished(); } //獲取當前翻頁動畫 private AnimationProvider getAnimationProvider() { final ZLView.Animation type = ZLApplication.Instance().getCurrentView().getAnimationType(); if (myAnimationProvider == null || myAnimationType != type) { myAnimationType = type; switch (type) { case none: myAnimationProvider = new NoneAnimationProvider(myBitmapManager); break; case curl: myAnimationProvider = new CurlAnimationProvider(myBitmapManager); break; case slide: myAnimationProvider = new SlideAnimationProvider(myBitmapManager); break; case slideOldStyle: myAnimationProvider = new SlideOldStyleAnimationProvider(myBitmapManager); break; case shift: myAnimationProvider = new ShiftAnimationProvider(myBitmapManager); break; } } return myAnimationProvider; } 複製程式碼
那麼就是當翻頁動畫正在執行的時候,繪製呼叫onDrawInScrolling,如果動畫沒在執行,說明當前是靜止的狀態,繪製呼叫onDrawStatic。這裡我們先看onDrawStatic:
public final ExecutorService PrepareService = Executors.newSingleThreadExecutor(); private void onDrawStatic(final Canvas canvas) { canvas.drawBitmap(myBitmapManager.getBitmap(ZLView.PageIndex.current), 0, 0, myPaint); drawFooter(canvas, null); post(new Runnable() { public void run() { PrepareService.execute(new Runnable() { public void run() { final ZLView view = ZLApplication.Instance().getCurrentView(); final ZLAndroidPaintContext context = new ZLAndroidPaintContext( mySystemInfo, canvas, new ZLAndroidPaintContext.Geometry( getWidth(), getHeight(), getWidth(), getMainAreaHeight(), 0, 0 ), view.isScrollbarShown() ? getVerticalScrollbarWidth() : 0 ); view.preparePage(context, ZLView.PageIndex.next); } }); } }); } public interface ZLViewEnums { public enum PageIndex { previous, current, next; //忽略部分程式碼... } //忽略部分程式碼... } private void drawFooter(Canvas canvas, AnimationProvider animator) { final ZLView view = ZLApplication.Instance().getCurrentView(); final ZLView.FooterArea footer = view.getFooterArea(); //忽略部分程式碼... if (myFooterBitmap == null) { myFooterBitmap = Bitmap.createBitmap(getWidth(), footer.getHeight(), Bitmap.Config.RGB_565); } final ZLAndroidPaintContext context = new ZLAndroidPaintContext( mySystemInfo, new Canvas(myFooterBitmap), new ZLAndroidPaintContext.Geometry( getWidth(), getHeight(), getWidth(), footer.getHeight(), 0, getMainAreaHeight() ), view.isScrollbarShown() ? getVerticalScrollbarWidth() : 0 ); footer.paint(context); final int voffset = getHeight() - footer.getHeight(); if (animator != null) { animator.drawFooterBitmap(canvas, myFooterBitmap, voffset); } else { //傳入的animator是null canvas.drawBitmap(myFooterBitmap, 0, voffset, myPaint); } } 複製程式碼
這裡可以看出幹了三件事:
- 在(0,0)繪製一個bitmap,該bitmap是從BitmapManagerImpl中根據ZLView.PageIndex.current獲取的
- 建立一個寬度為控制元件寬度,高度為footer.getHeight()的bitmap,隨後呼叫當前型別footer的paint方法,在bitmap上繪製出要顯示的內容。隨後在(0,getHeight() - footer.getHeight())繪製該bitmap。
- 通過Executors去執行一個Runnable,其中傳遞引數ZLView.PageIndex為next
前兩部比較比較清晰,是繪製了兩個bitmap,那這兩個biamap分別是什麼呢?
debug看一下,第一個bitmap:

第二個bitmap:

再來看一下整個頁面的顯示效果:


額,手機截圖不是很全,但是已經能夠看出,最終結果是連個bitmap拼接後鋪滿了整個控制元件。而且從對上面整個過程的分析來看: FBReader繪製的時候,針對某一頁page,都會去獲取該頁page對應的bitmap,然後再繪製在cavas上 。
三、滑動翻頁時的繪製
在翻頁動畫執行中,介面的顯示是這樣的(側滑翻頁):

在上面的分析過程中,已經知道如果當前翻頁動畫正在執行,那麼onDraw會呼叫onDrawInScrolling來繪製頁面內容:
private void onDrawInScrolling(Canvas canvas) { //忽略部分程式碼... final AnimationProvider animator = getAnimationProvider();//獲取當前動畫方式 //忽略部分程式碼... animator.draw(canvas);//繪製頁面內容 //忽略部分程式碼... } 複製程式碼
這裡我們就拿側滑翻頁動畫來分析:
AnimationProvider.class public final void draw(Canvas canvas) { //忽略部分程式碼... drawInternal(canvas); //忽略部分程式碼... } protected void drawBitmapFrom(Canvas canvas, int x, int y, Paint paint) { myBitmapManager.drawBitmap(canvas, x, y, ZLViewEnums.PageIndex.current, paint); } protected void drawBitmapTo(Canvas canvas, int x, int y, Paint paint) { myBitmapManager.drawBitmap(canvas, x, y, getPageToScrollTo(), paint); } public final ZLViewEnums.PageIndex getPageToScrollTo() { //根據滑動時的角標,獲取下方顯示的是上一頁還是下一頁 return getPageToScrollTo(myEndX, myEndY); } SimpleAnimationProvider.class extends AnimationProvider @Override public ZLViewEnums.PageIndex getPageToScrollTo(int x, int y) { if (myDirection == null) { return ZLViewEnums.PageIndex.current; } //myDirection表示如何滑動是正向,即能滑到下一頁的滑動方向 switch (myDirection) { case rightToLeft: return myStartX < x ? ZLViewEnums.PageIndex.previous : ZLViewEnums.PageIndex.next; case leftToRight: return myStartX < x ? ZLViewEnums.PageIndex.next : ZLViewEnums.PageIndex.previous; case up: return myStartY < y ? ZLViewEnums.PageIndex.previous : ZLViewEnums.PageIndex.next; case down: return myStartY < y ? ZLViewEnums.PageIndex.next : ZLViewEnums.PageIndex.previous; } return ZLViewEnums.PageIndex.current; } SlideAnimationProvider.class extends SimpleAnimationProvider//側滑翻頁 @Override protected void drawInternal(Canvas canvas) { if (myDirection.IsHorizontal) {//水平方向翻頁 final int dX = myEndX - myStartX; setDarkFilter(dX, myWidth);//下面一頁的半透明蒙層 drawBitmapTo(canvas, 0, 0, myDarkPaint);//繪製下面一頁 drawBitmapFrom(canvas, dX, 0, myPaint);//繪製正在滑動的一頁 drawShadowVertical(canvas, 0, myHeight, dX);//繪製分界線處的陰影 } else {//豎直翻頁 final int dY = myEndY - myStartY; setDarkFilter(dY, myHeight); drawBitmapTo(canvas, 0, 0, myDarkPaint); drawBitmapFrom(canvas, 0, dY, myPaint); drawShadowHorizontal(canvas, 0, myWidth, dY); } } 複製程式碼
這個地方,其實也比較簡單,原理就是根據滑動的方向和當前設定的翻頁方式(水平翻頁或豎直翻頁),來獲取底下的bitmap是上一頁內容還是下一頁內容。而當前跟隨手指滑動發生位置變化的bitmap,就是currentPage對應的bitmap。而且是在繪製的時候是根據橫向的滑動偏移dx,來確定canvas的繪製bitmap時的left,這樣隨著手指的移動,頁面也就“動”了起來。
當然,由於本人接觸此專案時間有限,而且書寫技術文章的經驗實在欠缺,過程中難免會有存在錯誤或描述不清或語言累贅等等一些問題,還望大家能夠諒解,同時也希望大家繼續給予指正。最後,感謝大家對我的支援,讓我有了強大的動力堅持下去。