О чём молчит developer.android.com про RecyclerView?

Вопрос о жизненном цикле (life cycle) активности (activity) или фрагмента (fragment) андроид-приложения чрезвычайно важен для практикующего андроидчика (андроид-разработчика). Почему? Потому что порядок выполнения обратных вызовов всех методов, связанных с состоянием жизненного цикла (onCreate(), onStart() и т.д.), жёстко задан и неправильное его применение приведёт к неработоспособности приложения. При чём здесь жизненный цикл? — спросит внимательный хаброчитатель. Ведь в заголовке, вроде бы, речь не о нём? Отвечаю: между жизненным циклом активности и работой RecyclerView есть нечто общее — это НАЛИЧИЕ ЖЁСТКОГО ПОРЯДКА выполнения методов обратного вызова при использовании данного виджета, и, следовательно, необходимость ЕГО ПРАВИЛЬНО ПРИМЕНЯТЬ.

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

Минимальный адаптер для RecyclerView


Например. Есть такой адаптер списка со стандартным минимальным нополнением:

Листинг 1


public class RvCustomAdapter extends
    RecyclerView.Adapter<RvCustomAdapter.CustomViewHolder> {

    private final Frag1 frag;
    private final LayoutInflater lInflater;
    private ArrayList<JSONDataSet> dataSet;
    ... ... ...

    public RvCustomAdapter(final Frag1 fragment) {
        this.frag = fragment;
        this.lInflater = (LayoutInflater) fragment.getContext()
                .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        this.dataSet = new ArrayList<>();
    }

    ... ... ...

    @Override
    public CustomViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        // Создание нового вью
        View view = lInflater.inflate(R.layout.recycler_view_data_item, 
                                      parent, false);
        /**
        *  Здесь можно программно менять атрибуты 
        *  лэйаута (size, margins, paddings и др.)
        */
        RecyclerView.LayoutParams params = 
           (RecyclerView.LayoutParams) view.getLayoutParams();
        params.height = RecyclerView.LayoutParams.WRAP_CONTENT;
        view.setLayoutParams(params);
        return new CustomViewHolder(view);
    }

    // Заменяет контент отдельного view (вызывается layout manager-ом)
    @Override
    public void onBindViewHolder(@NonNull CustomViewHolder holder,
                                   int position) {
        holder.showData(position);
    }

    @Override
    public int getItemCount() {
        return dataSet.size();
    }
    /**
     *  Класс view holder-а с помощью которого мы получаем 
     *  ссылку на каждый элемент
     *  отдельного пункта списка и обрабатываем его нажатие
     */
    class CustomViewHolder extends RecyclerView.ViewHolder {
        ... ... ...
        @BindView(R.id.ll_Data)
        LinearLayout ll_Data;
        @BindView(R.id.cb_Data)
        CheckBox cb_Data;
        ... ... ...
        private JSONDataSet cur;

        CustomViewHolder(View itemView) {
            super(itemView);
            ButterKnife.bind(this, itemView);
        }

       /**
       *   Методы, размещающие данные в элементах списка
       *    и определяющие их реакцию на действия пользователя.
       */
       ... ... ...
}

В коде метода onBindViewHolder() адаптера нашего списка, элементы которого содержат чек-бокс (CheckBox), есть обращение к методу обработчика (holder'a), в котором считываются данные из подключенной к адаптеру коллекции и на их основании устанавливается его — чек-бокса — состояние, а также к различным элементам интерфейса подключаются необходимые слушатели (Listener):

Листинг 2


        void showData(final int position) {
            cur = dataSet.get(position);
            cb_Data.setChecked(cur.isChecked());
            ... ... ...
            cb_Data.setOnCheckedChangeListener(cb_DataOnCheckedChangeListener);
            ll_Data.setOnClickListener(ll_DataOnClickListener);
        }

        private OnClickListener ll_DataOnClickListener =
            new OnClickListener() {
                @Override
                public void onClick(View view) {
                    cur.setChecked(!cur.isChecked());
                    cb_Data.setChecked(cur.isChecked());
                }
            };

        private OnCheckedChangeListener cb_DataOnCheckedChangeListener =
            new OnCheckedChangeListener() {
                @Override
                public void onCheckedChanged(CompoundButton compoundButton,
                                             boolean checked) {
                     cur.setChecked(checked);
                     compoundButton.setChecked(checked);
                     setItemsColor(checked);
                     if (checked) {
                         if (...) {
                             (frag).addSelectedItemsCounter(cur);
                         } else {
                             cur.setChecked(!checked);
                             compoundButton.setChecked(!checked);
                             setItemsColor(!checked);
                             if (...) {
                                 createPrimaryDialog();
                             } else {
                                 createSecondaryDialog();
                             }
                         }
                     } else {
                         (frag).remSelectedItemsCounter(cur);
                     }
                 }
            };

Слушатели при установке флага и выполнении некоего условия изменяют данные в коллекции, а при его невыполнении выводят на экран или одно, или другое диалоговое окно.

Получается примерно так:


На Рис-1 — сформированный список. На Рис-2 — Отмеченный элемент списка. На Рис-3 — диалог, сообщающий о нарушении условия при отметке очередного элемента.

Для получения результата с Рис-1 менеджером разметки списка (LayoutManager'ом) выполняется такой порядок вызова необходимых функций:

Алгоритм 1


  1. Rv_Adapter.getItemCount() — проверяется количество элементов в коллекции;
  2. Rv_Adapter.onAttachedToRecyclerView( ) — адаптер подключается к виджету;
  3. Пока элементами списка не будет заполнено пространство экрана, отведённое под список выполняются следующие шаги алгоритма 2:

Алгоритм 2


  1. Rv_Adapter.onCreateViewHolder( ) — для каждого элемента коллекции создаётся свой обработчк;
  2. CustomViewHolder( ) — выполняется конструктор обработчика;
  3. Rv_Adapter.onBindViewHolder( ) — для каждого экземпляра запускается построитель вида (view);
  4. Rv_Adapter.onViewAttachedToWindow( ) — сформированный вид подключается к окну;

Всё замечательно! Если бы не «Но». Вернее НО!

Проблема


При прокрутке длинного списка, содержащего хотя бы пару десятков позиций, мы без всяких других действий будем получать сообщение с Рис-3.

Устранение проблемы


Причина в том, что при написании кода адаптера МЫ НЕ УЧЛИ ПОРЯДОК ВЫПОЛНЕНИЯ функций обратного вызова, перечисленных здесь и здесь при прокрутке. А он таков:

Алгоритм 3


  1. При скрытии за границей видимости каждого элемента списка для связанного с ним экземпляра обработчика выполняется метод Rv_Adapter.onViewDetachedFromWindow(), который отключает скрывшийся вид от окна;
  2. При появлении из-за границы видимости каждого нового элемента списка (itemView) для связанного с ним экземпляра обработчика (handler) выполняется Алгоритм 2;

Но это ещё не всё. При «поумолчательных» настройках менеджера разметки каждый отключенный от окна элемент списка не долго остаётся в очереди для быстрого доступа. Как только их там оказывается 2 — они перемещаются менеджером в очередь утилизированных экземпляров, что отмечается вызовом метода Rv_Adapter.onViewRecycled() для каждого утилизируемого пункта списка и наоборот.

По этому Алгоритм 3 на самом деле выглядит так:

Алгоритм 3'


//Признак направления прокрутки списка: прямое - true, обратное - false:
bool direction;
if(direction){
     /** 
    *   отключаем вью от окна и сдвигаем её в следующую 
    *    позицию прямой очереди отключенных вью
    *    (назовём её directDetachedViews) 
    */
    Rv_Adapter.onViewDetachedFromWindow(holder);
    /**
    * Если в прямой очереди отключенных от окна 
    * вью их более, чем max
    */
    if(directDetachedViews.size() > max) {
              /**
              *   то переносим связанный с вью обработчик (holder) 
              *     из хвоста прямой очереди отключенных
              *     в голову прямой очереди  утилизированных 
              *     (назовём её directRecycleredHolders)
              */
             Rv_Adapter.onViewRecycled(holder);
    }
    /** 
    *   Если позиция появляющегося элемента 
    *     (visiblePos) меньше объёма коллекции, то
    */
    if(visiblePos < Rv_Adapter.getItemCount()) {
        /** 
        *    Если в голове обратной очереди отключенных
        *    от окна вью (назовём её reverseDetachedViews) 
        *    отсутствует вью (itemView), соответствующее позиции 
        *    появляющегося элемента (назовём её visiblePos),
        */
        if(reverseDetachedViews.content(itemView)){
            /**
            *   то если в голове обратной очереди утилизированных
            *    обработчиков (назовём её reverseRecycleredHolders)
            *    элементов отсутствует  holder, соответствующий его позиции 
            *    в коллекции, равный visiblePos, то создаём его
            */
            Rv_Adapter.onCreateViewHolder(itemView) -> {
                holder = CustomViewHolder(itemView);
            };
        } else {
            /**
            *   иначе - извлекаем его из головы обратной очереди
            *    утилизированных обработчиков (reverseRecycleredHolders)
            */
            holder = reverseRecycleredHolders.getHolder(visiblePos);
        }
            /**
            *    и формируем вью на основе данных соответствующего 
            *    элемента коллекции
            */
        Rv_Adapter.onBindViewHolder(holder, visiblePos);
    } else {
        /**
       *     иначе - извлекаем его из обратной очереди 
       *     отключенных от окна вью (reverseDetachedViews)
       */
       holder = reverseDetachedViews.getHolder(visiblePos)
    }
    //и подключаем его к окну
    Rv_Adapter.onViewAttachedToWindow(holder);
} else {
    ... ... ... ... ...
}


Из приведённого Алгоритма 3' видно, что в случае пролистывания списка более, чем на max количество позиций вью в нём будут создаваться заново, для чего будет задействован метод Rv_Adapter.onBindViewHolder(holder, visiblePos), который будет повторять действия пользователя.

Вывод и рекомендация


Для того, чтобы избежать повторения операций в методе onBindViewHolder(holder, visiblePos) при скроллинге списка на количество позиции, большее, чем max необходимо:

  1. Дополнить элементы коллекции полем с признаком вытеснения связанного с ним вью в очередь утилизированных обработчиков, например bool recycled;
  2. Вставить в метод onViewRecycled(holder) инструкции по установке этого признака, например ....setRecycled(true);
  3. Вставить в метод onBindViewHolder(holder, visiblePos) проверку этого признака, например if(!handler.cur.isRecycled())...;
  4. Вставить в метод onViewAttachedToWindow(holder) инструкцию по снятию этого признака, например ....setRecycled(false);

Например, так:

Листинг 3


    @Override
    public void onViewRecycled(@NonNull CustomViewHolder holder) {
        super.onViewRecycled(holder);
        holder.cur.setRecycled(true);
    }

    @Override
    public void onBindViewHolder(@NonNull CustomViewHolder holder, int position) {
        if (!holder.cur.isRecycled()){
            ... ... ...
        }
    }

    @Override
    public void onViewAttachedToWindow(@NonNull CustomViewHolder holder) {
        super.onViewAttachedToWindow(holder);
        holder.cur.setRecycled(false);
    }
Поделиться публикацией
Комментарии 5
    +3

    Подождите, при чем тут recycling? Это внутренние детали реализации, они вообще интересовать не должны. У вас есть событие onViewAttachedToWindow – в нем можно навесить listener, есть onViewDetachedFromWindow – в нем убрать listener. Почему вы вообще listener навешиваете в onBindViewHolder? Он же может несколько раз вызваться для одного и того же элемента, если в нем что-то поменялось.

      0
      Во-первых, я совершенно не претендую на знание истины в последней инстанции. Во-вторых, по-существу вопроса: метка утилизатора в коллекции данных для адаптера позволяет избежать ненужного повтора выполнения кода, хоть в методе onBindViewHolder, хоть в методах onViewAttachedToWindow и onViewDetachedFromWindow. Это имеет смысл тогда, когда код слушателей «весит» больше, чем метка с кодом её проверки, или когда в коде необходима позиция элемента списка. Возможно, есть ещё какие-нибудь варианты.
        0

        Дело не в "весе" слушателей, дело в самом подходе. Завязываться на recycling неправильно, потому что он не имеет никакого отношения к жизненному циклу самого view.


        Как один из вариантов: представьте, что дефолтная реализация recycling'а изменилась (или кто-то изменил ее в проекте для оптимизации производительности), и теперь в кэше хранится 100 элементов. Вы не убираете слушателей в onViewDetachedFromWindow, поэтому они будут продолжать выполняться для всех 100 элементов, хотя их уже нет в списке.


        Кроме того, зачем вообще усложнять код какими-то флагами? Есть вполне определенные события onViewAttachedToWindow и onViewDetachedFromWindow. Какая разница, сохраняется ли view в кэше, если его нет на экране и взаимодействовать с ним не надо?

          0
          Во-первых, мне не ясно, почему вы считаете, что метод onViewRecycled() не имеет отношения к жизненному циклу вью? Ведь согласно этому документу это не так. Во-вторых, видимо, я не очень внятно в заметке обратил внимание на то, что я НЕ предлагаю менять реализацию жизненного цикла вью. Я предлагаю учитывать порядок обращения ОС к методам обратного вызова, одним из которых является onViewRecycled(). В-третьих, разница в том в каком кэше сохраняется вью как раз-таки и есть, потому, что в одном случае производится вызов метода onCreateViewHolder() и, следом, onBindViewHolder(), а в другом — нет.
          Безусловно, есть случаи, когда эта разница не актуальна. Но есть случаи, когда она важна. Например, когда в слушателе необходимо учитывать позицию элемента в списке, доступ к которой есть в методе onBindViewHolder() и отсутствует в onViewAttachedToWindow()

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

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