Разрабатывая под 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 для активити, которые вы больше никогда не увидите.
UPD: Как правильно заметил пользователь bejibx, замечание о Activity.isFinishing() не совсем верно. Чтобы понять почему, лучше прочитать ветку комментариев.

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<E extends Enum<E>> implements JsonDeserializer<EnumSet<E>> {
	Class<E> enumClass;

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

	@Override
	public EnumSet<E> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
		long mask = json.getAsLong();
		EnumSet<E> 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<EnumSet<Access>>() {}).getType(), new EnumMaskConverter<>(Access.class));
Gson gson = gsonBuilder.create();


В результате:
Использование
class MyModel {
        @SerializedName("mask")
	public EnumSet<Access> 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<MonthSelector> getPriorityMonthSelector();

        @GET("index.php?page[api]=years")
        Observable<Month> 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<Data> 
    just(@Header("auth-token") String authToken);
    //           ^шлём auth-token

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

    @GET("index.php?page[api]=gold")
    Observable<Data> 
    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, популярных библиотеках и т.д., и т.п.

Комментарии (22)


  1. Artem_007
    09.04.2016 10:02

    По воле случая на первую работу попал на андроид вместо энтерпрайза. Ох… Теперь иначе отношусь к стереотипу о кривых приложениях на андроиде.


  1. drhouse
    09.04.2016 15:17

    Очень полезно. Спасибо.


  1. FirsofMaxim
    09.04.2016 21:16

    Спасибо. Interceptor — некая прослойка которая может вставлять стандартные хедеры (и не только) во все запросы в Retrofit/okHTTP я правильно понял?


    1. Yoto
      09.04.2016 21:45

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


  1. Kamerad
    10.04.2016 06:29

    Первый пункт не понял вообще, довольно часто пользуюсь TableLayout и в приведенном примере он подходит идеально. Будет у нас что-то вроде этого



    Если кто-то в таких ситуациях использует RelativeLayout или nested LinearLayouts, то он тот еще индус.


    1. Yoto
      10.04.2016 06:41

      Да, вы правы. Не совсем удачно подобрал картинку. На деле бывает нужно уместить сразу 2-3 поля в одну строку (типа такого или такого). Насколько мне известно, в TableLayout этого сделать нельзя (кстати, тут же и советуют RelativeLayout и LinearLayout от которых вы отреклись. Подобные советы вижу довольно часто и сам когда начинал использовал именно LinearLayout'ы)


  1. basnopisets
    10.04.2016 14:22

    В чем проблема в макет с webView добавить еще прогрессбар? Переопределяю вызовы `onPageStarted()` и `onPageFinished` у WebViewClient и соответственно показываю/скрываю прогресс


    1. ookami_kb
      10.04.2016 14:43
      +1

      Да хотя бы в том, что:

      When onPageFinished() is called, the rendering picture may not be updated yet.

      Сам долго воевал с этими нерабочими и deprecated методами. В итоге таки вступил в «клуб элитных мазохистов».


    1. Yoto
      10.04.2016 14:43

      onPageFinished() может сработать раньше, чем контент действительно доступен. В соответствующем пункте я оставил ссылки на stackoverflow с обсуждением этого вопроса. Вот один из тамошних комментариев.


  1. ArkadyGamza
    10.04.2016 16:43

    Хочется добавить, что основной бонус от RecyclerView.Adapter.setHasStableIds(true) — это анимации изменений модели.
    Благодаря тому, что RecyclerView понимает какие айтемы добавились, какие удалились или переместились, оно может красиво эти изменения анимировать после notifyDataSetChanged.
    Вот тут есть пара гифок, где видно разницу при смене модели со стабильными ID и без.


    1. ArkadyGamza
      10.04.2016 17:29

      Ссылка не вставилась, вот линк на гифки https://github.com/ArkadyGamza/MasteringRecyclerView_StableIDs/issues/1


    1. DZVang
      11.04.2016 12:06

      RecyclerView и без этого флага может красиво анимироваться, если правильно ему об этом сообщать.


      1. ArkadyGamza
        11.04.2016 12:38

        Да, можно использовать notifyItemInserted/Moved/и т.д. Но это не всегда удобно. Например, если модель приходит с сервера.


        1. DZVang
          11.04.2016 12:41

          Не удобно, да. А как влияет то, что модель приходит с сервера? Позиция элемента же никак на это не завязана.


          1. ArkadyGamza
            12.04.2016 16:07
            +2

            Это просто как пример той ситуации, когда неудобно (мы не знаем что изменилось в модели). Да, конечно, мы можем сами посчитать разницу и сообщить RecyclerView через notifyItemSmth, но гораздо удобнее передать модель целиком и сказать notifyDataSetChanged. RecyclerView сделает всю работу по вычислению изменений (если есть stable IDs) и покажет эти изменения визуально.


  1. bejibx
    14.04.2016 16:43

    Не могли бы вы поподробнее пояснить по поводу isFinishing()?


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

    Предположим следующую ситуацию:
    Back Stack: Activity1 -> Activity2 -> [Activity3]
    Свернули приложение, дождались нехватки памяти, вернулись обратно, нажали кнопку назад, получили
    BackStack: Activity1 -> [Activity2]
    Для какой именно Activity я получу isFinishing() == false? Для Activity3?


    1. Yoto
      14.04.2016 20:49
      +1

      Изначально я собирался описать это отдельным кюветом, но потом решил оставить только Fragment.isRemoving(), а об этом упомянуть вскользь… зря видимо :)
      По поводу вашего примера. Я упомянул не кнопку back, а Up Navigation. Это иное. Оно работает по принципу Intent.FLAG_ACTIVITY_CLEAR_TOP. То есть, будет следующее:
      Юзер свернул приложение и сейчас для него верно: Back Stack: Activity1 -> Activity2 -> Activity3.
      Произошла нехватка памяти и Activity1, Activity2 и Activity3 получили Activity.onDestroy() с Activity.isFinishing() равное false.
      В обычной ситуации юзер просто возвращается в приложение, где воссоздается Activity3, затем нажимает кнопку back, из-за которой воссоздается Activity2 и уничтожается Activity3 с Activity.isFinishing() равное true. Однако это не наш случай.
      Activity3 имеет возможность при помощи Up Navigation вернуться к Activity1. В итоге, когда юзер вернется в приложение и нажмет на Up Navigation, будет:
      1. Воссоздано Activity3
      2. Уничтожено Activity3
      3. Воссоздано Activity1
      4. Вычищен stack
      Думаю, тут вы уже и сами догадались. Activity2 было утеряно, а последний его вызов Activity.onDestroy() был с Activity.isFinishing() равное false. Это и есть неприятная ситуация о которой я упомянул.


