пятница, 29 ноября 2013 г.

Volley + библиотеки десериализации

Писать парсеры всегда долго и неинтересно. Особенно для XML — большая куча однообразного неприятного кода. Бывает, что это надоедает и начинаешь пытаться упростить себе жизнь.

Один из способов оптимизировать время разработки — использование библиотек десериализации. API обычно используют форматы JSON и XML. Для первого есть годная библиотека Gson, для второго Simple. Рассмотрим обе.

Собственно, смысл у них обеих один и тот же: разобрать заданную строку в объектную модель. Работа обеих основана на Reflection, что не лучшим образом сказывается на производительности. Обе предоставляют аннотации для маппинга полей, успешно разбирают как объекты, так и списки.

Особенности работы библиотек — не тема данной статьи. Здесь мы будем рассматривать, как скрестить их с Volley, чтоб потом быстро запросы писать и горя не знать. Для этого напишем несколько реализаций нашего любимого класса Request (вернее, написанного в прошлой части ExtendedRequest).

Не забудьте самостоятельно подключить библиотеки Gson и/или Simple к проекту.

GsonRequest

Вот обёртка номер раз — для ответов в формате JSON. Переопределяем у неё parseNetworkResponse и там пытаемся разобрать полученный ответ с помощью Gson.

GsonRequest.java

public class GsonRequest<T> extends ExtendedRequest<T> {
    private Class<T> mResponseType;

    public GsonRequest(Class<T> responseType, int method, String url, Response.Listener<T> listener, Response.ErrorListener errorListener) {
        super(method, url, listener, errorListener);
        mResponseType = responseType;
    }

    @Override
    protected Response<T> parseNetworkResponse(NetworkResponse response) {
        String jsonString;
        try {
            jsonString = getResponseString(response);
        } catch (UnsupportedEncodingException e) {
            return Response.error(new VolleyError(e));
        }

        Gson gson = new Gson();
        T result = gson.fromJson(jsonString, mResponseType);

        return Response.success(result, null);
    }
}

Да, класс T никак нельзя вычислить, так что его нужно передавать в параметрах конструктора. Меня тоже это огорчает.

Кода довольно мало, и ничего супер-сложного в нём нет. Рассмотрим пример использования.

Пример

Рассмотрим снова поиск на IMDB, но немного другой запрос. В прошлый раз мы писали поиск по фильмам, который возвращал список с краткой информацией, а сейчас будем получать всего один элемент, но зато в подробностях. Адрес у нас будет иметь вид:

http://www.omdbapi.com/?i=tt0436992

Ответ же будет примерно таким:

{
  "Title": "Doctor Who",
  "Year": "2005",
  "Rated": "TV-PG",
  "Released": "26 Mar 2005",
  "Runtime": "1 h",
  "Genre": "Adventure, Drama, Family, Sci-Fi",
  "Director": "N/A",
  "Writer": "N/A",
  "Actors": "Matt Smith, David Tennant, Jenna Coleman, Karen Gillan",
  "Plot": "The further adventures of the time traveling alien adventurer and his companions.",
  "Poster": "http://ia.media-imdb.com/images/M/MV5BMTU1NDExNDg1MV5BMl5BanBnXkFtZTcwMjM0OTY5Ng@@._V1_SX300.jpg",
  "imdbRating": "8.7",
  "imdbVotes": "83,304",
  "imdbID": "tt0436992",
  "Type": "series",
  "Response": "True"
}

Пишем объектную модель, подготовленную для Gson:

FilmDetails.java

public class FilmDetails {
    @SerializedName("Title") private String mTitle;
    @SerializedName("Year") private int mYear;
    @SerializedName("Genre") private String mGenre;
    @SerializedName("Actors") private String mActors;
    @SerializedName("Plot") private String mSummary;
    @SerializedName("imdbRating") private float mRating;

    
}

Отправляем запрос:

String url = "http://www.omdbapi.com/?i=" + id;

