Android开发之图片加载

图片文件在不同文件夹下的差异

将同一张图片放在不同分辨率的文件夹下会有什么差异呢?
首先我们要先来了解一下手机屏幕密度的概念。手机屏幕密度可通过如下代码获取:

		float xdpi = getResources().getDisplayMetrics().xdpi;
        float ydpi = getResources().getDisplayMetrics().ydpi;

其中xdpi表示屏幕宽度的dpi值,ydpi表示屏幕高度的dpi值,通常这两个值都是相等或极其接近的,如两个值都等于403,那么403又表示什么呢?我们参考下表即可知道:

dpi范围 密度
0dpi~120dpi ldpi
120dpi~160dpi mdpi
160dpi~240dpi hdpi
240dpi~320dpi xhdpi
320dpi~480dpi xxhdpi
480dpi~640dpi xxxhdpi

可以看出403dpi处于320dpi~480dpi之间,因此属于xxhdpi范围。

当我们使用资源id去引用图片资源时,Android会使用一些规则帮我们匹配最适合的图片。如我们的手机屏幕密度为xxhdpi,那么drawable-xxhdpi文件夹下的图片就是最适合的。因此但我们引用资源id去加载图片时,就会优先去drawable-xxhdpi文件夹下去找这张图,如果这个文件夹下没有这张图片,那么会优先去更高密度的文件夹下去找这张图,若发现没有更高密度的文件夹时,就会去drawable-nodpi文件夹下找这张图,如果发现没有,那就去更低密度文件夹下查找,依次是xhdpi->hdpi->mdpi->ldpi。

总体的匹配规则就是这样,如果说这张图最终在mdpi文件夹下找到,此时系统会认为这张图是为低密度屏幕设计的,如果直接将这张图在当前高密度屏幕显示可能出现像素过低的情况,于是系统帮我们做了放大操作。同理,如果系统是在高密度文件夹下找到这张图,系统会帮我们做缩小操作。

另外,刚才在介绍规则时提到一个drawable-nodpi文件夹,这个文件夹是一个与密度无关的文件夹,放在这里的图片系统不会对其进行缩放操作,原图片多大就会实际展示多大。drawable-nodpi文件夹是在匹配密度文件夹和高密度文件夹都找不到的情况下才会去这里查找图片,因此放在drawable-nodpi文件夹里的图片通常情况下不建议再放到别的文件夹里。

图片被缩放的原因找到了,那么缩放比例是怎么确定的呢?规律总结如下:mdpi密度的最大值为160dpi,而xxhdpi密度的最大值为480dpi,当屏幕密度为xxhdpi时,去加载mdpi文件夹下的图片,那么图片将被放大480/160=3倍,同理,若去加载xxxhdpi文件夹下的图片时,图片将缩小480/640=0.75倍。

通常,公司的ui只会给一套图片资源,那么这一套资源应该放在哪个资源密度的文件夹下呢?一张图被缩小没有什么副作用,但是一张图被放大就意味着要占用更多的内存。因此图片资源应放在高密度的文件夹下,而ui设计时也应该尽量面向高密度屏幕的设备来设计。就目前来讲,最佳放置图片的文件夹为drawable-xxhdpi。为什么不放在更高的drawable-xxxhdpi文件夹下呢?那是因为市面上在这个密度区间的设备太少了,如果针对这种级别的密度进行设计,图片在不缩放的情况下本身就已经很大了,基本也起不到节省内存开支的作用了。

Bitmap内存计算

Bitmap单个像素的字节大小由Bitmap的一个可配置参数Config来决定。Config为一个枚举类,详情如下:

Config 占用字节大小 说明
ALPHA_8(1) 1 单透明通道
RGB_565(3) 2 简易RGB色调
ARGB_4444(4) 4 已废弃
ARGB_8888(5) 4 24位真彩色
RGBA_F16(6) 8 Android 8.0新增(更加丰富的色彩表现HDR)
HARDWARE(7) Special Android 8.0新增(Bitmap直接存储在graphic memory)

在Android系统中,默认Bitmap加载图片,使用ARGB_8888模式。
Bitmap占用内存大小的公式如下:

Bitmap内存占用≈像素数据总大小=图片宽x图片高x(设备屏幕密度区间最大值/资源文件夹密度区间最大值)^2x每个像素的字节大小。

如一张800x600大小的图片放在mdpi文件夹下,那么在xhdpi设备上加载它的时候,它的内存占用为800x600x(320/160)^2x4=7680000。

Bitmap的高效加载

