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

Предисловие Однажды понадобилось мне выводить в одном ListView карточки разных типов, да еще и полученные с сервера по разным API. Мол, пусть пользователь порадуется и в одной ленте новостей увидит: карточки видео, с тамнейлами и описаниями; карточки авторов или тегов, с большой кнопкой «подписаться». Очевидно, что мастерить один большой layout, в котором учитывать все мыслимые варианты карточек — плохо, да и расширяться это будет так себе.c6ee4cfdc3fa0ef1518d2d2972413506.pngВторой сложностью было то, что источниками данных для карточек могли быть совершенно разные ресурсы сервера, список должен был собираться с помощью одновременных запросов к нескольким разным API, отдающим разные типы данных.da7beb5df59ab747e4794a2ec1d04cbf.pngНу и чтобы жизнь медом не казалась, серверное 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 unionColumnsSet = new HashSet(); // список Uri всех таблиц, участвующих в подзапросах (videos, authors и tags) ListcontentUriList = getSubqueryContentUriList (); // для каждой таблицы необходимо вычислить значение viewType String[] viewTypeColumns = new String[contentUriList.size ()]; // для каждой таблицы вычисляем ее contentType String[] contentTypeColumns = new String[contentUriList.size ()]; for (int i=0; i projection = getProjection (contentUri); // получаем множество всех различных колонок таблиц unionColumnsSet.addAll (projection); }

// Итого, на данный момент для для каждого подзапроса, мы знаем: тип карточки, // значение 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 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 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 unionColumnsSet = new HashSet(); // список Uri всех таблиц, участвующих в подзапросах (videos, authors и tags) ListcontentUriList = getSubqueryContentUriList (); // для каждой таблицы необходимо вычислить значение viewType String[] viewTypeColumns = new String[contentUriList.size ()]; // для каждой таблицы вычисляем ее contentType String[] contentTypeColumns = new String[contentUriList.size ()]; for (int i=0; i projection = getProjection (contentUri); // получаем множество всех различных колонок таблиц unionColumnsSet.addAll (projection); }

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

© Habrahabr.ru