GsonRequest<FilmDetails> request = new GsonRequest<FilmDetails>(FilmDetails.class, Request.Method.GET, url,
    new Response.Listener<FilmDetails>() {
        @Override
        public void onResponse(FilmDetails response) {
            // отображение результата
        }
    },
    new Response.ErrorListener() {
        @Override
        public void onErrorResponse(VolleyError error) {
           // обработка ошибки
        }
    }
);

mRequestQueue.add(request);

Вот и всё! И никаких парсеров!

SimpleXmlRequest

Пишем аналогичную обёртку.

SimpleXmlRequest.java

public class SimpleXmlRequest<T> extends ExtendedRequest<T> {
    private final Class<T> mResponseType;
    private Matcher mMatcher;

    public SimpleXmlRequest(Class<T> responseType, int method, String url,
                        Response.Listener<T> listener, Response.ErrorListener errorListener) {
        super(method, url, listener, errorListener;
        mResponseType = responseType;
    }

    public void setMatcher(Matcher matcher) {
        mMatcher = matcher;
    }

    @Override
    protected Response<T> parseNetworkResponse(NetworkResponse response) {
        String responseString;
        try {
            responseString = getResponseString(response);
        } catch (UnsupportedEncodingException e) {
            return Response.error(new VolleyError(e));
        }

        Serializer serializer = mMatcher == null ? new Persister() : new Persister(mMatcher);
        try {
            T result = serializer.read(mResponseType, responseString);
            return Response.success(result, HttpHeaderParser.parseCacheHeaders(response));

        } catch (Exception e) {
            return Response.error(new VolleyError(e));
        }
    }
}

Насчёт Matcher-ов. Это нужно для того, чтобы разобрать объект такого класса, для которого аннотации не спасут (например, Calendar). Про эти особенности можно почитать в специальной статье.

Пример

Публичный XML API для примера найти уже сложнее. Возьмём MediaWiki (пренеприятнейшие, кстати, что API, что документация).

Поисковый запрос выглядит так: http://ru.wikipedia.org/w/api.php?action=opensearch&search=Архангельск&format=xml. Ответ — примерно так (привожу сокращённый вариант):

<SearchSuggestion xmlns="http://opensearch.org/searchsuggest2" version="2.0">
    <Query xml:space="preserve">Архангельск</Query>
    <Section>
        <Item>
            <Image source="http://upload.wikimedia.org/wikipedia/ru/thumb/…" width="50" height="36"/>
            <Text xml:space="preserve">Архангельск</Text>
      <Description xml:space="preserve">Арха́нгельск  город на севере европейской части России. Административный центр Архангельской области и Приморского муниципального района, образует городской округ Архангельск.</Description>
      <Url xml:space="preserve">http://ru.wikipedia.org/wiki/%D0%90%D1%80%D1%85%D0%B0%D0%BD%D0%B3%D0%B5%D0%BB%D1%8C%D1%81%D0%BA</Url>
        </Item>
        <Item>
            <Image source="http://upload.wikimedia.org/…"
                   width="50" height="31"/>
            <Text xml:space="preserve">Архангельская область</Text>
      <Description xml:space="preserve">Арха́нгельская о́бласть  область на севере Европейской части России. В её состав входит Ненецкий автономный округ.</Description>
      <Url xml:space="preserve">
                http://ru.wikipedia.org/wiki/%D0%90%D1%80%D1%85%D0%B0%D0%BD%D0%B3%D0%B5%D0%BB%D1%8C%D1%81%D0%BA%D0%B0%D1%8F_%D0%BE%D0%B1%D0%BB%D0%B0%D1%81%D1%82%D1%8C
            </Url>
        </Item>
        
    </Section>
</SearchSuggestion>

Объектная модель:

Подготавливать объектную модель для Simple немного сложнее. К счастью, у меня про это тоже есть статья.

SearchResult.java

@Root(name = "Item", strict = false)
public class SearchResult {
    @Path(value = "Image")
    @Attribute(name = "source")
    private String mImageUrl;

    @Element(name = "Text")
    private String mTitle;

    @Element(name = "Description", required = false)
    private String mDescription;

