Delegate Adapter — зачем и как

    Практически во всех проектах, которыми я занимался, приходилось отображать список элементов (ленту), и эти элементы были разного типа. Часто задача решалась внутри главного адаптера, определяя тип элемента через instanceOf в getItemViewType(). Когда в ленте 2 или 3 типа, кажется, что такой подход себя оправдывает… Или нет? Что, если завтра придет требование ввести еще несколько типов да еще и по какой-то замысловатой логике?



    В статье хочу показать, как паттерн DelegateAdapter позволяет решить эту проблему. Знакомым с паттерном может быть интересно посмотреть реализацию на Kotlin с использованием LayoutContainer.

    Проблема


    Начнем с примера. Предположим, у нас есть задача отобразить ленту с двумя типами данных — текст с описанием и картинка.

    Создадим модели для типов.
    public interface IViewModel {}

    public class TextViewModel implements IViewModel {
    
        @NonNull public final String title;
        @NonNull public final String description;
    
        public TextViewModel(@NonNull String title, @NonNull String description) {
            this.title = title;
            this.description = description;
        }
    }

    public class ImageViewModel implements IViewModel {
    
        @NonNull public final String title;
        @NonNull public final @DrawableRes int imageRes;
    
        public ImageViewModel(@NonNull String title, @NonNull int imageRes) {
            this.title = title;
            this.imageRes = imageRes;
        }
    }
    


    Типичный адаптер выглядел бы примерно так
    public class BadAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
    
        private static final int TEXT_VIEW_TYPE = 1;
        private static final int IMAGE_VIEW_TYPE = 2;
    
        private List<IViewModel> items;
        private View.OnClickListener imageClickListener;
    
        public BadAdapter(List<IViewModel> items, 
                          View.OnClickListener imageClickListener) {
            this.items = items;
            this.imageClickListener = imageClickListener;
        }
    
        public int getItemViewType(int position) {
            IViewModel item = items.get(position);
            if (item instanceof TextViewModel) return TEXT_VIEW_TYPE;
            if (item instanceof ImageViewModel) return IMAGE_VIEW_TYPE;
            throw new IllegalArgumentException(
                "Can't find view type for position " + position);
        }
    
        @Override
        public RecyclerView.ViewHolder onCreateViewHolder(
            ViewGroup parent, int viewType) {
    
            RecyclerView.ViewHolder holder;
            LayoutInflater inflater = LayoutInflater.from(parent.getContext());
            if (viewType == TEXT_VIEW_TYPE) {
                holder = new TextViewHolder(
                    inflater.inflate(R.layout.text_item, parent, false));
            } else if (viewType == IMAGE_VIEW_TYPE) {
                holder = new ImageViewHolder(
                    inflater.inflate(R.layout.image_item, parent, false),
                    imageClickListener);
            } else {
                throw new IllegalArgumentException(
                    "Can't create view holder from view type " + viewType);
            }
            return holder;
        }
    
        @Override
        public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
            int viewType = getItemViewType(position);
            if (viewType == TEXT_VIEW_TYPE) {
                TextViewHolder txtViewHolder = (TextViewHolder) holder;
                TextViewModel model = (TextViewModel) items.get(position);
                txtViewHolder.tvTitle.setText(model.title);
                txtViewHolder.tvDescription.setText(model.description);
            } else if (viewType == IMAGE_VIEW_TYPE) {
                ImageViewHolder imgViewHolder = (ImageViewHolder) holder;
                ImageViewModel model = (ImageViewModel) items.get(position);
                imgViewHolder.tvTitle.setText(model.title);
                imgViewHolder.imageView.setImageResource(model.imageRes);
            } else {
                throw new IllegalArgumentException(
                    "Can't create bind holder fro position " + position);
            }
        }
    
        @Override
        public int getItemCount() {
            return items.size();
        }
    
        private static class TextViewHolder extends RecyclerView.ViewHolder {
    
            private TextView tvTitle;
            private TextView tvDescription;
    
            private TextViewHolder(View parent) {
                super(parent);
                tvTitle = parent.findViewById(R.id.tv_title);
                tvDescription = parent.findViewById(R.id.tv_description);
            }
        }
    
        private static class ImageViewHolder extends RecyclerView.ViewHolder {
    
            private TextView tvTitle;
            private ImageView imageView;
    
            private ImageViewHolder(View parent, 
                                    View.OnClickListener listener) {
                super(parent);
                tvTitle = parent.findViewById(R.id.tv_title);
                imageView = parent.findViewById(R.id.img_bg);
                imageView.setOnClickListener(listener);
            }
        }
    }


    Минус такой реализации в нарушении принципов DRY и SOLID (single responsibility и open closed). Чтобы в этом убедиться, достаточно добавить два требования: ввести новый тип данных (чекбокс) и еще одну ленту, где будут только чекбоксы и картинки.

    Перед нами встает выбор — использовать этот же адаптер для второй ленты или создать новый? Независимо от решения, которое мы выберем, нам придется менять код (об одном и том же, но в разных местах). Надо будет добавить новый VIEW_TYPE, новый ViewHolder и отредактировать методы: getItemViewType(), onCreateViewHolder() и onBindViewHolder().

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

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

    Готовые решения


    С данной проблемой успешно справляется паттерн Delegate Adapter — не нужно изменять уже написанный код, легко переиспользовать имеющиеся адаптеры.

    Впервые с паттерном я столкнулся, читая цикл статей Жуана Игнасио о написании проекта на Котлин. Реализация Жуана, как и решение, освещенное на хабре — RendererRecyclerViewAdapter, — не нравится мне тем, что знание о ViewType распространяется по всем адаптерам и даже дальше.

    Подробное объяснение
    В решении Жуана нужно загеристрировать ViewType:

    object AdapterConstants {
        val NEWS = 1
        val LOADING = 2
    }

    создать модель, реализующую интерфейс ViewType:

    class SomeModel : ViewType {
        override fun getViewType() = AdapterConstants.NEWS
    }

    зарегистрировать DelegateAdapter c нужно константой:

    delegateAdapters.put(AdapterConstants.NEWS, NewsDelegateAdapter(listener))

    Таким образом, логика с типом данных размазывается по трем классам (константы, модель и место, где происходит регистрирование). Кроме того, нужно следить за тем, чтобы случайно не создать две константы с одним и тем же значением, что очень легко сделать в решении с RendererRecyclerViewAdapter:

    class SomeModel implements ItemModel {
        public static final int TYPE = 0; // вдруг 0 есть у какой-то еще модели?
        @NonNull private final String mTitle;
        ...
        @Override public int getType() {
            return TYPE;
        }
    }


    Оба описанных подхода основаны на библиотеке AdapterDelegates Ханса Дорфмана, которая мне нравится больше, хотя и вижу недостаток в необходимости создавать адаптер. Эта часть — «бойлерплейт», без которого можно было бы обойтись.

    Другое решение


    Код лучше слов скажет за себя. Давайте попробуем реализовать ту же ленту с двумя типами данных (текст и картинка). Реализацию напишу на Kotlin с использованием LayoutContainer (подробнее расскажу ниже).

    Пишем адаптер для текста:

    class TxtDelegateAdapter : KDelegateAdapter<TextViewModel>() {
    
        override fun onBind(item: TextViewModel, viewHolder: KViewHolder) =
                with(viewHolder) {
                    tv_title.text = item.title
                    tv_description.text = item.description
                }
    
        override fun isForViewType(items: List<*>, position: Int) =
                items[position] is TextViewModel
    
        override fun getLayoutId(): Int = R.layout.text_item
    }

    адаптер для картинок:

    class ImageDelegateAdapter(private val clickListener: View.OnClickListener)
        : KDelegateAdapter<ImageViewModel>() {
    
        override fun onBind(item: ImageViewModel, viewHolder: KViewHolder) =
                with(viewHolder) {
                    tv_title.text = item.title
                    img_bg.setOnClickListener(clickListener)
                    img_bg.setImageResource(item.imageRes)
                }
    
        override fun isForViewType(items: List<*>, position: Int) =
                items[position] is ImageViewModel
    
        override fun getLayoutId(): Int = R.layout.image_item
    }

    и регистрируем адаптеры в месте создания главного адаптера:

            val adapter = CompositeDelegateAdapter.Builder<IViewModel>()
                    .add(ImageDelegateAdapter(onImageClick))
                    .add(TextDelegateAdapter())
                    .build()
            recyclerView.layoutManager = LinearLayoutManager(this)
            recyclerView.adapter = adapter
    

    Это все, что нужно сделать для решения поставленной задачи. Обратите внимание, насколько меньше кода, по сравнению с классической реализацией. Кроме того, данный подход позволяет легко добавлять новые типы данных и комбинировать DelegateAdapter-ы между собой.

    Давайте представим, что поступило требование добавить новый тип данных (чекбокс). Что нужно будет сделать?

    Создать модель:

    class CheckViewModel(val title: String, var isChecked: Boolean): IViewModel

    написать адаптер:

    
    class CheckDelegateAdapter : KDelegateAdapter<CheckViewModel>() {
        
        override fun onBind(item: CheckViewModel, viewHolder: KViewHolder) =
                with(viewHolder.check_box) {
                    text = item.title
                    isChecked = item.isChecked
                    setOnCheckedChangeListener { _, isChecked ->
                        item.isChecked = isChecked
                    }
                }
    
        override fun onRecycled(viewHolder: KViewHolder) {
            viewHolder.check_box.setOnCheckedChangeListener(null)
        }
    
        override fun isForViewType(items: List<*>, position: Int) =
                items[position] is CheckViewModel
    
        override fun getLayoutId(): Int = R.layout.check_item
    }
    

    и добавить строчку к созданию адаптера:

            val adapter = CompositeDelegateAdapter.Builder<IViewModel>()
                    .add(ImageDelegateAdapter(onImageClick))
                    .add(TextDelegateAdapter())
                    .add(CheckDelegateAdapter())
                    .build()
    

    Новый тип данных в ленте — это layout, ViewHolder и логика байндинга. Предложенный подход мне нравится еще и тем, что все это находится в одном классе. В некоторых проектах ViewHolder-ы и ViewBinder-ы выносят в отдельные классы, а инфлейтинг layout-а происходит в главном адаптере. Представьте задачу — нужно просто изменить размер шрифта в одном из типов данных в ленте. Вы заходите во ViewHolder, там видите findViewById(R.id.description). Щелкаете по description, и Идея предлагает 35 layout-ов, в которых есть view с таким id. Тогда вы идете в главный адаптер, затем в ParentAdapter, затем в метод onCreateViewHolder, и наконец, надо найти нужный внутри switch в 40 элементов.

    В разделе «проблема» было требование с созданием еще одной ленты. С delegate adapter задача становится тривиальной — просто создать CompositeAdapter и зарегистрировать нужные типы DelegateAdapter-ов:

    
            val newAdapter = CompositeDelegateAdapter.Builder<IViewModel>()
                    .add(ImageDelegateAdapter(onImageClick))
                    .add(CheckDelegateAdapter())
                    .build()
    

    Т.е. адаптеры не зависимы друг от друга и их можно легко комбинировать. Еще одним преимуществом является удобство передачи обработчиков (onСlickListener). В BadAdapter (пример выше) обработчик передавался адаптеру, а тот уже передавал его ViewHolder-у. Это увеличивает связность кода. В предложенном же решении обработчики передаются через конструктор только тем классам, которым они необходимы.

    Реализация


    Для базовой реализации (без Котлина и LayoutContainer), нужно 4 класса:

    interface DelegateAdapter
    public interface IDelegateAdapter<VH extends RecyclerView.ViewHolder, T> {
    
        @NonNull
        RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType);
    
        void onBindViewHolder(@NonNull VH holder,
                              @NonNull List<T> items,
                              int position);
    
        void onRecycled(VH holder);
    
        boolean isForViewType(@NonNull List<?> items, int position);
    }


    Основной адаптер
    public class CompositeDelegateAdapter<T> 
        extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
    
        private static final int FIRST_VIEW_TYPE = 0;
    
        protected final SparseArray<IDelegateAdapter> typeToAdapterMap;
        protected final @NonNull List<T> data = new ArrayList<>();
    
        protected CompositeDelegateAdapter(
            @NonNull SparseArray<IDelegateAdapter> typeToAdapterMap) {
            this.typeToAdapterMap = typeToAdapterMap;
        }
    
        @Override
        public final int getItemViewType(int position) {
            for (int i = FIRST_VIEW_TYPE; i < typeToAdapterMap.size(); i++) {
                final IDelegateAdapter delegate = typeToAdapterMap.valueAt(i);
                //noinspection unchecked
                if (delegate.isForViewType(data, position)) {
                    return typeToAdapterMap.keyAt(i);
                }
            }
            throw new NullPointerException(
                "Can not get viewType for position " + position);
        }
    
        @Override
        public final RecyclerView.ViewHolder onCreateViewHolder(
            ViewGroup parent, int viewType) {
            return typeToAdapterMap.get(viewType)
                                   .onCreateViewHolder(parent, viewType);
        }
    
        @Override
        public final void onBindViewHolder(
            RecyclerView.ViewHolder holder, int position) {
            final IDelegateAdapter delegateAdapter =
                typeToAdapterMap.get(getItemViewType(position));
            if (delegateAdapter != null) {
                //noinspection unchecked
                delegateAdapter.onBindViewHolder(holder, data, position);
            } else {
                throw new NullPointerException(
                    "can not find adapter for position " + position);
            }
        }
    
        @Override
        public void onViewRecycled(RecyclerView.ViewHolder holder) {
            //noinspection unchecked
            typeToAdapterMap.get(holder.getItemViewType())
                            .onRecycled(holder);
        }
    
        public void swapData(@NonNull List<T> data) {
            this.data.clear();
            this.data.addAll(data);
            notifyDataSetChanged();
        }
    
        @Override
        public final int getItemCount() {
            return data.size();
        }
    
        public static class Builder<T> {
    
            private int count;
            private final SparseArray<IDelegateAdapter> typeToAdapterMap;
    
            public Builder() {
                typeToAdapterMap = new SparseArray<>();
            }
    
            public Builder<T> add(
                @NonNull IDelegateAdapter<?, ? extends T> delegateAdapter) {
                typeToAdapterMap.put(count++, delegateAdapter);
                return this;
            }
    
            public CompositeDelegateAdapter<T> build() {
                if (count == 0) {
                    throw new IllegalArgumentException("Register at least one adapter");
                }
                return new CompositeDelegateAdapter<>(typeToAdapterMap);
            }
        }
    }


    Как видите, никакой магии, просто делегируем вызовы onBind, onCreate, onRecycled (так же, как в реализации AdapterDelegates Ханса Дорфмана).

    Напишем теперь базовые ViewHolder и DelegateAdpater, чтобы убрать еще немного «бойлерплейта»:

    BaseViewHolder
    public class BaseViewHolder extends RecyclerView.ViewHolder {
        private ItemInflateListener listener;
    
        public BaseViewHolder(View parent) {
            super(parent);
        }
    
        public final void setListener(ItemInflateListener listener) {
            this.listener = listener;
        }
    
        public final void bind(Object item) {
            listener.inflated(item, itemView);
        }
    
        interface ItemInflateListener {
            void inflated(Object viewType, View view);
        }
    }


    BaseDelegateAdapter
    public abstract class BaseDelegateAdapter
        <VH extends BaseViewHolder, T> implements IDelegateAdapter<VH,T> {
    
        abstract protected void onBindViewHolder(
            @NonNull View view, @NonNull T item, @NonNull VH viewHolder);
    
        @LayoutRes
        abstract protected int getLayoutId();
    
        @NonNull
        abstract protected VH createViewHolder(View parent);
    
        @Override
        public void onRecycled(VH holder) {
        }
    
        @NonNull
        @Override
        public final RecyclerView.ViewHolder onCreateViewHolder(
            @NonNull ViewGroup parent, int viewType) {
    
            final View inflatedView = LayoutInflater
                .from(parent.getContext())
                .inflate(getLayoutId(), parent, false);
            final VH holder = createViewHolder(inflatedView);
            holder.setListener(new BaseViewHolder.ItemInflateListener() {
                @Override
                public void inflated(Object viewType, View view) {
                    onBindViewHolder(view, (T) viewType, holder);
                }
            });
            return holder;
        }
    
        @Override
        public final void onBindViewHolder(
            @NonNull VH holder,
            @NonNull List<T> items,
            int position) {
            ((BaseViewHolder) holder).bind(items.get(position));
        }
    }


    Теперь можно будет создавать адаптеры, практически как в примере выше:

    пример TextDelegateAdapter
    public class TextDelegateAdapter extends
        BaseDelegateAdapter<TextDelegateAdapter.TextViewHolder, TextViewModel> {
    
        @Override
        protected void onBindViewHolder(@NonNull View view,
                                        @NonNull TextViewModel item,
                                        @NonNull TextViewHolder viewHolder) {
            viewHolder.tvTitle.setText(item.title);
            viewHolder.tvDescription.setText(item.description);
        }
    
        @Override
        protected int getLayoutId() {
            return R.layout.text_item;
        }
    
        @Override
        protected TextViewHolder createViewHolder(View parent) {
            return new TextViewHolder(parent);
        }
    
        @Override
        public boolean isForViewType(@NonNull List<?> items, int position) {
            return items.get(position) instanceof TextViewModel;
        }
    
        final static class TextViewHolder extends BaseViewHolder {
    
            private TextView tvTitle;
            private TextView tvDescription;
    
            private TextViewHolder(View parent) {
                super(parent);
                tvTitle = parent.findViewById(R.id.tv_title);
                tvDescription = parent.findViewById(R.id.tv_description);
            }
        }
    }


    Чтобы ViewHolder-ы создавались автоматически(будет работать только на Котлине), нужно сделать сделать 3 вещи:

    1. Подключить плагин для синтетического импорта ссылок на View

      apply plugin: 'kotlin-android-extensions'
    2. Разрешить для него опцию experimental

          androidExtensions {
              experimental = true
          }
    3. Реализовать интерфейс LayoutContainer
      По умолчанию, ссылки кешируются только для Activity и Fragment. Подробнее здесь.

    Теперь можем написать базовый класс:

    abstract class KDelegateAdapter<T>
        : BaseDelegateAdapter<KDelegateAdapter.KViewHolder, T>() {
    
        abstract fun onBind(item: T, viewHolder: KViewHolder)
    
        final override fun onBindViewHolder(view: View, item: T, viewHolder: KViewHolder) {
            onBind(item, viewHolder)
        }
    
        override fun createViewHolder(parent: View?): KViewHolder {
            return KViewHolder(parent)
        }
    
        class KViewHolder(override val containerView: View?)
            : BaseViewHolder(containerView), LayoutContainer
    }

    Недостатки


    1. На поиск адаптера, когда нужно определить viewType, в среднем уходит N/2, где N — число зарегистрированных адаптеров. Так что решение будет работать несколько медленней с большим числом адаптеров.
    2. Возможен конфликт двух адаптеров, подписывающихся на один и тот же ViewModel.
    3. Классы получаются компактным только на Котлине.

    Заключение


    Данный подход хорошо зарекомендовал себя как для сложных списков, так и для однородных — написание адаптера превращается буквально в 10 строк кода, при этом архитектура позволяет расширять и усложнять ленту, не изменяя имеющиеся классы.

    На тот случай, если кому-то нужны исходники, даю ссылку на проект. Буду рад любой обратной связи.
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 4
    • 0
      Создание адаптера через билдер — очень ограниченный вариант, потому что часто требуется реализовать пагинацию, а тут ни куда без наследования. А так, конечно, Дорфмановские делегаты очень помогают!
      • 0
        Недавно писал на kotlin похожую реализацию, только вместо использования билдера, я оборачиваю каждую модель данных в созданный для нее адаптер и этот элемент добавляю в главный адаптер. Если интересно, то вот ссылка на проект.
        Кстати у Вас в проекте notifyDataSetChanged() в функции swapDataset ограничит тех, кто использует DiffUtil.
        • 0
          Можно отнаследоваться от CompositeDelegateAdapter, конструктор protected.
        • 0
          Как вариант, если структура отображаемых даных не сильно отличается, можно использовать единный интерфейс и при появлении нового типа данных, вы просто добавляете имплементацию этого интерфейса.

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