Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> 探究drawable圖片的加載原理和縮放規律

探究drawable圖片的加載原理和縮放規律

編輯:關於Android編程

前言

Android的源碼公開策略豐富了手持設備的多樣性,但隨之而來的卻是較為嚴重的”碎片化”——版本繁多、尺寸多樣、功能定制。在Android項目開發中,軟件工程師都會面臨一個問題:如何適配多不同分辨率的設備?

許多人采用的是這樣的方式:利用不同的dimens和drawable資源適配不同分辨率的設備。這麼做當然沒錯,可是它也同時帶來一些弊端

在調試UI時挨個修改多個dimen文件中的每個值。
多數時候會先做一個分辨率出來,比如1920*1080;然後再對照這個效果適配其他分辨率的展示效果。如果要調整某個尺寸的大小,那麼先要找到其對應的dimens文件,再去修改。 UI標注的困惑
UI設計師一般只會在一套UI上標注具體的尺寸大小和顏色。比如,只在1920*1080上標注了一個TextView的長度是100px,那麼在1280*720上的分辨率上該控件的大小又該是多少呢?自己再換算一下? 多套drawable容易導致APK文件較大。
圖片多了,那麼資源所占的體積必然會隨之增大;在發布前為了減小APK的大小,可能又不得不做一些瘦身的操作,至於效果有時也覺得不痛不癢,乏善可陳。 不同drawable資源帶來的繁瑣
如果某個切圖需要修改,那麼就需要替換各個drawable中對應的圖片。這個過程中,如果錯放了或者漏放了某個尺寸的圖片,那麼又是一個小悲劇了,它會導致圖片在某些分辨率的手機上失真

嗯哼,毫不避諱的說:以上這些坑我都掉進去過,有的坑還有點深,快到我脖子了。
當我最後一次掉在坑裡的時候,我就下定決心,我要想個辦法:一套圖片,一套布局,一個dimen完成多分辨率的適配!

哇哈,如果你也有這個想法,那就上車吧!


Android中的度量單位

在此以華為P7為例,解釋inch、px、pt、dpi、dip、densityDpi、TypedValue、sp等等Android中常見的度量單位

inch

inch即為英寸,它表示設備的物理屏幕的對角線長度。
比如該例中P7的屏幕尺寸為5英寸,表示的就是手機的右上角與左下角之間的距離,其中1 inch = 2.54 cm

px

pixel簡稱為px,它表示屏幕的像素,也就是大家常說的屏幕分辨率。
比如在該例中P7的分辨率為1920*1080,它表示屏幕的X方向上有1080個像素,Y方向上有1920個像素。

pt

pt類似於px,但常用於字體的單位,不再贅述

dpi和densityDpi

dot per inch簡稱為dpi,它表示每英寸上的像素點個數,所以它也常為屏幕密度。
在Android中使用DisplayMetrics中的densityDpi字段表示該值,並且不少文檔中常用dpi來簡化或者指代densityDpi。

在手機屏幕一定的情況下,如果分辨率越高那麼該值則越大,這就意味著畫面越清晰、細膩和逼真。
在此,仍然以華為P7為例,計算其dpi值。先利用勾股定理得其對角線的像素值為2202.91,再除以對角線的大小5,即2202.91/5=440.582;此處計算出的440.582便是該設備的屏幕密度。

Android中依據densityDpi的不同將設備分成了多個顯示級別:
ldpi、mdpi、hdpi、xhdpi、xxhdpi
這些顯示級別分別表示一定范圍的dpi,比如160dpi—240dpi都稱為hdpi,更多詳情請參見下圖。

其實,在Android的源碼中也定義了這些常量,比如:

public static final int DENSITY_LOW = 120;

public static final int DENSITY_MEDIUM = 160;

public static final int DENSITY_XXHIGH = 480;

嗯哼,在了解了這些之後,現在我們再通過代碼來獲取設備的dpi值

private void getDisplayInfo(){
    Resources resources=getResources();
    DisplayMetrics displayMetrics = resources.getDisplayMetrics();
    float density = displayMetrics.density;
    int densityDpi = displayMetrics.densityDpi;
    System.out.println("----> density=" + density);
    System.out.println("----> densityDpi=" + densityDpi);
}

輸出結果:

—-> density=3.0
—-> densityDpi=480

呃,獲取到的densityDpi是480和我們計算出來的屏幕實際密度值440.582不一樣。這是為什麼呢?
在每部手機出廠時都會為該手機設置屏幕密度,若其屏幕的實際密度是440dpi那麼就會將其屏幕密度設置為與之接近的480dpi;如果實際密度為325dpi那麼就會將其屏幕密度設置為與之接近的320dpi。這也就是說常見的屏幕密度是與每個顯示級別的最大值相對應的,比如:120、160、240、320、480、640等。順便說一下,看到代碼中的density麼?嗯哼,其實它就是一個倍數關系罷了,它表示當前設備的densityDpi和160的比值,例如此處480/160=3。為啥是除以160而不是其他數值呢?甭急,馬上就會講到了。

