重新認識 Android 圖片適配

0. 前言

Android 圖片適配,真的不是你想像的那樣,至少在寫這篇文章之前,我陷在一個很大很大的誤區中。

1. 關於適配

所有關於適配的基本概念,這裏不多介紹,資料有很多。下面只介紹點比較重要的部分。

等級 密度 比例
ldpi 120dpi 1dp=0.75px
mdpi 160dpi 1dp=1px
hdpi 240dpi 1dp=1.5px
xhdpi 320dpi 1dp=2px
xxhdpi 480dpi 1dp=3px
xxxhdpi 640dpi 1dp=4px

上面這張表介紹了 dpi 與 px 之間的關係。而多數手機廠商沒有嚴格按照上述規範生產屏幕,纔會有如今令人噁心的 Android 適配問題。

如:三星 C9,6英寸屏幕,分辨率 1920x1080 ,按照公式計算屏幕密度 367 dpi ,更接近 320dpi ,因此適配時,會取 xhdpi 目錄下的數據。

但實際中,會取 xxhdpi 數據,因爲實際屏幕密度是 420 dpi。(通過代碼的方式獲取)

DisplayMetrics dm = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(dm);
Log.d(TAG, "onCreate: "+dm.density);
Log.d(TAG, "onCreate: "+dm.densityDpi);

2019-11-29 14:50:03.879 28034-28034/com.flueky.demo D/MainActivity: onCreate: 2.625

2019-11-29 14:50:03.879 28034-28034/com.flueky.demo D/MainActivity: onCreate: 420

2.625 是 420/160 的結果。表示在 C9 上,1dp=2.625 px ,411dp 約等於 1080px ,表示整個屏幕的寬度。

如:三星 S8,5.8英寸屏幕,分辨率 2960x1440 ,屏幕密度 568 dpi,接近 640 dpi ,因此適配時,會取 xxxhdpi 目錄下數據。

但實際中,會取 xxhdpi 數據,因爲實際屏幕密度是 560 dpi 。

2019-11-29 14:59:49.604 20793-20793/com.flueky.demo D/MainActivity: onCreate: 3.5

2019-11-29 14:59:49.604 20793-20793/com.flueky.demo D/MainActivity: onCreate: 560

在 S8 上 ,1dp=3.5px ,411dp 約等於 1440px ,表示整個屏幕的寬度。

很慶幸,這兩臺手機上的適配數據是一樣的,高度會存在差異,但是通常都是滾動長頁面,或者留白端頁面不受太大影響。若恰好是滿屏頁面,則不適用。

今日頭條的適配方案即是通過修改 density 的 值進行適配。不知道什麼原因,他們在《今日頭條》7.5 版本中未使用此適配方式。

2. 圖片適配

言歸正傳,關於圖片適配纔是我們的主題。

秉着實踐是檢驗真理的唯一標準這一原則,做了如下實驗。三種尺寸的圖片,放置在四個目錄目錄,用三種尺寸的 ImageView ,用三種方式加載圖片,檢查其內存使用的情況。

  1. 圖片尺寸

    • large 1600x900 ,佔用內存 1600x900x4/1024/1024 = 5.49m
    • middle 800x450 ,佔用內存 800x450x4/1024/1024 = 1.37m
    • small 400x225 ,佔用內存 400x225x4/1024/1024 = 0.34m
  2. 圖片目錄

    • asset
    • drawable hdpi
    • drawable xhdpi
    • drawable xxhdpi
  3. ImageView

    • wrap-content
    • 280dp
    • 160dp
  4. 引用方式

    • android:src
    • setImageResource
    • setImageBitmap

加載 asset 目錄下的圖片,只能使用 setImageBitmap 的方式。

第一組實驗,使用 1 2 3 以及 setImageBitmap ,得出 3x4x3x1 = 36 條數據,如下表。

  • B 表示內存中圖片的 bitmap 大小。
  • G 表示內存中 Graphics 佔用的空間。
  • N 表示內存中 Native 佔用的空間。
  • 序號 0 表示,未使用圖片時的情況。
  • 實驗基於屏幕密度 540dpi 的設備。
