Кювети Android, Частина 1: SDK

Досить довгий час я ніяк не міг зрозуміти, в чому ж різниця між «бібліотекою» та «фреймворком». Ні-ні, я вмів читати, і гуглити, але до мене все ніяк не доходив зміст цих понять. Почавши ж програмувати під андроїд, я нарешті зрозумів, що означають слова «бібліотеку використовує програміст, але програміста використовує фреймворк».
У цій серії статті я хочу розповісти про проблеми, з якими мені довелося столнулся при розробці під android. Моєю метою є не надання будь-яких убер-рішень наведених проблем, а лише інформування про те, з якими проблемами може зіткнутися той, хто посягне на святий грааль Android SDK. Не думаю, що суворі синьйори відкриють для себе Америку, але як мовиться: «повторення — мати вчення».
image


1. Dismiss DatePickerDialog викликає обробник OnDateSetListener

Ситуація
Досить неприємна проблема для початківця. Особливо якщо ви розраховуєте, що SDK працює як годинник.
У свій час довелося повозитися, щоб зрозуміти, в чому справа. Проблема ускладнилася тим, що після установки часу не було ніякої зворотного зв'язку в додатку (на екрані новий час не відображався). Всі дані заносилися в об'єкт, який зберігався в БД і через кілька екранів читався назад.
Нескладно уявити, звідки починався дебаг — з екрана відображення (т. к. для тестів використовувалася дата Date.now() — це внесло додатковий конфуз), а потім по ланцюжку.

Рішення
Насправді в Lollipop баг був усунутий, проте кого це влаштовує? AppCompat додавати гугл фікс не планує, тому потрібен обхідний шлях. І він є — скопіювали весь файл цілком і понеслать. Інформацію про реалізації можна прочитати на stackoverflow.
DatePickerDialogFragment
/*
* Copyright 2012 David Cesarino de Sousa
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.davidcesarino.android.common.ui;

import android.annotation.TargetApi;
import android.app.Activity;
import android.app.DatePickerDialog;
import android.app.DatePickerDialog.OnDateSetListener;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Build;
import android.os.Bundle;
import android.support.v4.app.DialogFragment;
import android.widget.DatePicker;

/**
* <p>Provides a usable {@link DatePickerDialog} wrapped as a {@link DialogFragment},
* using the compatibility package v4. Its main advantage is handling Issue 34833 
* automatically for you.</p>
* 
* <p>Current implementation (because I wanted that way =) ):</p>
* 
* <ul>
* <li>Only two buttons, a {@code BUTTON_POSITIVE} and a {@code BUTTON_NEGATIVE}.
* <li>Buttons labeled from {@code android.R.string.ok} and {@code android.R.string.cancel}.
* </ul>
* 
* <p><strong>Usage sample:</strong></p>
* 
* <pre>class YourActivity extends Activity implements OnDateSetListener
* 
* // ...
* 
* Bundle b = new Bundle();
* b.putInt(DatePickerDialogFragment.YEAR, 2012);
* b.putInt(DatePickerDialogFragment.MONTH, 6);
* b.putInt(DatePickerDialogFragment.DATE, 17);
* DialogFragment picker = new DatePickerDialogFragment();
* picker.setArguments(b);
* picker.show(getActivity().getSupportFragmentManager(), "fragment_date_picker");</pre>
* 
* @author davidcesarino@gmail.com
* @version 2015.0904
* @see <a href="http://code.google.com/p/android/issues/detail?id=34833">Android Issue 34833</a>
* @see <a href="http://stackoverflow.com/q/11444238/489607"
* >Jelly Bean DatePickerDialog — there is a way to cancel?</a>
*
*/
public class DatePickerDialogFragment extends DialogFragment {

public static final String YEAR = "Year";
public static final String MONTH = "Month";
public static final String DATE = "Day";

private OnDateSetListener mListener;

@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
this.mListener = (OnDateSetListener) activity;
}

@Override
public void onDetach() {
this.mListener = null;
super.onDetach();
}

@TargetApi(11)
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Bundle b = getArguments();
int y = b.getInt(YEAR);
int m = b.getInt(MONTH);
int d = b.getInt(DATE);

// Jelly Bean introduced a bug in DatePickerDialog (and possibly 
// TimePickerDialog as well), and one of the possible solutions is 
// to postpone the creation of both the listener and the BUTTON_* .
// 
// Passing a null here won't harm because DatePickerDialog checks for a null
// whenever it reads the listener that was passed here. >>> This seems to be 
// true down to 1.5 / API 3, up to 4.1.1 / API 16. <<< No worries. For now.
//
// See my own question and answer, and details I included for the issue:
//
// http://stackoverflow.com/a/11493752/489607
// http://code.google.com/p/android/issues/detail?id=34833
//
// Of course, suggestions welcome.

final DatePickerDialog picker = new DatePickerDialog(getActivity(),
getConstructorListener(), y, m, d);

if (isAffectedVersion()) {
picker.setButton(DialogInterface.BUTTON_POSITIVE,
getActivity().getString(android.R.string.ok),
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
DatePicker dp = picker.getDatePicker();
mListener.onDateSet(dp,
dp.getYear(), dp.getMonth(), dp.getDayOfMonth());
}
});
picker.setButton(DialogInterface.BUTTON_NEGATIVE,
getActivity().getString(android.R.string.cancel),
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {}
});
}
return picker;
}

private static boolean isAffectedVersion() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP;
}

private OnDateSetListener getConstructorListener() {
return isAffectedVersion() ? null : mListener;
}
}



2. Кнопка вимагає подвійний клік перш ніж спрацювати

Ситуація
Ще один випадок з розряду «так як так!». Уявіть собі ситуацію, коли у відкритому діалоговому вікні ви заповнюєте дані в декількох EditText'ах, а потім натискаєте ОК. Що може піти не так? Ну наприклад кнопка OK може ігнорувати ваше натискання! Але тільки перше… і не завжди… і тільки раз в місяць.... І звичайно ж знову починається дебаг і дебаг, і дебаг…

Рішення
А рішення дуже просте. Потрібно знати про магії setFocusableInTouchMode() і що це взагалі таке. Багато хто з нас не приділяють належної уваги властивості focusable. Дійсно, воно відчувається саме по собі і в більшості своїй веде себе як слід. І ось тут нас і ловлять:
  • Коли користувач клікає по кнопці, він переміщає фокус на неї.
  • Коли користувач клікає по екрану, але не за якого-небудь елементу — фокус передається на екран, тобто розсіюється, тобто фокус переходить в режим Touch Mode або в режим відсутність фокусу.
Просто? Не тут то було, завжди є винятки. У деяких випадках, фокус може бути присутнім і в цьому режимі (який, повторюся, називається «режим відсутність фокусу»). Найяскравіший приклад — EditText. Така поведінка необхідно для можливості одночасного взаємодії і клавіатури, і EditText'а. Інакше б написати щось не вийшло.
У підсумку, рішенням є focusableInTouchMode=true для кнопки. Рішення виглядає простим, але коли не знаєш, з чого почати, все знаходить інші фарби. Більш детально можна почитати на android-developers.blogspot.ru.

3. Bundle.putParcelable() — не завжди серіалізація

Ситуація
Діалогове вікно, є актівіті. Ви, як бравий товариш, вирішуєте передати свій об'єкт класу VeryComplexModel діалогове вікно, щоб здійснити над ним які-небудь дії (наприклад, редагування), а потім повернути назад, щоб актівіті зберегти в БД нову версію.
І знову ж магія відбувається під час закриття діалогу. Здавалося б, локальна копія об'єкт повинна залишитися старою, але немає. Вона змінилася.

