- PVSM.RU - https://www.pvsm.ru -
Довольно долгое время я никак не мог понять, в чём же разница между «библиотекой» и «фреймворком». Нет-нет, я умел и читать, и гуглить, но до меня всё никак не доходил смысл этих понятий. Начав же программировать под андроид, я наконец понял, что значат слова «библиотеку использует программист, но программиста использует фреймворк».
В этой серии статьей я хочу рассказать о проблемах, с которыми мне пришлось столнулся при разработке под андроид. Моей целью является не предоставление каких-либо убер-решений приведенных проблем, а лишь информирование о том, с какими проблемами может столкнуться тот, кто посягнет на святой грааль Android SDK. Не думаю, что суровые синьоры откроют для себя Америку, но как говорится: «повторение — мать учения».
Довольно неприятная проблема для начинающего. Особенно если вы рассчитываете, что SDK работает как часы.
В своё время пришлось повозиться, чтобы понять, в чём дело. Проблема усугубилась тем, что после установки времени не было никакой обратной связи в приложении (на экране новое время не отображалось). Все данные сразу заносились в объект, который сохранялся в БД и спустя несколько экранов читался обратно.
Несложно представить, откуда начинался дебаг — с экрана отображения (т.к. для тестов использовалась дата Date.now() — это внесло дополнительный конфуз), а затем по цепочке.
На самом деле в Lollipop баг был устранен [1], однако кого [2] это устраивает? В AppCompat добавлять гугл фикс не планирует [3], поэтому нужен обходной путь. И он есть [4] — скопировали весь файл целиком и понеслать. Информацию о реализации можно прочитать на stackoverflow [5].
/*
* 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 — is there 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;
}
}
Ещё один случай из разряда «да как так то!». Представьте себе ситуацию, когда в открытом диалоговом окне вы заполняете данные в нескольких EditText'ах, а затем нажимаете OK. Что может пойти не так? Ну например кнопка OK может игнорировать ваше нажатие! Но только первое… и не всегда… и только раз в месяц.... И конечно же опять начинается дебаг и дебаг, и дебаг…
А решение донельзя простое. Нужно знать о магии setFocusableInTouchMode() и что вообще это такое. Многие из нас не уделяют должного внимания свойству focusable. Действительно, оно чувствуется само по себе и в большинстве своём ведёт себя как следует. И вот тут то нас и ловят:
Просто? Не тут то было, всегда есть исключения. В некоторых случаях, фокус может присутствовать и в этом режиме (который, повторюсь, называется «режим отсутствия фокуса»). Самый яркий пример — EditText. Такое поведение необходимо для возможности одновременного взаимодействия и клавиатуры, и EditText'а. Иначе бы написать что-то не вышло.
В итоге, решением является focusableInTouchMode=true для кнопки. Решение выглядит простым, но когда не знаешь, с чего начать, всё обретает иные краски. Более детально можно почитать на android-developers.blogspot.ru [6].
Есть диалоговое окно, есть активити. Вы, как бравый товарищ, решаете передать свой объект класса VeryComplexModel в диалоговое окно, чтобы проделать над ним какие-либо действия (например, редактирование), а затем вернуть обратно, чтобы в активити сохранить в БД новую версию.
И опять же магия происходит во время закрытия диалога. Казалось бы, локальная копия объект должна остаться старой, но нет. Она изменилась.
Всё дело в неверном представлении механизмов в Bundle'а. В моём понимании, Bundle, как и всякие Serializable и JSONObject — всегда создают объект с нуля, если сделать последовательно serialize() и deserialize(). Во всяком случае, так я думал. Однако то ли из соображения оптимизаций, то ли по какой-то другой причине, Bundle может нести в себе указатель на ваш объект без сериализации. Отсюда и изменение данных в диалоговом окне несмотря на dismiss. Ожидалось, что пострадает лишь копия, но Android SDK распорядился иначе. [7]
Пожалуй, это самая распространенная проблема среди начинающих (и не только!) программистов. Стоит немного расслабиться и часок-другой дебага обеспечен.
FragmentManager [8] используется для управления фрагментами внутри активити, а также для управления вложенными фрагментами внутри других фрагментов.
У активити есть метод getFragmentManager(), у фрагмента есть метод getFragmentManger() — вызвал, использовал, работает… или нет?.. Что-то опять сломалось. SDK, пожалей!
К сожалению, злую шутку здесь играют две вещи:
Если глянуть в документацию, то сразу видно, что getFragmentManager() [10]для фрагмента возвращает… FragmentManager родительского активити (не родителя, а именно активити!). Чтобы получить нормальный, работающий так, как ожидается, FragmentManager необходимо использовать getChildFragmentManager() [11].
С данной проблемой мне довелось столкнутся во время создания разноцветных background'ов для разных объектов. Представить подобное можно на примере чата, где bubble (пузырь сообщения) для вашего сообщения окрашен в серый, а для собеседника в красный.
Конечно, самым простым решением будет создать 2 независимых ресурса. Но что если это ваш домашний проект «на раз», а художник из вас как балерина? Тут и приходят на помощь различные методы вида setColorFilter() [12]. Верно?.. Нет.
Просто так взяв да применив setColorFilter() [12] на каком-нибудь R.drawable.bg_bubble, будет произведено изменение над всеми bg_bubble на районе в проекте.
Дело в том, что если пользователь видит 100 сообщений с bg_bubble, это не значит, что имеется 100 копий этого ресурса. Это просто не имеет смысла. В оптимизационных целях, копия хранится лишь одна и поэтому изменения в bg_bubble коснутся сразу всех сообщений.
Простейшее решение — создать локальную копию [13]:
Drawable clone = drawable.getConstantState().newDrawable();
Более подробно суть проблемы описывается здесь на другом примере [14].
Без лишних слов, сразу ссылку — Watch That Baseline Alignment [15]. А то и две [16].
Сказать о baseline ровным счетом нечего, но только когда знаешь, что оно существует. А вот если не знаешь… вот тогда начинается веселье с padding & margin. Лично видел подобное. Даже «зверем» такой код язык не поворачивается назвать.
Есть Spinner для которого нужно добавить «защиту от дурака» в виде подсказки, которая не является значением самого спиннера.
Можно подумать, что метод Spinner.setPrompt() [17] делает дело, но не тут то было [18]. Работает он только для диалоговых окон, да и не на всех версиях андроида отображается. Что же делать?
«А ничего. Живи с этим» (с) Android SDK.
Как обычно, необходим хак. Первое, что приходит в голову: добавить 1 элемент с описанием в начала массива. Однако это плохое решение. Мало того, что «подсказку» теперь можно будет выбрать в качестве значения Spinner'а, так ещё и начинаются проблемы при использовать R.array / CursorAdapter.
И как всегда, самый лучший источник хаков на stackoverflow [19].
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));
По воле случая, нужно было иметь дело с картами… благо, было это на один раз, но даже этого хватило, чтобы облысеть чуть-чуть повырывать волосы из-за стресса.
Как всегда считая, что SDK абсолютно безбажно и работает как часы, я схватил мем-лик. На тот момент я обрадовался недавно поставленному LeakCanary [20], похвалил его мысленно и принялся изучать логи. Они были странными (например, была строка com.google.android.gms.location.internal.zzk), но говорили о том, что утекает мой MapFragment. Что же я нашел в итоге после часу изучения своих исходников вдоль и поперёк? Ну, думаю, ответ и так ясен.
А на самом деле виновна SDK и еже с ней. Каюсь, моя ошибка, стоило сразу обратить внимание на странные логи, но как-то не срослось. Логи в LeakCanary часто не особо понятны, кроме последних строчек, где видны именно «свои» ссылки, поэтому всё остальное было благополучно проигнорировано. Лично я столкнулся со следующими проблемами, которые, кстати, схватил за один раз:
Особенно неприятен был последний баг. Впервые используя библиотеку Parceler [25], я решил, что баг в ней или в том, что я неправильно её готовлю использую. Идея о том, что баг возник из-за SupportMapFragment у меня ну никак не возникала — согласитесь, причем тут карты и BadParcelableException, который возникает когда ты лично добавляешь и вытаскиваешь какие-то данные из Bundle? И так я снова потратил несколько часов, изучая исходники Parceler и Bundle.putParcelable() как умалишенный.
Несмотря на все приведенные здесь проблемы и странности, а также общий тон изложения статьи, мне всё равно нравится программировать под андроид. Да, иногда SDK даёт пощечину-другую, но в целом оно предоставляет много других, хорошо реализованных (!), возможностей. Чего только стоят новые Toolbar [26], NavigationDrawer [27]и Behavior [28]? Что и говорить о Shared Element Activity Transition [29]!
Статьей я хотел добиться лишь одного — чтобы если вы ещё не сталкивались с подобными проблемами, столкнувшись, сразу лезли в гугл, а не сидели битый час поуши в дебаге. Я планирую написать ещё 2 части «кюветов»: SDK+libraries и RxJava, но, конечно же, всё зависит от результатов этой части.
Для новичков, да и для программистов средняков, крайне рекомендую почитать на досуге CodePath Android Cliffnotes [30]. Оно не затрагивает именно «кюветы» (хотя и не без этого), но приводит очень детальное описание всего SDK.
Автор: Yoto
Источник [31]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/116133
Ссылки в тексте:
[1] баг был устранен: https://code.google.com/p/android/issues/detail?id=34833#c39
[2] кого: http://developer.android.com/intl/ru/about/dashboards/index.html#Platform
[3] AppCompat добавлять гугл фикс не планирует: https://code.google.com/p/android/issues/detail?id=34833#c41
[4] И он есть: https://gist.github.com/davidcesarino/ff7c1336e9daedd15e7f
[5] stackoverflow: http://stackoverflow.com/a/11493752/2653714
[6] android-developers.blogspot.ru: http://android-developers.blogspot.ru/2008/12/touch-mode.html
[7] Android SDK распорядился иначе.: http://stackoverflow.com/questions/30439108/bundle-putparcelable-getparcelable-pair-returns-same-object-instance
[8] FragmentManager: http://developer.android.com/intl/ru/guide/components/fragments.html#Managing
[9] RTFM : https://ru.wikipedia.org/wiki/RTFM_%28%D0%B7%D0%BD%D0%B0%D1%87%D0%B5%D0%BD%D0%B8%D1%8F%29
[10] getFragmentManager() : http://developer.android.com/intl/ru/reference/android/app/Fragment.html#getFragmentManager%28%29
[11] getChildFragmentManager(): http://developer.android.com/intl/ru/reference/android/app/Fragment.html#getChildFragmentManager%28%29
[12] setColorFilter(): http://developer.android.com/intl/ru/reference/android/graphics/drawable/Drawable.html#setColorFilter%28android.graphics.ColorFilter%29
[13] создать локальную копию: http://stackoverflow.com/questions/7979440/android-cloning-a-drawable-in-order-to-make-a-statelistdrawable-with-filters
[14] здесь на другом примере: http://www.curious-creature.com/2009/05/02/drawable-mutations/
[15] Watch That Baseline Alignment: https://possiblemobile.com/2013/10/shifty-baseline-alignment/
[16] две: http://stackoverflow.com/questions/21144227/whats-the-difference-between-alignbaseline-and-alignbottom-in-android
[17] Spinner.setPrompt(): http://developer.android.com/intl/ru/reference/android/widget/Spinner.html#setPrompt%28java.lang.CharSequence%29
[18] но не тут то было: http://stackoverflow.com/a/13329980/2653714
[19] stackoverflow: http://stackoverflow.com/questions/867518/how-to-make-an-android-spinner-with-initial-text-select-one?lq=1
[20] LeakCanary: https://github.com/square/leakcanary
[21] Утечка: https://code.google.com/p/gmaps-api-issues/issues/detail?id=8596
[22] OutOfMemoryError №1: https://code.google.com/p/gmaps-api-issues/issues/detail?id=5621
[23] OutOfMemoryError №2: https://code.google.com/p/gmaps-api-issues/issues/detail?id=7187
[24] BadParcelableException: https://code.google.com/p/gmaps-api-issues/issues/detail?id=6237
[25] Parceler: https://github.com/johncarl81/parceler
[26] Toolbar: http://developer.android.com/intl/ru/reference/android/widget/Toolbar.html
[27] NavigationDrawer : http://developer.android.com/intl/ru/training/implementing-navigation/nav-drawer.html
[28] Behavior: http://developer.android.com/intl/ru/reference/android/support/design/widget/CoordinatorLayout.Behavior.html
[29] Shared Element Activity Transition: https://guides.codepath.com/android/Shared-Element-Activity-Transition
[30] CodePath Android Cliffnotes: http://guides.codepath.com/android#navigation
[31] Источник: https://habrahabr.ru/post/279811/
Нажмите здесь для печати.