Предисловие
Однажды понадобилось мне выводить в одном 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<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/
Добавить комментарий