    @Element(name = "Url")
    private String mUrl;

    
}

SearchSuggestion.java

@Root(strict = false)
public class SearchSuggestion {
    @ElementList(inline = false, name = "Section")
    private List<SearchResult> mSearchResults;

    public List<SearchResult> getSearchResults() {
        return mSearchResults;
    }
}

Отправка запроса:

String url = String.format("http://ru.wikipedia.org/w/api.php?action=opensearch&search=%s&format=xml", Uri.encode(getSearchString()));

SimpleXmlRequest<SearchSuggestion> request = new SimpleXmlRequest<SearchSuggestion>(SearchSuggestion.class, Request.Method.GET, url,
    new Response.Listener<SearchSuggestion>() {
        @Override
        public void onResponse(SearchSuggestion response) {
            // отображение результата
        }
    },
    new Response.ErrorListener() {
        @Override
        public void onErrorResponse(VolleyError error) {
            // обработка ошибки
        }
    });

mRequestQueue.add(request);

С разбором результата на этом всё. Дальше пишем логику, UI и всё такое прочее.

Обработка ошибок

Как мы уже говорили в первой части, сервера иногда склонны возвращать ошибки. Иногда ошибка обозначается статусом ответа (приходит не 200 OK, а что-нибудь другое. Volley это ловит и вызывает соответствующий callback), но довольно часто они являются частью API. То есть при ошибке сервер присылает 200 OK, но в ответе не желанные нам данные, а какое-нибудь ругательство вроде:

{error: 2, message:"Server did a boo boo"}

Но и по этому поводу есть у меня реквесты. Рассмотрим их вместе, ибо они почти одинаковые.

GsonWithErrorRequest

GsonWithErrorRequest.java

public class GsonWithErrorRequest<T, E extends ApiError> extends ExtendedRequest<T> {
    private Class<T> mResponseType;
    private Class<E> mApiErrorType;

    public GsonWithErrorRequest(Class<T> responseType, Class<E> errorType, int method, String url,
                                  Response.Listener<T> listener, Response.ErrorListener errorListener) {
        super(method, url, listener, errorListener);

        mResponseType = responseType;
        mApiErrorType = errorType;
    }

    @Override
    protected Response<T> parseNetworkResponse(NetworkResponse response) {
        String jsonString = null;
        try {
            jsonString = getResponseString(response);
        } catch (UnsupportedEncodingException e) {
            return Response.error(new VolleyError(e));
        }
        Gson gson = new Gson();

        try {
            E error = gson.fromJson(jsonString, mApiErrorType);
            if (error != null && error.isReasonable()) {
                return Response.error(error);
            }
        } catch (Exception ignored) {
        }

        T result = gson.fromJson(jsonString, mResponseType);

        return Response.success(result, HttpHeaderParser.parseCacheHeaders(response));
    }
}

SimpleXmlWithErrorRequest

SimpleXmlWithErrorRequest

public class SimpleXmlWithErrorRequest<T, E extends ApiError> extends ExtendedRequest<T> {
    private final Class<T> mResponseType;
    private final Class<E> mErrorType;

    private Matcher mMatcher;

    public SimpleXmlWithErrorRequest(Class<T> responseType, Class<E> errorType,
                                 int method, String url,
                                 Response.Listener<T> listener, Response.ErrorListener errorListener) {
        super(method, url, listener, errorListener);

        mResponseType = responseType;
        mErrorType = errorType;
    }

    public void setMatcher(Matcher matcher) {
        mMatcher = matcher;
    }