我们编写的应用程序是有一定内存限制的,程序占用过高的内存就容易出现OOM(out of memory)错误,我们可以通过如下代码得到应用程序的最高可用内存:

		int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        Log.e("tag", "-------->>" + maxMemory+"kb");

因此,在展示高分辨率图片的时候,最好先将图片进行压缩。压缩后图片的尺寸大小应该和用来展示它的控件大小相近。

BitmapFactory类提供了多个解析方法用于创建Bitmap对象,如sd卡中的图片可以用decodeFile(),网络上的图片用decodeStream(),资源文件中的方法用decodeResource()。

高效加载Bitmap的核心思想就是通过BitmapFactory.Options来加载所需的图片,主要是用到它的inSampleSize参数,即采样率。inSampleSize必须是大于1的整数图片才会有缩小的效果,若inSampleSize的值小于1,其作用等同于1,即无缩放效果。另外,官方文档建议inSampleSize的取值应该总是为2的指数(如1,2,4,8…),如果外界传递给系统的不为2的指数,那么系统将会向下取整选择一个最接近2的指数来代替,如3会用2来代替,但是这个结论并非在所有Android版本上都成立。

获取采样率可遵循如下流程:

  1. 将BitmapFactory.Options的inJustDecodeBounds参数设置为true并加载图片。
  2. 从BitmapFactory.Options中取出图片的原始宽高,对应参数为outWidth和outHeight。
  3. 根据采样率的规则并结合目标View的实际大小计算出采样率inSampleSize。
  4. 将BitmapFactory.Options的inJustDecodeBounds参数设置为false,然后重新加载图片。

inJustDecodeBounds参数为true时,BitmapFactory只会解析图片的原始宽高信息,并不会真正的去加载图片,所以这个操作是轻量级的。

根据以上四个流程可得如下代码:

    public static Bitmap decodeBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, resId, options);
        options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
        options.inJustDecodeBounds = false;
        Bitmap bitmap = BitmapFactory.decodeResource(res, resId, options);
        return bitmap;
    }

    public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
        int outWidth = options.outWidth;
        int outHeight = options.outHeight;
        int inSampleSize = 1;
        if (outHeight > reqHeight || outWidth > reqWidth) {
            int halfHeight = outHeight / 2;
            int halfWidth = outWidth / 2;
            while ((halfHeight / inSampleSize) > reqHeight
                    && (halfWidth / inSampleSize) > reqWidth) {
                inSampleSize *= 2;
            }
        }
        return inSampleSize;
    }

此时,就可以通过上面两个方法高效加载图片了,代码如下:

       iv.post(new Runnable() {
            @Override
            public void run() {
                iv.setImageBitmap(decodeBitmapFromResource(getResources()
                , R.drawable.timg, iv.getWidth(), iv.getHeight()));
            }
        });

Bitmap内存缓存

内存缓存可以让组件快速的重新加载和处理图片,从而让应用在加载很多图片的时候可以提高响应速度和流畅性。内存缓存的核心类为LruCache,它的主要算法原理是把最近使用的对象强引用存储在LinkedHashMap中,并且把最近最少使用的对象在缓存值达到预定值之前从内存中移除。

为了能够选择一个合适缓存大小给LruCache,有以下因素应放入考虑范围,如:

  • 你的设备上可以为每个应用程序分配多大的内存?
  • 设备屏幕上一次最多能够显示多少图片?有多少图片需要进行预加载,因为有可能很快也会显示在屏幕上?
  • 你的设备屏幕大小与分辨率分别是多少?一个超高分辨率的设备比起一个较低分辨率的设备,在持有相同数量图片时,需要更大的缓存空间。
  • 图片的尺寸和大小,还有每张图片会占据多少内存空间。
  • 图片被访问的频率有多高?会不会有一些图片的访问率比其他图片高?如果有的话,你也许应该让一些图片常驻在内存中,或者使用多个LruCache对象区分不同组的图片。
  • 你能维持好数量和质量之间的平衡吗?有时候,存储多个低像素的图片,而在后台去开线程加载高像素的图片会更加有效。

内存缓存示例代码如下:

    private LruCache mBitmapCache;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        //使用最大可用内存的1/8作为缓存大小
        int cacheSize = maxMemory / 8;
        mBitmapCache = new LruCache(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap value) {
                return value.getByteCount() / 1024;
            }
        };
        setContentView(R.layout.activity_bitmap);
        ImageView iv = findViewById(R.id.iv_bitmap);
        iv.post(new Runnable() {
            @Override
            public void run() {
                loadBitmap(iv, R.drawable.timg);
            }
        });
    }

    public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
        if (getBitmapFromMemCache(key) == null) {
            mBitmapCache.put(key, bitmap);
        }
    }

    public Bitmap getBitmapFromMemCache(String key) {
        return mBitmapCache.get(key);
    }

    public void loadBitmap(ImageView target, int resId) {
        String key = String.valueOf(resId);
        Bitmap bitmap = getBitmapFromMemCache(key);
        if (bitmap != null) {
            target.setImageBitmap(bitmap);
        } else {
            target.setImageResource(R.mipmap.ic_launcher);
            BitmapWorker worker = new BitmapWorker(target);
            worker.execute(resId);
        }
    }

    class BitmapWorker extends AsyncTask {
        private ImageView target;

        public BitmapWorker(ImageView target) {
            this.target = target;
        }

        @Override
        protected Bitmap doInBackground(Integer... integers) {
            Bitmap bitmap = decodeBitmapFromResource(getResources(), integers[0], target.getWidth(), target.getHeight());
            addBitmapToMemoryCache(String.valueOf(integers[0]), bitmap);
            return bitmap;
        }

        @Override
        protected void onPostExecute(Bitmap bitmap) {
            super.onPostExecute(bitmap);
            target.setImageBitmap(bitmap);
        }
    }

Bitmap磁盘缓存

网络上获取的数据和图片都会缓存到本地存储中,这样即使手机在没有网络的情况下依然能够加载处以前浏览过的内容,而使用的缓存技术就是DiskLruCache,那么这些缓存被放置在手机的什么位置呢?缓存地址通常会存放在/sdcard/Android/data//cache路径下,但是需要考虑手机没有SD卡或SD卡被移除的情况。获取缓存地址可用如下代码实现:

    public File getDiskCacheDir(Context context, String dirName) {
        String cachePath;
        if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
                || !Environment.isExternalStorageRemovable()) {
            cachePath = context.getExternalCacheDir().getPath();
        } else {
            cachePath = context.getCacheDir().getPath();
        }
        return new File(cachePath + File.separator + dirName);
    }

可以看出,当SD卡存在或SD卡不可被移除时,就调用getExternalCacheDir()方法获取缓存路径,否则就调用getCacheDir()方法获取缓存路径,前者得到的路径为/sdcard/Android/data//cache,而后者得到的路径为/data/data//cache。

由于DiskLruCache并不是Google官方编写的,因此在使用前,我们需要将它从网上下载下来,手动添加到项目中。DiskLruCache可在Jake大神的GitHub上找到:https://github.com/JakeWharton/DiskLruCache

准备工作做好后,看看DiskLruCache如何使用:

打开缓存

DiskLruCache不能通过new实例化,需要通过其open()方法获取实例,接口如下所示:

public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)

