Кюветы Android, Часть 2: SDK и Libraries

Разрабатывая под Android, всегда нужно быть начеку. Шаг влево / шаг вправо — и вот прошел ещё один час за дебагом. Кюветы могут быть какие угодно: начиная от обычных багов в SDK и заканчивая неочевидными именами методов с контекстно зависимым результатом (да-да, Fragment.getFragmentManager (), это я о тебе).

В предыдущей статье были описаны кюветы «на поверхности» SDK, в которые угодить очень легко. На этот же раз кюветы будут поглубже, помудрёнее и поспецифичнее. Также будет несколько моментов, связанных с Retrofit 2 & Gson.
image

1. GridLayout не реагирует на layout_weight


Ситуация
Иногда так случается, что обычный способ создания объекта с кучей не подходит:

Обычная форма
image


Такой ситуацией может быть, например, landscape отображение формы. Хотелось бы в таком случае иметь нечто вроде такого:

Форма для landscape
image


Как сделать такое же выравнивание 50 на 50? Существует несколько основных подходов:
Однако они все имеют свои недостатки:

  • Обилие LinearLayout приводит к монструозности xml’ки, а она приводит к смерте котиков.
  • RelativeLayout усложняет изменение в будущем (поменять местами несколько строк в форме или добавить разделитель будет той ещё задачкой. Про View.setVisibility (View.GONE) я и вовсе молчу).
  • Ну, а TableLayout вообще никто не использует… или используют, но редко. Я таких людей не знаю.


Более того, довольно часто необходимо использовать магию числа 0dp & weight=1, чтобы добиться гибкого дизайна. Ни TableLayout, ни RelativeLayout тут вам не помогут. При первой же попытке использовать что-то вроде TextView.setEllipsize (), начнутся проблемы и боль.
И тут вы наверное подметили, что я пропустил ещё один элемент. Казалось бы, на помощь приходит GridLayout, но и тот оказывается бесполезен из-за того, что не поддерживает свойство layout_weight. Так что же делать? Решение
До некоторых пор делать было действительно нечего — либо мучайся с RelativeLayout, либо применяй LinearLayout, либо заполняй всё программный путем (для особо извращенных).
Однако с 21 версии GridLayout наконец-то начал поддерживать свойство layout_weight и, что самое важное, это изменение было добавлено в AppCompat в виде android.support.v7.widget.GridLayout!
К моменту когда я узнал об этом (и вообще о том, что обычный GridLayout чхать хотел на мой weight), я потратил по меньшей мере неделю, пытаясь понять почему мой layout поплыл вправо (как здесь). Пожалуй, это одно из самых важных нововведений, которое, почему-то, осталось без должного внимания. К счастью, ответы на stackoverflow (1, 2) уже начинают дописывать.
Также советую заглянуть на страничку к новым PercentRelativeLayout и PercentFrameLayout — это действительно бомба. Название говорит само за себя и позволяет сделать крайне адаптивный дизайн. iOS’ники оценят. И ах да, оно есть в AppCompat.

2. Fragment.isRemoving () и Acitivity.isFinishing () равны?


Ситуация
Как-то раз захотел я написать свой PresenterManager в виде синглтона (привет от MVP). Чтобы вовремя удалять Presenter'ов, я использовал Activity.isFinishing (), собирая id Presenter'ов фрагментов в активити и удаляя их вместе с ним. Естественно, такой способ плохо работал в случае с NavigationView — фрагменты менялись через FragmentTransaction.replace (), Presenter'ы копились и всё шло коту под хвост.
Погуглив смальца, был найден метод Fragment.isRemoving (), который вроде бы делает то же самое, но для фрагментов. Я переписал код PresenterManager'а и был доволен. Конец…Решение
… наступил моей спокойной жизни, когда я пытался заставить это работать. Честно, я пытался и так, и эдак, но поведение этого метода вкорне отличается от Activity.isFinishing (). Гугл был неправ. Если у вас когда-нибудь возникнет подобная задача, подумайте трижды прежде чем использовать Fragment.isRemoving (). Я серьезно. Особенно уделите внимание логам при повороте экрана.

