Совсем недавно, на собеседовании в Яндексе, мне довелось обсуждать организацию Rest-взаимодействия в Android-приложениях. В ходе обсуждения всплыл вопрос – почему из трех паттернов, предложенных на Google IO 2010 Virgil Dobjanschi, первый используется существенно чаще двух других. Вопрос меня заинтересовал.
Поскольку тема обсуждения достаточно узкоспециализированная, я с позволения читателей пропущу слова о том, насколько правильная архитектура Rest-взаимодействия важна в Android-приложениях и как часто Android-разработчики сталкиваются с подобными задачами.
Беглый обзор подтвердил, что Pattern A используется действительно гораздо шире. Dv в своей замечательной статье «REST под Android. Часть 1: паттерны Virgil Dobjanschi» упоминает целый ряд библиотек и примеров реализации Pattern A (есть такой и на хабре), и всего один пример Pattern B – описание в книге «Programming Android, 2nd Edition» by Zigurd Mednieks, Laird Dornin, G. Blake Meike and Masumi Nakamura [1], в главе 13 «A Content Provider as a Facade for a RESTful Web Service», реализация есть на github.com. Других мне найти не удалось.
Чтение оригинала доклада Virgil Dobjanschi только добавило интриги.
Предлагаю кратко рассмотреть существующую реализацию Pattern B и попытаться понять, в чём же заключаются его особенности.
Сразу отмечу, что этот код писался уважаемым G. Blake Meike в 2012 году, и с тех пор существенно не модифицировался, поэтому мы отнесёмся с пониманием к использованию всяких deprecated конструкций типа managedQuery, неиспользованию таких замечательных вещей как Loader, synchronized (HashMap) вместо ConcurrentHashMap и прочего – на архитектуру приложения они никак не влияют.
Итак, начнём с пользовательского интерфейса. В FinchVideoActivity всё вполне прозрачно – к ListView через SimpleCursorAdapter привязывается Cursor, в который и сливаются результаты запросов managedQuery к FinchVideoContentProvider.
Дальше – интереснее.
Сопоставим существующие классы со схемой Pattern B.
С Activity и Content Provider всё достаточно ясно. Объект Service в явном виде в примере не используется, его функции и частично функции ServiceHelper по структурированию и запуску запросов выполняет FinchVideoContentProvider. Он же выполняет функции Processor, про Rest method написано выше. Такая вот упрощённая реализация.
На основе анализа существующей реализации Pattern B и её описания, я сделал для себя следующие выводы
Таким образом, при использовании Pattern B необходимо учитывать вышеуказанные моменты.
Может быть, кто-нибудь использовал этот паттерн в рабочих проектах или знает более удачные примеры реализации? Есть ли вообще смысл реализовать его более качественно (была такая идея), если за 4 года этим никто не озаботился? Буду рад видеть ответы в комментариях.
Поскольку тема обсуждения достаточно узкоспециализированная, я с позволения читателей пропущу слова о том, насколько правильная архитектура Rest-взаимодействия важна в Android-приложениях и как часто Android-разработчики сталкиваются с подобными задачами.
Краткое описание паттернов и обзор
(подробнее)Pattern A | Pattern B | Pattern C |
Используется Service API: Activity -> Service -> Content Provider. В данном варианте Activity работает с API Android Servcie. При необходимости послать REST-запрос Activity создает Service, Service асинхронно посылает запросы к REST-серверу и сохраняет результаты в Content Provider (sqlite). Activity получает уведомление о готовности данных и считывает результаты из Content Provider (sqlite). | Используется ContentProvider API: Activity -> Content Provider -> Service. В этом случае Activity работает с API Content Provider, который выступает фасадом для сервиса. Данный подход основан на схожести Content Provider API и REST API: GET REST эквивалентен select-запросу к базе данных, POST REST эквивалентен insert, PUT REST ~ update, DELETE REST ~ delete. Результаты Activity так же загружает из sqlite. | Используется Content Provider API + SyncAdapter: Activity -> Content Provider -> Sync Adapter. Вариация подхода "B", в котором вместо сервиса используется собственный Sync Adapter. Activity дает команду Content Provider, который переадресовывает ее в Sync Adapter. Sync Adapter вызывается из Sync Manager, но не сразу, а в "удобный" для системы момент. Т.е. возможны задержки в исполнении команд. |
![]() |
![]() |
![]() |
Чтение оригинала доклада Virgil Dobjanschi только добавило интриги.
Please note that in this particular pattern we broke the Content Provider contract a little bit. … Again, we’re not forcing you to adopt these particular design patterns.В общем, не хотите – не используйте. Это воодушевляет.
Предлагаю кратко рассмотреть существующую реализацию Pattern B и попытаться понять, в чём же заключаются его особенности.
Приложение FinchVideo

