RESTful API под Android: pattern B

в 17:36, , рубрики: android, android development, mobile development, rest, Разработка под android

Совсем недавно, на собеседовании в Яндексе, мне довелось обсуждать организацию Rest-взаимодействия в Android-приложениях. В ходе обсуждения всплыл вопрос – почему из трех паттернов, предложенных на Google IO 2010 Virgil Dobjanschi, первый используется существенно чаще двух других. Вопрос меня заинтересовал.

Поскольку тема обсуждения достаточно узкоспециализированная, я с позволения читателей пропущу слова о том, насколько правильная архитектура 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, но не сразу, а в "удобный" для системы момент. Т.е. возможны задержки в исполнении команд.
RESTful API под Android: pattern B RESTful API под Android: pattern B RESTful API под Android: pattern B

Беглый обзор подтвердил, что 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 только добавило интриги.

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

RESTful API под Android: pattern BСразу отмечу, что этот код писался уважаемым G. Blake Meike в 2012 году, и с тех пор существенно не модифицировался, поэтому мы отнесёмся с пониманием к использованию всяких deprecated конструкций типа managedQuery, неиспользованию таких замечательных вещей как Loader, synchronized (HashMap) вместо ConcurrentHashMap и прочего – на архитектуру приложения они никак не влияют.

Итак, начнём с пользовательского интерфейса. В FinchVideoActivity всё вполне прозрачно – к ListView через SimpleCursorAdapter привязывается Cursor, в который и сливаются результаты запросов managedQuery к FinchVideoContentProvider.
Дальше – интереснее.

FinchVideoContentProvider
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;
    }
}

FinchVideoContentProvider кроме реализации базовых для ContentProvider (query, insert и т.д.) операций к SQLiteDatabase наследует от RESTfulContentProvider механизм запуска http-запросов в отдельных потоках

asyncQueryRequest

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();
}
}
}

и HashMap<String, UriRequestTask> — mRequestsInProgress (соответственно, набор выполняемых запросов). Обрабатываются результаты запросов в YouTubeHandler implements ResponseHandler, который и передаётся в задачу UriRequestTask при её создании.

Сопоставим существующие классы со схемой Pattern B.
RESTful API под Android: pattern B С Activity и Content Provider всё достаточно ясно. Объект Service в явном виде в примере не используется, его функции и частично функции ServiceHelper по структурированию и запуску запросов выполняет FinchVideoContentProvider. Он же выполняет функции Processor, про Rest method написано выше. Такая вот упрощённая реализация.

Выводы

На основе анализа существующей реализации Pattern B и её описания, я сделал для себя следующие выводы

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

Таким образом, при использовании Pattern B необходимо учитывать вышеуказанные моменты.

Может быть, кто-нибудь использовал этот паттерн в рабочих проектах или знает более удачные примеры реализации? Есть ли вообще смысл реализовать его более качественно (была такая идея), если за 4 года этим никто не озаботился? Буду рад видеть ответы в комментариях.

Автор: mairos

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js