Кстати с Acitivity.isFinishing () тоже не всё так гладко: сверните приложение с >1 активити в стэке, дождитесь ситуации нехватки памяти, вернитесь обратно и воспользуйтесь Up Navigation и *вуаля*!… Это был простой рецепт того, как поиметь Activity.isFinishing () == false для активити, которые вы больше никогда не увидите.

3. Header/Footer в RecyclerView


Ситуация
Обычная задача при реализации пагинации — необходимо отображать ProgressBar на время загрузки новых данных.
В отличие от ListView, RecyclerView обладает куда большими возможностями — чего только стоит RecyclerView.Adapter.notifyItemRangeInserted () по сравнению с той самой головной болью ListView.
Однако попробовав использовать его в проекте вместо ListView, сразу же натыкаешься на множество нюансов: где свойство ListView.setDivider ()? Где нечто вроде ListView.addHeaderView ()? Что ещё за RecyclerView.Adapter.getItemViewType () и т.д., и т.п.
Разобраться то со всей этой свалкой новой информации несложно, однако кое-что неприятное всё равно остается. Добавление Divider/Header заствляет писать тучи кода. Что уж и говорить о сложных layout'ах? Довеча доводилось делать RecyclerView с 4-мя различными Header'ами и Footer'ом с контроллами. Скажем так, опечаленным и удрученным я ходил очень долго.Решение
На самом деле всё не так плохо, если знать, что искать. Самая основная проблема RecyclerView (и оно же его основное преимущество) — с ним можно делать всё, что угодно. Нет практически никаких рамок. Отсюда и вытекает проблема: хочешь Header — сделай сам. Но к счастью, «сделай сам» уже сделали за нас другие, так что давайте пользоваться.
Типичные проблемы и их решения:

  • Заголовки для групп элементов (например, в словаре «А» будет являться заголовком для всех слов, начинающихся с этой буквы) — проще всего сделать через единственный item-layout, не добавляя 2-ой ненужный тип ViewHolder'а. Добавьте проверку на то, что текущий элемент ознаменует переход от одной буквы к другой и включите спрятанный в layout заголовок через View.VISIBLE.
  • Простой divider — копи-паст этого кода в проект. Никаких лишних махинаций. Работает через RecyclerView.addItemDecoration ()
  • Добавлените Header / Footer / Drag&Drop и т.д. — если делать ручками, то либо заводить новый тип на каждый новый ViewHolder (не советую), либо делать WrapperAdapter (куда приятнее). Но ещё лучше посмотреть тут и выбрать понравившуюся либу. Лично мне нравятся сразу две: FastAdapter и UltimateRecyclerView
  • Нужна пагинация, но лень возиться с Header / Footer для ProgressBar'ов — библиотека Paginate от одного из разработчиков твиттера.


Хотя для меня всё равно остается загадкой — почему нельзя было сделать какие-нибудь SimpleDivider / SimpleHeaderAdapter и т.д. сразу в SDK?

4. Ускорение с RecyclerView.Adapter.setHasStableIds ()


Что с ним не так?
Нестолько проблема, сколько недостаток документации. Вот что там написано:

Returns true if this adapter publishes a unique long value that can act as a key for the item at a given position in the data set. If that item is relocated in the data set, the ID returned for that item should be the same.


И тут люди делятся на два типа. Первые: всё ж ясно. Вторые: чо это вообще значит то?
Проблема в том, что даже если вы отнесли себя к первым людям, вас может поставить в тупик вопрос:, а зачем этот метод? Да-да, чтобы вернуть уникальный ID! Я знаю. Но зачем оно надо? И нет, ответ «гугл пишет, что так быстрее скроллиться будет!» меня не устроит.А вот в чём дело
Ускорение от RecyclerView.Adapter.setHasStableIds () действительно можно получить, но только в одном случае — если вы повсеместно используете RecyclerView.Adapter.notifyDataSetChanged () (а тут они соизволили написать, зачем нужны stable id). Если вы имеете статичные данные, то вам этот метод не даст ровным счетом ничего, а возможно даже и немного замедлит из-за внутренних проверок ID. Узнал я об этом только после чтения исходников, а чуть позже случайно наткнулся на эту статью.

5. WebView


Ситуация
Задача — получить html-текст от сервера и вывести его на экран. Текст сервером отдается в виде »& lt; html& gt;». Всё. Это вся задача. Сложно? Существует же WebView, который может отобразить html в пару строк. Да что там, даже TextView может это сделать! Раз-два и готово… да?… нет?… ну должно же?! Решение
К сожалению, тут всё не так гладко:

  • Начнём с того, что нет метода типа HtmlUtils.unescape () в Android SDK. Если хочешь »& lt;» превратить в »<", то самый простой способ (кроме прописывания regex'а ручками) — подключить apache с его StringUtils.unescapeHtml4().
  • Следующей проблемой будут артефакты при прокрутке. Совершенно внезапно (да, Android SDK?), WebView будет мигать черным цветом. Что делать — рассказывается тут и тут. Лично мне помогла только комбинация этих подходов.
  • И если вас ещё не удивило обилие проблем от столь простой задачи, то вот добивалочка: нужно отобразить ProgressBar, пока html-страничка не отрендерилась. И тут всё плохо. То есть реально плохо. Все представленые на stackoverflow решения работают через раз или не работаю вовсе (тык, тык). Единственный работающий доселе способ был с применением WebView.setPictureListener (), однако тот теперь объявлен deprecated и тут уже ничего не попишешь.
    В итоге, единственное, что можно посоветовать — отказаться от ProgressBar'а. Либо, если уж совсем-совсем-совсем приспичит — добавить его прямо в html-код, проверяя через javascript готовность страницы. Но это уже для клуба элитных мазахистов.

6. Gson: битовая маска в виде EnumSet


Когда/Где/Зачем?
(Ситуация специфична и напрямую к проблемам Android’а не относится, но в качестве затравки перед следующим кюветом решил добавить)
В ответ на один из api-запросов приходит битовая маска прав доступа в виде int'а. Нужно обрабатывать элементы этой маски.
Первое, что приходит в голову — int'овые константы и битовые операциями для проверок. Несомненно, оно всегда работает. Но что если хочется большего? Как насчет EnumSet?
«Без проблем» — ответит проггер-бородач и разобьет архитектуру моделей ещё на несколько уровней: POJO, Model, Entity, UiModel и чем ещё чёрт не шутит. Но если лень и хочется без доп. классов? Что тогда? Решение
Создаём нужный нам enum, позаботившись о «битовости» имён в @SerializedName:

enum Access
public enum Access {
    @SerializedName("1")
    CREATE,
    @SerializedName("2")
    READ;
    @SerializedName("4")
    UPDATE;
    @SerializedName("8")
    DELETE;
}



Определяем JsonDeserializer для десериализации из json в EnumSet:

EnumMaskConverter
public class EnumMaskConverter> implements JsonDeserializer> {
        Class enumClass;

        public EnumMaskConverter(Class enumClass) {
                this.enumClass = enumClass;
        }

        @Override
        public EnumSet deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
                long mask = json.getAsLong();
                EnumSet set = EnumSet.noneOf(enumClass);

                for (E bit : enumClass.getEnumConstants()) {
                        final String value = EnumUtils.GetSerializedNameValue(bit);
                        assert value != null;

                        long key = Integer.valueOf(value);
                        if ((mask & key) != 0) {
                                set.add(bit);
                        }
                }
                return set;
        }
}



И добавляем его в Gson:

GsonBuilder
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter((new TypeToken>() {}).getType(), new EnumMaskConverter<>(Access.class));
Gson gson = gsonBuilder.create();



В результате:

Использование
class MyModel {
        @SerializedName("mask")
        public EnumSet access;
}

/* ...some lines later... */

if (myModel.access.containsAll(EnumSet.of(Access.READ, Access.UPDATE, Access.DELETE))) { 
        /* do something really cool */ 
}


7. Retrofit: Enum в @GET запросе


Ситуация
Начнём с настройки. Gson формируется также, как и ранее. Retrofit создаётся вот так:

