ImageView加载图片源码分析——src是如何变成可见图片的

前段时间码代码时碰到了一个比较奇怪的问题:ImageView去加载不用分辨率文件夹下的同一张图片时,读出来的图片大小是不一样的。打个比方:

我分别在drawable、drawable-xhdpi、drawable-xxhdpi文件夹中放入同一张图片,但是名字不同,分别是"a.png"、"b.png"、"c.png",这样就可以在不论在什么分辨率的情况下同一个src只会加载同一张图片。接下来在Activity的布局中写上三个ImageView 宽高模式都是wrap_content,分别去加载图片a、b、c。接下在你会发现这三个ImageView的大小完全不一样,加载a图片的最大,b次之,加载c的最小。如图:
对比.png

其实有经验的同学一看就知道,这是Android系统为了适配不同分辨率屏幕做的处理,今天我们就带着这个问题,去看一下布局文件中的src属性,到底是如何变成肉眼可见的图片的,以及发生以上问题的原因,和基于此问题的优化建议。

ImageView与AppCompatImageView

AppCompatImageView是appcompatV7包中的AppCompatXX视图组件,假如工程中使用的是AppCompatActivity,那么布局文件中ImageView在LayoutInflater.inflate时都会被替换成AppCompatImageView,具体的替换是在AppCompatViewInflater类中:

  final View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs, boolean inheritContext,
            boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
        final Context originalContext = context;

        // We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
        // by using the parent's context
        if (inheritContext && parent != null) {
            context = parent.getContext();
        }
        if (readAndroidTheme || readAppTheme) {
            // We then apply the theme on the context, if specified
            context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
        }
        if (wrapContext) {
            context = TintContextWrapper.wrap(context);
        }

        View view = null;

        // We need to 'inject' our tint aware Views in place of the standard framework versions
        switch (name) {
            ...
            case "ImageView":
                view = createImageView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "Button":
                view = createButton(context, attrs);
                verifyNotNull(view, name);
                break;
          ...
        }
        return view;
    }

    protected AppCompatImageView createImageView(Context context, AttributeSet attrs) {
        return new AppCompatImageView(context, attrs);
    }

AppCompatViewInflater解析到ImageView时直接返回了一个AppCompatImageView。
虽然现在大部分工程都是用的AppCompatActivity,但其实对于由src到图片绘制这一块的逻辑基本还是在ImageView上。

src转换成可绘制图片的过程

从上段可以知道,xml在被读取之后经过view的名字对比,进而生成了AppCompatImageView。

    public AppCompatImageView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public AppCompatImageView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(TintContextWrapper.wrap(context), attrs, defStyleAttr);

        mBackgroundTintHelper = new AppCompatBackgroundHelper(this);
        mBackgroundTintHelper.loadFromAttributes(attrs, defStyleAttr);

        mImageHelper = new AppCompatImageHelper(this);
        mImageHelper.loadFromAttributes(attrs, defStyleAttr);
    }

AppCompatImageView的构造函数中,主要是额外的实现了对高版本特性的适配,对于本身的绘制逻辑的初始化还是在父构造函数中实现的。即ImageView中

 public ImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr,
            int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);

        initImageView();

        // ImageView is not important by default, unless app developer overrode attribute.
        if (getImportantForAutofill() == IMPORTANT_FOR_AUTOFILL_AUTO) {
            setImportantForAutofill(IMPORTANT_FOR_AUTOFILL_NO);
        }

        final TypedArray a = context.obtainStyledAttributes(
                attrs, R.styleable.ImageView, defStyleAttr, defStyleRes);

        final Drawable d = a.getDrawable(R.styleable.ImageView_src);
        if (d != null) {
            setImageDrawable(d);
        }        
         ...
}

在这可以看到正是TypedArray 的getDrawable方法,返回了绘制用的Drawable ,后经setImageDrawable将返回的Drawable正式设置给ImageView。
跟着方法一路看下去,最终调用了TypedArray 的getDrawableForDensity

    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*AssetManager.STYLE_NUM_ENTRIES, value)) {
            if (value.type == TypedValue.TYPE_ATTRIBUTE) {
                throw new UnsupportedOperationException(
                        "Failed to resolve attribute at index " + index + ": " + value);
            }

            if (density > 0) {
                // If the density is overridden, the value in the TypedArray will not reflect this.
                // Do a separate lookup of the resourceId with the density override.
                mResources.getValueForDensity(value.resourceId, density, value, true);
            }
            return mResources.loadDrawable(value, value.resourceId, density, mTheme);
        }
        return null;
    }

index代表了src的属性标识,density表示了当前屏幕密度,这个方法主要是对value 进行赋值
重点看下给value赋值这块

    private boolean getValueAt(int index, TypedValue outValue) {
        final int[] data = mData;
        final int type = data[index+AssetManager.STYLE_TYPE];
        if (type == TypedValue.TYPE_NULL) {
            return false;
        }
        outValue.type = type;
        outValue.data = data[index+AssetManager.STYLE_DATA];
        outValue.assetCookie = data[index+AssetManager.STYLE_ASSET_COOKIE];
        outValue.resourceId = data[index+AssetManager.STYLE_RESOURCE_ID];
        outValue.changingConfigurations = ActivityInfo.activityInfoConfigNativeToJava(
                data[index + AssetManager.STYLE_CHANGING_CONFIGURATIONS]);
        outValue.density = data[index+AssetManager.STYLE_DENSITY];
        outValue.string = (type == TypedValue.TYPE_STRING) ? loadStringValueAt(index) : null;
        return true;
    }

mData里边存储了控件属性的值,mData的长度是属性数量*6+1,数组的第一个元素储存的是属性的个数,乘以6是因为每个属性都有6个字段

    /*package*/ static final int STYLE_TYPE = 0;
    /*package*/ static final int STYLE_DATA = 1;
    /*package*/ static final int STYLE_ASSET_COOKIE = 2;
    /*package*/ static final int STYLE_RESOURCE_ID = 3;

    /* Offset within typed data array for native changingConfigurations. */
    static final int STYLE_CHANGING_CONFIGURATIONS = 4;

    /*package*/ static final int STYLE_DENSITY = 5;

getValueAt的参数index就是要处理的属性的起始坐标,里边依次将属性的六个字段的值赋予outValue,在这完成了属性值的填充。outValue.string存储了ImageView加载图片的path路径
再回来继续看mResources.loadDrawable
density一般是0标识取系统的默认值,所以直接看mResources.loadDrawable

    Drawable loadDrawable(@NonNull TypedValue value, int id, int density, @Nullable Theme theme)
            throws NotFoundException {
        return mResourcesImpl.loadDrawable(this, value, id, density, theme);
    }

可以看到具体的实现在ResourcesImpl中,但是这要说一下国产的ROM中基本都自己重写了ResourcesImpl,也就是说只有原生Android才是使用了ResourcesImpl。

    @Nullable
    Drawable loadDrawable(@NonNull Resources wrapper, @NonNull TypedValue value, int id,
            int density, @Nullable Resources.Theme theme)
            throws NotFoundException {
            ...
   
            if (isColorDrawable) {
                cs = sPreloadedColorDrawables.get(key);
            } else {
                cs = sPreloadedDrawables[mConfiguration.getLayoutDirection()].get(key);
            }

            Drawable dr;
            boolean needsNewDrawableAfterCache = false;
            if (cs != null) {
                if (TRACE_FOR_DETAILED_PRELOAD) {
                    // Log only framework resources
                    if (((id >>> 24) == 0x1) && (android.os.Process.myUid() != 0)) {
                        final String name = getResourceName(id);
                        if (name != null) {
                            Log.d(TAG_PRELOAD, "Hit preloaded FW drawable #"
                                    + Integer.toHexString(id) + " " + name);
                        }
                    }
                }
                dr = cs.newDrawable(wrapper);
            } else if (isColorDrawable) {
                dr = new ColorDrawable(value.data);
            } else {
                dr = loadDrawableForCookie(wrapper, value, id, density, null);
            }
          ...
    }

