Списки с разными типами элементов и разными провайдерами данных
Предисловие Однажды понадобилось мне выводить в одном ListView карточки разных типов, да еще и полученные с сервера по разным API. Мол, пусть пользователь порадуется и в одной ленте новостей увидит: карточки видео, с тамнейлами и описаниями; карточки авторов или тегов, с большой кнопкой «подписаться». Очевидно, что мастерить один большой layout, в котором учитывать все мыслимые варианты карточек — плохо, да и расширяться это будет так себе.Второй сложностью было то, что источниками данных для карточек могли быть совершенно разные ресурсы сервера, список должен был собираться с помощью одновременных запросов к нескольким разным API, отдающим разные типы данных.Ну и чтобы жизнь медом не казалась, серверное API менять нельзя.От API к ListView Virgil Dobjanschi на Google I/O 2010 отлично разложил по полочкам, как реализовывать взаимодействие с REST API. Самый первый паттерн гласит: Activity создает Service, выполняющий запрос к REST API; Service разбирает ответ и сохраняет данные в БД через ContentProvider; 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
// Итого, на данный момент для для каждого подзапроса, мы знаем: тип карточки,
// значение content-type и список всех колонок, участвующих в основном запросе.
String[] subqueries = new String[contentUriList.size ()];
for (int i=0; i // добавляем в начало списка всех столбцов запроса колонку »1 as content_type»
// данный хак нужен для того, чтобы builder корректно обрабатывал
// выражения «SELECT X as Y» в подзапросах
String[] unionColumns = prependContentTypeExpr (contentTypeColumns[i], unionColumnSet); // добавляем в список «собственных» колонок таблицы подзапроса выражение »0 as view_type»
// опять хак, позволяющий добавлять вычисляемые значения в подзапрос
Set // фильтруем подзапрос, по необходимости
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
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 // Итого, на данный момент для для каждого подзапроса, мы знаем: тип карточки,
// значение content-type и список всех колонок, участвующих в основном запросе.
String[] subqueries = new String[contentUriList.size ()];
for (int i=0; i // добавляем в начало списка всех столбцов запроса колонку »1 as content_type»
// данный хак нужен для того, чтобы builder корректно обрабатывал
// выражения «SELECT X as Y» в подзапросах
String[] unionColumns = prependContentTypeExpr (contentTypeColumns[i], unionColumnSet); // добавляем в список «собственных» колонок таблицы подзапроса выражение »0 as view_type»
// опять хак, позволяющий добавлять вычисляемые значения в подзапрос
Set // фильтруем подзапрос, по необходимости
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