重新认识 Android 图片适配

0. 前言

Android 图片适配,真的不是你想像的那样,至少在写这篇文章之前,我陷在一个很大很大的误区中。

1. 关于适配

所有关于适配的基本概念,这里不多介绍,资料有很多。下面只介绍点比较重要的部分。

等级密度比例
ldpi120dpi1dp=0.75px
mdpi160dpi1dp=1px
hdpi240dpi1dp=1.5px
xhdpi320dpi1dp=2px
xxhdpi480dpi1dp=3px
xxxhdpi640dpi1dp=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 的设备。
序号目录分辨率宽度BGN
0----1.8m7.8m
1asset1600x900wrap5.49m8.7m14.6m
2asset1600x900w2805.49m8.7m14.7m
3asset1600x900w1605.49m8.6m13.2m
4asset800x450wrap1.37m3.8m9.3m
5asset800x450w2801.37m3.8m9.2m
6asset800x450w1601.37m3.8m9.3m
7asset400x225wrap0.34m2.6m8.2m
8asset400x225w2800.34m2.6m8.2m
9asset400x225w1600.34m2.6m8.2m
10hdpi1600x900wrap27.8m37.1m37.3m
11hdpi1600x900w28027.8m37.1m31.7m
12hdpi1600x900w16027.8m31.7m36.9m
13hdpi800x450wrap6.95m9.7m14.9m
14hdpi800x450w2806.95m9.7m14.8m
15hdpi800x450w1606.95m9.7m15.3m
16hdpi400x225wrap1.73m4.1m9.9m
17hdpi400x225w2801.73m4m9.7m
18hdpi400x225w1601.73m4.1m10.1m
19xhdpi1600x900wrap15.6m18.9m24.9m
20xhdpi1600x900w28015.6m18.9m24.7m
21xhdpi1600x900w16015.6m18.9m24.7m
22xhdpi800x450wrap3.9m6.3m12.4m
23xhdpi800x450w2803.9m6.3m11.5m
24xhdpi800x450w1603.9m6.3m12.2m
25xhdpi400x225wrap0.97m3.2m9m
26xhdpi400x225w2800.97m3.2m8.8m
27xhdpi400x225w1600.97m3.2m9.1m
28xxhdpi1600x900wrap6.95m9.7m16.7m
29xxhdpi1600x900w2806.95m9.7m16m
30xxhdpi1600x900w1606.95m9.7m16m
31xxhdpi800x450wrap1.73m4.1m9.7m
32xxhdpi800x450w2801.73m4.1m9.7m
33xxhdpi800x450w1601.73m4.1m9.6m
34xxhdpi400x225wrap0.43m2.6m8.4m
35xxhdpi400x225w2800.43m2.6m8.4m
36xxhdpi400x225w1600.43m2.6m8.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 的设备,排除多数无用项。

序号目录分辨率宽度BGN
37----1.8m7.4m
38asset1600x900w1605.49m8.7m14.7m
39asset800x450w2801.37m3.8m9.3m
40asset400x225wrap0.34m2.6m8.3m
41hdpi1600x900wrap12.3m15.4m21.4m
41hdpi1600x900w28012.3m15.4m21.3m
42hdpi1600x900w16012.3m15.4m21.4m
43hdpi800x450w2803.08m5.9m11m
44hdpi400x225w1600.77m3m8.8m
45xhdpi1600x900wrap6.95m9.7m16m
46xhdpi1600x900w2806.95m9.7m16.1m
47xhdpi1600x900w1606.95m9.7m16.1m
48xhdpi800x450w2801.73m4.1m9.7m
49xhdpi400x225w1600.43m2.6m8.3m
50xxhdpi1600x900wrap3.08m5.9m12.3m
51xxhdpi1600x900w2803.08m5.9m12.4m
52xxhdpi1600x900w1603.08m5.9m12.2m
53xxhdpi800x450w2800.77m3m8.7m
54xxhdpi400x225w1600.19m2.4m8.1m

结果分析:

  1. 图片内存与屏幕密度无关。

第三组实验基于屏幕密度 540 dpi 的设备,使用 setImageResource 方式加载图片。

序号目录分辨率宽度BGN
55hdpi1600x900w1605.49m8.7m19m
56hdpi800x450wrap1.37m3.8m9.3m
57hdpi400x225w2800.34m2.6m8.2m
58xhdpi1600x900w2805.49m8.7m19.9m
59xhdpi800x450w1601.37m3.8m9.3m
60xhdpi400x225wrap0.34m2.6m8.6m
61xxhdpi1600x900wrap5.49m8.7m14.6m
62xxhdpi800x450w2801.37m3.9m9.6m
63xxhdpi400x225w1600.34m2.6m8.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 方式使用图片,图片不会根据密度缩放。

源码地址

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

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值