这里边的逻辑,会先尝试从缓存去拿Drawable,没有缓存的话会去判断是否是ColorDrawable,现在咱们要看的是图片类型的Drawable,所以最终是由loadDrawableForCookie方法生成的Drawable。

    private Drawable loadDrawableForCookie(@NonNull Resources wrapper, @NonNull TypedValue value,
            int id, int density, @Nullable Resources.Theme theme) {
            ...
            if (file.endsWith(".xml")) {
                final XmlResourceParser rp = loadXmlResourceParser(
                        file, id, value.assetCookie, "drawable");
                dr = Drawable.createFromXmlForDensity(wrapper, rp, density, theme);
                rp.close();
            } else {
                final InputStream is = mAssets.openNonAsset(
                        value.assetCookie, file, AssetManager.ACCESS_STREAMING);
                dr = Drawable.createFromResourceStream(wrapper, value, is, file, null);
                is.close();
            }
            ...
    }

这个方法主要也是做了一个简单的判断,file就是getValueAt()中填充进string字段的值。可以理解成图片文件的路径,但不是绝对路径类似“drawable-xxhdpi/image.png”,一个根据屏幕分辩率指定的相对文件地址。在这个方法中很明显会走下面的分支判断,也就是调用 Drawable.createFromResourceStream方法。mAssets.openNonAsset生成的流是AssetManager.AssetInputStream,这一点会在后续的判断中起到作用。
接下来看createFromResourceStream,这里边其实逻辑比较简单,首先获取了屏幕像素密度,然后调用了BitmapFactory.decodeResourceStream方法。像素密度其实就是调用了getResources().getDisplayMetrics().density;获取了系统的逻辑像素密度,值存放到了opts.inScreenDensity 。
然后看BitmapFactory.decodeResourceStream

    public static Bitmap decodeResourceStream(Resources res, TypedValue value,
            InputStream is, Rect pad, Options opts) {
        validate(opts);
        if (opts == null) {
            opts = new Options();
        }

        if ( opts.inTargetDensity  == 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);
    }

这一段其实还是针对dpi做的文章,目标dpi,屏幕dpi,逻辑分辨率,在这详细的捋一下。这段代码主要是给 opts.inTargetDensity 和 opts.inDensity 赋值,
opts.inTargetDensity 代表了绘制目标设备的像素密度,说白了就是手机屏幕的像素密度。
opts.inDensity则指的是资源的目标像素密度“The pixel density to use for the bitmap”,即图片是哪个文件夹的:drawable(160dpi)、drawable-xhdpi(320dpi)、drawable-xxhdpi(480dpi)、drawable-xxxhdpi(720dpi)等。上个方法中的opts.inScreenDensity Google给的注释是“The pixel density of the actual screen that is being used”,正在使用的实际屏幕的像素密度,我是没太琢磨出来是啥意思,跟 opts.inTargetDensity 的意思有点相同。咱们来看他们的实际值
opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
opts.inScreenDensity = getResources().getDisplayMetrics().density;
这就能看出来区别了其实inScreenDensity 指的是屏幕的相对像素密度,值为dpi/160,inTargetDensity 的值则是屏幕的dpi。
着重说一下TypedValue.DENSITY_NONE 这个类型,这指的是资源没有目标像素密度,没有对
opts.inDensity 进行赋值。会对后续的缩放计算有很大的影响。decodeStreamb比较简单 就是对图片资源的流类型做了个判断。AssetInputStream流会调用nativeDecodeAsset方法,而nativeDecodeAsset是native方法。直接去androidxref找源码。位置在/frameworks/base/core/jni/android/graphics/BitmapFactory.cpp
nativeDecodeAsset 又调用了doDecode方法,doDecode方法中有一个比较重要的变量:

float scale = 1.0f;

scale 正是代表了资源图片生成Bitmap时的缩放比例。scale的计算公式如下:

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;
}

到现在我们终于可以看到这个缩放比例是怎么算出来了,其实比较简单就是手机dpi/目标dpi,比如现在主流手机dpi都是480dpi~640dpi,咱们取480计算,当你加载一张drawable文件夹的120 * 120 像素的图片时,实际生成的bitmap的大小(480/160)*120也就是 360 * 360像素。这也就解释了为什么文章开头 加载出来的a图片最大。
并且从计算公式中还可以看出当density =0时是不会进行计算的,什么时候density =0呢,其实就是上面写的当density = TypedValue.DENSITY_NONE时,也就是只要吧图片放在drawable-nodpi文件夹的时候。

总结