      1. bejibx
        14.04.2016 23:24
        -1

        Всё было бы так если бы Android при недостатке памяти уничтожал только Activity, однако при нехватке памяти Android убивает целиком процесс.


        1. Yoto
          15.04.2016 04:56
          +1

          Хм, возможно действительно это называется не так. Я столкнулся с этим конечно же не благодаря тому, что дожидался момента нехватки памяти. Я использовал опцию Don't Keep Activities в dev-настройках. Гугл говорит, что:

          Tells the system to destroy an activity as soon as it is stopped (as if Android had to reclaim memory). This is very useful for testing the onSaveInstanceState(Bundle) / onCreate(android.os.Bundle) code path, which would otherwise be difficult to force. Choosing this option will probably reveal a number of problems in your application due to not saving state. For more information about saving an activity's state, see the Activities document.

          «if Android had to reclaim memory» — возможно тут я недопонял, в каких именно случаях андроид хочет выгрузить лишь Activity, а не цельный App.


          1. bejibx
            15.04.2016 12:23
            +1

            «if Android had to reclaim memory» — возможно тут я недопонял, в каких именно случаях андроид хочет выгрузить лишь Activity, а не цельный App.

            Это одно из самых больших заблуждений среди разработчиков Android — что система может удалять из памяти отдельные Activity. Возникает оно, к сожалению из-за неточной информации в официальной документации, однако даже ведущие инженеры платформы утверждают обратное.


            1. Yoto
              15.04.2016 12:53
              +1

              Да это же заслуживает masterprice из всех кюветов андроида! Просто невероятно!
              Пожалуй, вставлю в статью ветку с этим рассуждением в таком случае, ибо просто с наскоку сказать нечто вроде «а вы знаете, документация врёт» — не выйдет.


              1. bejibx
                15.04.2016 13:56
                +1

                Хочу лишь уточнить, что всё вышесказанное актуально для ситуации нехватки памяти! Система всё ещё может уничтожать отдельные Activity, например если таск висит в фоне долгое время. Цитата из документации:


                If the user leaves a task for a long time, the system clears the task of all activities except the root activity. When the user returns to the task again, only the root activity is restored. The system behaves this way, because, after an extended amount of time, users likely have abandoned what they were doing before and are returning to the task to begin something new.

                Однако для уничтоженных таким образом Activity isFinishing() будет корректно возвращать true. Но даже здесь похоже документация привирает! Есть небезосновательные подозрения, что начиная с определённой версии Android этот механизм больше не работает.