前段时间码代码时碰到了一个比较奇怪的问题:ImageView去加载不用分辨率文件夹下的同一张图片时,读出来的图片大小是不一样的。打个比方:
其实有经验的同学一看就知道,这是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文件夹中。可以提升应用的性能。
网友评论