話說,林子大了什麼鳥都有,有的手機不一定會選擇120、160、240、320、480、640中的值作為屏幕密度,而是選擇實際的dpi作為屏幕密度。比如為了發燒而生的小咪手機,它的某些機型的densityDpi就是個非常規的值。

其實,關於這一點,我們從Android源碼對於densityDpi的注釋也可以看到一些端倪:

The screen density expressed as dots-per-inch.
May be either DENSITY_LOW,DENSITY_MEDIUM or DENSITY_HIGH

請注意這裡的措辭”May be”,它也沒有說一定非要是DENSITY_LOW、DENSITY_MEDIUM、 DENSITY_HIGH這些系統常量。
好吧,這可能就是Android”碎片化”的一個佐證吧。

dp

density-independent pixel簡稱為dip或者dp,它表示與密度無關的像素。
如果使用dp作為長度單位,那麼該長度在不同密度的屏幕中顯示的比例將保持一致。

既然dp與密度無關,那麼它與px又有什麼關系呢?

在剛提到的Android的多個顯示級別中有一個mdpi,它被稱為基准密度
正如官方文檔所言:

The density-independent pixel is equivalent to one physical pixel on a 160 dpi screen, which is the baseline density assumed by the system for a “medium” density screen.

當dpi=160時1px=1dp,也就是說所有dp和px的轉換都是基於mdpi而言的。
比如當dpi=320(即xhdpi)時1dp=2px;當dpi=480(即xxhdpi)時1dp=3px,該過程的換算公式為:

dp * (dpi / 160)

完整的對應關系,請參照下圖。

sp

scale-independent pixel簡稱為sp,它類似於dp,但主要用於表示字體的大小,不再贅述

TypedValue
剛才提到,依據densityDpi的不同將設備分成了多個顯示級別:ldpi、mdpi、hdpi、xhdpi、xxhdpi。看到這句話時想必很多人都覺得這個玩意太眼熟了,在res下不是有drawable-ldpi、drawable-mdpi、drawable-hdpi、drawable-xhdpi、drawable-xxhdpi、drawable-xxxdpi文件夾麼?是的,但是它們有什麼聯系麼?
之前也說了:Android設備千差萬別,不同設備的屏幕密度(densityDpi)自然也就各不相同,有的屬於mdpi,某些又屬於xhdpi,或者xxhdpi等等其他顯示級別。設計師為了讓同一個APP在各種手機上都獲得較好的顯示效果就會針對densityDpi的不同而單獨提供一套UI圖。
比如,客戶要求APP適配顯示級別為:ldpi、mdpi、hdpi、xhdpi、xxhdpi的設備,那麼UI設計師就需要5套尺寸不一的UI圖分別放入res下的drawable-ldpi、drawable-mdpi、drawable-hdpi、drawable-xhdpi、drawable-xxhdpi文件夾裡。當手機設備的顯示級別為hdpi時,此時APP會去加載drawable-hdpi中對應圖片;同理如果手機的顯示級別為xxhdpi那麼APP就會去自動加載drawable-xxhdpi中的資源圖片。

關於此處的這種對應關系,我們再來看一段代碼:

/**
 * 原創作者:
 * 谷哥的小弟
 *
 * 博客地址:
 * http://blog.csdn.net/lfdfhl
 */
private void getDrawableFolderDensity(){
    TypedValue typedValue = new TypedValue();
    Resources resources=mContext.getResources();
    int id = getResources().getIdentifier(imageName, "drawable" , packageName);
    resources.openRawResource(id, typedValue);
    int density=typedValue.density;
    System.out.println("----> density="+density);
}

在此,我們可以發現:
如果將圖片放入drawable-ldpi中那麼density值為120
如果將圖片放入drawable-mdpi那麼density的值為160

類似地操作總結如下圖:

嗯哼,看到這是不是就將densityDpi和TypedValue中的density理解性地結合在一起了呢?說白了,設備會去res下找尋與之適應的資源圖片,在這個找尋的過程中判斷”是否合適”的方式就是將自身的densityDpi與res文件夾的TypedValue.density字段相比較。

