Pull to refresh

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

Reading time 12 min
Views 27K
Довольно долгое время я никак не мог понять, в чём же разница между «библиотекой» и «фреймворком». Нет-нет, я умел и читать, и гуглить, но до меня всё никак не доходил смысл этих понятий. Начав же программировать под андроид, я наконец понял, что значат слова «библиотеку использует программист, но программиста использует фреймворк».
В этой серии статьей я хочу рассказать о проблемах, с которыми мне пришлось столнулся при разработке под андроид. Моей целью является не предоставление каких-либо убер-решений приведенных проблем, а лишь информирование о том, с какими проблемами может столкнуться тот, кто посягнет на святой грааль 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 — 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.

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.
Tags:
Hubs:
+23
Comments 21
Comments Comments 21

Articles