habrahabr

64 миллисекунды после нажатия

  • суббота, 21 июня 2014 г. в 03:10:35
http://habrahabr.ru/post/226897/

Если ваше приложение загружает данные из интернета, отображает в ListView и обрабатывает нажатия на ячейки, то можете продолжать читать. Это рассказ о том как можно закрашиться в течение 64 мс после клика на ячейку списка.

У нас был обычный список в котором было 2 типа ячеек: некликабельные категории и кликабельные ячейки

image
Random пикча с подкатегориями

Адаптер который мы использовали можно увидеть здесь:
github.com/siyusong/foodtruck-master-android/blob/master/src/com/foodtruckmaster/android/adapter/SeparatedListAdapter.java

Данные загружались с сервера, отображались в ListView, при нажатии на ячейку открывался отдельный экран с подробным описанием.
Для обработки нажатий использовали AdapterView.OnItemClickListener. Наши адаптеры в getItem возвращали объекты, которые передавались дальше на экраны детального описания.

Обработка нажатий делалась так:
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
    Description desc = parent.getItemAtPosition(position);
    DescriptionActivity.open(context, desc);
}


В crashlytics начали появляться крэши ClassCastException(String -> Description). Это означало что на некликабельные подзаголовки в списках все таки кликнули и вместо объекта Description мы получили String. На некликабельные ячейки можно кликнуть используя performItemClick, но такие методы мы не использовали и крэши были на всех экранах со списками и подзаголовками, хоть их было и немного.

Дальше мы будем копаться в исходниках 4.2.2
AbsListView, метод onTouchEvent
case MotionEvent.ACTION_UP: {
switch (mTouchMode) {
    case TOUCH_MODE_DOWN:
    case TOUCH_MODE_TAP:
    case TOUCH_MODE_DONE_WAITING:
        ...
        final AbsListView.PerformClick performClick = mPerformClick;
        ...
        if (mTouchMode == TOUCH_MODE_DOWN || mTouchMode == TOUCH_MODE_TAP) {
            ...
            if (mTouchModeReset != null) {
                removeCallbacks(mTouchModeReset);
            }
            mTouchModeReset = new Runnable() {
                @Override
                public void run() {
                    mTouchMode = TOUCH_MODE_REST;
                    child.setPressed(false);
                    setPressed(false);
                    if (!mDataChanged) {
                        performClick.run();
                    }
                }
            };
            if (!mDataChanged && mAdapter.isEnabled(motionPosition)) {
                ...
                postDelayed(mTouchModeReset,
                        ViewConfiguration.getPressedStateDuration());
            } 
            ...
            return true;
        } 
        ...
    }


В исходники android без пива лучше не лезть, видимо разработчики ос руководствовались тем же принципом.
Здесь видим что если мы кликнули на ячейку списка и она enabled, то вызываем PefrormClick через определенный интервал. В android 4.2.2 этот интервал 64 мс.

Так выглядит Runnable PerformClick
private class PerformClick extends WindowRunnnable implements Runnable {
    int mClickMotionPosition;

    public void run() {
        // The data has changed since we posted this action in the event queue,
        // bail out before bad things happen
        if (mDataChanged) return;

        final ListAdapter adapter = mAdapter;
        final int motionPosition = mClickMotionPosition;
        if (adapter != null && mItemCount > 0 &&
                motionPosition != INVALID_POSITION &&
                motionPosition < adapter.getCount() && sameWindow()) {
            final View view = getChildAt(motionPosition - mFirstPosition);
            // If there is no view, something bad happened (the view scrolled off the
            // screen, etc.) and we should cancel the click
            if (view != null) {
                performItemClick(view, motionPosition, adapter.getItemId(motionPosition));
            }
        }
    }
}


Этот runnable вызывает performItemClick, где уже вызывается наш OnItemClickListener. Видим, что если данные в адаптере поменялись, то ливаем. Проверяем границы адаптера и прочее. Самое интересное что если установить новый адаптер, а не поменять данные в старом, то mDataChanged будет равным false, еще стоит заметить что нет проверки на isEnabled ячейки.

Т.е. мы кликаем на ячейку, в течение 64 мс меняем адаптер, выполняется этот runnable и в итоге клик происходит не по тем данным, которые мы видели на телефоне, а по новым. Причем если в новом адаптере у ячейки isEnabled = false, то она все равно кликнется, onItemClickListener вызовется.

Так, в строке:
Description desc = parent.getItemAtPosition(position);

мы чудесным образом получали ClassCastException

Решение: очевидно это баг ос, и самое простое решение было бы установка флага mDataChanged в true, либо очистка очереди сообщений при смене адаптера.

Вывод:
Если вы кликнули на ячейку, а в этот момент загрузились новые данные с сервера и установились в список, значит вы кликнули по новым данным (если вы создавали адаптер заново).
Всегда проверяйте результат метода getItemAtPosition на null и на instanceof если у вас несколько типов ячеек и объектов item.