Рішення
Вся справа в неправильному уявленні механізмів у Bundle'а. В моєму розумінні, Bundle, як і всякі Serializable та JSONObject — завжди створюють об'єкт із нуля, якщо зробити послідовно serialize() та deserialize(). У всякому разі, так я думав. Однак чи то міркування оптимізацій, чи то з якоїсь іншої причини, Bundle може нести в собі вказівник на ваш об'єкт без серіалізації. Звідси і зміна даних у діалоговому вікні незважаючи на dismiss. Очікувалося, що постраждає лише копія, але Android SDK розпорядився інакше.

4. getFragmentManager() всередині фрагменту

Ситуація
Мабуть, це найпоширеніша проблема серед початківців (і не тільки!) програмістів. Варто трохи розслабитися і годинку-інший дебага забезпечений.
FragmentManager використовується для управління фрагментами всередині актівіті, а також для управління вкладеними фрагментами всередині інших фрагментів.
У актівіті є метод getFragmentManager() фрагмента є метод getFragmentManger() — викликав, використав, працює… чи ні?.. Щось знову зламалося. SDK, пожалій!

Рішення
На жаль, злий жарт тут грають дві речі:
  • очікування того, що активують і фрагменти працюю приблизно однаково
  • ігнорування принципу RTFM
Якщо глянути в документацію, то відразу видно, що getFragmentManager() для фрагмента повертає… FragmentManager батьків! Щоб отримати нормальний, працює так, як очікується, FragmentManager необхідно використовувати getChildFragmentManager().

5. Модифікація Drawable runtime

Ситуація
З даною проблемою мені довелося зіткнутися під час створення різнокольорових background'ів для різних об'єктів. Уявити подібне можна на прикладі чату, де bubble (міхур повідомлення) для вашого повідомлення пофарбований у сірий, а для співрозмовника в червоний.
Звичайно, найпростішим рішенням буде створити 2 незалежних ресурсу. Але що якщо це ваш домашній проект «на раз», а художник з вас як балерина? Тут і приходять на допомогу різні методи виду setColorFilter(). Вірно?.. Немає.

Рішення
Просто так взявши так застосувавши setColorFilter() на якому-небудь R. drawable.bg_bubble, буде проведено зміну над усіма bg_bubble на районі у проекті.
Справа в тому, що якщо користувач бачить 100 повідомлень з bg_bubble, це не означає, що є 100 копій цього ресурсу. Це просто не має сенсу. В оптимізаційних цілях, копія зберігається лише одна і тому зміни в bg_bubble торкнуться відразу всіх повідомлень.
Найпростіше рішення — створити локальну копію:
Drawable clone = drawable.getConstantState().newDrawable();

Більш докладно суть проблеми описується тут на іншому прикладі.

6. Вирівнювання TextView з TextView, незважаючи на різні розміри/шрифти

Що? Нічого не зрозумівimage

Без зайвих слів, відразу посилання Watch That Baseline Alignment. А то і две.
Сказати про baseline рівним рахунком нічого, але коли знаєш, що воно існує. А от якщо не знаєш… ось тоді починається веселощі з padding & margin. Особисто бачив подібне. Навіть «звіром» такий код язик не повертається назвати.

7. Spinner без дефолтного значення

Ситуація
Є Spinner для якого потрібно додати «захист від дурня» у вигляді підказки, яка не є значенням самого спиннер.
Spinner з підказкоюimage

Можна подумати, що метод Spinner.setPrompt() робить справу, але не тут то було. Працює він тільки для діалогових вікон, та й не на всіх версіях андроїда відображається. Що ж робити?

Рішення
«А нічого. Живи з цим» © Android SDK.
Як правило, необхідний хак. Перше, що приходить в голову: додати 1 елемент з описом початку масиву. Однак це погане рішення. Мало того, що «підказку» тепер можна буде вибрати в якості значення Spinner'а, так ще і починаються проблеми при використовувати R. array / CursorAdapter.
І як завжди, найкращий джерело хаків на stackoverflow.
NothingSelectedSpinnerAdapter
import android.content.Context;
import android.database.DataSetObserver;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ListAdapter;
import android.widget.SpinnerAdapter;

/**
* Decorator Adapter to allow a Spinner to show a 'Nothing Selected...' initially
* displayed instead of the first choice in the Adapter.
*/
public class NothingSelectedSpinnerAdapter implements SpinnerAdapter, ListAdapter {

protected static final int EXTRA = 1;
protected SpinnerAdapter adapter;
protected Context context;
protected int nothingSelectedLayout;
protected int nothingSelectedDropdownLayout;
protected LayoutInflater layoutInflater;

/**
* Use this constructor to have NO 'Select One...' item, instead use
* the standard prompt or nothing at all.
* @param spinnerAdapter wrapped Adapter.
* @param nothingSelectedLayout layout for nothing selected, perhaps
* you want text grayed out like a prompt...
* @param context
*/
public NothingSelectedSpinnerAdapter(
SpinnerAdapter spinnerAdapter,
int nothingSelectedLayout, Context context) {

this(spinnerAdapter, nothingSelectedLayout, -1, context);
}

/**
* Use this constructor to Define your 'Select One...' layout as the first
* row in the returned choices.
* If you do this, you probably don't want a prompt on your spinner or it'll
* have two 'Select' rows.
* @param spinnerAdapter wrapped Adapter. Should probably return false for isEnabled(0)
* @param nothingSelectedLayout layout for nothing selected, perhaps you want
* text grayed out like a prompt...
* @param nothingSelectedDropdownLayout layout for your 'Select an Item...' in
* the dropdown.
* @param context
*/
public NothingSelectedSpinnerAdapter(SpinnerAdapter spinnerAdapter,
int nothingSelectedLayout, int nothingSelectedDropdownLayout, Context context) {
this.adapter = spinnerAdapter;
this.context = context;
this.nothingSelectedLayout = nothingSelectedLayout;
this.nothingSelectedDropdownLayout = nothingSelectedDropdownLayout;
layoutInflater = LayoutInflater.from(context);
}

@Override
public final View getView(int position, View convertView, ViewGroup parent) {
// This provides the View for the Selected Item in the Spinner, not
// the dropdown (unless dropdownView is not set).
if (position == 0) {
return getNothingSelectedView(parent);
}
return adapter.getView(position - EXTRA, null, parent); // Could re-use
// the convertView if possible.
}

/**
* View to show in Spinner with Nothing Selected
* Override this to do something dynamic... e.g. "37 Options Found"
* @param parent
* @return
*/
protected View getNothingSelectedView(ViewGroup parent) {
return layoutInflater.inflate(nothingSelectedLayout, parent, false);
}

@Override
public View getDropDownView(int position, View convertView, ViewGroup parent) {
// Android BUG! http://code.google.com/p/android/issues/detail?id=17128 -
// Spinner does not support multiple view types
if (position == 0) {
return nothingSelectedDropdownLayout == -1 ?
new View(context) :
getNothingSelectedDropdownView(parent);
}

// Could re-use the convertView if possible, use setTag...
return adapter.getDropDownView(position - EXTRA, null, parent);
}

/**
* Override this to do something dynamic... For example, "Pick your favorite
* of these 37".
* @param parent
* @return
*/
protected View getNothingSelectedDropdownView(ViewGroup parent) {
return layoutInflater.inflate(nothingSelectedDropdownLayout, parent, false);
}

@Override
public int getCount() {
int count = adapter.getCount();
return count == 0 ? 0 : count + EXTRA;
}

@Override
public Object getItem(int position) {
return position == 0 ? null : adapter.getItem(position - EXTRA);
}

@Override
public int getItemViewType(int position) {
return 0;
}

@Override
public int getViewTypeCount() {
return 1;
}

@Override
public long getItemId(int position) {
return position >= EXTRA ? adapter.getItemId(position - EXTRA) : position - EXTRA;
}

@Override
public boolean hasStableIds() {
return adapter.hasStableIds();
}

@Override
public boolean isEmpty() {
return adapter.isEmpty();
}

@Override
public void registerDataSetObserver(DataSetObserver observer) {
adapter.registerDataSetObserver(observer);
}

@Override
public void unregisterDataSetObserver(DataSetObserver observer) {
adapter.unregisterDataSetObserver(observer);
}

@Override
public boolean areAllItemsEnabled() {
return false;
}

@Override
public boolean isEnabled(int position) {
return position != 0; // don't allow the 'nothing selected'
// item to be picked.
}

}


