Ленивый ListView, ViewHolder и кеширование данных

в 21:45, , рубрики: android, caching, GUI, lazy initialization, ListView, интерфейсы, Разработка под android, метки: , , , ,

Ленивый ListView, ViewHolder и кеширование данных

Вступление

На Хабре уже есть статьи о кастомизации ListView в Android, но я бы хотел преподнести информацию в виде более наглядного примера (из жизни). Предположим, что мы пишем приложение, которое должно уметь подгружать список контактов пользователя из Facebook. И не только оттуда, а еще, например, из Google+. И все эти контакты мы хотим поместить в один список, причем слева должна отображаться аватарка, а справа индикатор того, какой социальной сети принадлежит этот контакт (см. рисунок слева). Одной из проблем данной задачи является то, что на загрузку информации о контакте (имя пользователя и аватарка) требуется время — чем медленнее у нас Интернет, тем больше времени соответственно. Поэтому нельзя просто взять и предварительно загрузить все контакты, а потом отображать наш список, иначе после перехода в адресную книгу пользователь некоторое время будет видеть черный экран и только через десяток секунд отобразится список. Выход из данной ситуации — использование т.н. «ленивого» списка (lazy list). О том, как это реализовать на практике, речь идет под катом.

MainActivity

Начнем с самого верхнего уровня и будем постепенно спускаться вниз. Итак, код главной активити:

package com.github.dtitov.spinlist;

import java.util.ArrayList;

import android.app.ListActivity;
import android.os.Bundle;

public class MainActivity extends ListActivity {

	@Override
	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);

		/**
		 * Create and fill list of usernames.
		 */
		ArrayList<String> users = new ArrayList<String>();
		users.add("adele");
		users.add("coldplay");
		users.add("gotye");
		users.add("huskyrescueofficial");
		users.add("kasabian");
		users.add("lanadelrey");
		users.add("muse");
		users.add("officialplacebo");
		users.add("theraveonettes");

		/**
		 * Create and initialize our own adapter.
		 */
		LazyAdapter lazyAdapter = new LazyAdapter(this, users);
		setListAdapter(lazyAdapter);
	}
}

Здесь все довольно просто, не правда ли? MainActivity наследуется от ListActivity, внутри метода onCreate() мы создаем «список контактов». Для простоты мы реальные контакты получать не будем (т.к. это не является темой туториала). Просто используем для получения информации публичные страницы некоторых музыкальных групп. Итак, мы составили список, интересующих нас юзеров, скормили его нашему ленивому адаптеру и задали этот адаптер, в качестве основого через метод setListAdapter(). Идем дальше.

LazyAdapter

Сразу к делу:

package com.github.dtitov.spinlist;

import java.util.ArrayList;

import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.BitmapDrawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ProgressBar;
import android.widget.TextView;

/**
 * 
 * BaseAdapter extension with lazy loading. Item template is designed in
 * res/layout/item_user.xml
 * 
 */
public class LazyAdapter extends BaseAdapter {
	private Activity activity;
	private LayoutInflater layoutInflater;
	private ArrayList<String> names;
	private FbUser[] folks; // array for caching users

	/**
	 * 
	 * Loading of all necessary objects: activity and user list. Getting the
	 * inflater.
	 */
	public LazyAdapter(Activity activity, ArrayList<String> names) {
		super();
		this.activity = activity;
		this.names = names;
		this.folks = new FbUser[this.names.size()];
		layoutInflater = (LayoutInflater) activity
				.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
	}

	/**
	 * 
	 * Using ViewHolder patter we save time by avoiding calling findViewById
	 * each time we need a view.
	 * 
	 */
	private static class ViewHolder {
		public TextView textView;
		public ProgressBar spinner;
	}