好了,到这已经能看见imageView 的src属性是如何一步步的从一个资源id变成了一张肉眼可见的图片。其实整体过程并不复杂,稍微复杂的就是各种density的处理。后续生成Bitmap之后会利用这个bitmap生成一个BitmapDrawable,再把这个Drawable设置给ImageView。后续就是ImageView自己对Drawable进行缩放,绘制的逻辑,继而变成了一张显示在手机上的图片。本篇主要分析src到Drawable的转换,不详叙述ImageView自己的处理逻辑了。
文章开篇的问题已经讲明白了。不过在这里会引申出另外一个问题:
依旧如开头所述,设想这么一种情况:我分别在drawable、drawable-xhdpi、drawable-xxhdpi文件夹中放入同一张图片,但是名字不同,分别是"a.png"、"b.png"、"c.png",这样就可以在不论在什么分辨率的情况下同一个src只会加载同一张图片。接下来在Activity的布局中写上三个ImageView 宽高模式都是wrap_content,分别去加载图片a、b、c。那么虽然是一张图片,加载时间一样吗?我们用代码验证一下.

    private ImageView iv1;
    private ImageView iv2;
    private ImageView iv3;

    public void load1(View view) {
        Log.i(TAG, "load1 start: "+System.currentTimeMillis());
        iv1.setImageResource(R.drawable.a);
        Log.i(TAG, "load1 end: "+System.currentTimeMillis());
    }

    public void load2(View view) {
        Log.i(TAG, "load2 start: "+System.currentTimeMillis());
        iv2.setImageResource(R.drawable.b);
        Log.i(TAG, "load2 end: "+System.currentTimeMillis());
    }

    public void load3(View view) {
        Log.i(TAG, "load3 start: "+System.currentTimeMillis());
        iv3.setImageResource(R.drawable.c);
        Log.i(TAG, "load3 end: "+System.currentTimeMillis());
    }

日志打印结果:

2019-08-18 19:32:52.177 14560-14560/com.ebupt.imageview I/123: load1 start: 1566127972176
2019-08-18 19:32:52.186 14560-14560/com.ebupt.imageview I/123: load1 end: 1566127972186
2019-08-18 19:32:53.562 14560-14560/com.ebupt.imageview I/123: load2 start: 1566127973561
2019-08-18 19:32:53.568 14560-14560/com.ebupt.imageview I/123: load2 end: 1566127973568
2019-08-18 19:32:54.207 14560-14560/com.ebupt.imageview I/123: load3 start: 1566127974207
2019-08-18 19:32:54.212 14560-14560/com.ebupt.imageview I/123: load3 end: 1566127974212

虽然这只是一次的测试结果,可能存在误差,但其实经过我多次尝试,的确加载drawable文件夹下的图片时最耗时的,由此可见,资源的缩放如果没有很好的处理的话会浪费不少的时间。
针对此情况就可以利用上述的提到的drawable-nodpi文件夹,将一些没做分辨率区分的通用图片都放在drawable-nodpi文件夹中。可以提升应用的性能。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,386评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,142评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,704评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,702评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,716评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,573评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,314评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,230评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,680评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,873评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,991评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,706评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,329评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,910评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,038评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,158评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,941评论 2 355

推荐阅读更多精彩内容

  • 图片加载 在客户端开发中,图片加载和显示,是非常常见的功能了。常见的图片获取途径有网络传输,本地文件获取和资源加载...
    zackyG阅读 1,907评论 1 2
  • 7.1 压缩图片 一、基础知识 1、图片的格式 jpg:最常见的图片格式。色彩还原度比较好,可以支持适当压缩后保持...
    AndroidMaster阅读 2,516评论 0 13
  • 作为一名Android开发人员,你见得最多的大概就是res/drawable-[density]/ 文件夹了,现在...
    C6C阅读 3,419评论 4 8
  • 1. 概述 在平时工作的过程中,总是会遇到这样的问题,将一张图片放到drawable、drawable-ldpi、...
    小芸论阅读 2,060评论 0 8
  • 1 此时此刻,我蛰伏于大溪边的一间单身公寓。临窗支起画架,雨季山水大涨,昨天还能看见的绿草长汀如今已被泱泱大水淹没...
    围裙书香阅读 909评论 8 9