Списки с разными типами элементов и разными провайдерами данных

Development for Android

Предисловие


Однажды понадобилось мне выводить в одном ListView карточки разных типов, да еще и полученные с сервера по разным API. Мол, пусть пользователь порадуется и в одной ленте новостей увидит:
  • карточки видео, с тамнейлами и описаниями;
  • карточки авторов или тегов, с большой кнопкой «подписаться».

Очевидно, что мастерить один большой layout, в котором учитывать все мыслимые варианты карточек — плохо, да и расширяться это будет так себе.



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



Ну и чтобы жизнь медом не казалась, серверное API менять нельзя.

От API к ListView


Virgil Dobjanschi на Google I/O 2010 отлично разложил по полочкам, как реализовывать взаимодействие с REST API. Самый первый паттерн гласит:
  1. Activity создает Service, выполняющий запрос к REST API;
  2. Service разбирает ответ и сохраняет данные в БД через ContentProvider;
  3. Activity получает уведомление об изменении данных и обновляет представление.


UPD Тут небольшой холивар на тему использования сервиса возник, так что лучше заменить это слово на «библиотеку, реализующую HTTP запросы» — неважно, каким именно способом.

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

  • Как правильно отобразить список карточек?
  • Как собрать запрос для ленты?


Отображаем разные типы карточек


Сначала разберемся с тем, что попроще. Решение легко находится в гугле, поэтому привожу его кратко.
В адаптере списка карточек переопределяем методы:

@Override
int getViewTypeCount() {
	// тут все просто, число реализованных типов карточек заранее известно
	return VIEW_TYPE_COUNT;
}

@Override
int getItemViewType(int position) {
	// По порядковому номеру текущей строки курсора определяем тип элемента
	Cursor c = (Cursor)getItem(position);
	int columnIndex = c.getColumnIndex(VIEW_TYPE_COLUMN);
	return c.getInt(columnIndex);
}	

@Override
void bindView(View view, Context context, Cursor c) {
	// обновляем данные в уже существующей вьюхе с учетом типа отображения
	int columnIndex = c.getColumnIndex(VIEW_TYPE_COLUMN);
	int viewType = c.getInt(columnIndex);
	switch(viewType) {
		case VIEW_TYPE_VIDEO:
			bindVideoView(view);
			break;
		case VIEW_TYPE_SUBSCRIPTION:
			// и так далее
	}
}

@Override
View newView(Context context, Cursor cursor, ViewGroup parent) {
	// создаем новую вьюху с учетом типа отображения
	int columnIndex = c.getColumnIndex(VIEW_TYPE_COLUMN);
	int viewType = c.getInt(columnIndex);
	switch(viewType) {
		case VIEW_TYPE_VIDEO:
			return newVideoView(cursor);
		case VIEW_TYPE_SUBSCRIPTION:
			// и так далее
	}
}


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

Собираем SQL-запрос для ленты


Пусть для определенности в БД есть таблицы:

  • videos — содержит список видео для ленты.
    Колонки id, title, picture, updated.
  • authors, tags — содержат списки сущностей, на которых можно подписаться (один к одному отображаются на API сервера).
    Колонки id, name, picture, updated.


Итого, необходимо сконструировать запрос, возвращающий следующие столбцы:

столбец видео автор тег комментарий
id video_id author_id tag_id первичный ключ в соответствующей таблице
view_type VIDEO SUBSCRIPTION SUBSCRIPTION тип карточки для отображения
content_type videos authors tags тип контента — или имя таблицы, если так удобнее
title video_title NULL NULL название видео
name NULL author_name tag_name имя автора или название тега
picture link link link ссылка на картинку
updated timestamp timestamp timestamp время обновления объекта на сервере


Поясню чуть подробнее.
  • view_type — отвечает за тип отображения. Обратите внимание, что для авторов и тегов тип отображения один и тот же.
  • content_type — отвечает за источник данных. Для автора и тега он уже отличается, что позволяет при необходимости обратиться к нужной таблице или нужному API за дополнительными данными.
  • title, name и picture — столбцы таблицы, которые могут быть общими для всех или уникальными для каждой конкретной таблицы
  • updated — поле, по которому строки будут упорядочиваться в результате.


В sqlite запрос получается достаточно простой:

SELECT
	0 as view_type,
	'videos' as content_type,
	title,
	NULL as name,
	picture,
	updated
FROM videos
UNION ALL
SELECT
	1 as view_type,
	'authors' as content_type,
	NULL as title,
	name,
	picture,
	updated
FROM authors
UNION ALL
SELECT
	1 as view_type,
	'tags' as content_type,
	NULL as title,
	name,
	picture,
	updated
FROM tags
ORDER BY updated


Конечно, можно такой запрос построить «руками», но в SQLiteQueryBuilder есть немножко глючные, но работающие методы построения такого запроса.

Итак, Activity запрашивает у нашего ContentProvider ленту:

Cursor c = getContext().getContentResolver().query(Uri.parse("content://MyProvider/feed/"));


При этом в методе MyProvider.query необходимо определить, что происходит запрос именно к Uri ленты, и переключиться в режим «интеллектуального» построения запроса.

Cursor query(Uri contentUri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
	if (isFeedUri(contentUri)) 
		return buildFeedUri();
	// иначе строим все остальные типы запросов
	// ...
}

