Android多解析度適配框架(1)— 核心基礎

探索Android軟鍵盤的疑難雜症
深入探討Android非同步精髓Handler
詳解Android主流框架不可或缺的基石
站在原始碼的肩膀上全解Scroller工作機制


Android多解析度適配框架(1)— 核心基礎
Android多解析度適配框架(2)— 原理剖析
Android多解析度適配框架(3)— 使用指南


自定義View系列教程00–推翻自己和過往,重學自定義View
自定義View系列教程01–常用工具介紹
自定義View系列教程02–onMeasure原始碼詳盡分析
自定義View系列教程03–onLayout原始碼詳盡分析
自定義View系列教程04–Draw原始碼分析及其實踐
自定義View系列教程05–示例分析
自定義View系列教程06–詳解View的Touch事件處理
自定義View系列教程07–詳解ViewGroup分發Touch事件
自定義View系列教程08–滑動衝突的產生及其處理


PS: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)

完整的對應關係,請參照下圖。
這裡寫圖片描述

例如:在佈局中指定了某個控制元件的高為100dp,那麼它在ldpi的手機上高為75px,在mdpi的手機上高為100px,在xhdpi手機上高為200px;以此類推,其高在不同螢幕中顯示的比例保持了一致

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,則其TypedValue.density 的值為120
如果將圖片放入drawable-mdpi,則其TypedValue.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顯示該圖片,程式碼如下:

<ImageView
android:id="@ id/imageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:src="@drawable/lfdfhl"/>

執行之後,看一下效果

這裡寫圖片描述

最後,在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中的圖片時的具體實現

  1. 呼叫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()

  2. 呼叫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

  3. 呼叫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後圖片不僅僅放大失真而且所佔記憶體也大幅增加了。

除了剛才提到的drawable-ldpi、drawable-mdpi、drawable-hdpi、drawable-xhdpi、drawable-xxhdpi、drawable-xxxdpi還有一個不得不提,那就是drawable-nodpi。和之前的操作一樣,我們把這張圖片放入drawable-nodpi會發現此時TypedValue的density值為65535,如果按照剛才的思路圖片豈不是要被縮放到無限小?非也!

請看上面的decodeResourceStream()方法的第10行

else if (density != TypedValue.DENSITY_NONE)

我們去原始碼中看這個欄位:

public static final int DENSITY_NONE = 0xffff;

這是一個十六進位制的數值,我們將其轉換為10進位制瞅瞅,嗯哼,它就等於65535。
這個DENSITY_NONE是幹嘛的?繼續瞅瞅官方文件的解釋:

If density is equal to this value, then there is no density associated with the resource and it should not be scaled.

喔,它的意思是說:如果圖片放在drawable-nodpi中,那麼該圖片不會被縮放;也就是說該圖片在不同解析度的手機上都只顯示原圖的大小。例如,把剛才這張圖片放到drawable-nodpi中,那麼它在各個手機上顯示時它的寬均為144,高均為180。


後語

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

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

who is the next one? ——> Theory



PS:Android多解析度適配框架視訊教程同步更新啦