	/**
	 * Method for processing ListView items. Inflates the item layout (if it's
	 * null) and passes the View to AyncTask processing.
	 */
	public View getView(int position, View convertView, ViewGroup parent) {
		FbUser cachedUser = folks[position]; // trying to get a user from cache

		View item = convertView;
		ViewHolder viewHolder;

		/**
		 * If item has not been created yet, we inflate it and pass its personal
		 * ViewHolder as a tag parameter. Otherwise we just get an existing
		 * ViewHolder.
		 */
		if (item == null) {
			item = layoutInflater.inflate(R.layout.item_user, null);
			viewHolder = new ViewHolder();
			viewHolder.textView = (TextView) item
					.findViewById(R.id.textViewUser);
			viewHolder.spinner = (ProgressBar) item
					.findViewById(R.id.progressBar);
			item.setTag(viewHolder);
		} else {
			viewHolder = (ViewHolder) item.getTag();
		}

		/**
		 * If the current user is not cached yet, we execute async task for
		 * retreiving the information. Otherwise we just load data from the
		 * cached object.
		 */
		if (cachedUser == null) {
			viewHolder.textView.setText("");
			viewHolder.textView.setCompoundDrawablesWithIntrinsicBounds(
					null,
					null,
					activity.getResources().getDrawable(
							R.drawable.facebook_icon), null);
			viewHolder.spinner.setVisibility(View.VISIBLE);
			new FetchDataTask(activity, this, position, viewHolder.textView,
					viewHolder.spinner, names.get(position))
					.execute(new Void[] {});
		} else {
			viewHolder.textView.setText(cachedUser.getName());
			viewHolder.spinner.setVisibility(View.GONE);
			viewHolder.textView.setCompoundDrawablesWithIntrinsicBounds(
					new BitmapDrawable(activity.getResources(), cachedUser
							.getBitmap()), null, activity.getResources()
							.getDrawable(R.drawable.facebook_icon), null);
		}
		return item;
	}

	@Override
	public int getCount() {
		return names.size();
	}

	@Override
	public Object getItem(int position) {
		return names.get(position);
	}

	@Override
	public long getItemId(int position) {
		return position;
	}

	public FbUser[] getFolks() {
		return folks;
	}
}

Основа работы со списками в Android — это реализация абстрактного класса BaseAdapter. Нам необходимо переопределить методы, касающиеся текущего элемента списка и его позиции (getCount(), getItem(), getItemId()), и самое главное — метод getView(). Внутри этого метода мы получаем очередной вью (строка списка) для того, чтобы запихнуть в него очередную порцию данных (в нашем случае — информацию о следующем пользователе). Если вью уже был создан, мы просто перезаписываем его, если нет — создаем.

Это стандартный подход, который практикуется в Android для экономии памяти при отображении списков. Что не стандартно — это применение шаблона ViewHolder. Дело в том, что каждый раз для перезаполнения отображаемой строки списка нам необходимо вызывать метод findViewById() для того, чтобы находить интересующие нас TextEdit'ы и прочие элементы, в которые мы собираемся писать. А это очень накладно и негативно влияет на performance. Для решения этой проблемы, был придуман замечательный подход: записывать информацию о элементах в тег каждого вьюва, используя вспомогательный nested-класс ViewHolder. В общем, о подробностях данного паттерна читайте в упомянутой во вступлении статье (там все очень подробно описано), а мы идем дальше.

Кеширование. Как уже было сказано, информация о контактах на лету подгружается из Интернета. Но нам нет никакого смысла подгружать ее каждый раз, когда перересовывается (скроллится) наш список — это было бы бессмысленно. Поэтому, выгодно сохранять уже загруженных пользователей в локальный кеш, что мы и делаем с помощью массива FbUser[] folks; В итоге при отрисовке отображаемой строки мы проверяем: был ли уже загружен пользователь с номером, соответствующим номеру строки. Если да — подставляем его, если нет — запускаем асинхронную задачу загрузки этого пользователя. Об этом речь идет далее.

FetchDataTask

Вот мы, наконец, и дошли до ленивости. Как мы уже поняли, информация о пользователе грузится медленно, а нам уже надо его отображать, если до него дошел скроллинг. Прокрастинация метода заключается в том, что пока контакт грузится, мы отобразим спиннер (крутящийся кружочек загрузки) на месте аватарки, и заменим его на настоящий аватар, как только тот загрузится. Загрузка, естественно, производится в фоновом режиме путем использования AsyncTask'а, для того чтобы не блокировать UI-тред. Итак, наблюдаем все вышесказанное в коде:

public class FetchDataTask extends AsyncTask<Void, Void, FbUser> {
	private Activity activity;
	private LazyAdapter adapter;
	private int position;
	private TextView textViewMate;
	private ProgressBar spinner;
	private String id;

	/**
	 * Get necessary UI objects.
	 */
	public FetchDataTask(Activity activity, LazyAdapter adapter, int position,
			TextView textView, ProgressBar spinner, String id) {
		this.activity = activity;
		this.adapter = adapter;
		this.position = position;
		this.textViewMate = textView;
		this.spinner = spinner;
		this.id = id;
	}

	/**
	 * Background bitmap fetching and pasting it into FbUser.
	 */
	@Override
	protected FbUser doInBackground(Void... params) {
		FbUser user = new FbUser(id);

		Bitmap bitmap = null;
		try {
			HttpURLConnection httpUrlConnection;
			httpUrlConnection = (HttpURLConnection) new java.net.URL(
					user.getPicture()).openConnection();
			httpUrlConnection.setReadTimeout(10000);
			httpUrlConnection.setConnectTimeout(10000);
			InputStream inputStream = httpUrlConnection.getInputStream();
			BufferedInputStream bufferedInputStream = new BufferedInputStream(
					inputStream);
			bitmap = BitmapFactory.decodeStream(bufferedInputStream);
			bufferedInputStream.close();
			inputStream.close();
			httpUrlConnection.disconnect();

		} catch (MalformedURLException e) {
		} catch (IOException e) {
		}

		user.setBitmap(bitmap);

		return user;
	}

	/**
	 * Setting the UI: hide progress bar, display both images (userpic on the
	 * left and tiny icon on the right) and user's full name
	 */
	@Override
	protected void onPostExecute(FbUser result) {
		super.onPostExecute(result);
		adapter.getFolks()[position] = result; // caching current user by it's position
		textViewMate.setText(result.getName());
		spinner.setVisibility(View.GONE);
		textViewMate
				.setCompoundDrawablesWithIntrinsicBounds(
						new BitmapDrawable(activity.getResources(), result
								.getBitmap()),
						null,
						activity.getResources().getDrawable(
								R.drawable.facebook_icon), null);
	}
}

Код снабжен достаточно подробными комментариями, поэтому я даже не знаю, что еще добавить. Класс FbUser я не буду приводить в статье, т.к. он является вспомагательным и служит исключительно для взаимодействия с Facebook Graph API. Если интересно, все исходники, включая FbUser, могут быть найдены в проекте на GitHub.

Чуть не забыл. Добавлю ресурсную XML'ку, которая описывает расположение контролов в строке списка и инфлатируется при создании нового вью:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content" >

    <TextView
        android:id="@+id/textViewUser"
        android:layout_width="fill_parent"
        android:layout_height="80dp"
        android:layout_alignParentTop="true"
        android:drawablePadding="5dp"
        android:gravity="center_vertical"
        android:paddingLeft="5dp"
        android:paddingRight="10dp"
        android:textSize="25dp" />

    <ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerVertical="true"
        android:paddingLeft="15dp" />

</RelativeLayout>

Заключение

Надеюсь, что статья послужит для кого-нибудь полезным обучающим материалом. Вконце хочу добавить ряд ссылок, которые помогут глубже вникнуть в курс дела:

Автор: drtitoff

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js