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

    Если ваше приложение загружает данные из интернета, отображает в 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.
    Поделиться публикацией

    Комментарии 23

      +7
      Отличный макаронный код в ядре, а 64мс — прекрасная цифра! Порой бывает думаешь что в крупных компаниях код пишут как-то иначе, даже при приближении дедлайна.
        +3
        Исходный код Android OS вообще часто радует глаз. Посмотрите, советую. Особенно наследников класса View.
          +1
          А я больше люблю сам класс View. Еще ни разу до конца не дочитал )
          А вообще, качество кода местами удручает и читается плохо. Но он по крайне мере есть и можно в него залезть и увидеть, что там происходит и как это обойти.
          0
          в некоторых версиях android она 128)
            +2
            в 4.4 его начали рефакторить)
            +11
            Зачем создавать новый адаптер каждый раз при обновлении данных?
              0
              Из-за реализации адаптера секций другого пути не было
                +1
                А причем тут реализация секций? С ними работаю через getViewType в getView и выводят нужный шаблон.
                Или просто github.com/emilsjolander/StickyListHeaders
              +24
              1. Перед тем как обновлять адаптер необходимо вызвать метод
              adapterMain.notifyDataSetInvalidated();
              2. После окончания работы с адаптером:
              adapterMain.notifyDataSetChanged();
              3. Прочитайте про viewHolder pattern, да и вобще, ваш код адаптера — ужасен. См. пункт 5
              4. Цифра 64мс не случайна. Именно за этот временной интервал должны быть выполнены изменения выполняемые в UI потоке. В противном случае у пользователя создастся ощущение лага. Я не первый раз слышу эту цифру от Гугла.
              5. Посмотрите как нужно правильно делать адаптер с секциями, например тут: github.com/recoilme/freemp/blob/master/src/ru/recoilme/freeamp/player/AdpPlayer.java
              6. У гугла куча косяков, но в данном конкретном случае — косячите Вы.
                +2
                Цифра 64мс не случайна. Именно за этот временной интервал должны быть выполнены изменения выполняемые в UI потоке.

                В данном случае это не о том. Чтобы не было лага надо уложиться в 16мс (60fps). Тут это задержка между тачем и перекраской итема в pressed-state. Чтобы при скроле не было морганий итема в прессед и обратно.
                  0
                  Согласен. Но все таки 16 мс это совсем жесткач, для приложений требования менее строги: developer.android.com/training/articles/perf-anr.html, последний абзац.
                    0
                    16мс это про плавные анимации. Первый абзац.
                    http://developer.android.com/training/custom-views/optimizing-view.html
                      0
                      Чуть чуть поправлю, ANR все же не появится при 100ms
                      // Default input dispatching timeout if there is no focused application or paused window
                      // from which to determine an appropriate dispatching timeout.
                      const nsecs_t DEFAULT_INPUT_DISPATCHING_TIMEOUT = 5000 * 1000000LL; // 5 sec
                      
                      
                  –4
                  Код адаптера не мой, просто как-то нашел его в исходниках чужого проекта, он там нормально работал и я решил перетащить его себе
                    +23
                    image
                      –2
                      минусуют ребята из Mail.ru которые не используют чужие библиотеки и по хардкору пишут весь код сами?
                        +2
                        Использование чужих библиотек — это не то же самое, что и программирование методом копи-паста.
                      0
                      1. Мы не обновляем адаптер — мы устанавливаем новый адаптер, соответственно notifyDataSetChanged вызывать не у кого.
                      3. В этом адаптере ViewHolder не нужен, т.к. он не инфлейтит вьюшки
                        0
                        6. Это ошибка гугла т.к. список должен корректно менять свои адаптеры раз такая возможность есть
                          +2
                          По хорошему, такой возможности нет. Метод setAdapter у ListView ставить адаптер, а не меняет его. Нету метода replaceAdapter или swapAdapter. Адаптер на то и адаптер, что его надо поставить, а потом просто данные ему скармливать, и он будет ими манипулировать.
                            0
                            зачем методы replaceAdapter/swapAdapter? Например, класс TextView обходится одним методом setText и он не только устанавливает текст в первый раз, но и меняет в последующие
                              0
                              Верно, но TextView-то виджет, а адаптер нет. Это из семантики вещей происходит. Для того, чтобы манипулировать данными в адаптере есть специальные методы, такие как add, addAll, remove, clear и т.д в то время как адаптер должен быть одним. Это просто логично:)

                              Если ваш адаптер работает таким образом, что его необходимо постоянно пересоздавать, то советую взглянуть на другие альтернативы. Например, как писали выше, StickyListHeaders. Пользовался им, отлично написано и работает правильно.
                        +1
                        Если вы кликнули на ячейку, а в этот момент загрузились новые данные с сервера и установились в список, значит вы кликнули по новым данным…
                        Всегда проверяйте результат метода ...

                        Но самой по себе такой проверки не достаточно. Ведь если новые данные в той же ячейке кликабельны, то пользователь получит не то, на то что кликал — тоже плохо. То есть обязательно надо что-то еще — например, как и написано, устанавливать mDataChanged. Я бы в такой ситуации просто ставил Enabled=false на время загрузки новых данных — чтобы пользователь даже не видел анимацию при нажатии и не удивлялся, что ничего не произошло.

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое