Маленькая хитрость для отображения большого объёма данных в ListView

  • Tutorial


В этой статье я хочу поделиться недавно найденным решением, позволяющем отображать, а главное легко скролить большие объёмы данных в стандартном ListView.

Проблема


Стандартный механизм отображения списков из базы данных в Android выглядит примерно так:
  • Activity содержит ListView
  • ListView обращается к экземпляру CursorAdapter
  • CursorAdapter получает данные из объекта, реализующего интерфейс Cursor
  • Cursor получен либо из ContentProvider, либо сразу из SQLiteDatabase


Всё работает нормально ровно да тех пор, пока количество строк в Cursor сравнительно небольшое. Но если в нём 50 тысяч, 100 тысяч и более строк (хотя дело не только в количестве строк, но об этом чуть позже), время от времени список будет притормаживать. Особенно это заметно при «быстрой прокрутке», если у ListView установлено в true свойство fastScrollEnabled.

Оставим за скобками, почему же нам всё-таки нужно, чтобы в ListView помещалось такое огромное количество данных. Будем считать это требованием заказчика, на которое мы повлиять не в состоянии. Так же будем считать невозможными воркэраунды с прелоадерами в духе Твиттера и «бесконечных списков», следующая порция данных в которых подгружается при достижении конца уже загруженных данных.

Нам нужно, чтобы в любой момент можно было подскролить к любому из ста тысяч элементов списка без заметных подвисаний интерфейса. Как же это сделать? Давайте попробуем, для начала, найти причину тормозов.

Причина


Рассматривать ViewHolder мы не будем — я предполагаю, что любой мало-мальски грамотный android-разработчик знает и использует этот паттерн. О недопустимости создания большого количества объектов в методе getView в силу неизбежности возмездия в лице сборщика мусора я тоже промолчу.

Нас интересует работа курсора к базе данных.

Cursor, который мы получаем из SQLiteDatabase, является экземпляром класса SQLiteCursor, который наследуется от AbstractWindowedCursor. Этот класс же, в свою очередь, содержит в себе экземпляр CursorWindow.

В последнем классе как раз и кроется наша проблема. Если вы взгляните на исходники CursorWindow, то увидите, что размер окна ограничен константой с именем com.android.internal.R.integer.config_cursorWindowSize. Пользовательский интерфейс притормаживает ровно в тот момент, когда место в окне заканчивается (имеет значение не только количетсво строк в выборке, но и длина каждой строки), и AbstractWindowedCursor запрашивает данные для нового окна, а затем их в это окно копирует.

Можно, конечно, попытаться увеличить размер окна. Но это, во-первых, плохое решение, так как не устранит проблему, а только отдалит её. Во-вторых мы не можем увеличивать его постоянно, так как память устройства ограничена. Ну а в третьих, технически это неоправданно сложно.

Мы пойдём другим путём.

Решение


Вообще говоря, SQLite — достаточно быстрая база данных, и большая часть «тормозов» вызвана неправильным её использованием. Особенно быстро она работает при запросах по первичному ключу.

Идея состоит в следующем: мы запрашиваем только первичные ключи, а затем, при отображении каждой из строк, запрашиваем остальные столбцы по этому первичному ключу. И это на самом деле работает быстрее.

Для иллюстрации этой идеи я написал свою реализацию класса BaseAdapter.

package me.ilich.fastscroll;

import android.content.Context;
import android.database.Cursor;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;

public abstract class QuickAdapter extends BaseAdapter {
	
	private final DataSource mDataSource;
	private int mSize = 0;
	private Cursor mRowIds = null;
	private final Context mContext;
	
	public QuickAdapter(Context context, DataSource dataSource){
		mDataSource = dataSource;
		mContext = context;
		doQuery();
	}
	
	private void doQuery(){
		if(mRowIds!=null){
			mRowIds.close();
		}
		mRowIds = mDataSource.getRowIds();
		mSize = mRowIds.getCount();
	}

	@Override
	public int getCount() {
		return mSize;
	}

	@Override
	public Object getItem(int position) {
		if(mRowIds.moveToPosition(position)){
			long rowId = mRowIds.getLong(0);
			Cursor c = mDataSource.getRowById(rowId);
			return c;
		}else{
			return null;
		}
	}

	@Override
	public long getItemId(int position) {
		if(mRowIds.moveToPosition(position)){
			long rowId = mRowIds.getLong(0);
			return rowId;
		}else{
			return 0;
		}
	}

