- PVSM.RU - https://www.pvsm.ru -

Кюветы Android, Часть 1: SDK

Довольно долгое время я никак не мог понять, в чём же разница между «библиотекой» и «фреймворком». Нет-нет, я умел и читать, и гуглить, но до меня всё никак не доходил смысл этих понятий. Начав же программировать под андроид, я наконец понял, что значат слова «библиотеку использует программист, но программиста использует фреймворк».
В этой серии статьей я хочу рассказать о проблемах, с которыми мне пришлось столнулся при разработке под андроид. Моей целью является не предоставление каких-либо убер-решений приведенных проблем, а лишь информирование о том, с какими проблемами может столкнуться тот, кто посягнет на святой грааль Android SDK. Не думаю, что суровые синьоры откроют для себя Америку, но как говорится: «повторение — мать учения».
image

1. Dismiss для DatePickerDialog вызывает обработчик OnDateSetListener

Ситуация

Довольно неприятная проблема для начинающего. Особенно если вы рассчитываете, что SDK работает как часы.
В своё время пришлось повозиться, чтобы понять, в чём дело. Проблема усугубилась тем, что после установки времени не было никакой обратной связи в приложении (на экране новое время не отображалось). Все данные сразу заносились в объект, который сохранялся в БД и спустя несколько экранов читался обратно.
Несложно представить, откуда начинался дебаг — с экрана отображения (т.к. для тестов использовалась дата Date.now() — это внесло дополнительный конфуз), а затем по цепочке.

Решение

На самом деле в Lollipop баг был устранен [1], однако кого [2] это устраивает? В AppCompat добавлять гугл фикс не планирует [3], поэтому нужен обходной путь. И он есть [4] — скопировали весь файл целиком и понеслать. Информацию о реализации можно прочитать на stackoverflow [5].

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 — 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;
    }
}

2. Кнопка требует двойной клик прежде чем сработать

Ситуация

Ещё один случай из разряда «да как так то!». Представьте себе ситуацию, когда в открытом диалоговом окне вы заполняете данные в нескольких EditText'ах, а затем нажимаете OK. Что может пойти не так? Ну например кнопка OK может игнорировать ваше нажатие! Но только первое… и не всегда… и только раз в месяц.... И конечно же опять начинается дебаг и дебаг, и дебаг…

Решение

А решение донельзя простое. Нужно знать о магии setFocusableInTouchMode() и что вообще это такое. Многие из нас не уделяют должного внимания свойству focusable. Действительно, оно чувствуется само по себе и в большинстве своём ведёт себя как следует. И вот тут то нас и ловят:

  • Когда юзер кликает по кнопке, он перемещает фокус на неё.
  • Когда юзер кликает по экрану, но не по какому-либо элементу — фокус передается на экран, то бишь рассеивается, то бишь фокус переходит в режим Touch Mode или в режим отсутствия фокуса.

Просто? Не тут то было, всегда есть исключения. В некоторых случаях, фокус может присутствовать и в этом режиме (который, повторюсь, называется «режим отсутствия фокуса»). Самый яркий пример — EditText. Такое поведение необходимо для возможности одновременного взаимодействия и клавиатуры, и EditText'а. Иначе бы написать что-то не вышло.
В итоге, решением является focusableInTouchMode=true для кнопки. Решение выглядит простым, но когда не знаешь, с чего начать, всё обретает иные краски. Более детально можно почитать на android-developers.blogspot.ru [6].

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

Ситуация

Есть диалоговое окно, есть активити. Вы, как бравый товарищ, решаете передать свой объект класса VeryComplexModel в диалоговое окно, чтобы проделать над ним какие-либо действия (например, редактирование), а затем вернуть обратно, чтобы в активити сохранить в БД новую версию.
И опять же магия происходит во время закрытия диалога. Казалось бы, локальная копия объект должна остаться старой, но нет. Она изменилась.

Решение

Всё дело в неверном представлении механизмов в Bundle'а. В моём понимании, Bundle, как и всякие Serializable и JSONObject — всегда создают объект с нуля, если сделать последовательно serialize() и deserialize(). Во всяком случае, так я думал. Однако то ли из соображения оптимизаций, то ли по какой-то другой причине, Bundle может нести в себе указатель на ваш объект без сериализации. Отсюда и изменение данных в диалоговом окне несмотря на dismiss. Ожидалось, что пострадает лишь копия, но Android SDK распорядился иначе. [7]

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

Ситуация

Пожалуй, это самая распространенная проблема среди начинающих (и не только!) программистов. Стоит немного расслабиться и часок-другой дебага обеспечен.
FragmentManager [8] используется для управления фрагментами внутри активити, а также для управления вложенными фрагментами внутри других фрагментов.
У активити есть метод getFragmentManager(), у фрагмента есть метод getFragmentManger() — вызвал, использовал, работает… или нет?.. Что-то опять сломалось. SDK, пожалей!

Решение

К сожалению, злую шутку здесь играют две вещи:

  • ожидание того, что активити и фрагменты работаю примерно одинаково
  • игнорирование принципа RTFM [9]

Если глянуть в документацию, то сразу видно, что getFragmentManager() [10]для фрагмента возвращает… FragmentManager родительского активити (не родителя, а именно активити!). Чтобы получить нормальный, работающий так, как ожидается, FragmentManager необходимо использовать getChildFragmentManager() [11].

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

Ситуация

С данной проблемой мне довелось столкнутся во время создания разноцветных 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].

6. Выравнивание TextView по TextView, невзирая на разные размеры/шрифты

Что? Ничего не понял

image

Без лишних слов, сразу ссылку — Watch That Baseline Alignment [15]. А то и две [16].
Сказать о baseline ровным счетом нечего, но только когда знаешь, что оно существует. А вот если не знаешь… вот тогда начинается веселье с padding & margin. Лично видел подобное. Даже «зверем» такой код язык не поворачивается назвать.

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

Ситуация

Есть Spinner для которого нужно добавить «защиту от дурака» в виде подсказки, которая не является значением самого спиннера.

Spinner с подсказкой

image

Можно подумать, что метод Spinner.setPrompt() [17] делает дело, но не тут то было [18]. Работает он только для диалоговых окон, да и не на всех версиях андроида отображается. Что же делать?

Решение

«А ничего. Живи с этим» (с) Android SDK.
Как обычно, необходим хак. Первое, что приходит в голову: добавить 1 элемент с описанием в начала массива. Однако это плохое решение. Мало того, что «подсказку» теперь можно будет выбрать в качестве значения Spinner'а, так ещё и начинаются проблемы при использовать R.array / CursorAdapter.
И как всегда, самый лучший источник хаков на stackoverflow [19].

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 [20], похвалил его мысленно и принялся изучать логи. Они были странными (например, была строка com.google.android.gms.location.internal.zzk), но говорили о том, что утекает мой MapFragment. Что же я нашел в итоге после часу изучения своих исходников вдоль и поперёк? Ну, думаю, ответ и так ясен.

На самом деле..

А на самом деле виновна SDK и еже с ней. Каюсь, моя ошибка, стоило сразу обратить внимание на странные логи, но как-то не срослось. Логи в LeakCanary часто не особо понятны, кроме последних строчек, где видны именно «свои» ссылки, поэтому всё остальное было благополучно проигнорировано. Лично я столкнулся со следующими проблемами, которые, кстати, схватил за один раз:

  1. Утечка [21]
  2. OutOfMemoryError №1 [22]
  3. OutOfMemoryError №2 [23]
  4. BadParcelableException [24]

Особенно неприятен был последний баг. Впервые используя библиотеку 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/