TypedValue中除了剛說的density字段外,還有一個挺重要的方法applyDimension( ),源碼如下:

 public static float applyDimension(int unit, float value,DisplayMetrics metrics)
    {
        switch (unit) {
        case COMPLEX_UNIT_PX:
            return value;
        case COMPLEX_UNIT_DIP:
            return value * metrics.density;
        case COMPLEX_UNIT_SP:
            return value * metrics.scaledDensity;
        case COMPLEX_UNIT_PT:
            return value * metrics.xdpi * (1.0f/72);
        case COMPLEX_UNIT_IN:
            return value * metrics.xdpi;
        case COMPLEX_UNIT_MM:
            return value * metrics.xdpi * (1.0f/25.4f);
        }
        return 0;
    }

該方法的作用是把Android系統中的非標准度量尺寸(比如dip、sp、pt等)轉變為標准度量尺寸px。在這段代碼裡,同樣可以見到一個density;但是請注意它是DisplayMetrics中的字段而不是TypedValue的,請注意區分。


探究drawable圖片的加載

這得從一次掉坑的經歷說起。

有天下午,都快下班了,測試妹子跑到我工位前,急匆匆地說:圖片失真了。哎,又不是失身,急啥嘛。我慢條斯理地瞅瞅了代碼:代碼沒錯呀,以前也都是這些的呀。到底是哪裡出了幺蛾子呢?經過一番排查,發現是圖片放錯了地方:本來是該放到drawable-xxhdpi中的但是小手一抖錯放到了drawable-xhdpi中導致了圖片放大失真。

嗯哼,這個坑我們可能自己踩過,或者說這個現象我們略知一二,但是導致這個現象的原因是什麼呢?它的背後隱藏著什麼呢?

來吧,一起瞅瞅。

在此,准備了一張圖,該圖就是我的CSDN博客頭像

圖片的寬為144,高為180。

然後在res文件夾下建立drawable-ldpi、drawable-mdpi、drawable-hdpi、drawable-xhdpi、drawable-xxhdpi、drawable-xxxdpi文件夾,並且將該圖片放入drawable-xxhdpi中

再利用ImageView顯示該圖片,代碼如下:

運行之後,看一下效果

最後,在Java代碼中獲取圖片的寬高及其所占內存的大小,代碼如下:

private void getImageInfo() {
    mImageView.post(new Runnable() {
        @Override
        public void run() {
            BitmapDrawable bitmapDrawable = (BitmapDrawable) mImageView.getDrawable();
            if (null != bitmapDrawable) {
                Bitmap bitmap = bitmapDrawable.getBitmap();
                int width = bitmap.getWidth();
                int height = bitmap.getHeight();
                int byteCount = bitmap.getByteCount();
                System.out.println("----> width=" + width + ",height=" + height);
                System.out.println("----> byteCount=" + byteCount);
            }
        }
    });
}

輸出結果如下:
width=144,height=180,byteCount=103680
嗯哼,獲取到的圖片寬高和其原本的寬高一致。那麼這個byteCount又是怎麼算出來的呢?
Android系統在利用drawable中的圖片生成Bitmap時默認采用的色彩模式是Bitmap.Config.ARGB_8888;在該模式中一共有四個通道,其中A表示Alpha,R表示Red,G表示Green,B表示Blue;並且這四個通道每個各占8位即一個字節,所以合起來共計4個字節。於是可以算出:144*180*4=103680字節

現在將圖片移至drawable-hdpi中,運行後查看效果:

輸出結果如下:
width=288,height=360,byteCount=414720
哇哈,看到沒有呢?——圖片的寬和高都翻倍了,圖片所占的內存大小也隨之變大了4倍。

繼續嘗試,在將圖片移至drawable-ldpi中,運行後查看效果:

輸出結果如下:
width=576,height=720,byteCount=1658880
這就更明顯了,圖片的寬和高都變大了4倍,圖片所占的內存大小也隨之變大了16倍。

嗯哼,如果將圖片放入drawable-mdpi,drawable-xhdpi,drawable-xxxhdpi中也會發現類似的現象:圖片的寬高及其所占內存在按照比例放大或者縮小,詳情請參見下圖

既然已經看到了這個現象,那就再從源碼(Lollipop 5.0)角度來看看當加載drawable中的圖片時的具體實現

調用BitmapFactory中的的decodeResource()加載drawable文件夾裡的圖片,源碼如下:

public static Bitmap decodeResource(Resources res, int id, Options opts) {
    Bitmap bm = null;
    InputStream is = null;
    try {
        final TypedValue value = new TypedValue();
        is = res.openRawResource(id, value);
        bm = decodeResourceStream(res, value, is, null, opts);
    } catch (Exception e) {

    } finally {
        try {
            if (is != null) is.close();
        } catch (IOException e) {

        }
    }

    if (bm == null && opts != null && opts.inBitmap != null) {
        throw new IllegalArgumentException("Problem decoding into existing bitmap");
    }
    return bm;
}

