• Android应用性能优化系列视图篇——隐藏在资源图片中的内存杀手


    图片加载性能优化永远是Android领域中一个无法绕过的话题,经过数年的发展,涌现了很多成熟的图片加载开源库,比如Fresco、Picasso、UIL等等,使得图片加载不再是一个头疼的问题,并且大幅降低了OOM发生的概率。然而,在图片加载方面我们是否可以就此放松警惕了呢?
    
    开源图片加载库能为我们解决绝大部分有关图片的问题,然而并不是所有!
    
    首先,图片从来源上可以分成三大类:网络图片、手机图片、APK资源图片。网络图片和手机图片都在图片加载库功能的覆盖范围内,基本上不用开发者太操心,但是APK资源图片却不在此范围!
    
    关于APK资源图片有3个特征:
    
    1、资源图片基本都是在xml中引用 ,在Java中也是通过资源ID查找 。
    
    2、资源图片一般不使用异步记载,不会出现loading图这些中间状态。
    
    3、资源图片不会加载失败,如果失败了那么APP也挂掉了。
    
    正是由于这3点特征,所以图片加载库实在鞭长莫及。那么就很容易出现一个问题:图片过大导致OOM!
    
    很多APP为了追求酷炫的效果,热衷于设计绚丽全屏背景页面。既然是为了炫酷,考虑到用户体验,这些全屏背景图自然不能使用网络图片了,所以,这些图片都被放在apk包中作为资源文件直接引用。
    
    使用这些资源图片的方式一般都是:
    
    android:background="@drawable/xxx"
    
    正常情况下,这样使用自然不会出现问题,但是如果APP内存紧张,很容易就出现OOM,尤其是5.0版本以下的手机,经常跑着跑着就Crash了,始作俑者就是这个。
    
    为了解决这种问题,最常用的方式是找设计师压缩图片。而压缩图片有两种方式:缩小尺寸和降低质量。那么,这两种方式是否有效呢?
    
    1、缩小尺寸: 压缩图片的宽度和高度。由于图片的内存占用与宽高成正比,这种方式确实有效,但是图片显示时会被拉伸导致变形,从而失却美感。
    
    2、降低质量: 降低图片的色彩度,像素颜色密度。这其实是一个误区,很多人认为图片的存储占用空间小,图片的内存占用就会小,其实是错误的观点。这是方式并不会影响图片的内存占用,反而由于质量降低(下文具体分析),使得页面缺乏质感。必须记住:图片的内存占用与图片质量毫无干系!
    
    为了寻求一个合理的解决方案,必须知彼知己。下面,我们来详细分析下资源图片的内存占用的情况!(后文所说的图片,除非特殊指明,否则都默认指APK资源图片)。
    1、计算Bitmap的内存占用
    
    我们以一张标准720p的全屏图片为例,宽高比为720×1280,对应的资源文件夹为drawable-xhdpi。同样,设备以标准720p的小米2S手机为例,density=320。
    
    首先,android设备上图片都被处理成Bitmap对象。生成Bitmap有一个非常重要的参数Config,属性值有ALPHA_8、RGB_565、ARGB_4444、ARGB_8888四种。不同的属性值对应的图片每个像素点占用内存大小不同,ALPHA_8每个像素占用1byte,RGB_565和ARGB_4444占用2byte,ARGB_8888占用4byte,其中ARGB_4444在高版本中已经废弃。
    
    那么,资源图片被decode成Bitmap的时候,Config参数值是哪个呢?来看几段代码。
    
    Resources.java
    
    private Drawable loadDrawableForCookie(TypedValue value, int id, Theme theme) {
       ...
       final String file = value.string.toString();
       ...
       final Drawable dr;
       if (file.endsWith(".xml")) {
           final XmlResourceParser rp = loadXmlResourceParser(file, id, value.assetCookie, "drawable");
           dr = Drawable.createFromXml(this, rp, theme);
           rp.close();
       } else {
           final InputStream is = mAssets.openNonAsset(value.assetCookie, file, AssetManager.ACCESS_STREAMING);
            dr = Drawable.createFromResourceStream(this, value, is, file, null);
            is.close();
       }
       ...
    }
    
    Drawable.java
    
    public static Drawable createFromResourceStream(Resources res, TypedValue value,
                InputStream is, String srcName, BitmapFactory.Options opts) {
        ...
        if (opts == null) opts = new BitmapFactory.Options();
        opts.inScreenDensity = res != null ? res.getDisplayMetrics().noncompatDensityDpi : DisplayMetrics.DENSITY_DEVICE;
        Bitmap  bm = BitmapFactory.decodeResourceStream(res, value, is, pad, opts);
        ...
        return null;
    }
    
    BitmapFactory.java
    
    public static class Options {
        ...
    
        /**
         * Image are loaded with the {@link Bitmap.Config#ARGB_8888} config by default.
         */
        public Bitmap.Config inPreferredConfig = Bitmap.Config.ARGB_8888;
    
        ...
    }
    
    从上面资源文件生成BitmapDrawable的代码可知,Bitmap.Config使用的是默认值ARGB_8888,即图像每个像素点占用内存4byte。
    
    我们图片的尺寸是720×1280,也就是说像素点个数是720×1280=921600,所有像素点占用内存=720x1280x4=3686400 byte=3.515625M,这个大小是图片不做任何处理时占用的内存大小。
    
    另外,不管图片的内容是什么样子,体现在内存中的也仅仅是每个像素点对应的字节的值不同,大小是一样的,即一张720×1280的空白图和一张720×1280的彩色绚图占用内存大小是一致的。所以说想要降低占用内存,唯有减小宽高尺寸。
    
    刚刚说过,计算出来的3.515625M大小是图片未作任何处理时的大小,但是系统在将图片处理成Drawable对象的时候是否未作处理呢?答案是:不!
    
    BitmapFactory.java
    
    public static Bitmap decodeResourceStream(Resources res, TypedValue value,
                InputStream is, Rect pad, Options opts) {
    
        if (opts == null) {
            opts = new Options();
        }
    
        if (opts.inDensity == 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);
    }
    
    代码中Options有两个非常重要的参数,inDensity和inTargetDensity,先来解释一下这俩参数的作用。
    
    inDensity表示被设定的图像密度,决定这个值的是图片所放置的文件目录,比如drawable-hdpi、drawable-xhdpi等等,其对应的density如下表:
    这里写图片描述
    代码中opts.inDensity 被赋值为 value.density,也就是资源维度对应的密度值。如果图片放在drawable-hdpi下,inDensity=240,如果放在drawable-xhdpi下,inDensity=320。
    
    inTargetDensity表示最终需要适配到的图片密度,这个值由手机设备来决定,上面代码中其值为DisplayMetrics的densityDpi,手机屏幕越高清这个值越大,而我们例子中720p的小米2S对应的densityDpi=320。
    
    如果inDensity的值和inTargetDensity的值不相等,那么图片尺寸就被会缩放,缩放的比例为 inTargetDensity / inDensity。当然,宽高是需要同时等比缩放的,不然图片就变形了。
    
    前面说过图片占用内存与图片的尺寸有关,如果被尺寸缩放了,内存大小就变了。前面未作任何缩放处理的720×1280图占用内存是3.515625M,假设放在drawable-ldpi目录下inDensity=120,设备inTargetDensity=320,那么最终的占用内存大小将是3.515625Mx(320/120)x(320/120)=25M。
    
    一张图片占用25M大小,很恐怖的一个值,这种情况下,app估计直接挂了,如果放在drawable-hdpi下,占用就是6.25M,drawable-xhdpi下占用是3.515625M。由此可见,图片放置的目录一定要慎重。
    
    最终我们得出一个公式:
    资源图片内存大小 = 宽 x 高 x 4 x (设备密度 / 资源维度密度)x(设备密度 / 资源维度密度)
    2、图像后门inPurgeable
    
    前面说到,资源图片防止的目录不对会导致内存占用翻倍,但也不是放的密度维度越高越好,毕竟还是要做适配,不然小尺寸图片显示在高清大屏幕上就不好看了。而即使图片放对位置,占用内存大小也是相当惊人的,来个十张大图应用内存就蹭蹭上去了,冷不丁还来个OOM。
    
    相信很多人都找到过解决方案:inPurgeable,代码网上一搜一大推:
    
    public static Bitmap readBitmap(Context context, int resId) {
        BitmapFactory.Options opt = new BitmapFactory.Options();
        opt.inPurgeable = true;
        opt.inInputShareable = true;
        InputStream is =context.getResources().openRawResource(resId);
        return BitmapFactory.decodeStream(is, null, opt);
    }
    
    那么,这段代码是否起效果呢?答案是肯定的,以前经常报OOM的现在都好了,而且用AS的内存监视器一看,加载图片时基本上不占内存。不管有没有其它问题,姑且把这个称之为图像后门吧。
    
    下面,我们来看这个后门为什么能起效果!
    
    BitmapFactory.java
    
    public static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) {
        if (is == null) {
            return null;
        }
    
        Bitmap bm = null;
        ...
        if (is instanceof AssetManager.AssetInputStream) {
            ...
        } else {
            bm = decodeStreamInternal(is, outPadding, opts);
        }
    }
    private static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {
        byte [] tempStorage = null;
        if (opts != null) tempStorage = opts.inTempStorage;
        if (tempStorage == null) tempStorage = new byte[DECODE_BUFFER_SIZE];
        return nativeDecodeStream(is, tempStorage, outPadding, opts);
    }
    private static native Bitmap nativeDecodeStream(InputStream is, byte[] storage,
        Rect padding, Options opts);
    
    很明显,decodeStream这段代码最终调用的是native层的类库,我们追踪下去查看(下面以JellyBean源码为例)。
    
    BitmapFactory.cpp
    
    static jobject doDecode(JNIEnv* env, SkStream* stream, jobject padding,
            jobject options, bool allowPurgeable, bool forcePurgeable = false,
            bool applyScale = false, float scale = 1.0f) {
    
        ...
        if (!isPurgeable) {
            decoder->setAllocator(&javaAllocator);
        }
        ...
        if (isPurgeable) {
            decodeMode = SkImageDecoder::kDecodeBounds_Mode;
        }
        ...
        if (isPurgeable) {
            pr = installPixelRef(bitmap, stream, sampleSize, doDither);
        } else {
            pr = bitmap->pixelRef();
        }
        ... 
    
    }
    static SkPixelRef* installPixelRef(SkBitmap* bitmap, SkStream* stream,
            int sampleSize, bool ditherImage) {
    
        SkImageRef* pr;
        // only use ashmem for large images, since mmaps come at a price
        if (bitmap->getSize() >= 32 * 1024) {
            pr = new SkImageRef_ashmem(stream, bitmap->config(), sampleSize);
        } else {
            pr = new SkImageRef_GlobalPool(stream, bitmap->config(), sampleSize);
        }
        ...
        return pr;
    }
    
    相关isPurgeable的代码就这么多,最终关于图片的decode逻辑都在installPixelRef中,有一段逻辑值得玩味。如果图片大小(占用内存)大于32×1024=32K,那么就使用Ashmem,否则就就放入一个引用池中。
    
    这个做法也很容易理解,如果图片不大,直接放到native层内存中,读取方便且迅速。如果图片过大,放到native层内存也就不合理了,不然图片一多,native层内存很难管理。但是如果使用Ashmem匿名共享内存方式,写入到设备文件中,需要时再读取就能避免很大的内存消耗了,另外,这块内存是由Linux系统的内存管理来管理的,系统内存不足可以直接回收。而且,由于Ashmem跨进程的特性,同一张图片内存是可以跨进程共享的,这也是inInputShareable属性的由来。
    
    关于Ashmem不明白的,可以参考老罗的博客:http://blog.csdn.net/luoshengyang/article/details/6664554
    
    由此可见,如果inPurgeable=true,图片所占用的内存就完全与Java Heap无关了,自然就不会有OOM这种烦恼了。
    
    但是,万事有利有弊,一件事情的成功往往是用牺牲其它方面换来的。
    
    前面说过,使用Resources获取图片Drawable的时候,会默认使用inDensity和inTargetDensity属性缩放图片来达到适配不同分辨率屏幕的目的。但是如果设置了inPurgeable=true来避免在Java Heap中分配内存,inDensity和inTargetDensity这两个属性就不能再使用了,否则即使inPurgeable=true,图片仍然会在Java Heap中分配内存。关于这一点,从以下代码中可以验证:
    
    BitmapFactory.cpp
    
    static jobject doDecode(JNIEnv* env, SkStream* stream, jobject padding,
            jobject options, bool allowPurgeable, bool forcePurgeable = false,
            bool applyScale = false, float scale = 1.0f) {
    
        ...
        bool willScale = applyScale && scale != 1.0f;
        bool isPurgeable = !willScale &&
                (forcePurgeable || (allowPurgeable && optionsPurgeable(env, options)));
        ...
    
    }
    
    在doDecode方法中,isPurgeable会重新赋值,首决条件是图片不会缩放(willScale),其次才会判断Options中的isPurgeable属性。很明显,如果inDensity和inTargetDensity两个属性断定图片需要缩放,isPurgeable会被强制设定成false。这么做的原因很简单,Ashmem不可能维护多套不同尺寸的相同图片。
    
    如果要解决这种适配问题,唯一的解决方案就是图片切成不同的尺寸,放到不同维度的drawable目录下。这样虽然不能做到精准适配,但是可以做到大体适配。原理就是,不同分辨率的屏幕decode相匹配密度维度目录下的对应尺寸图片。
    
    说完适配的问题,你以为坑就到此结束了?其实不然,真正的大问题不是这个!
    
    我们来看inPurgeable属性的一段官方注释:
    
    While inPurgeable can help avoid big Dalvik heap allocations (from API level 11 onward), it sacrifices performance predictability since any image that the view system tries to draw may incur a decode delay which can lead to dropped frames。
    
    意思就是:虽然inPurgeable能避免在Heap中分配一大段内存,但这个是以牺牲性能为代价的,如果图片要绘制到View上可能出现延时导致掉帧。
    
    前面说过,inPurgeable=true的情况下,大图使用Ashmem共享内存存储图片,但是这部分内存存储的仅仅是解码前的图片数据,我们获取的Bitmap只是一个空包弹,不含任何像素信息。当图片需要渲染的时候,先要对一个个像素点进行解码,这个过程是比较耗时的,而偏偏又发生在UI线程中,必须等图像解码完成,UI线程才能继续渲染。如果图片像素点过多,计算量大,很容易就导致卡帧。最坑爹的是,Ashmem内存是由Linux系统统一管理的,如果系统内存紧张,这块儿图片内存很容易被回收,当图片再次被渲染时,Ashmem设备文件就需要重新映射内存再重新解码。
    
    综上所诉,虽然inPurgeable既能导致适配问题,又可能导致性能问题,那么我们为什么还要使用呢?理由很简单:相对于出现OOM导致Crash,这两点牺牲仍然是值得的!
    
    Facebook出品的大名鼎鼎的图片加载库Fresco中对图片的处理都使用了inPurgeable=true,代码如下 :
    
    BitmapFactory.Options = new BitmapFactory.Options();
    options.inPurgeable = true;
    Bitmap bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length, options);
    
    虽然,Fresco和我们所说的资源图片干系并不大,但是很多思想还是值得我们借鉴。另外,关于inPurgeable的问题点以及Fresco为什么仍然继续使用这个属性,在其介绍文章中写得很详细,有兴趣可以去阅读下: https://code.facebook.com/posts/366199913563917 (需要翻墙)。
    3、被堵上的后门你还在用?
    
    很多时候,知其然而不知其所以然,很容易出问题,如果又不关注版本变化,就肯定会出问题,inPurgeable就是一个很典型的例子。
    
    在5.0版本及以上,inPurgeable这个属性已经被标志为过时了!即使inPurgeable=true,也不会再使用Ashmem内存存放图片,而是直接放到了Java Heap中,简而言之就是inPurgeable属性被忽略了。
    
    因为Android系统从5.0开始对Java Heap内存管理做了大幅的优化。和以往不同的是,对象不再统一管理和回收,而是在Java Heap中单独开辟了一块区域用来存放大型对象,比如Bitmap这种,同时这块内存区域的垃圾回收机制也是和其它区域完全分开的,这样就使得OOM的概率大幅降低,而且读取效率更高。所以,用Ashmem来存储图片就完全没有必要了,何况后者还会导致性能问题。
    
    既然这样,我们就需要考虑继续使用inPurgeable方式处理资源图片是否合理了。
    
    如果仔细阅读过Resources的源码,会发现对于Drawable对象有一套缓存机制,比如当一张图片被解码成BitmapDrawable对象后,会被存储到缓存中,下次再使用这张图片将优先从缓存中获取,既避免了图片重复decode的耗时,又做到了内存的复用,大体代码如下:
    
    Resources.java
    
    Drawable loadDrawable(TypedValue value, int id, Theme theme) throws NotFoundException {
        ...
    
        // First, check whether we have a cached version of this drawable
        // that was inflated against the specified theme.
        if (!mPreloading) {
            final Drawable cachedDrawable = caches.getInstance(key, theme);
            if (cachedDrawable != null) {
                return cachedDrawable;
            }
        }
    
        // Next, check preloaded drawables. These may contain unresolved theme
        // attributes.
        final ConstantState cs;
        if (isColorDrawable) {
            cs = sPreloadedColorDrawables.get(key);
        } else {
            cs = sPreloadedDrawables[mConfiguration.getLayoutDirection()].get(key);
        }
    
        ...
    
        // If we were able to obtain a drawable, store it in the appropriate
        // cache: preload, not themed, null theme, or theme-specific.
        if (dr != null) {
            dr.setChangingConfigurations(value.changingConfigurations);
            cacheDrawable(value, isColorDrawable, caches, theme, canApplyTheme, key, dr);
        }
        ...
    }
    
    在inPurgeable后门被堵上之后,如果我们仍然通过BitmapFactory.decodeStream的方式获取资源图片的Bitmap,就会导致相同的图片重复decode,且多次在Java Heap中开辟内存。
    
    同样的方式,原本在5.0以下可以节省Java Heap内存占用,在5.0及以上反而成了真正内存杀手!
    
    所以在真正使用inPurgeable时是需要区分版本的,最简单的解决方案如下:
    
    public static Drawable decodeLargeResourceImage(Resources resources, int resId) {
        Drawable drawable;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            drawable = resources.getDrawable(resId, null);
        } else {
            try {
                BitmapFactory.Options opt = new BitmapFactory.Options();
                opt.inPurgeable = true;
                opt.inInputShareable = true;
                InputStream is = resources.openRawResource(resId);
                drawable = new BitmapDrawable(resources, BitmapFactory.decodeStream(is, null, opt));
            } catch (OutOfMemoryError e) {
                drawable = null;
            }
        }
        return drawable;
    }
    
    4、总结
    
    之前看过一篇文章,阿里手机淘宝客户端对存到Ashmem的图片解码做了优化,在工作线程中对图片真正解码,从而避免在UI线程渲染图片时解码,同时锁住Ashmem内存,防止在系统内存紧张时回收出现二次解码。
    
    再者,针对资源图片,目前出现了SVG矢量图代替常规PNG图片的解决方案,但也仅仅限于线条简易的Icon图。
    
    对于图片处理这一块,需要学习和研究的方面太多,路漫漫其修远兮,吾将上下而求索!
    
    转载请注明:Android开发中文站 » Android应用性能优化系列视图篇——隐藏在资源图片中的内存杀手
    
     
  • 相关阅读:
    IIS的各种身份验证详细测试
    HTTP Error 401.3 Unauthorized Error While creating IIS 7.0 web site on Windows 7
    C/S and B/S
    WCF ContractFilter mismatch at the EndpointDispatcher exception
    Configure WCF
    Inheritance VS Composition
    Unhandled Error in Silverlight Application, code 2103 when changing the namespace
    Java RMI VS TCP Socket
    Principles Of Object Oriented Design
    Socket处理发送和接收数据包,一个小实例:
  • 原文地址:https://www.cnblogs.com/Free-Thinker/p/6396942.html
Copyright © 2020-2023  润新知