    @Override
    protected Response<T> parseNetworkResponse(NetworkResponse response) {
        String responseString;
        try {
            responseString = getResponseString(response);
        } catch (UnsupportedEncodingException e) {
            return Response.error(new VolleyError(e));
        }

        Serializer serializer = mMatcher == null ? new Persister() : new Persister(mMatcher);

        // check if response contains error
        try {
            E error = serializer.read(mErrorType, responseString);

            if (error != null && error.isReasonable()) {
                return Response.error(error);
            }

        } catch (Exception ignored) {
            // In seems there is no error here
        }

        // no error – parse result
        try {
            T result = serializer.read(mResponseType, responseString);
            return Response.success(result, HttpHeaderParser.parseCacheHeaders(response));

        } catch (Exception e) {
            return Response.error(new VolleyError(e));
        }
    }
}

Всё бы хорошо, но что это за ApiError такой? А вот же он:

ApiError.java

public abstract class ApiError extends VolleyError {
    public abstract boolean isReasonable();
}

Это базовый класс для ошибки, возвращаемой API. Унаследован от VolleyError, чтобы можно было возвращать его в Response.error. Для конкретных реализаций мы будем указывать поля и аннотации к ним точно так же, как и остальным классам объектной модели, согласно установленному формату ошибки.

Метод isReasonable очень нужен и вот почему. Десериализатор при желании из любой входной строки сможет соорудить любой объект, не выбросив никакого исключения, но просто с пустыми полями. Вот и ApiError вполне способен образоваться даже из неошибочного ответа. Так что просто проверки на null недостаточно. Или другая история: в некоторых протоколах ошибку можно опознать только по какому-нибудь специальному id, который приходит всегда, но при успехе имеет значение 0, а при ошибке что-нибудь другое. Так что хорошо бы иметь возможность определить собственную логику проверки состоятельности ошибки.

Пример

Пример будет только для JSON-ответа, для XML ровно то же самое. Снова будем мучить наш поиск на IMDB, благо объектная модель готова, и ошибки приходят именно по описанной схеме. Например, если по запросу ничего не найдено, вернётся такое:

{"Response":"False","Error":"Movie not found!"}

Определяем класс для ошибки:

ImdbError.java

public class ImdbError extends ApiError {
    @SerializedName("Error")
    private String mErrorMessage;

    @Override
    public boolean isReasonable() {
        return !TextUtils.isEmpty(mErrorMessage);
    }

    @Override
    public String getMessage() {
        return mErrorMessage;
    }
}

Создаём и запускаем запрос:

String url = "http://www.omdbapi.com/?t=" + Uri.encode(getSearchString());

GsonWithErrorRequest<FilmDetails, ImdbError> request = new GsonWithErrorRequest<FilmDetails, ImdbError>(
    FilmDetails.class, ImdbError.class, Request.Method.GET, url,
    new Response.Listener<FilmDetails>() {
        @Override
        public void onResponse(FilmDetails data) {
            // отображение результата
        }
    },
    new Response.ErrorListener() {
        @Override
        public void onErrorResponse(VolleyError error) {
            // отображение ошибки
        }
    }
);

mRequestQueue.add(request);

Ошибки отловлены, лишнего писать не надо, жизнь прекрасна.

(Кстати, запрос тут немного другой. В прошлый раз мы указывали параметр i и искали по IMDB ID, а сейчас с помощью параметра t ищем по названию)

Итак

Скрестив Volley с Gson или Simple, можно значительно сократить время разработки. Но есть и минусы:

  • Дополнительные библиотеки увеличивают размер приложения. simple весит полмегабайта, gson — около 200 Кб
  • Reflection! По сравнению с нативными парсерами производительность хуже на порядок. Если сервер склонен присылать большую кучу данных, лучше написать парсер старым дедовским способом
  • Со сложно организованными протоколами также может не взлететь. К примеру, нередка ситуация, когда некое поле читается из нескольких ключей  JSON. Вроде этого:
    id = obj.has("id") ? obj.getLong("id") : obj.getLong("anotherId")
    

    Даже такая простая вещь уже будет вызывать сложности при автоматическом маппинге.

На этом моё допиливание инфраструктуры для Volley пока приостановлено.

Исходники

Исходники ко всей эпопее

Немного об организации кода.

  • ImdbSearchActivity — пример наследования от класса Request (про что шла речь в первой части)
  • ImdbFilmDetailsActivity — пример использования GsonRequest. Запускается по тыку на результат поиска в ImdbSearchActivity
  • WikiSearchActivity — пример использования SimpleXmlRequest.
  • ImdbSearchSingleItemActivity — пример использования GsonWithErrorRequest.

Комментариев нет: