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

от автора

Предисловие

Однажды понадобилось мне выводить в одном 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 получает уведомление об изменении данных и обновляет представление.

Так в итоге все и работает: делаем через сервис пачку запросов к 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 — дает профит в случае, когда необходимо отсортировать и показать не очень большое число строк.

Дальше чудесный класс 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 

Конечно, можно такой запрос построить с помощью StringBuilder, но в SQLiteQueryBuilder есть немножко глючные, но работающие методы построения такого запроса.
Итак, Activity запрашивает у нашего ContentProvider ленту:

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 — дает профит в случае, когда необходимо отсортировать и показать не очень большое число строк.
  • Вообще-то я не java-программист, поэтому примеры могут показаться жутким говнокодом. Если код глаза режет, не стесняйтесь, пишите в личку. Надо же расти над собой. Спасибо за внимание!

ссылка на оригинал статьи http://habrahabr.ru/post/221851/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *