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

в 18:02, , рубрики: android, Android sdk, fragments lifecycle, gson, java, retrofit, разработка мобильных приложений, Разработка под android

Разрабатывая под 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<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, популярных библиотеках и т.д., и т.п.

Автор: Yoto

Источник

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


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