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

в 7:13, , рубрики: android kotlin adapter recyclerview, Разработка под android

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

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

В статье хочу показать, как паттерн 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<Any>, 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<Any>, position: Int) =
            items[position] is ImageViewModel

    override fun getLayoutId(): Int = R.layout.image_item
}

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

        val adapter = CompositeDelegateAdapter.Builder()
                .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<out Any>, position: Int) =
            items[position] is CheckViewModel

    override fun getLayoutId(): Int = R.layout.check_item
}

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

        val adapter = CompositeDelegateAdapter.Builder()
                .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()
                .add(ImageDelegateAdapter(onImageClick))
                .add(CheckDelegateAdapter())
                .build()

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

Реализация

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

interface DelegateAdapter

public interface IDelegateAdapter<VH extends RecyclerView.ViewHolder> {

    @NonNull
    RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType);

    void onBindViewHolder(@NonNull VH holder,
                          @NonNull List<? extends Object> items,
                          int position);

    void onRecycled(VH holder);

    boolean isForViewType(@NonNull List<? extends Object> items, int position);
}

Основной адаптер

public class CompositeDelegateAdapter
    extends RecyclerView.Adapter<RecyclerView.ViewHolder> {

    private static final int FIRST_VIEW_TYPE = 0;

    protected final SparseArray<IDelegateAdapter> typeToAdapterMap;
    private @NonNull List<? extends Object> data = new ArrayList<>();

    protected CompositeDelegateAdapter(
        @NonNull SparseArray<IDelegateAdapter> typeToAdapterMap) {
        this.typeToAdapterMap = typeToAdapterMap;
    }

    @Override
    public int getItemViewType(int position) {
        for (int i = FIRST_VIEW_TYPE; i < typeToAdapterMap.size(); i++) {
            final IDelegateAdapter delegate = typeToAdapterMap.valueAt(i);
            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) {
            delegateAdapter.onBindViewHolder(holder, data, position);
        } else {
            throw new NullPointerException(
                "can not find adapter for position " + position);
        }
    }

    @Override
    public void onViewRecycled(RecyclerView.ViewHolder holder) {
        typeToAdapterMap.get(holder.getItemViewType()).onRecycled(holder);
    }

    public void swapData(@NonNull List<? extends Object> data) {
        this.data = data;
        notifyDataSetChanged();
    }

    @Override
    public final int getItemCount() {
        return data.size();
    }

    public static class Builder {

        private int count;
        private final SparseArray<IDelegateAdapter> typeToAdapterMap;

        public Builder() {
            typeToAdapterMap = new SparseArray<>();
        }

        public Builder add(@NonNull IDelegateAdapter delegateAdapter) {
            typeToAdapterMap.put(count++, delegateAdapter);
            return this;
        }

        public CompositeDelegateAdapter 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> {

    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<? extends Object> 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<? extends Object> 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 строк кода, при этом архитектура позволяет расширять и усложнять ленту, не изменяя имеющиеся классы.

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

Автор: Артур Думчев

Источник

Поделиться

* - обязательные к заполнению поля