Предисловие
Однажды понадобилось мне выводить в одном ListView карточки разных типов, да еще и полученные с сервера по разным API. Мол, пусть пользователь порадуется и в одной ленте новостей увидит:
- карточки видео, с тамнейлами и описаниями;
- карточки авторов или тегов, с большой кнопкой «подписаться».
Очевидно, что мастерить один большой layout, в котором учитывать все мыслимые варианты карточек — плохо, да и расширяться это будет так себе.
Второй сложностью было то, что источниками данных для карточек могли быть совершенно разные ресурсы сервера, список должен был собираться с помощью одновременных запросов к нескольким разным API, отдающим разные типы данных.
Ну и чтобы жизнь медом не казалась, серверное API менять нельзя.
От API к ListView
Virgil Dobjanschi на Google I/O 2010 отлично разложил по полочкам, как реализовывать взаимодействие с REST API. Самый первый паттерн гласит:
- Activity создает Service, выполняющий запрос к REST API;
- Service разбирает ответ и сохраняет данные в БД через ContentProvider;
- 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 — дает профит в случае, когда необходимо отсортировать и показать не очень большое число строк.