	@Override
	public View getView(int position, View convertView, ViewGroup parent) {
		mRowIds.moveToPosition(position);
		long rowId = mRowIds.getLong(0);
		Cursor cursor = mDataSource.getRowById(rowId);
		cursor.moveToFirst();
        View v;
        if (convertView == null) {
            v = newView(mContext, cursor, parent);
        } else {
            v = convertView;
        }
        bindView(v, mContext, cursor);
        cursor.close();
        return v;
	}
	
	public abstract View newView(Context context, Cursor cursor, ViewGroup parent);
	public abstract void bindView(View view, Context context, Cursor cursor);
	
	public interface DataSource {
		Cursor getRowIds();
		Cursor getRowById(long rowId);
	}

}


Для использования этого класса нужно реализовать методы newView и bindView точно так же, как это делается для CursorAdapter, а так же написать реализацию QuickAdapter.DataSource, например так:

	class MyDataSource implements QuickAdapter.DataSource {

		@Override
		public Cursor getRowIds() {
			return mDatabase.rawQuery("SELECT rowid FROM table1", new String[]{});
		}

		@Override
		public Cursor getRowById(long rowId) {
			return mDatabase.rawQuery("SELECT * FROM table1 WHERE rowid = ?", new String[]{Long.toString(rowId)});
		}
		
	}


Заключение


На Samsung Galaxy Tab 10.1 без каких-либо заметных тормозов работал «быстрый скрол» для списка из 300 тысяч элементов, каждый из которых до 2Кб. Стандартный CursorAdapter же тормозил так, что смотреть было страшно.

Для CursorAdapter максимальная задержка вызова метода getView составила 553 мс, для QuickAdapter — 47 мс. Замеры производительности выполнялись с помощью следующего кода:
		@Override
		public View getView(int arg0, View arg1, ViewGroup arg2) {
			long t1 = System.currentTimeMillis();
			View result = super.getView(arg0, arg1, arg2);
			long t2 = System.currentTimeMillis();
			long dt = t2-t1;
			if(dt>10){
				Log.i("QuickAdapter", dt+"");
			}
			return result;
		}


Идея, изложенная здесь, взята из статьи Нестандартный подход к «повышению производительности» select-запросов в SQLite Сергея Славина. Так же хотелось бы сказать спасибо моему коллеге, Дмитрию Тухтаманову, который несколько месяцев назад реализовал тот же подход для iOS.

Картинку взял отсюда.
Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 26
  • 0
    В getView() используется:
    mRowIds.moveToPosition(position); long rowId = mRowIds.getLong(0); Cursor cursor = mDataSource.getRowById(rowId); cursor.moveToFirst();

    А должен getItem().
    • 0
      Ну и получать rowId надо через getItemId, а еще надо проверять на null.
      • +3
        да, вы правы. я хотел в первую очередь продемонстрировать идею
        • 0
          Честно, я пока не вижу идеи, чем ваша идея отличается от developer.android.com/reference/android/widget/CursorAdapter.html?
          • +7
            CursorAdapter бегает по курсору и берёт из него данные. Мой адаптер бегает по курсору с одним только первичным ключом, и перезапрашивает базу по первичному ключу. CursorAdapter заметено тормозит при быстром скроле, а мой — нет. Не поленитесь, накидайте пример и сами попробуйте.
            • –5
              Так у вас сравнения нет. Вот если бы вы показали, что ваша реализация быстрее в тестах на n-секунд, то другое дело.
              • +3
                если бы прочли статью… а впрочем ладно, подскажите методологию измерения задержек в ui?
                • –6
                  Время между получением и отображением.
                  А вообще, зачем вы в Cursor храните > 500 значений? Насколько я помню, то CursorAdapter умеет работать с limit.
                  • +8
                    я всё-таки настоятельно рекомендую вам прочитать статью
                    • –5
                      Я ее прочитал.
                      Проблему надо показать, а потом представить ее решение.
                      Проблема в том, что CursorAdapter тормозит при вызове Cursor.getCount.
                      • +3
                        нет. даже если вы переопределите его, скажем так

                        		@Override
                        		public int getCount() {
                        			return 50000;
                        		}
                        


                        то ListView всё равно будет притормаживать при быстром скроле
    • 0
      Интересно, а как по скорости будет работать OrmLite?
      Там используется обычный List
      • 0
        Кстати, а это вы разрабатывали Pravo.ru? Там пока не очень быстро все работает :)
        • 0
          да, мы там это ещё не внедрили
          • 0
            Было бы круто показать на цифрах во сколько раз быстрее.
            Как насчет того, что бы ответить на habrahabr.ru/company/turbomilk/blog/149955/#comment_5076397?
            • 0
              сори, эти вопросы вне моей компетенции
              • 0
                Я понимаю. Надеюсь на обновление топика по производительности.
                • 0
                  Прошу.
                  Для CursorAdapter максимальная задержка вызова метода getView составила 553 мс, для QuickAdapter — 47 мс.
                  • 0
                    Так это в топик надо, вместе с тем, как замерялось :)
                    А получается хорошо, почти в 10 раз, надо будет самому проверить, как время появится.
                    • +3
                      скажите, а это ваша принципиальная позиция — не читать статью, к которой вы пишите комментарии?
                      • 0
                        Пока не обновил страницу — вывода не было.
        • +2
          А зачем в ListView отображать «50 тысяч, 100 тысяч и более строк»? Мне кажется тут проблема в логике, а не в производительности ListView
          • +2
            в этой статье я ставил целью ответить на вопрос «что делать?», а не «кто виноват?» :)
          • 0
            Могли бы вы выложить небольшой готовый пример, пожалуйста
            • +3
              ну вроде там и так всё ясно, но если вы просите

              Активити

              package me.ilich.hellodocumentview;
              
              import android.app.Activity;
              import android.content.Context;
              import android.database.Cursor;
              import android.os.Bundle;
              import android.util.Log;
              import android.view.Menu;
              import android.view.View;
              import android.view.ViewGroup;
              import android.widget.ListView;
              import android.widget.TextView;
              
              public class QuickMainActivity extends Activity {
              
              	ListView mListView;
              
              	@Override
              	public void onCreate(Bundle savedInstanceState) {
              		super.onCreate(savedInstanceState);
              		setContentView(R.layout.activity_list);
              		mListView = (ListView) findViewById(R.id.list);
              		final QuickAdapter a = new MyQuickAdapter(this, new MyDataSource());
              		mListView.setAdapter(a);
              	}
              
              	@Override
              	public boolean onCreateOptionsMenu(Menu menu) {
              		getMenuInflater().inflate(R.menu.activity_main, menu);
              		return true;
              	}
              	
              	class MyQuickAdapter extends QuickAdapter {
              
              		public MyQuickAdapter(Context context, DataSource dataSource) {
              			super(context, dataSource);
              		}
              		
              		@Override
              		public View getView(int arg0, View arg1, ViewGroup arg2) {
              			long t1 = System.currentTimeMillis();
              			View result = super.getView(arg0, arg1, arg2);
              			long t2 = System.currentTimeMillis();
              			long dt = t2-t1;
              			if(dt>10){
              				Log.i("Sokolov", arg0 + " " + dt);
              			}
              			return result;
              		}
              
              		@Override
              		public View newView(Context context, Cursor cursor, ViewGroup parent) {
              			View view = getLayoutInflater().inflate(
              					android.R.layout.simple_list_item_1, null);
              			ViewHolder vh = new ViewHolder();
              			vh.tv = (TextView) view.findViewById(android.R.id.text1);
              			view.setTag(vh);
              			return view;
              		}
              
              		@Override
              		public void bindView(View view, Context context, Cursor cursor) {
              			ViewHolder vh = (ViewHolder) view.getTag();
              			if(vh!=null){
              				vh.tv.setText(cursor.getString(cursor.getColumnIndex("text1")));
              			}
              		}
              		
              		class ViewHolder {
              			TextView tv;
              		}
              		
              	}
              	
              	class MyDataSource implements QuickAdapter.DataSource {
              
              		@Override
              		public Cursor getRowIds() {
              			return ((HelloDocumentView) getApplication()).mDatabase.rawQuery(
              					"SELECT rowid FROM table1", new String[]{});
              		}
              
              		@Override
              		public Cursor getRowById(long rowId) {
              			return ((HelloDocumentView)getApplication()).mDatabase.rawQuery("SELECT * FROM table1 WHERE rowid = ?", new String[]{Long.toString(rowId)});
              		}
              		
              	}
              
              }
              


              Аппликейшен

              package me.ilich.hellodocumentview;
              
              import android.app.Application;
              import android.content.Context;
              import android.database.sqlite.SQLiteDatabase;
              
              public class HelloDocumentView extends Application {
              	
              	public SQLiteDatabase mDatabase;
              	
              	@Override
              	public void onCreate() {
              		super.onCreate();
              		mDatabase = openOrCreateDatabase("/mnt/sdcard/db.sqlite", Context.MODE_PRIVATE, null);
              	}
              
              }
              


              Вам остаётся только создать базу db.sqlite с таблицей table1. Достаточно наглядно?

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.