Практически во всех проектах, которыми я занимался, приходилось отображать список элементов (ленту), и эти элементы были разного типа. Часто задача решалась внутри главного адаптера, определяя тип элемента через instanceOf в getItemViewType(). Когда в ленте 2 или 3 типа, кажется, что такой подход себя оправдывает… Или нет? Что, если завтра придет требование ввести еще несколько типов да еще и по какой-то замысловатой логике?
В статье хочу показать, как паттерн DelegateAdapter позволяет решить эту проблему. Знакомым с паттерном может быть интересно посмотреть реализацию на Kotlin с использованием LayoutContainer.
Начнем с примера. Предположим, у нас есть задача отобразить ленту с двумя типами данных — текст с описанием и картинка.
Минус такой реализации в нарушении принципов DRY и SOLID (single responsibility и open closed). Чтобы в этом убедиться, достаточно добавить два требования: ввести новый тип данных (чекбокс) и еще одну ленту, где будут только чекбоксы и картинки.
Перед нами встает выбор — использовать этот же адаптер для второй ленты или создать новый? Независимо от решения, которое мы выберем, нам придется менять код (об одном и том же, но в разных местах). Надо будет добавить новый VIEW_TYPE, новый ViewHolder и отредактировать методы: getItemViewType(), onCreateViewHolder() и onBindViewHolder().
Если мы решим оставить один адаптер, то на этом изменения закончатся. Но если в будущем новые типы данных с новой логикой будут добавляться только во вторую ленту, первая будет иметь лишний функционал, и ее тоже нужно будет тестировать, хотя она не изменялась.
Если решим создать новый адаптер, то будет просто масса дублирующего кода.
С данной проблемой успешно справляется паттерн Delegate Adapter — не нужно изменять уже написанный код, легко переиспользовать имеющиеся адаптеры.
Впервые с паттерном я столкнулся, читая цикл статей Жуана Игнасио о написании проекта на Котлин. Реализация Жуана, как и решение, освещенное на хабре — RendererRecyclerViewAdapter, — не нравится мне тем, что знание о ViewType распространяется по всем адаптерам и даже дальше.
Оба описанных подхода основаны на библиотеке AdapterDelegates Ханса Дорфмана, которая мне нравится больше, хотя и вижу недостаток в необходимости создавать адаптер. Эта часть — «бойлерплейт», без которого можно было бы обойтись.
Код лучше слов скажет за себя. Давайте попробуем реализовать ту же ленту с двумя типами данных (текст и картинка). Реализацию напишу на Kotlin с использованием LayoutContainer (подробнее расскажу ниже).
Пишем адаптер для текста:
адаптер для картинок:
и регистрируем адаптеры в месте создания главного адаптера:
Это все, что нужно сделать для решения поставленной задачи. Обратите внимание, насколько меньше кода, по сравнению с классической реализацией. Кроме того, данный подход позволяет легко добавлять новые типы данных и комбинировать DelegateAdapter-ы между собой.
Давайте представим, что поступило требование добавить новый тип данных (чекбокс). Что нужно будет сделать?
Создать модель:
написать адаптер:
и добавить строчку к созданию адаптера:
Новый тип данных в ленте — это layout, ViewHolder и логика байндинга. Предложенный подход мне нравится еще и тем, что все это находится в одном классе. В некоторых проектах ViewHolder-ы и ViewBinder-ы выносят в отдельные классы, а инфлейтинг layout-а происходит в главном адаптере. Представьте задачу — нужно просто изменить размер шрифта в одном из типов данных в ленте. Вы заходите во ViewHolder, там видите findViewById(R.id.description). Щелкаете по description, и Идея предлагает 35 layout-ов, в которых есть view с таким id. Тогда вы идете в главный адаптер, затем в ParentAdapter, затем в метод onCreateViewHolder, и наконец, надо найти нужный внутри switch в 40 элементов.
В разделе «проблема» было требование с созданием еще одной ленты. С delegate adapter задача становится тривиальной — просто создать CompositeAdapter и зарегистрировать нужные типы DelegateAdapter-ов:
Т.е. адаптеры не зависимы друг от друга и их можно легко комбинировать. Еще одним преимуществом является удобство передачи обработчиков (onСlickListener). В BadAdapter (пример выше) обработчик передавался адаптеру, а тот уже передавал его ViewHolder-у. Это увеличивает связность кода. В предложенном же решении обработчики передаются через конструктор только тем классам, которым они необходимы.
Для базовой реализации (без Котлина и LayoutContainer), нужно 4 класса:
Как видите, никакой магии, просто делегируем вызовы onBind, onCreate, onRecycled (так же, как в реализации AdapterDelegates Ханса Дорфмана).
Напишем теперь базовые ViewHolder и DelegateAdpater, чтобы убрать еще немного «бойлерплейта»:
Теперь можно будет создавать адаптеры, практически как в примере выше:
Чтобы ViewHolder-ы создавались автоматически(будет работать только на Котлине), нужно сделать сделать 3 вещи:
Теперь можем написать базовый класс:
Данный подход хорошо зарекомендовал себя как для сложных списков, так и для однородных — написание адаптера превращается буквально в 10 строк кода, при этом архитектура позволяет расширять и усложнять ленту, не изменяя имеющиеся классы.
На тот случай, если кому-то нужны исходники, даю ссылку на проект. Буду рад любой обратной связи.
В статье хочу показать, как паттерн 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:
создать модель, реализующую интерфейс ViewType:
зарегистрировать DelegateAdapter c нужно константой:
Таким образом, логика с типом данных размазывается по трем классам (константы, модель и место, где происходит регистрирование). Кроме того, нужно следить за тем, чтобы случайно не создать две константы с одним и тем же значением, что очень легко сделать в решении с RendererRecyclerViewAdapter:
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 вещи:
- Подключить плагин для синтетического импорта ссылок на View
apply plugin: 'kotlin-android-extensions'
- Разрешить для него опцию experimental
androidExtensions { experimental = true }
- Реализовать интерфейс 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
}
Недостатки
- На поиск адаптера, когда нужно определить viewType, в среднем уходит N/2, где N — число зарегистрированных адаптеров. Так что решение будет работать несколько медленней с большим числом адаптеров.
- Возможен конфликт двух адаптеров, подписывающихся на один и тот же ViewModel.
- Классы получаются компактным только на Котлине.
Заключение
Данный подход хорошо зарекомендовал себя как для сложных списков, так и для однородных — написание адаптера превращается буквально в 10 строк кода, при этом архитектура позволяет расширять и усложнять ленту, не изменяя имеющиеся классы.
На тот случай, если кому-то нужны исходники, даю ссылку на проект. Буду рад любой обратной связи.