序號 目錄 分辨率 寬度 B G N
0 - - - - 1.8m 7.8m
1 asset 1600x900 wrap 5.49m 8.7m 14.6m
2 asset 1600x900 w280 5.49m 8.7m 14.7m
3 asset 1600x900 w160 5.49m 8.6m 13.2m
4 asset 800x450 wrap 1.37m 3.8m 9.3m
5 asset 800x450 w280 1.37m 3.8m 9.2m
6 asset 800x450 w160 1.37m 3.8m 9.3m
7 asset 400x225 wrap 0.34m 2.6m 8.2m
8 asset 400x225 w280 0.34m 2.6m 8.2m
9 asset 400x225 w160 0.34m 2.6m 8.2m
10 hdpi 1600x900 wrap 27.8m 37.1m 37.3m
11 hdpi 1600x900 w280 27.8m 37.1m 31.7m
12 hdpi 1600x900 w160 27.8m 31.7m 36.9m
13 hdpi 800x450 wrap 6.95m 9.7m 14.9m
14 hdpi 800x450 w280 6.95m 9.7m 14.8m
15 hdpi 800x450 w160 6.95m 9.7m 15.3m
16 hdpi 400x225 wrap 1.73m 4.1m 9.9m
17 hdpi 400x225 w280 1.73m 4m 9.7m
18 hdpi 400x225 w160 1.73m 4.1m 10.1m
19 xhdpi 1600x900 wrap 15.6m 18.9m 24.9m
20 xhdpi 1600x900 w280 15.6m 18.9m 24.7m
21 xhdpi 1600x900 w160 15.6m 18.9m 24.7m
22 xhdpi 800x450 wrap 3.9m 6.3m 12.4m
23 xhdpi 800x450 w280 3.9m 6.3m 11.5m
24 xhdpi 800x450 w160 3.9m 6.3m 12.2m
25 xhdpi 400x225 wrap 0.97m 3.2m 9m
26 xhdpi 400x225 w280 0.97m 3.2m 8.8m
27 xhdpi 400x225 w160 0.97m 3.2m 9.1m
28 xxhdpi 1600x900 wrap 6.95m 9.7m 16.7m
29 xxhdpi 1600x900 w280 6.95m 9.7m 16m
30 xxhdpi 1600x900 w160 6.95m 9.7m 16m
31 xxhdpi 800x450 wrap 1.73m 4.1m 9.7m
32 xxhdpi 800x450 w280 1.73m 4.1m 9.7m
33 xxhdpi 800x450 w160 1.73m 4.1m 9.6m
34 xxhdpi 400x225 wrap 0.43m 2.6m 8.4m
35 xxhdpi 400x225 w280 0.43m 2.6m 8.4m
36 xxhdpi 400x225 w160 0.43m 2.6m 8.7m

結果分析:

  1. 使用的圖片越大,越耗內存。實驗數據:1/4/7。
  2. 圖片內存與其顯示大小無關。實驗數據:1/2/3,4/5/6,7/8/9。誤區1:圖片顯示區域越大,越耗內存。
  3. 加載 asset 目錄的圖片,圖片佔用內存等於實際大小,實驗數據:1/2/3,4/5/6,7/8/9。計算方式:l x w x 4,長乘寬乘 4 (每個像素點佔用 4 字節)。
  4. 加載 drawable 目錄的圖片,圖片佔用內存存在縮放。如:large 佔用內存 5.49m , hdpi 對應 240 dpi 。因此圖片實際佔用內存 5.49 x (540/240)^2 = 27.79m 。誤區2:5.49 x (540/240) = 12.35m。

關於 B/G/N 之間的關係還未研究透徹,如有了解還請告知。

第二組實驗基於屏幕密度 360dpi 的設備,排除多數無用項。