在該方法中第6行調用openRawResource()後,value中就保存了該資源所在文件夾的destiny,這點和剛才的講解是一致的,不再贅述。在此之後,繼續執行decodeResourceStream()

調用decodeResourceStream( )方法

public static Bitmap decodeResourceStream(Resources res, TypedValue value,
        InputStream is, Rect pad, Options opts) {
    if (opts == null) {
        opts = new Options();
    }
    if (opts.inDensity == 0 && value != null) {
        final int density = value.density;
        if (density == TypedValue.DENSITY_DEFAULT) {
            opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
        } else if (density != TypedValue.DENSITY_NONE) {
            opts.inDensity = density;
        }
    }       
    if (opts.inTargetDensity == 0 && res != null) {
        opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
    }     
    return decodeStream(is, pad, opts);
}

在該方法中有兩個非常重要的操作。

第一步:
為opts.inDensity賦值,請參見代碼第6-13行。

經過操作opts.inDensity會被賦值為120、160、240、320、480、640中的一個值

第二步:
為opts.inTargetDensity賦值,請參見代碼第14-16行。

經過操作opts.inTargetDensity會被賦值為手機屏幕的densityDpi

調用decodeStream()方法
在該方法中會調用decodeStreamInternal();它又會繼續調用nativeDecodeStream( ),該方法是native的;在BitmapFactory.cpp可見這個方法內部又調用了doDecode()它的核心源碼如下:

static jobject doDecode(JNIEnv*env,SkStreamRewindable*stream,jobject padding,jobject options) {
......
if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
    const int density = env->GetIntField(options, gOptions_densityFieldID);
    const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
    const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
    if (density != 0 && targetDensity != 0 && density != screenDensity) {
        scale = (float) targetDensity / density;
    }
}
}
const bool willScale = scale != 1.0f;
......
SkBitmap decodingBitmap;
if (!decoder->decode(stream, &decodingBitmap, prefColorType,decodeMode)) {
return nullObjectReturn("decoder->decode returned false");
}
int scaledWidth = decodingBitmap.width();
int scaledHeight = decodingBitmap.height();
if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
scaledWidth = int(scaledWidth * scale + 0.5f);
scaledHeight = int(scaledHeight * scale + 0.5f);
}
if (willScale) {
const float sx = scaledWidth / float(decodingBitmap.width());
const float sy = scaledHeight / float(decodingBitmap.height());
......
SkPaint paint;
SkCanvas canvas(*outputBitmap);
canvas.scale(sx, sy);
canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
}
......
}

主要步驟分析如下:

第一步:
獲取opts.inDensity的值賦給density,請參見代碼第4行。

第二步:
獲取opts.inTargetDensity的值賦給targetDensity,請參見代碼第5行。

第三步:
計算縮放比scale,請參見代碼第8行。

從這裡也可以看出,這個縮放比scale就等於opts.inTargetDensity/opts.inDensity

第四步:
得到圖片原始的寬和高,請參見代碼第18-19行。

請注意此時的圖像在frameworks/base/core/jni/android/graphics/BitmapFactory.cpp中是一個SkBitmap

第五步:
依據scale計算縮放後SkBitmap的寬和高,請參見代碼第21-22行。

第六步:
計算SkBitmap的寬和高縮放的倍數,請參見代碼第25-26行。

在此得到寬的縮放倍數為sx, 高的縮放倍數為sy

第七步:
依據sx和sy縮放canvas,請參見代碼第30行。

第八步:
畫出圖片,請參見代碼第31行。

至此終於完成了doDecode()版的天龍八部。在梳理了整個過程之後不難發現:對於圖片縮放的比例其實還是scale即opts.inTargetDensity/opts.inDensity起了決定性的作用。

好吧,現在回過頭瞅瞅我掉進去的那個坑:我的手機華為P7其dpi值為480,有一張圖片我把它放到drawable-xxhdpi裡在手機上顯示出來是不失真的,非常合適;但是錯放到了drawable-xhdpi(其TypedValue的value值為320)後再次顯示時發現圖片被放大了,而且放大了480/320=1.5倍。既然圖片被放大了那麼該圖片所占的內存當然也變大了。

這也就解釋了我們有時遇到的類似困惑:為什麼圖片放在drawable-xxhdpi是正常的,但是放到drawable-mdpi後圖片不僅僅放大失真而且所占內存也大幅增加了。


後語

至此,對於Andoid中常見的度量單位已經介紹完了;關於drawable的加載原理也做了一個完整分析。

在明白這些之後,我們再去談多分辨率的適配也就會多了一份從容和自信。


  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved