重新认识 Android 图片适配

4,599 阅读11分钟

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 方式使用图片,图片不会根据密度缩放。

源码地址

觉得有用?那打赏一个呗。去打赏