其中四个参数分别为数据缓存地址;当前应用版本号;对应多少个缓存文件,通常填1即可;可以缓存多少字节的数据。其中第一个参数的获取方法可采用上述getDiskCacheDir方法获取,接着获取应用程序版本号可通过以下代码获取:

    public int getAppVersion(Context context) {
        try {
            PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
            return packageInfo.versionCode;
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
        return 1;
    }

需要注意的是,每当版本号改变,缓存路径下的所有数据都会被清楚掉,因为DiskLruCache认为当应用程序版本更新时,所有数据都应该从网上重新获取。第三个参数通常填1即可,第四个参数通常填10M的大小就够了,可根据自身的情况进行调节。因此,标准的open方法可如下所示:

 DiskLruCache diskLruCache;
        File bitmap = getDiskCacheDir(this, "bitmap");
        if (!bitmap.exists()) {
            bitmap.mkdirs();
        }
        try {
            diskLruCache = DiskLruCache.open(bitmap, getAppVersion(this), 1, 10 * 1024 * 1024);
        } catch (IOException e) {
            e.printStackTrace();
        }

有了DIskLruCache实例后,我们就可以对缓存的数据进行操作了,操作类型主要有写入、访问、移除等。

写入缓存

首先,我们需要先在网上找到一张图片资源,并将其下载下来,下载图片的代码如下:

    public boolean downloadUrlToStream(String url, OutputStream os) {
        HttpURLConnection urlConnection = null;
        BufferedOutputStream bos = null;
        BufferedInputStream bis = null;
        try {
            URL url1 = new URL(url);
            urlConnection = (HttpURLConnection) url1.openConnection();
            bis = new BufferedInputStream(urlConnection.getInputStream(), 8 * 1024);
            bos = new BufferedOutputStream(os, 8 * 1024);
            int b;
            while ((b = bis.read()) != -1) {
                bos.write(b);
            }
            return true;
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (urlConnection != null) {
                urlConnection.disconnect();
                try {
                    if (bos != null) {
                        bos.close();
                    }
                    if (bis != null) {
                        bis.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return false;
    }

写入的操作是通过DiskLruCache.Editor这个类完成的,这个类也不能用new实例化,而是通过DiskLruCache的edit()方法获取,edit方法需要传入一个字符串参数,这个参数即为key,这个key要能够和图片的URL一一对应。直接用URL地址作为key时不合适的,因为URL中包含一些特殊字符,这些字符在命名文件是不合法的,最简单的做法就是对URL地址进行MD5编码。代码如下:

    public String hashKeyForDisk(String key) {
        String cacheKey;
        try {
            MessageDigest mDigest = MessageDigest.getInstance("MD5");
            mDigest.update(key.getBytes());
            cacheKey = byteToHexString(mDigest.digest());
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            cacheKey = String.valueOf(key.hashCode());
        }
        return cacheKey;
    }

    public String byteToHexString(byte[] bytes) {
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < bytes.length; i++) {
            String hex = Integer.toHexString(bytes[i]);
            if (hex.length() == 1) {
                builder.append("0");
            }
            builder.append(hex);
        }
        return builder.toString();
    }

有了DiskLruCache.Editor对象后,即可通过它的newOutputStream()方法创建一个输出流,将它传入到downloadUrlToStream()方法中就能实现下载并写入缓存的功能了。newOutputStream()方法接收一个index参数,由于前面在设置valueCount的时候指定的是1,所以这里的index传0就可以了。在写入操作执行完成之后,我们还需要调用一下commit方法进行提交才能使写入生效,调用abort方法则表示放弃此次写入。

因此,一次完整的写入操作代码如下:

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    String imgUrl = "https://img-my.csdn.net/uploads/201309/01/1378037235_7476.jpg";
                    String key = hashKeyForDisk(imgUrl);

                    DiskLruCache.Editor editor = diskLruCache.edit(key);
                    if (editor != null) {
                        OutputStream os = editor.newOutputStream(0);
                        if (downloadUrlToStream(imgUrl, os)) {
                            editor.commit();
                        } else {
                            editor.abort();
                        }
                    }
                    diskLruCache.flush();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }).start();

读取缓存

缓存的读取主要是借助DiskLruCache的get()方法实现的,此方法返回一个DiskLruCache.Snapshot对象,通过调用此对象的getInputStream()方法即可得到缓存文件的输入流了,此方法也需要传入一个index参数,在此传0就可以了。一段完整的读取缓存,并将缓存加载到控件上的代码如下:

        String imgUrl = "https://img-my.csdn.net/uploads/201309/01/1378037235_7476.jpg";
        String key = hashKeyForDisk(imgUrl);
        try {
            DiskLruCache.Snapshot snapshot = diskLruCache.get(key);
            if (snapshot != null) {
                InputStream inputStream = snapshot.getInputStream(0);
                Bitmap bitmap1 = BitmapFactory.decodeStream(inputStream);
                iv.setImageBitmap(bitmap1);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

移除缓存

移除缓存主要借助DiskLruCache的remove()方法,remove方法中要求传入一个key,然后会删除这个key对应的缓存图片。用法虽然简单,但是我们并不应该经常去调用它。因为你完全不需要担心缓存的数据过多从而占用SD卡太多空间,DiskLruCache会根据我们在调用open()方法时设定的缓存最大值来自动删除多余的缓存。只有你确定某个key对应的缓存内容已经过期了,需要从网络获取最新数据的时候才应该调用remove()方法来移除缓存。

其他API

1.size()
返回当前缓存路径下所有缓存的总字节数,以byte为单位。
2.flush()
用于将内存中的操作记录同步到日志文件中。通常在Activity的onPause()方法中去调用此方法就可以了。
3.close()
关闭DiskLruCache,与open()对应。关闭后不能再调用DiskLruCache中任何操作缓存的方法,通常在Acitivity的onDestroy()方法调用。
4.delete()
将所有缓存都删除的方法。

照片墙代码地址:https://github.com/swallowwen/Android_Element_Study.git

参考资料

  • 《Android开发艺术探索》
  • Android中Bitmap内存优化
  • Android drawable微技巧,你所不知道的drawable的那些细节
  • Android高效加载大图、多图解决方案,有效避免程序OOM
  • Android DiskLruCache完全解析,硬盘缓存的最佳方案
  • Android照片墙完整版,完美结合LruCache和DiskLruCache

你可能感兴趣的:(Android开发之图片加载)