開源電子書專案FBReader初探(五)

CrazyCoder發表於2018-11-30

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看一下:

開源電子書專案FBReader初探(五)

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

開源電子書專案FBReader初探(五)

有好多方法都是灰色的,證明在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被呼叫時具體引數傳遞情況:

開源電子書專案FBReader初探(五)

很明顯了,這個檔案路徑跟我們之前傳遞進去的路徑是一個路徑,副檔名是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看一下:

開源電子書專案FBReader初探(五)

地址還是我們傳入的地址,但是這裡檔案型別變成了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生成的快取檔案:

開源電子書專案FBReader初探(五)

果然!這裡有.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,看一下它是什麼,在哪定義的:

開源電子書專案FBReader初探(五)

原來是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:

開源電子書專案FBReader初探(五)

第二個bitmap:

開源電子書專案FBReader初探(五)

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

開源電子書專案FBReader初探(五)

開源電子書專案FBReader初探(五)

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

三、滑動翻頁時的繪製

在翻頁動畫執行中,介面的顯示是這樣的(側滑翻頁):

開源電子書專案FBReader初探(五)

在上面的分析過程中,已經知道如果當前翻頁動畫正在執行,那麼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,這樣隨著手指的移動,頁面也就“動”了起來。

當然,由於本人接觸此專案時間有限,而且書寫技術文章的經驗實在欠缺,過程中難免會有存在錯誤或描述不清或語言累贅等等一些問題,還望大家能夠諒解,同時也希望大家繼續給予指正。最後,感謝大家對我的支援,讓我有了強大的動力堅持下去。

相關文章