Универсальный ImageLoader для Android

В этой статье Я расскажу о разработанном мной (и не только) инструменте для асинхронной подгрузки изображений, их кэширования и отображения. На данном этапе развития его можно использовать повсеместно, где надо загрузить картинку в ImageView из интернета или из файловой системы. Все, что нужно, это URL картинки (для файловой системы он будет начинаться на «file://») и собственно ImageView, в который загруженную картинку надо будет положить. Более подробно о возможностях универсального разработанного ImageLoader'а читайте ниже.Началось все в одном проекте, в котором мне довелось учавствовать: необходимо было реализовать просмотр новостей списком.И тут конечно встал вопрос об отображении картинок в элементах списка. Картинки подгружались из интернета, поэтому надо было реализовать их асинхронную подгрузку, отображение и кэширование. Беглый поиск в сети навел меня на следующее почти готовое решение данной задачи. Реализованный LazyImageLoader асинхронно загружал картинки из интернета, кэшировал их в файловой системе, а также хранил их в памяти. Способ хранения в памяти представлял собой простой HashMap без каких-либо слабых ссылок, в результате чего на определенном этапе прокрутки списка (а списков к тому же было много) стал вылетать OutOfMemoryError. HashMap был заменен на WeakValueHashMap, а затем на собственную реализацию Map с ограничением на использование памяти.Постепенно, на базе этого LazyImageLoader'а стал вырастать собственный ImageLoader со своими фишечками и рюшечками. Его можно было использовать для отображения картинок не только в списках, но и в галерее, и для простого «одноразового» отображения. Этот ImageLoader был в дальнейшем переиспользован в двух других проектах, что подтвердило его состоятельность. Значительно отрефакторив существующий код и наведя приемлемую красоту, Я выложил исходники на GitHub, где сейчас постепенно ведется дальнейшая оптимизация интсрумента, повышение гибкости и настраиваемости.Итак, что все таки может этот ImageLoader?Отображать картинки — это ясно. Что насчет кэширования?Кэширование разделено на:
  • кэширование в памяти
  • кэширование на файловой системе (память телефона или SD-карта)
В роли кэша в памяти выступает HashMap<String, Bitmap> со «слабыми» ссылками в значениях. Насколько «слабыми» (Soft, Weak, Phantom) — решать вам:
public abstract class Cache<K, V> {

	protected final Map<K, Reference<V>> softMap = new HashMap<K, Reference<V>>();

	public V get(K key) {
		if (softMap.containsKey(key)) {
			Reference<V> reference = softMap.get(key);
			return reference.get();
		} else {
			return null;
		}
	}

	public void put(K key, V value) {
		softMap.put(key, createReference(value));
	}

	public void clear() {
		softMap.clear();
	}

	protected abstract Reference<V> createReference(V value);
}
В текущей версии используется кэш Bitmap'ов, контролирующий свой размер. Это было реализовано посредством введения дополнительного «жесткого» списка, где хранились «сильные» ссылки на Bitmap'ы из softMap'ы. Как только размер кэша превышает допустимый лимит, «самые старые» объекты удаляются из «жесткого списка», тем самым теряя сильную ссылку. Слабая ссылка все ещё сохраняется в softMap'e, но там Bitmap уже полностью во власти Garbage Collector'a.При кэшировании на файловой системе файлы именуются как imageUrl.hashCode() и в дальнейшем по такому же принципу проводится поиск в кэше.Рассмотрим самый полнофункциональный метод ImageLoader'а — это:
void displayImage(String imageUrl, ImageView imageView, DisplayImageOptions options, ImageLoadingListener listener)
Параметры imageUrl и imageView, Я думаю, вопросов не вызовут.Класс DisplayImageOptions предназначен для настройки процесса загрузки, кэширования и отображения картинки. С помощью него можно указать:
  • надо ли отображать картинку-заглушку в ImageView, пока загружается реальная картинка, и какую именно заглушку отображать;
  • надо ли кэшировать загруженную картинку в памяти;
  • надо ли кэшировать загруженную картинку на файловой системе.
Интерфейс ImageLoadingListener позволяет «слушать» процесс загрузки изображения:
public interface ImageLoadingListener {
    void onLoadingStarted();
    void onLoadingComplete();
}
Но если текущая картинка присутствует в кэше в памяти, то listener не будет бросать события. События бросаются на UI-потоке, так что можно со спокойной душой трогать UI в listener'e.Итак, пример использования ImageLoader'a:
ImageLoader imageLoader = ImageLoader.getInstance(context);
DisplayImageOptions options = new DisplayImageOptions.Builder()
                                       .showStubImage(R.drawable.stub_image)
                                       .cacheInMemory()
                                       .cacheOnDisc()
                                       .build();
imageLoader.displayImage(imageUrl, imageView, options, new ImageLoadingListener() {
    @Override
    public void onLoadingStarted() {
       spinner.show();
    }
    @Override
    public void onLoadingComplete() {
        spinner.hide();
    }
});
Сильно распространяться про сам механизм работы ImageLoader'а не буду. Скажу только пару вещей:
  • задания на отображение картинки кладутся в очередь: если картинка уже есть в кэше на файловой системе, задание попадает на начало очереди, если нет — в конец. Задания выполняются с начала очереди, тем самым отображая в первую очередь закэшированные картинки; (UPD: После введения многопоточного механизма отображения картинок данная логика была упразднена. Теперь загрузкой закэшированных и незакэшированный изображений занимаются два разных пула потоков: для закэшированных — однопоточный, для остальных — многопоточный)
  • в кэше в памяти хранятся не полноразмерные Bitmap'ы, а размера не менее того, который нужен для отображения в ImageView. Этот размер вычисляется, исходя из атрибутов maxWidth и maxHeight, layout_width и layout_height, размеров экрана аппарата (размер исходной картинки уменьшается на степень двойки, в соответствии с рекомендациями по декодированию изображений).
  • т.к. в первую очередь ImageLoader предназначается для отображения картинок в списке, а в списках, как правило, хорошим тоном являтся переиспользование View, то и ImageLoader отслеживает такие ситуации, сохраняя загружаемый URL картинки в Tag ImageView с собственным ключом.
Ещё раз дам ссылку на исходники на GitHub.Надеюсь данный ImageLoader пригодится и Вам.

UPD (19.12.2011): В инструмент были внесены некторые существенные изменения, подробно о них и о проекте в целом можно прочитать здесь.
UPD (23.02.2012): Была сделана куча изменений и улучшений (в т.ч. многопоточность, внешнее конфигурирование). Но основное API в принципе все то же. Теперь инструмент доступен в качетве jar-ки. Введена версионность.
UPD (11.03.2012): Написал подробное руководство по использованию библиотеки:
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 28

    0
    Попробую в следующем проекте, пока в закладки закину. Но описание звучит интересно.
      +1
      Насколько я вижу — сделано достаточно много, но не хватает двух важных вещей:
      1) Использования опций кеширования протокола HTTP
      2) поддержка HTTP persistent connection
        0
        Проекту есть куда развиваться, поэтому это возможно его будущие фичи :) Спасибо, надо будет подумать над их реализацией.
        0
        Кстати любые предложения по улучшению функциональности ImageLoader'а приветствуются. Возможно кто-то имеет свои use-case'ы, для которых ImageLoader можно адаптировать.
          0
          под какой лицензией (BSD, Apache, GPL) вы это выложили? на github ничего не написано
            0
            Под лицензией «юзай, изменяй, распространяй без ограничений» :) Но для формальности пусть будет Apache License v2.0.
              0
              Хотя для совместмости с GPL пожалуй BSD будет лучше.
              +2
              Неплохо. Для универсализации на мой взгляд было бы круто:
              1. Возможность переопределять размер картинки динамически (а не целиком для всех, соответственно сделать хеш урл + размер). Это полезно когда, например, дисплеим картинки разного размера для планшетников/телефонов
              2. Возможность скалить картинку не только кратно 2-ке но и абсолютно и относительно (если размер задан как в dip'ах)
              Да наверно пожалуй и всё.

              Немного смутила только работа с сетью:
              InputStream is = new URL(imageUrl).openStream();
              Мне кажется тут могут быть накладки типа подвисаний при обрыве.

              Я бы сделал с инитом синглтона клиента в аппликейшен, что то типа:
              public static void setClient() {
              HttpParams params = new BasicHttpParams();

              // Turn off stale checking. Our connections break all the time anyway,
              // and it's not worth it to pay the penalty of checking every time.
              HttpConnectionParams.setStaleCheckingEnabled(params, false);

              // Default connection and socket timeout of 10 seconds. Tweak to taste.
              HttpConnectionParams.setConnectionTimeout(params, 10 * 1000);
              HttpConnectionParams.setSoTimeout(params, 10 * 1000);
              HttpConnectionParams.setSocketBufferSize(params, 8192);

              // Don't handle redirects -- return them to the caller. Our code
              // often wants to re-POST after a redirect, which we must do ourselves.
              HttpClientParams.setRedirecting(params, false);
              // Set the specified user agent and register standard protocols.
              HttpProtocolParams.setUserAgent(params, "bigbuzzy business");
              SchemeRegistry schemeRegistry = new SchemeRegistry();
              schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
              schemeRegistry.register(new Scheme("https", SSLSocketFactory.getSocketFactory(), 443));

              ClientConnectionManager manager = new ThreadSafeClientConnManager(params, schemeRegistry);

              AndroidApplication.client = new DefaultHttpClient(manager,params);
              }

              //И загрузку через него (нет сети - берем из кеша иначе грузим):

              public static String retrieve(String url, boolean cache) {

              StringBuilder sb = new StringBuilder();
              sb.append(AndroidApplication.DOMEN);
              sb.append(url);
              url = sb.toString();
              Log.d("retrieve", url);
              String md5 = Md5.md5(url);
              File casheDir = AndroidApplication.cacheDir;//context.getCacheDir();

              File f = null;
              if (casheDir!=null && cache) {
              f = new File(casheDir, md5);
              final long time = new Date().getTime() / 1000;
              if (f.exists()) {
              if ((f.lastModified()/1000+600)>time) {
              return readFile(f);
              }
              }
              }

              HttpGet getRequest = new HttpGet(url);

              try {
              HttpResponse getResponse = AndroidApplication.getClient().execute(getRequest);
              final int statusCode = getResponse.getStatusLine().getStatusCode();
              if (statusCode != HttpStatus.SC_OK) {
              Log.e("statusCode",statusCode+"");
              }

              HttpEntity getResponseEntity = getResponse.getEntity();


              if (getResponseEntity != null && statusCode == HttpStatus.SC_OK) {

              String s = EntityUtils.toString(getResponseEntity);
              if (statusCode != HttpStatus.SC_OK && f!=null) {
              BufferedOutputStream os = new BufferedOutputStream(new FileOutputStream(f));
              os.write(s.getBytes());
              os.close();
              }
              return s;
              }

              }
              catch (IOException e) {
              getRequest.abort();
              if (f!=null) {
              if (f.exists()) {
              return readFile(f);
              }
              }
              Log.e("NetUtils error",e.toString());
              }
              return null;
              }

              public static String readFile(File file) {
              String data = null;
              try {
              FileInputStream fis = null;
              InputStreamReader isr = null;

              fis = new FileInputStream(file);
              isr = new InputStreamReader(fis);
              char[] inputBuffer;
              inputBuffer = new char[fis.available()];
              isr.read(inputBuffer);
              data = new String(inputBuffer);
              isr.close();
              fis.close();
              } catch (IOException e) {
              e.printStackTrace();
              }
              return data;
              }

                0
                ой как расколбасило то тег сурс( Сожалею…
                  0
                  Если я правильно понял первый пункт — это для того если уменьшенная картинка уже есть в кэше в памяти, а мы хотим ее отобразить в ImageView больше предыдущего по размерам. Справедливо. В «дисковом» кэше хранятся полноразмерные картинки, если что.
                  Насчет второго пункта, гугло-доки гласят:
                  «Note: the decoder will try to fulfill this request, but the resulting bitmap may have different dimensions that precisely what has been requested. Also, powers of 2 are often faster/easier for the decoder to honor. „
                  И я боюсь что декодирование до точных размеров снизит производительность, может даже и существенно. Тут, пожалуй, можно отдать данное решение на выбор разработчику, что для него приоритетнее: память или процессорное время.
                  0
                  Придирки к Cache:

                  1. Зачем поле softMap объявлено protected? Даже если нужен доступ у наследников (в данном случае я думаю не нужен) лучше написать модификатор private и добавить protected метод getCache

                  2. Потенциальный NPE в методе get(): не сихронизованы методы clear() и get(). Более подробно: cointaisKey() возвращает true, вызывается метод clear(), get() от Map возвращает null.

                  Решение без synchronized: внутри метода Cache#get() использовать сразу Map#get() и проверять результат на null. В таком случае нужно поле Map нужно делать private и запрещать любые доступы к нему извне


                  Возможно в вашем случае это не критично, но для других пользователей этого кода может стать проблемой.
                    +1
                    3. Фиговый Singleton из ImageLoader — возможна повторная инициализация (нет синхронизации)

                    4. PhotosQueue#clean(): гораздо красивее и более читабельно писать такие шутки через iterator:
                    Iterator<ImageView> it = photosToLoad.iterator();
                    while ( it.hasNext() ) {
                       final ImageView imageView = it.next();
                       if ( imageView == imageForRemove ) {
                          it.remove();
                       }
                    }
                    
                    В вашем случае, по-моему, возможно падение с ConcurrentModificationException
                    
                      0
                      5. Я не знаю как у вас используется FileUtils#copyStream(), но, что-то мне подсказывает, что скрывать IoException не правильно. Достаточно задать вопрос: будет ли приложение работать дальше нормально, если потоки не откопировались?
                        0
                        Работать будет, но не совсем нормально :)
                        Спасибо за замечания, все учту.
                    0
                    Хочется иметь возможность остановить загрузку во время прокрутки списка
                      0
                      Всмысле приостановить загрузку пока список мотается? Зачем?
                        0
                        Есть мысль, что так UI будет более отзывчивый.
                      +4
                      Нигде не заметил bitmap.recycle() после превышения размера кеша, который держит картинки в памяти,
                      то что вы почистили ссылки на bitmap, даст работу для GC в вируальной машине.
                      Сами же bitmaps лежат вне основной памяти java-машины.
                        0
                        При превышении размера кэша удаляется «сильная» ссылка на Bitmap, но остается «слабая». Я не знаю, когда Bitmap будет собран сборщиком, а до этого делать recycle() ему нельзя, т.к. он может быть ещё переиспользован. В окончательном стирании Bitmap'ов полагаюсь на Android, ибо
                        "This is an advanced call, and normally need not be called, since the normal GC process will free up this memory when there are no more references to this bitmap."
                        +1
                        Также советую взглянуть на опции декодирования картинки inPurgeable и inInputShareable — они имеют отношение к тому как Bitmap управляет ресурсами.
                          0
                          Ох, давно это было, но смотрели мы на эти опции. Почему-то не заюзали, возможно были причины. Но, пожалуй, ещё раз поизучаю этот вопрос. Спасибо.
                          0
                          Вот реализация, которой я пользовался. В ваш код особо не смотрел, но общее наверняка есть (это не намек на плагиат)http://stackoverflow.com/a/3068012/423868
                            0
                            Этот проект (LazyList), как и мой, базировался на LazyImageLoader'e (ссылку на который я давал в статье). Там (в LazyList) приведена в порядок работа с потоками, своя реализация кэша, и другие мелкие улучшения. Кое-что там действительно можно подсмотреть :) Но, на мой взгляд, UniversalImageLoader более гибкий, ибо с этой целью он разрабатывается.
                              0
                              Хотя нет, судя по всему этот проект и есть родоначальник всего. А индус (по упомянутой мной ссылке) просто скопипастил его в свой блог.
                            0
                            А не хотите сделать pom и залить на какой-нибудь maven-репозиторий?
                              0
                              Не особо дружу с Maven'ом, но не в этом дело.
                              На текущий момент, я считаю, проект не готов в качестве отдельной библиотеки. Сейчас многие параметры ImageLoader'а можно настраивать под себя (они у меня сейчас в Constants). А когда все задуманные фичи будут завершены, и я придумаю красивый способ предоставить настройку ImageLoader'а не меняя исходники — тогда, почему бы и нет, можно и в maven-репозиторий закинуть.
                              0
                              Вызываю
                              ImageView imageView = ...
                              String imageUrl = "http://site.com/image.png"; // or "file:///mnt/sdcard/images/image.jpg"

                              // Get singletone instance of ImageLoader
                              ImageLoader imageLoader = ImageLoader.getInstance();
                              // Initialize ImageLoader with configuration. Do it once.
                              imageLoader.init(ImageLoaderConfiguration.createDefault(context));
                              // Load and display image asynchronously
                              imageLoader.displayImage(imageUrl, imageView);


                              Из кастомного адаптера ListView… ругается на контекст, какой ему контекст нужен не понимаю.
                              И как вообще с этой кучей классов не сделать из своего простенького приложения помойку?

                                0
                                Здравствуйте, при создании ImageView в конструктор надо передавать текущий Activity, а не getApplicationContext().
                                Если у вас аллергия на большое количество классов — используйте jar-ку.

                              Only users with full accounts can post comments. Log in, please.