Совсем недавно, на собеседовании в Яндексе, мне довелось обсуждать организацию 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
Сразу отмечу, что этот код писался уважаемым G. Blake Meike в 2012 году, и с тех пор существенно не модифицировался, поэтому мы отнесёмся с пониманием к использованию всяких deprecated конструкций типа managedQuery, неиспользованию таких замечательных вещей как Loader, synchronized (HashMap) вместо ConcurrentHashMap и прочего – на архитектуру приложения они никак не влияют.Итак, начнём с пользовательского интерфейса. В 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.
С Activity и Content Provider всё достаточно ясно. Объект Service в явном виде в примере не используется, его функции и частично функции ServiceHelper по структурированию и запуску запросов выполняет FinchVideoContentProvider. Он же выполняет функции Processor, про Rest method написано выше. Такая вот упрощённая реализация.Выводы
На основе анализа существующей реализации 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 года этим никто не озаботился? Буду рад видеть ответы в комментариях.