序號 目錄 分辨率 寬度 B G N
37 - - - - 1.8m 7.4m
38 asset 1600x900 w160 5.49m 8.7m 14.7m
39 asset 800x450 w280 1.37m 3.8m 9.3m
40 asset 400x225 wrap 0.34m 2.6m 8.3m
41 hdpi 1600x900 wrap 12.3m 15.4m 21.4m
41 hdpi 1600x900 w280 12.3m 15.4m 21.3m
42 hdpi 1600x900 w160 12.3m 15.4m 21.4m
43 hdpi 800x450 w280 3.08m 5.9m 11m
44 hdpi 400x225 w160 0.77m 3m 8.8m
45 xhdpi 1600x900 wrap 6.95m 9.7m 16m
46 xhdpi 1600x900 w280 6.95m 9.7m 16.1m
47 xhdpi 1600x900 w160 6.95m 9.7m 16.1m
48 xhdpi 800x450 w280 1.73m 4.1m 9.7m
49 xhdpi 400x225 w160 0.43m 2.6m 8.3m
50 xxhdpi 1600x900 wrap 3.08m 5.9m 12.3m
51 xxhdpi 1600x900 w280 3.08m 5.9m 12.4m
52 xxhdpi 1600x900 w160 3.08m 5.9m 12.2m
53 xxhdpi 800x450 w280 0.77m 3m 8.7m
54 xxhdpi 400x225 w160 0.19m 2.4m 8.1m

結果分析:

  1. 圖片內存與屏幕密度無關。

第三組實驗基於屏幕密度 540 dpi 的設備,使用 setImageResource 方式加載圖片。

序號 目錄 分辨率 寬度 B G N
55 hdpi 1600x900 w160 5.49m 8.7m 19m
56 hdpi 800x450 wrap 1.37m 3.8m 9.3m
57 hdpi 400x225 w280 0.34m 2.6m 8.2m
58 xhdpi 1600x900 w280 5.49m 8.7m 19.9m
59 xhdpi 800x450 w160 1.37m 3.8m 9.3m
60 xhdpi 400x225 wrap 0.34m 2.6m 8.6m
61 xxhdpi 1600x900 wrap 5.49m 8.7m 14.6m
62 xxhdpi 800x450 w280 1.37m 3.9m 9.6m
63 xxhdpi 400x225 w160 0.34m 2.6m 8.3m

結果分析:

  1. 使用 setImageResource 加載圖片,沒有對圖片進行縮放。實驗數據:55/58/61。誤區3:使用不同屏幕密度下的圖片存在縮放情況。

實驗的最後發現,在佈局用使用 android:src 引用圖片時,圖片內存也不縮放。因此,沒有列出實驗數據。

3. 源碼分析

基於以上結果,通過分析源碼,得以驗證。

  1. asset 目錄下圖片佔用內存是圖片實際大小。
// 通過流的方式解析圖片。
bitmap = BitmapFactory.decodeStream(getAssets().open("test.jpg"));

public static Bitmap decodeStream(InputStream is) {
    return decodeStream(is, null, null);
}
/**
 * 實際執行到下面的代碼
 */
public static Bitmap decodeStream(@Nullable InputStream is, @Nullable Rect outPadding,
        @Nullable Options opts) {
    
    ......

    Bitmap bm = null;

    Trace.traceBegin(Trace.TRACE_TAG_GRAPHICS, "decodeBitmap");
    try {
        if (is instanceof AssetManager.AssetInputStream) {
            final long asset = ((AssetManager.AssetInputStream) is).getNativeAsset();
            // 解析 asset 目錄下的 文件,opts == null ,所以按照設備的 density 解析。
            bm = nativeDecodeAsset(asset, outPadding, opts);
        } else {
            // 解密普通的文件流
            bm = decodeStreamInternal(is, outPadding, opts);
        }

        if (bm == null && opts != null && opts.inBitmap != null) {
            throw new IllegalArgumentException("Problem decoding into existing bitmap");
        }
        // 更新 bitmap 的 density 
        setDensityFromOptions(bm, opts);
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_GRAPHICS);
    }

    return bm;
}

private static void setDensityFromOptions(Bitmap outputBitmap, Options opts) {
    // opts==null,因此未做處理。
    if (outputBitmap == null || opts == null) return;

    ......
}
  1. drawable 目錄下圖片佔用內存被縮放。
// 只有在使用下面的方式獲取 bitmap 會縮放。
bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.test);

