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

    Предисловие


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


    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 — дает профит в случае, когда необходимо отсортировать и показать не очень большое число строк.
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 9

      0
      Кто-то еще использует сервисы для загрузки данных?
        +2
        Я использую библиотеку volley, так сложилось. А как нынче модно?
          0
          Так она не использует сервис. Она использует потоки.
          Или я вас недопонял?
            0
            Ну что Вы прицепились к сервису, статья вообще не о том. Упомянутый паттерн, как я его понял, вообще про кэширование запросов к API в базе, а как реализован сервис исполнения запросов — как Service ли, как пул потоков — не важно. Впрочем, может я все напутал. А что сейчас в тренде, если не сервисы?
              0
              Не знаю, как-то зацепилось.
              Бывает.
                0
                Можно использовать библиотеку от создателей Path github.com/path/android-priority-jobqueue, которая независимо от цикла жизни ui выполняет таски. Результат получаем с помощью любого Event Bus.
                0
                Я так понимаю, tumbler использует сервис для того, чтобы из него стартовать реквесты и ловить в нём калбэки. Т.к. если стартовать запрос из активити, и убить активити, то калбэк, например, для сохранения результата, не будет вызван.
                  0
                  Я использую готовую библиотеку volley, внутри у нее нет сервиса, только пул потоков. То есть при убивании activity необходимо останавливать выполнение всех живых запросов, иначе калбэки будут вызваны у наполовину мертвой активити.
                  Как мне кажется, сервис необходим, когда нужно гарантированное подтверждение доставки и обработки запроса (например, если приложение что-то изменяет на сервере и должно обработать результаты изменения полюбому). В моем случае это не нужно.
              +1
              Да, а что в этом плохого? (не упускаю случая поучиться)

            Only users with full accounts can post comments. Log in, please.