new Retrofit.Builder ()
retrofit = new Retrofit.Builder()
                .baseUrl(ApiConstants.API_ENDPOINT)
                .client(httpClient)
                .addConverterFactory(GsonConverterFactory.create(gson))
                .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
                .build();



А данные выглядят так:

enum Season
public enum Season {
    @SerializedName("3")
    AUTUMN,
    @SerializedName("1")
    SPRING;
}



Благодаря возможности Gson к прямому парсингу enum через обычный @SerializedName, стало возможным избавиться от необходимости создавать всякие дополнительные классы-прослойки. Все данные будут сразу идти из запросов в Model. Всё прекрасно:

Retrofit Service
public interface MonthApi {
        @GET("index.php?page[api]=selectors")
        Observable getPriorityMonthSelector();

        @GET("index.php?page[api]=years")
        Observable getFirstMonth(@Query("season") Season season);
}



Применение
class MonthSelector {
        @SerializedName("season")
        public Season season;
}

/* ...some mouses later... */
MonthSelector selector = monthApi.getPriorityMonthSelector();
Season season = selector.season;

/* ...some cats later... */
Month month = monthApi.getFirstMonth(season);



А теперь, уважаемые знатоки, внимание вопрос! Что пошло не так и почему оно не работает? Решение
Я специально опустил информацию о том, что именно здесь не работает. Дело в том, что если посмотреть в логи, то запрос monthApi.getFirstMonth (season) будет обработан, как index.php? page[api]=years&season_lookup=AUTUMN… «ээээ, что за дела?» — скажу я. А каков ваш ответ? Почему такой результат? Ещё не догадались? Тогда вы попали.
Когда я столкнулся с этой задачей, мне потребовалось несколько часов поисков в исходниках, чтобы понять одну вещь (или скорее даже вспомнить): да не используется Gson при отправке @GET / @POST и других подобных _запросов_ вообще! Ведь действительно, когда вы последний раз видели нечто вроде index.php? page[api]=years&season_lookup={a:123; b:321}? Это не имеет смысла. Retrofit 2 использует Gson только при конвертации Body, но никак не для самих запросов. В итоге? используется просто season.toString () — отсюда и результат.
Однако, если уж ооочень хочется (а я из таких) использовать enum с конвертацией через Gson в запросе, то вам сюда — ещё один конвертор, всё как всегда.

8. Retrofit: передача auth-token


И напоследок, хотелось бы сказать одну вещь тем, кто пишет так:

Любой Retrofit Service
public interface CoolApi {
    @GET("index.php?page[api]=need")
    Observable 
    just(@Header("auth-token") String authToken);
    //           ^шлём auth-token

    @GET("index.php?page[api]=more")
    Observable 
    not(@Header("auth-token") String authToken);
    //           ^шлём auth-token ещё раз

    @GET("index.php?page[api]=gold")
    Observable 
    doIt(@Header("auth-token") String authToken);
    //           ^шлём auth-token в 101ый раз!
}



Начните уже использовать Interceptor’оры! Я понимаю, что Retrofit использовать очень просто и поэтому никто не читает документацию, но когда 3 часа сидишь и вычищаешь код не только от auth-token, но и ото всяких специфических current_location, battery_level, busy_status — настигает великая печалька (не спрашивайте, зачем передавать battery_level в каждый запрос. Сам в шоке). Почитать об этом можно тут.

Вместо заключения


Что ж, на этот раз вышло куда больше текста, чем я планировал. Некоторые менее интересные кюветы пришлось выкинуть, другие же я решил оставил для следующего раза.
Вопреки посылу предыдущей части, в этот раз я старался заставить вас не «гуглить в первую очередь», а прежде всего подумать «а зачем я это делаю?». Иногда проблему создает не SDK или библиотека, а сам программист и, к сожалению, в этом случае всё куда плачевнее. Не стоит недооценивать выбранный инструментарий, как и не стоит переоценивать его.
В общем, если вам нравится андроид и/или вы планируете им заняться — всегда держите себя в курсе мировых трендов. Ну или поищите здесь более удобный для себя новостной ресурс. Там же вы можете найти много информации об Android SDK, популярных библиотеках и т.д., и т.п.

© Habrahabr.ru