不少安卓開發(fā)者都有圖片加載的處理經(jīng)驗(yàn),比如通過壓縮節(jié)省圖片加載中對(duì)內(nèi)存的消耗尊惰。
我們經(jīng)常做的是把一張1280之類大小的圖片以適應(yīng)屏幕大小的尺寸展現(xiàn)出來,同時(shí)能夠通過縮放來觀察。
不過這是一般水平,通過壓縮來處理的話通常會(huì)導(dǎo)致在最大尺寸放大后看不清細(xì)節(jié)峡扩,比如拿到一張蒼老師...哦不,拿到一張清明上河圖障本,或者一張世界地圖教届,這個(gè)時(shí)候我們要保證在最大限度的放大后仍然能夠看清楚每個(gè)人物每個(gè)城市,一般的壓縮的方案就不合適了驾霜。
這里我們要討論的是如何用局部解析(BitmapRegionDecoder)來做到在不占用過多內(nèi)存的情況下實(shí)現(xiàn)超大圖的縮放案训。
慣例貼源碼:XPhotoView Demo
XPhotoView繼承ImageView實(shí)現(xiàn)了超大圖加載,Demo中演示了如何在Pager加載靜態(tài)圖片和動(dòng)圖寄悯,同時(shí)也支持各種手勢(shì)操作萤衰。
我在公司的產(chǎn)品上自定義了XPhotoView,在包括聊天列表猜旬,動(dòng)圖播放脆栋,還有高清大圖查看的功能上已經(jīng)驗(yàn)證了它的穩(wěn)定和高效,平時(shí)的開發(fā)中可以直接使用洒擦。
超大圖片加載和局部解析
對(duì)于普通的圖片椿争,我們加載的思路很簡單就是壓縮大小,用Options來獲得大小然后和當(dāng)前屏幕大小進(jìn)行比較熟嫩,然后以一定的值壓縮秦踪。但是這樣帶來的問題是,壓縮后的圖片會(huì)丟失細(xì)節(jié)掸茅,如果是小澤...呸椅邓,如果是清明上河圖壓縮到屏幕大小,放大后怕是人都看不見昧狮。而整張圖片加載肯定是行不通的景馁,內(nèi)存絕對(duì)立馬爆。
解決方案很簡單逗鸣,我們只加載圖片的局部區(qū)域合住,這部分區(qū)域適配屏幕大小绰精,配合手勢(shì)移動(dòng)的時(shí)候更新顯示對(duì)應(yīng)的區(qū)域就可以了。
Android提供了BitmapRegionDecoder來進(jìn)行圖片的局部解析透葛,這是XPhotoView的主要思路笨使。
剩下的就是如何高效的加載的問題了,如何設(shè)計(jì)代碼邏輯僚害,讓它能夠快速的響應(yīng)手勢(shì)動(dòng)作呢硫椰。
局部解析的代碼邏輯
代碼結(jié)構(gòu)
XPhotoView的代碼概要如下所示,
--|--|--XPhotoView
| |
| |--PhotoViewAttacher
|--GestureManager
大體可以分為兩部分贡珊,XPhotoView和PhotoViewAttacher負(fù)責(zé)圖片的加載和解析最爬,GestureManager負(fù)責(zé)手勢(shì)的判斷和響應(yīng)。整個(gè)庫對(duì)外暴露的只是XPhotoView的幾個(gè)public方法用來setImage和相關(guān)的Listener门岔,還有是否是Gif的參數(shù)爱致。
Attacher本身只負(fù)責(zé)圖片的拆分解析和渲染過程,同時(shí)Bitmap也是保存在Attacher中寒随。Attacher和XPhotoView之間通過Interface互相調(diào)用糠悯,以此隔離。
Attacher 的解析過程
我們暫時(shí)忽略Gif的部分妻往,先描述一下Attacher的思路互艾。
Attacher有一個(gè)內(nèi)部子類BitmapUnit和BitmapGridStrategy,初始圖片會(huì)被BitmapRegionDecoder切割為 N*M 的網(wǎng)格讯泣,然后存儲(chǔ)在BitmapUnit[N][M]二維數(shù)組 mGrids 中纫普。
以清明上河圖為例,圖中高亮的線條把圖片分割為三部分好渠,就是說我們用 BitmapUnit[1][3] 來存儲(chǔ)這張圖片昨稼。這么做的原因是,當(dāng)我們放大圖片來查看的時(shí)候拳锚,只需要解析單個(gè)格子以及它相鄰格子里的圖片假栓。
當(dāng)然在我們以適配屏幕的條件下查看全圖時(shí),是經(jīng)過mSampleSize比例壓縮的霍掺,也就是說在mGrids中的Bitmap是壓縮過后的占小內(nèi)存的位圖匾荆,不用擔(dān)心OOM的問題。
/** 當(dāng)前圖片的的采樣率 */
private int mSampleSize = 0;
/***
* View Rect
* View 坐標(biāo)系*/
private Rect mViewRect = new Rect();
/** 原圖 Rect
* Bitmap 坐標(biāo)系 */
private Rect mImageRect = new Rect();
/**
* 實(shí)際展示的 Bitmap 大小
* Bitmap 坐標(biāo)系 */
private RectF mShowBitmapRect = new RectF();
/**
* view 相對(duì) Show Bitmap 的坐標(biāo)
* Bitmap 坐標(biāo)系 */
private Rect mViewBitmapRect = new Rect();
以上是Attacher中的關(guān)鍵變量杆烁,整個(gè)解析和渲染的過程基于這四個(gè)Rect的坐標(biāo)牙丽。
現(xiàn)在我們開始整個(gè)流程。
初始化
/**
* @param bitmap 設(shè)置 bitmap
* @param cache 是否cache
*/
void setBitmap(Bitmap bitmap, boolean cache);
/**
* @param is 設(shè)置輸入流
* @param config config
*/
void setInputStream(InputStream is, Bitmap.Config config);
這兩個(gè)是Attacher定義的對(duì)外接口兔魂,它只允許兩種方式來設(shè)置圖片剩岳,不管是哪個(gè)方式,都會(huì)轉(zhuǎn)換為InputStream對(duì)象mDecodeInputStream入热,來作為BitmapRegionDecoder的來源拍棕。
若以setBitmap()方法初始化的話,會(huì)多設(shè)置一個(gè)mSrcBitmap勺良,當(dāng)進(jìn)行局部解析時(shí)就不會(huì)通過BitmapRegionDecoder來解析绰播,而是會(huì)直接從mSrcBitmap中createBitmap對(duì)應(yīng)的區(qū)域出來。這種方式的前提是默認(rèn)不會(huì)出現(xiàn)OOM尚困,畢竟已經(jīng)可以整個(gè)Bitmap作為參數(shù)傳進(jìn)來了蠢箩,但是不能保證在后面createBitmap時(shí)不會(huì)OOM,所以不提倡用這個(gè)方法來初始化事甜。
在調(diào)用這兩個(gè)方法任何一個(gè)之后谬泌,都會(huì)調(diào)用initialize()來初始化需要的線程和Handler,
/** 初始化所需參數(shù)和線程*/
private synchronized void initialize(Bitmap.Config config)
然后我們來到setBitmapDecoder(final InputStream is)方法逻谦,此時(shí)我們開始真正的拆圖和解析掌实。這個(gè)方法是所有的起點(diǎn),而且只會(huì)也只應(yīng)該走一次邦马。
它會(huì)把mInstanceDecoderRunnable丟給handler然后開始運(yùn)行贱鼻,在解析完成后通過回調(diào)告知上層解析完畢,可以進(jìn)行關(guān)閉進(jìn)度條之類的操作滋将。
獲取圖片初始顯示參數(shù)
此時(shí)我們會(huì)調(diào)用這個(gè)方法
private void initiateViewRect(int viewWidth, int viewHeight)
但是第一次調(diào)用的時(shí)候是在onDraw之前邻悬,在setImage之后,此時(shí)我們并不知道具體的Canvas的大小随闽,因此沒法確定縮放比例父丰,還有其他的Rect所需要的初始化的具體值。因此此時(shí)mViewRect的值都還是0掘宪,作為參數(shù)傳進(jìn)來后經(jīng)過校驗(yàn)是無效值蛾扇,則會(huì)退出此次的方法。
這時(shí)我們來看看draw()方法添诉,
@Override
public boolean draw(@NonNull Canvas canvas, int width, int height) {
if (isNotAvailable()) {
return false;
}
if (mSrcBitmap != null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
int mw = canvas.getMaximumBitmapWidth();
int mh = canvas.getMaximumBitmapHeight();
/**
* 如果圖片太大屁桑,直接使用bitmap 會(huì)占用很大內(nèi)存,所以建議緩存為文件再顯示
*/
if (mSrcBitmap.getHeight() > mh || mSrcBitmap.getWidth() > mw) {
//TODO
}
}
/**
* 更新視圖或者畫出圖片
*/
return !checkOrUpdateViewRect(width, height) && mBitmapGrid.drawVisibleGrid(canvas);
}
在XPhotoView調(diào)用draw()方法后栏赴,最后會(huì)進(jìn)行有效性檢查蘑斧,就是checkOrUpdateViewRect(),這時(shí)會(huì)把真正的視圖的大小作為參數(shù)傳給 initiateViewRect()须眷,然后再真正的進(jìn)行參數(shù)的初始化竖瘾。
是的,這里我們用延遲的方式來獲取到真正的視圖大小花颗,雖然代碼不容易理解捕传,但是穩(wěn)定性提高了。
接下來我們要初始化幾個(gè)參數(shù)扩劝,
mShowBitmapRect, mViewBitmapRect, mSampleSize
圖片的初始化顯示方式有幾種庸论,按Android的定義有FIT_CENTER,CENTER_CROP等职辅,這里我們默認(rèn)用 CENTER_CROP 的方式,而且顯示圖片的起始部分聂示,橫圖從左邊開始域携,豎圖從最上面開始。
這里需要關(guān)注的關(guān)鍵代碼是鱼喉,
private void initiateViewRect(int viewWidth, int viewHeight) {
····
/** 以 view 寬/長比例和 image 寬/長比例做比較
* iW/iH < vW/vH : 左右留空秀鞭,取高比值
* iW/iH > vW/vH : 上下留空,取寬比值 */
float ratio = (imgWidth/imgHeight < viewWidth/viewHeight) ? (imgHeight * 1.0f / viewHeight) : (imgWidth * 1.0f / viewWidth);
mShowBitmapRect.set(0, 0, (int) (imgWidth / ratio), (int) (imgHeight / ratio));
/** 保存初始大小 */
mShowBitmapRect.round(mInitiatedShowBitmapRect);
/** 取縮小到適配view時(shí)的bitmap的起始位置 */
int left = (int) ((mShowBitmapRect.width() - mViewRect.width()) / 2);
int top = (int) ((mShowBitmapRect.height() - mViewRect.height()) / 2);
left = mShowBitmapRect.width() < mViewRect.width() ? left : 0;
int right = left + mViewRect.width();
top = mShowBitmapRect.height() < mViewRect.height() ? top : 0;
int bottom = top + mViewRect.height();
mViewBitmapRect.set(left, top, right, bottom);
····
}
我們通過比較View和Image的高/寬比例扛禽,確定圖片是橫圖還是豎圖锋边,
以橫圖為例子,此時(shí)我們需要將它上下?lián)未蟮絼偤娩仢M屏幕编曼,那么就得以View的高度和Image的高度來算出壓縮值 ratio豆巨,用它來算出壓縮后的圖片的實(shí)際大小,保存在 mShowBitmapRect中灵巧。
完成這一步后搀矫,接下來計(jì)算mViewBitmapRect。還記得我們的設(shè)定是顯示圖片的起始部位么刻肄,
/** 取縮小到適配view時(shí)的bitmap的起始位置 */
int left = (int) ((mShowBitmapRect.width() - mViewRect.width()) / 2);
int top = (int) ((mShowBitmapRect.height() - mViewRect.height()) / 2);
left = mShowBitmapRect.width() < mViewRect.width() ? left : 0;
int right = left + mViewRect.width();
top = mShowBitmapRect.height() < mViewRect.height() ? top : 0;
int bottom = top + mViewRect.height();
mViewBitmapRect.set(left, top, right, bottom);
這部分代碼瓤球,既保證了超大圖在縮放后從起始位置開始,也保證了普通圖片縮放后不滿屏的情況下居中顯示敏弃,大家可以琢磨琢磨卦羡。
要留意initiateViewRect這個(gè)方法,它不僅在初始化的時(shí)候調(diào)用麦到,后面每次的縮放绿饵,都需要調(diào)用它來更新mShowBitmapRect,因?yàn)槊看蔚目s放都會(huì)讓實(shí)際顯示的圖片大小發(fā)生改變瓶颠。
繪制流程
關(guān)注 draw()方法拟赊,這里是繪制流程的關(guān)鍵部分,
每次繪制前都會(huì)先更新當(dāng)前的各個(gè)Rect對(duì)象粹淋,以獲得對(duì)應(yīng)的顯示中的Grid吸祟,我們只繪制顯示出來的部分Grid單元,
下面的圖大致描述在繪制時(shí)的情形桃移,
* +--+--+--+--+--+
* |XX|XX|11|11|XX|
* +--+--+--+--+--+
* |XX|XX|11|11|XX|
* +--+--+--+--+--+
* |XX|XX|XX|XX|XX|
* +--+--+--+--+--+
標(biāo)記為11的四個(gè)格子屋匕,表示目前可見的區(qū)域,xx的表示不可見區(qū)域借杰。
對(duì)于可見區(qū)域过吻,會(huì)結(jié)合當(dāng)前的縮放值,從mBitmapGrid中取出蔗衡,然后通過XPhotoView傳進(jìn)來的Canvas對(duì)象繪制纤虽,
對(duì)于不可見區(qū)域乳绕,會(huì)回收掉對(duì)應(yīng)的bitmap對(duì)象,以節(jié)省內(nèi)存廓推。
手勢(shì)響應(yīng)
關(guān)于手勢(shì)響應(yīng)刷袍,是比較簡單的一個(gè)部分,
我們定義了GestureManager樊展,把XPhotoView的事件交給GestureManager的onTouchEvent()處理,
這部分代碼相對(duì)簡單堆生,不做過多解釋专缠。
兼容動(dòng)圖
動(dòng)圖的顯示方式有兩種方案,
- 用Movie類來顯示
- 托管給Glide的GifDrawable去渲染
Movie的方式
這種方式相對(duì)簡單淑仆,
在我們不知道對(duì)應(yīng)的文件或者圖片是否是動(dòng)圖的情況下涝婉,以正常邏輯設(shè)置即可,
設(shè)置之后會(huì)用Movie類來判斷是否是一個(gè)有效的GIF圖蔗怠,
之后在draw時(shí)墩弯,在Gif的情況下會(huì)用Movie類來進(jìn)行渲染
Glide的方式
很多項(xiàng)目會(huì)用Glide來做圖片的下載和顯示,
Glide本身會(huì)判斷圖片是否為Gif寞射,當(dāng)是Gif時(shí)會(huì)構(gòu)造一個(gè) GifDrawable 對(duì)象渔工,
我們直接把這個(gè)GifDrawable對(duì)象用 setImageDrawable 的方式設(shè)置到XPhotoView,
GifDrawable會(huì)接管動(dòng)圖的繪制流程桥温。
注意如果這種情況下動(dòng)圖不動(dòng)的話引矩,需要在 setImageDrawable 之后調(diào)用 GifDrawable 的 start()方法,
if(glideDrawable instanceof GifDrawable) {
holder.photoView.setGif(true);
holder.photoView.setImageDrawable(glideDrawable);
((GifDrawable) glideDrawable).start();
}