public static Bitmap decodeResource(Resources res, int id) {
    return decodeResource(res, id, null);
}
public static Bitmap decodeResource(Resources res, int id, Options opts) {
    validate(opts);
    Bitmap bm = null;
    InputStream is = null; 
    
    try {
        final TypedValue value = new TypedValue();
        // 根據 id 得到文件流,AssetInputStream
        is = res.openRawResource(id, value);
        // 根據流得到 bitmap
        bm = decodeResourceStream(res, value, is, null, opts);
    } catch (Exception e) {
    ......
    }
    return bm;
}
@Nullable
public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value,
        @Nullable InputStream is, @Nullable Rect pad, @Nullable Options opts) {
    validate(opts);
    if (opts == null) {
        // 生成 Option 
        opts = new Options();
    }
    // 以 設備 320dpi ,圖片在 xxhdpi 爲例
    if (opts.inDensity == 0 && value != null) {
        final int density = value.density; // density = 480
        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) {
        // res.getDisplayMetrics().densityDpi = 320
        opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
    }
    
    return decodeStream(is, pad, opts);
}
@Nullable
public static Bitmap decodeStream(@Nullable InputStream is, @Nullable Rect outPadding,
        @Nullable Options opts) {
    
    ......

    Trace.traceBegin(Trace.TRACE_TAG_GRAPHICS, "decodeBitmap");
    try {
        if (is instanceof AssetManager.AssetInputStream) {
            final long asset = ((AssetManager.AssetInputStream) is).getNativeAsset();
            // opts inDensity 480 ,inTargetDensity 320 ,因此需要縮放。
            bm = nativeDecodeAsset(asset, outPadding, opts);
        } else {
            bm = decodeStreamInternal(is, outPadding, opts);
        }

        if (bm == null && opts != null && opts.inBitmap != null) {
            throw new IllegalArgumentException("Problem decoding into existing bitmap");
        }
        // 根據 opts 設置圖片的 density 
        setDensityFromOptions(bm, opts);
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_GRAPHICS);
    }

    return bm;
}
private static void setDensityFromOptions(Bitmap outputBitmap, Options opts) {
    if (outputBitmap == null || opts == null) return;

    final int density = opts.inDensity;
    if (density != 0) {
        // 先設置成 480
        outputBitmap.setDensity(density);
        final int targetDensity = opts.inTargetDensity;
        if (targetDensity == 0 || density == targetDensity || density == opts.inScreenDensity) {
            return;
        }

        byte[] np = outputBitmap.getNinePatchChunk();
        final boolean isNinePatch = np != null && NinePatch.isNinePatchChunk(np);
        // 由於支持縮放,再設置成 320 
        if (opts.inScaled || isNinePatch) {
            outputBitmap.setDensity(targetDensity);
        }
    } else if (opts.inBitmap != null) {
        // bitmap was reused, ensure density is reset
        outputBitmap.setDensity(Bitmap.getDefaultDensity());
    }
}
  1. 通過 setImageResource,或佈局引用,圖片不縮放。
// 佈局引用時,在 ImageView 的構造函數中加載圖片
public ImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr,
        int defStyleRes) {
    super(context, attrs, defStyleAttr, defStyleRes);
    ......
    final TypedArray a = context.obtainStyledAttributes(
            attrs, R.styleable.ImageView, defStyleAttr, defStyleRes);
    // 得到 Drawable 對象,如果使用 png 或 jpg 等圖片,則是 BitmapDrawable 
    final Drawable d = a.getDrawable(R.styleable.ImageView_src);
    ......
}

// TypedArray 類
public Drawable getDrawable(@StyleableRes int index) {
    // 注意此處的 density 是 0
    return getDrawableForDensity(index, 0);
}

public Drawable getDrawableForDensity(@StyleableRes int index, int density) {
    if (mRecycled) {
        throw new RuntimeException("Cannot make calls to a recycled instance!");
    }

    final TypedValue value = mValue;
    if (getValueAt(index * STYLE_NUM_ENTRIES, value)) {
        ......
        // density = 0 ,執行下面代碼
        return mResources.loadDrawable(value, value.resourceId, density, mTheme);
    }
    return null;
}
// ResourcesImpl 類
Drawable loadDrawable(@NonNull Resources wrapper, @NonNull TypedValue value, int id,
        int density, @Nullable Resources.Theme theme)
        throws NotFoundException {
    // useCache = true,後面的代碼忽略
    final boolean useCache = density == 0 || value.density == mMetrics.densityDpi;
    ......
    try {
        ......

        // 讀加載過的 BitmapDrawable
        if (!mPreloading && useCache) {
            final Drawable cachedDrawable = caches.getInstance(key, wrapper, theme);
            if (cachedDrawable != null) {
                cachedDrawable.setChangingConfigurations(value.changingConfigurations);
                return cachedDrawable;
            }
        }
        final Drawable.ConstantState cs;
        if (isColorDrawable) {
            cs = sPreloadedColorDrawables.get(key);
        } else {
            cs = sPreloadedDrawables[mConfiguration.getLayoutDirection()].get(key);
        }

        Drawable dr;
        boolean needsNewDrawableAfterCache = false;
        if (cs != null) {
            ......
        } else if (isColorDrawable) {
            dr = new ColorDrawable(value.data);
        } else {
            // 最終執行到此處加載圖片
            dr = loadDrawableForCookie(wrapper, value, id, density);
        }
        ......
        return dr;
    } catch (Exception e) {
        ......
    }
}