Итак, начнём с пользовательского интерфейса. В FinchVideoActivity всё вполне прозрачно – к ListView через SimpleCursorAdapter привязывается Cursor, в который и сливаются результаты запросов managedQuery к FinchVideoContentProvider.
Дальше – интереснее.
FinchVideoContentProvider
FinchVideoContentProvider кроме реализации базовых для ContentProvider (query, insert и т.д.) операций к SQLiteDatabase наследует от RESTfulContentProvider механизм запуска http-запросов в отдельных потоках public class FinchVideoContentProvider extends RESTfulContentProvider {
public static final String VIDEO = "video";
public static final String DATABASE_NAME = VIDEO + ".db";
static int DATABASE_VERSION = 2;
public static final String VIDEOS_TABLE_NAME = "video";
private static final String FINCH_VIDEO_FILE_CACHE = "finch_video_file_cache";
private static final int VIDEOS = 1;
private static final int VIDEO_ID = 2;
private static final int THUMB_VIDEO_ID = 3;
private static final int THUMB_ID = 4;
private static UriMatcher sUriMatcher;
// Statically construct a uri matcher that can detect URIs referencing
// more than 1 video, a single video, or a single thumb nail image.
static {
sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
sUriMatcher.addURI(FinchVideo.AUTHORITY,
FinchVideo.Videos.VIDEO, VIDEOS);
// use of the hash character indicates matching of an id
sUriMatcher.addURI(FinchVideo.AUTHORITY,
FinchVideo.Videos.VIDEO + "/#",
VIDEO_ID);
sUriMatcher.addURI(FinchVideo.AUTHORITY,
FinchVideo.Videos.THUMB + "/#",
THUMB_VIDEO_ID);
sUriMatcher.addURI(FinchVideo.AUTHORITY,
FinchVideo.Videos.THUMB + "/*",
THUMB_ID);
}
/** uri for querying video, expects appending keywords. */
private static final String QUERY_URI =
"http://gdata.youtube.com/feeds/api/videos?" +
"max-results=15&format=1&q=";
private DatabaseHelper mOpenHelper;
private SQLiteDatabase mDb;
private static class DatabaseHelper extends SQLiteOpenHelper {
private DatabaseHelper(Context context, String name,
SQLiteDatabase.CursorFactory factory)
{
super(context, name, factory, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase sqLiteDatabase) {
createTable(sqLiteDatabase);
}
private void createTable(SQLiteDatabase sqLiteDatabase) {
String createvideoTable =
"CREATE TABLE " + VIDEOS_TABLE_NAME + " (" +
BaseColumns._ID +
" INTEGER PRIMARY KEY AUTOINCREMENT, " +
FinchVideo.Videos.TITLE + " TEXT, " +
FinchVideo.Videos.DESCRIPTION + " TEXT, " +
FinchVideo.Videos.THUMB_URI_NAME + " TEXT," +
FinchVideo.Videos.THUMB_WIDTH_NAME + " TEXT," +
FinchVideo.Videos.THUMB_HEIGHT_NAME + " TEXT," +
FinchVideo.Videos.TIMESTAMP + " TEXT, " +
FinchVideo.Videos.QUERY_TEXT_NAME + " TEXT, " +
FinchVideo.Videos.MEDIA_ID_NAME + " TEXT UNIQUE," +
FinchVideo.Videos.THUMB_CONTENT_URI_NAME +
" TEXT UNIQUE," +
FinchVideo.Videos._DATA + " TEXT UNIQUE" +
");";
sqLiteDatabase.execSQL(createvideoTable);
}
@Override
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int oldv,
int newv)
{
sqLiteDatabase.execSQL("DROP TABLE IF EXISTS " +
VIDEOS_TABLE_NAME + ";");
createTable(sqLiteDatabase);
}
}
public FinchVideoContentProvider() {
}
public FinchVideoContentProvider(Context context) {
}
@Override
public boolean onCreate() {
FileHandlerFactory fileHandlerFactory =
new FileHandlerFactory(new File(getContext().getFilesDir(),
FINCH_VIDEO_FILE_CACHE));
setFileHandlerFactory(fileHandlerFactory);
mOpenHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null);
mDb = mOpenHelper.getWritableDatabase();
return true;
}
@Override
public SQLiteDatabase getDatabase() {
return mDb;
}
/**
* Content provider query method that converts its parameters into a YouTube
* RESTful search query.
*
* @param uri a reference to the query for videos, the query string can
* contain, "q='key_words'". The keywords are sent to the google YouTube
* API where they are used to search the YouTube video database.
* @param projection
* @param where not used in this provider.
* @param whereArgs not used in this provider.
* @param sortOrder not used in this provider.
* @return a cursor containing the results of a YouTube search query.
*/
@Override
public Cursor query(Uri uri, String[] projection, String where,
String[] whereArgs, String sortOrder)
{
Cursor queryCursor;
int match = sUriMatcher.match(uri);
switch (match) {
case VIDEOS:
// the query is passed out of band of other information passed
// to this method -- its not an argument.
String queryText = uri.
getQueryParameter(FinchVideo.Videos.QUERY_PARAM_NAME);
if (queryText == null) {
// A null cursor is an acceptable argument to the method,
// CursorAdapter.changeCursor(Cursor c), which interprets
// the value by canceling all adapter state so that the
// component for which the cursor is adapting data will
// display no content.
return null;
}
String select = FinchVideo.Videos.QUERY_TEXT_NAME +
" = '" + queryText + "'";
// quickly return already matching data
queryCursor =
mDb.query(VIDEOS_TABLE_NAME, projection,
select,
whereArgs,
null,
null, sortOrder);
// make the cursor observe the requested query
queryCursor.setNotificationUri(
getContext().getContentResolver(), uri);
/**
* Always try to update results with the latest data from the
* network.
*
* Spawning an asynchronous load task thread, guarantees that
* the load has no chance to block any content provider method,
* and therefore no chance to block the UI thread.
*
* While the request loads, we return the cursor with existing
* data to the client.
*
* If the existing cursor is empty, the UI will render no
* content until it receives URI notification.
*
* Content updates that arrive when the asynchronous network
* request completes will appear in the already returned cursor,
* since that cursor query will match that of
* newly arrived items.
*/
if (!"".equals(queryText)) {
asyncQueryRequest(queryText, QUERY_URI + encode(queryText));
}
break;
case VIDEO_ID:
case THUMB_VIDEO_ID:
long videoID = ContentUris.parseId(uri);
queryCursor =
mDb.query(VIDEOS_TABLE_NAME, projection,
BaseColumns._ID + " = " + videoID,
whereArgs, null, null, null);
queryCursor.setNotificationUri(
getContext().getContentResolver(), uri);
break;
case THUMB_ID:
String uriString = uri.toString();
int lastSlash = uriString.lastIndexOf("/");
String mediaID = uriString.substring(lastSlash + 1);
queryCursor =
mDb.query(VIDEOS_TABLE_NAME, projection,
FinchVideo.Videos.MEDIA_ID_NAME + " = " +
mediaID,
whereArgs, null, null, null);
queryCursor.setNotificationUri(
getContext().getContentResolver(), uri);
break;
default:
throw new IllegalArgumentException("unsupported uri: " +
QUERY_URI);
}
return queryCursor;
}
/**
* Provides a handler that can parse YouTube gData RSS content.
*
* @param requestTag unique tag identifying this request.
* @return a YouTubeHandler object.
*/
@Override
protected ResponseHandler newResponseHandler(String requestTag) {
return new YouTubeHandler(this, requestTag);
}
/**
* Provides read only access to files that have been downloaded and stored
* in the provider cache. Specifically, in this provider, clients can
* access the files of downloaded thumbnail images.
*/
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode)
throws FileNotFoundException
{
// only support read only files
if (!"r".equals(mode.toLowerCase())) {
throw new FileNotFoundException("Unsupported mode, " + mode + ", for uri: " + uri);
}
return openFileHelper(uri, mode);
}
@Override
public String getType(Uri uri) {
switch (sUriMatcher.match(uri)) {
case VIDEOS:
return FinchVideo.Videos.CONTENT_TYPE;
case VIDEO_ID:
return FinchVideo.Videos.CONTENT_VIDEO_TYPE;
case THUMB_ID:
return FinchVideo.Videos.CONTENT_THUMB_TYPE;
default:
throw new IllegalArgumentException("Unknown video type: " +
uri);
}
}
@Override
public Uri insert(Uri uri, ContentValues initialValues) {
// Validate the requested uri
if (sUriMatcher.match(uri) != VIDEOS) {
throw new IllegalArgumentException("Unknown URI " + uri);
}
ContentValues values;
if (initialValues != null) {
values = new ContentValues(initialValues);
} else {
values = new ContentValues();
}
SQLiteDatabase db = getDatabase();
return insert(uri, initialValues, db);
}
private void verifyValues(ContentValues values)
{
if (!values.containsKey(FinchVideo.Videos.TITLE)) {
Resources r = Resources.getSystem();
values.put(FinchVideo.Videos.TITLE,
r.getString(android.R.string.untitled));
}
if (!values.containsKey(FinchVideo.Videos.DESCRIPTION)) {
Resources r = Resources.getSystem();
values.put(FinchVideo.Videos.DESCRIPTION,
r.getString(android.R.string.untitled));
}
if (!values.containsKey(FinchVideo.Videos.THUMB_URI_NAME)) {
throw new IllegalArgumentException("Thumb uri not specified: " +
values);
}
if (!values.containsKey(FinchVideo.Videos.THUMB_WIDTH_NAME)) {
throw new IllegalArgumentException("Thumb width not specified: " +
values);
}
if (!values.containsKey(FinchVideo.Videos.THUMB_HEIGHT_NAME)) {
throw new IllegalArgumentException("Thumb height not specified: " +
values);
}
// Make sure that the fields are all set
if (!values.containsKey(FinchVideo.Videos.TIMESTAMP)) {
Long now = System.currentTimeMillis();
values.put(FinchVideo.Videos.TIMESTAMP, now);
}
if (!values.containsKey(FinchVideo.Videos.QUERY_TEXT_NAME)) {
throw new IllegalArgumentException("Query Text not specified: " +
values);
}
if (!values.containsKey(FinchVideo.Videos.MEDIA_ID_NAME)) {
throw new IllegalArgumentException("Media ID not specified: " +
values);
}
}
/**
* The delegate insert method, which also takes a database parameter. Note
* that this method is a direct implementation of a content provider method.
*/
@Override
public Uri insert(Uri uri, ContentValues values, SQLiteDatabase db) {
verifyValues(values);
// Validate the requested uri
int m = sUriMatcher.match(uri);
if (m != VIDEOS) {
throw new IllegalArgumentException("Unknown URI " + uri);
}
// insert the values into a new database row
String mediaID = (String) values.get(FinchVideo.Videos.MEDIA_ID_NAME);
Long rowID = mediaExists(db, mediaID);
if (rowID == null) {
long time = System.currentTimeMillis();
values.put(FinchVideo.Videos.TIMESTAMP, time);
long rowId = db.insert(VIDEOS_TABLE_NAME,
FinchVideo.Videos.VIDEO, values);
if (rowId >= 0) {
Uri insertUri =
ContentUris.withAppendedId(
FinchVideo.Videos.CONTENT_URI, rowId);
getContext().getContentResolver().notifyChange(insertUri, null);
return insertUri;
}
throw new IllegalStateException("could not insert " +
"content values: " + values);
}
return ContentUris.withAppendedId(FinchVideo.Videos.CONTENT_URI, rowID);
}
private Long mediaExists(SQLiteDatabase db, String mediaID) {
Cursor cursor = null;
Long rowID = null;
try {
cursor = db.query(VIDEOS_TABLE_NAME, null,
FinchVideo.Videos.MEDIA_ID_NAME + " = '" + mediaID + "'",
null, null, null, null);
if (cursor.moveToFirst()) {
rowID = cursor.getLong(FinchVideo.ID_COLUMN);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
return rowID;
}
@Override
public int delete(Uri uri, String where, String[] whereArgs) {
int match = sUriMatcher.match(uri);
int affected;
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
switch (match) {
case VIDEOS:
affected = db.delete(VIDEOS_TABLE_NAME,
(!TextUtils.isEmpty(where) ?
" AND (" + where + ')' : ""),
whereArgs);
break;
case VIDEO_ID:
long videoId = ContentUris.parseId(uri);
affected = db.delete(VIDEOS_TABLE_NAME,
BaseColumns._ID + "=" + videoId
+ (!TextUtils.isEmpty(where) ?
" AND (" + where + ')' : ""),
whereArgs);
getContext().getContentResolver().notifyChange(uri, null);
break;
default:
throw new IllegalArgumentException("unknown video element: " +
uri);
}
return affected;
}
@Override
public int update(Uri uri, ContentValues values, String where,
String[] whereArgs)
{
getContext().getContentResolver().notifyChange(uri, null);
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
int count;
switch (sUriMatcher.match(uri)) {
case VIDEOS:
count = db.update(VIDEOS_TABLE_NAME, values, where, whereArgs);
break;
case VIDEO_ID:
String videoId = uri.getPathSegments().get(1);
count = db.update(VIDEOS_TABLE_NAME, values,
BaseColumns._ID + "=" + videoId
+ (!TextUtils.isEmpty(where) ?
" AND (" + where + ')' : ""),
whereArgs);
break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return count;
}
}
asyncQueryRequest
и HashMap<String, UriRequestTask> — mRequestsInProgress (соответственно, набор выполняемых запросов). Обрабатываются результаты запросов в YouTubeHandler implements ResponseHandler, который и передаётся в задачу UriRequestTask при её создании.public void asyncQueryRequest(String queryTag, String queryUri) {
synchronized (mRequestsInProgress) {
UriRequestTask requestTask = getRequestTask(queryTag);
if (requestTask == null) {
requestTask = newQueryTask(queryTag, queryUri);
Thread t = new Thread(requestTask);
// allows other requests to run in parallel.
t.start();
}
}
}
synchronized (mRequestsInProgress) {
UriRequestTask requestTask = getRequestTask(queryTag);
if (requestTask == null) {
requestTask = newQueryTask(queryTag, queryUri);
Thread t = new Thread(requestTask);
// allows other requests to run in parallel.
t.start();
}
}
}
Сопоставим существующие классы со схемой Pattern B.

Выводы
На основе анализа существующей реализации Pattern B и её описания, я сделал для себя следующие выводы
- Самый большой плюс Pattern B, как и описывает автор примера в разделе «Summary of Benefits» [1 — стр. 369] – увеличенная производительность запросов, поскольку они в первую очередь осуществляются к локальной БД (Content Provider);
- Обратная сторона этого плюса – рассогласование локальной и серверной БД и усложнённая логика получения данных.
Неудивительно, что автор примера использовал только query (GET) запрос – это самый простой вариант. Не получили новые данные – возьмём старые из кэша. А если реализовывать insert (PUT)? Нужно будет сначала внести изменения в локальную БД, выставить им (изменениям) флаг «несинхронизировано», потом при неудачной попытке GET-запроса – повторять эту попытку, например с экспоненциально возрастающей паузой (как предлагает автор паттерна) … Всё это время пользователь будет видеть добавленные данные, которых нет на сервере. Более того, что их нет на сервере, он тоже узнать не сможет (см. пункт 3); - И неприятный побочный эффект, связанный с ограниченностью взаимодействия Activity с REST (только через механизмы Content Provider) – в GUI мы не можем получить ничего, кроме данных.
К примеру, мы никогда не узнаем о причинах отсутствия данных. Ошибка в парсинге? Сервер ничего не вернул? Вообще нет сети? Результат один – нет данных. В реализации Pattern A для этой цели мы могли передать из Activity в ServiceHelper RequestListener. C Content Provider этот номер не пройдёт.
Конечно, мы можем получить данные, например через Broadcast Receiver, и в обход Content Provider, но к Pattern B это уже не будет иметь отношения.
Таким образом, при использовании Pattern B необходимо учитывать вышеуказанные моменты.
Может быть, кто-нибудь использовал этот паттерн в рабочих проектах или знает более удачные примеры реализации? Есть ли вообще смысл реализовать его более качественно (была такая идея), если за 4 года этим никто не озаботился? Буду рад видеть ответы в комментариях.