Cursor buildFeedUri() {
	// множество всех "не-вычисляемых" столбцов участвующих в запросе таблиц
	HashSet<String> unionColumnsSet = new HashSet<String>(); 
	// список Uri всех таблиц, участвующих в подзапросах (videos, authors и tags)
	List<Uri>contentUriList = getSubqueryContentUriList(); 
	// для каждой таблицы необходимо вычислить значение viewType
	String[] viewTypeColumns = new String[contentUriList.size()]; 
	// для каждой таблицы вычисляем ее contentType
	String[] contentTypeColumns = new String[contentUriList.size()]; 
	for (int i=0; i<contentUriList.size(); i++) {
		Uri contentUri = contentUriList.get(i);
		// для каждого подзапроса вычисляем тип карточки
		viewTypeColumns[i] = getViewTypeExpr(contentUri); // "0 as view_type"
		// значение колонки content_type
		contentTypeColumns[i] = getContentTypeExpr(contentUri); // "'videos' as content_type"
		// а также список необходимых столбцов
		List<String> projection = getProjection(contentUri);
		// получаем множество всех различных колонок таблиц
		unionColumnsSet.addAll(projection); 
	}

	// Итого, на данный момент для для каждого подзапроса, мы знаем: тип карточки,
	// значение content-type и список всех колонок, участвующих в основном запросе.
	String[] subqueries = new String[contentUriList.size()];
	for (int i=0; i<contentUriList.size(); i++) {
		Uri contentUri = contentUriList.get(i);
		SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
		builder.setTables(getTable(contentUri));

		// добавляем в начало списка всех столбцов запроса колонку "1 as content_type"
		// данный хак нужен для того, чтобы builder корректно обрабатывал 
		// выражения "SELECT X as Y" в подзапросах
		String[] unionColumns = prependContentTypeExpr(contentTypeColumns[i], unionColumnSet);

		// добавляем в список "собственных" колонок таблицы подзапроса выражение "0 as view_type"
		// опять хак, позволяющий добавлять вычисляемые значения в подзапрос
		Set<String> projection = prependViewTypeExpr(viewTypeColumns[i], getProjection(contentUri));

		// фильтруем подзапрос, по необходимости
		String selection = computeWhere(contentUri);
		subqueries[i] = builder.buildUnionSubQuery(
			"content_type", // typeDiscriminatorColumn - отвечает за то, 
			// из какой таблицы взята текущая строка данных
			unionColumns,
			projection,
			0,
			getTable(contentUri), // значение для колонки content_type
			//  (в данном примере совпадает с названием таблицы)
			selection,
			null, //  selectionArgs - ВНЕЗАПНО методом buildUnionSubQuery вообще не используется 
			// (бага такая с API level 1, в API level 11 - вообще параметр удален)
			null, // groupBy
			null // having
		);
	}
	
	// все подзапросы построены, осталось собрать их вместе и добавить порядок сортировки.
	SQLiteQueryBuilder builder = new SQLiteQueryBuilder()
	String orderBy = "updated DESC";
	String query = builder.buildUnionQuery(
		subqueries,
		orderBy,
		null // limit - нам не нужен, вроде как.
	); 
	return getDBHelper().getReadableDatabase().rawQuery(
		query,
		null // selectionArgs - нами не используется
	);
}


В общем, если пример написан правильно, при обращении к content://MyProvider/feed/ наш ContentProvider сгенерирует нужный нам UNION-запрос и отдаст необходимые данные адаптеру.

Получаем обновления данных с сервера



Но что такое? Запрашиваем вторую страницу API video, данные, судя по логам, сохраняются в БД, но ListView не обновляется…
Дело в реализации LoaderCallbacks

@Override
public Loader<Cursor> onCreateLoader(int loaderId, Bundle params) {
	return new CursorLoader(
		getContext(),
		Uri.parse("content://MyContentProvider/feed/"),
		...
	);
}


Когда Activity запрашивает ContentProvider, CursorLoader создает ContentObserver, следящий за Uri content://MyProvider/feed/; когда же наш сервис сохраняет результаты запроса к API сервера, ContentProvider автоматически уведомляет об изменении данных по другому Uri, content://MyProvider/videos/.

Как правильно и окончательно решить эту проблему, я не знаю. В моем приложении оказалось достаточно в коде, сохраняющем результаты запроса в БД, явно уведомлять об изменении данных ленты (уведомление об изменениях в конкретной таблице ложится на плечи провайдера):

getContext.getContentResolver().notifyChange(Uri.parse("content://MyProvider/feed/", null));


Альтернативные решения


  • MergeCursor — оборачивает список курсоров в интерфейс курсора, при итерации возвращая последовательно все строки из первого курсора, затем второго и т.д.
    В случае, когда порядок строк в запросе не важен — позволяет очень сильно упростить код.
  • MatrixCursor — позволяет не обращаясь к БД предоставить интерфейс курсора к любому двумерному массиву. MergeCursor + сортировка + MatrixCursor — дает профит в случае, когда необходимо отсортировать и показать не очень большое число строк.
Tags:androidunionmergeListViewContentProviderCursorAdapterMergeCursor
Hubs: Development for Android
+4
7.2k 79
Comments 9

Popular right now