Приклад
Spinner spinner = (Spinner) findViewById(R. id.spinner);
ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this, R. array.planets_array, android.R.layout.simple_spinner_item);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinner.setPrompt("Select your favorite Planet!");

spinner.setAdapter(
new NothingSelectedSpinnerAdapter(
adapter,
R. layout.contact_spinner_row_nothing_selected,
// R. layout.contact_spinner_nothing_selected_dropdown, // Optional
this));



8. Решето під назвою SupportMapFragment

Ситуація
По волі випадку, потрібно було мати справу з картами… благо, було це на один раз, але навіть цього вистачило, щоб облисіти трохи повиривати волосся через стрес.
Як завжди вважаючи, що SDK абсолютно безбажно і працює як годинник, я схопив мем-лик. На той момент я зрадів нещодавно поставленому LeakCanary, похвалив його подумки і заходився вивчати логи. Вони були дивними (наприклад, була рядок com.google.android.gms.location.internal.zzk), але говорили про те, що витікає мій MapFragment. Що ж я знайшов у результаті після години вивчення своїх джерел вздовж і впоперек? Ну, думаю відповідь і так зрозуміла.

насправді..
А насправді винна SDK і іже з нею. Каюсь, моя помилка, варто відразу звернути увагу на дивні логи, але якось не зрослося. Логи у LeakCanary часто не особливо зрозумілі, крім останніх рядків, де видно саме «свої» посилання, тому все інше було благополучно проігноровано. Особисто я зіткнувся з такими проблемами, які, до речі, схопив за один раз:
  1. Витік
  2. OutOfMemoryError №1
  3. OutOfMemoryError №2
  4. BadParcelableException
Особливо неприємний останній баг. Вперше використовуючи бібліотеку Parceler, я вирішив, що баг у ній або у тому, що я неправильно її готую використовую. Ідея про те, що баг виник через SupportMapFragment у мене ну ніяк не виникала — погодьтеся, причому тут карти і BadParcelableException, який виникає коли ти особисто додаєш і витягаєш якісь дані з Bundle? І так я знову витратив кілька годин, вивчаючи исходники Parceler та Bundle.putParcelable() як божевільний.

Висновок

Незважаючи на всі наведені тут проблеми і дивацтва, а також загальний тон викладу статті, мені все одно подобається програмувати під андроїд. Так, іноді SDK дає ляпаса-іншу, але в цілому воно надає багато інших, добре реалізованих (!), можливостей. Чого тільки варті нові Toolbar, NavigationDrawer , Behavior? Що і говорити про Shared Element Activity Transition!
Статтею я хотів домогтися лише одного — щоб якщо ви ще не стикалися з подібними проблемами, зіткнувшись, відразу лізли в гугл, а не сиділи цілу годину поуши в дебаге. Я планую написати ще 2 частини «кюветів»: SDK+libraries і RxJava, але, звичайно ж, все залежить від результатів цієї частини.
Для новачків, так і для програмістів средняков, вкрай рекомендую почитати на дозвіллі CodePath Android Cliffnotes. Воно не зачіпає саме «кювети» (хоча і не без цього), але наводить дуже детальний опис всього SDK.

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

0 коментарів

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