private Drawable loadDrawableForCookie(@NonNull Resources wrapper, @NonNull TypedValue value,
            int id, int density) {
    ......
    final Drawable dr;
    Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, file);
    LookupStack stack = mLookupStack.get();
    try {
        // Perform a linear search to check if we have already referenced this resource before.
        if (stack.contains(id)) {
            throw new Exception("Recursive reference in drawable");
        }
        stack.push(id);
        try {
            // 處理使用 shape selector 等 使用 xml 生成的資源文件
            if (file.endsWith(".xml")) {
                final XmlResourceParser rp = loadXmlResourceParser(
                        file, id, value.assetCookie, "drawable");
                dr = Drawable.createFromXmlForDensity(wrapper, rp, density, null);
                rp.close();
            } else {
                // 通過 asset 的方式讀取資源  file:///res/drawable-xhdpi/test.jpg
                final InputStream is = mAssets.openNonAsset(
                        value.assetCookie, file, AssetManager.ACCESS_STREAMING);
                AssetInputStream ais = (AssetInputStream) is;
                // 解析得到 BitmapDrawable
                dr = decodeImageDrawable(ais, wrapper, value);
            }
        } finally {
            stack.pop();
        }
    } catch (Exception | StackOverflowError e) {
        Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
        final NotFoundException rnf = new NotFoundException(
                "File " + file + " from drawable resource ID #0x" + Integer.toHexString(id));
        rnf.initCause(e);
        throw rnf;
    }
    Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
    .......
    return dr;
}
// 使用 setImageResource 方式同佈局引用一致。
public void setImageResource(@DrawableRes int resId) {
    ......
    resolveUri();
    ......
}

private void resolveUri() {
    ......
    if (mResource != 0) {
        try {
            // 讀取 Drawable
            d = mContext.getDrawable(mResource);
        } catch (Exception e) {
            Log.w(LOG_TAG, "Unable to find resource: " + mResource, e);
            // Don't try again.
            mResource = 0;
        }
    } else if (mUri != null) {
        ......
    } else {
        return;
    }
    updateDrawable(d);
}
// Context 類
public final Drawable getDrawable(@DrawableRes int id) {
    return getResources().getDrawable(id, getTheme());
}
// Resources 類 
public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme)
        throws NotFoundException {
    return getDrawableForDensity(id, 0, theme);
}

public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
    final TypedValue value = obtainTempTypedValue();
    try {
        final ResourcesImpl impl = mResourcesImpl;
        impl.getValueForDensity(id, density, value, true);
        // 依然執行到 ResourcesImpl.loadDrawable 且 density = 0
        return impl.loadDrawable(this, value, id, density, theme);
    } finally {
        releaseTempTypedValue(value);
    }
}

4. 總結

經過上述實踐驗證,建議在使用圖片時,控制好圖片尺寸。避免直接根據 resId 轉化成 bitmap 對象。如需實時釋放 bitmap 對象,建議通過 BitmapDrawable 取到 bitmap 引用再釋放。

另外,以前存在的三個誤區請避免。

  1. 圖片佔用的內存只與圖片大小有關。非圖片文件大小。
  2. 圖片縮放計算,長scalescale = 長寬*scale^2。
  3. 佈局中引用的圖片以及 setImageResource 方式使用圖片,圖片不會根據密度縮放。

源碼地址

覺得有用?那打賞一個唄。去打賞

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章