Кювети Android, Частина 2: SDK і Libraries

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

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


1. GridLayout не реагує на layout_weight

Ситуація
Іноді так трапляється, що звичайний спосіб створення об'єкта з купою не підходить:
Звичайна формаimage

Такою ситуацією може бути, наприклад, landscape відображення форми. Хотілося би в такому разі мати щось на зразок такого:
Форма для landscapeimage

Як зробити таке ж вирівнювання 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()

Що з ним не так?
Нестільки проблема, скільки брак документации. Ось що там написано:
Повертає 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

Коли/Де/Навіщо?
(Ситуація специфічна і безпосередньо до проблем На а не відноситься, але в якості затравки перед наступним кюветом вирішив додати)
У відповідь на один з 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")
Вами<MonthSelector> getPriorityMonthSelector();

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

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

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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.