Это первая часть из серии статей, состоящей из пяти частей, посвященных принципам SOLID. Чтобы не пропустить следующие статьи на тему Android и мобильной разработки подписывайтесь на Telegram-канал
SOLID — это аббревиатура, помогающая определить пять основных принципов объектно-ориентированного проектирования:
- Single Responsibility – Принцип единой ответственности (этот пост)
- Open-Closed Principle – Принцип открытости-закрытости
- Liskov Substitution Principle – Принцип подстановки Лисков
- Interface Segregation – Принцип разделения интерфейса
- Dependency Inversion Principle – Принцип инверсии зависимостей
В течение следующих нескольких недель я углублюсь в каждый принцип, объясню, что он означает и как он связан с разработкой Android. К концу серии вы получите четкое представление о том, что означают эти основные принципы, почему они важны для вас как разработчика Android и как вы можете применять их в своих повседневных задачах по разработке.
Немного истории
SOLID был представлен Робертом Мартином (также известным как дядюшка Боб) в начале 2000-х годов, а этот термин был придуман Майклом Фезерсом. Когда эти пять основных принципов объектно-ориентированного программирования применяются вместе, они помогают разработчикам разрабатывать более масштабируемые и гибкие приложения.
Часть 1. Принцип единой ответственности
Принцип единой ответственности (SRP) довольно легко понять. В нем говорится следующее:
У класса должна быть только одна причина для изменения.
Давайте возьмем типичную задачу где нужно использовать RecyclerView и его Adapter. Как вы, скорее всего знаете, RecyclerView — способен отображать набор данных на экране. Чтобы эти данные попали на экран, нам необходимо реализовать Adapter для RecyclerView.
Адаптер берет данные из переданного ему набора данных и адаптирует их к нужному UI. Наиболее загруженной частью адаптера, пожалуй, является метод onBindViewHolder (а иногда и сам ViewHolder, но для краткости мы просто остановимся на onBindViewHolder). Задача (то есть ответственность) Adapter для RecyclerView это: сопоставление данных к соответствующему UI – представлению, которое будет отображаться на экране пользователя.
Предположим, что данные и реализация RecyclerView.Adapter такие:
data class ProductItem( val description: String = "", val quantity: Int = 0, val price: Long = 0 ) data class Order( val orderNumber: Int = 0, val productItems: List<ProductItem> = ArrayList() ) class OrderRecyclerAdapter(private val items: List<Order>, private val itemLayout: Int) : RecyclerView.Adapter<OrderRecyclerAdapter.ViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val v = LayoutInflater.from(parent.context).inflate(itemLayout, parent, false) return ViewHolder(v) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { // TODO: bind the view here } override fun getItemCount(): Int { return items.size } class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var orderNumber: TextView var orderTotal: TextView init { orderNumber = itemView.findViewById<View>(R.id.order_number) as TextView orderTotal = itemView.findViewById<View>(R.id.order_total) as ImageView } } }
В приведенном выше примере onBindViewHolder пуст. Реализация, которую я видел много раз, могла бы выглядеть так:
override fun onBindViewHolder(holder: ViewHolder, position: Int) { val order = items[position]; holder.orderNumber.text = order.orderNumber.toString() var total:Long = 0 for (item in order.productItems){ total += item.price } val formatter:NumberFormat = NumberFormat.getCurrencyInstance(Locale.US); val totalValue = formatter.format(total / 100.0) holder.orderTotal.text = totalValue }
Приведенный выше код нарушает принцип единой ответственности.
Почему?
Метод onBindViewHolder адаптера не только сопоставляет объект Order с UI – представлением, но также выполняет расчет цен и форматирование. Это нарушает принцип единой ответственности. Адаптер должен отвечать только за мапинг данных заказа к его UI-представлению. onBindViewHolder выполняет две дополнительные обязанности, которых быть не должно.
Почему это проблема?
Включение нескольких обязанностей в класс может вызвать различные проблемы. Во-первых, логика расчета заказа теперь связана с адаптером. Если вам нужно отобразить сумму заказа в другом месте (скорее всего, так и есть), вам придется дублировать эту логику. Как только это произойдет, ваше приложение столкнется с традиционными проблемами дублирования программной логики, с которыми мы все знакомы. Вы обновляете код в одном месте и забываете обновить его в другом месте и получаете кучу багов на ровном месте.
Вторая проблема аналогична первой — вы подключили логику форматирования к адаптеру. Что, если ее будет необходимо переместить или обновить? В конце концов, мы заставляем этот класс делать больше, чем он должен, и теперь приложение более подвержено ошибкам из-за слишком большой ответственности в одном месте.
К счастью, этот простой пример можно легко исправить, выделив расчет общей суммы заказа в объект Order и переместив форматирование валюты в какой-либо утилитный класс форматирования валюты. Этот форматтер затем может быть использован классом Order тоже.
Обновленный метод onBindViewHolder может выглядеть так:
override fun onBindViewHolder(holder: ViewHolder, position: Int) { val order = items[position]; holder.orderNumber.text = order.orderNumber.toString() // A String, the calculation and formatting moved elsewhere holder.orderTotal.text = order.getOrderTotal().toString() }
Я уверен, что вы, вероятно, думаете: «Ок, это было легко.». Всегда ли это так легко? Как и большинство ответов в разработке – «Ну, это зависит…». Давайте копнем немного глубже…
Что подразумевается под «ответственностью»?
Трудно сказать лучше, чем дядя Боб, поэтому я процитирую его здесь:
В контексте Принципа единой ответственности (SRP) мы определяем ответственность как «причину для изменений». Если вы можете придумать более одного мотива для смены класса, значит, у этого класса больше одной ответственности.
Дело в том, что иногда это очень сложно увидеть, особенно если вы уже давно работаете с кодовой базой. В этот момент на ум обычно приходит знаменитая цитата: За деревьями не видно леса.
В контексте программного обеспечения это означает, что вы слишком хорошо разбираетесь в деталях своего кода, чтобы увидеть общую картину. Например, класс, над которым вы работаете, может выглядеть великолепно, но это потому, что вы работали с ним так долго, что трудно представить, что у него может быть несколько обязанностей. Задача состоит в том, чтобы знать, когда применять SRP, а когда нет. Принимая во внимание пример адаптера, если мы снова посмотрим на код, мы увидим различные вещи, которые могут вызвать необходимость изменений в разных областях по разным причинам:
class OrderRecyclerAdapter(private val items: List<Order>, private val itemLayout: Int) : RecyclerView.Adapter<OrderRecyclerAdapter.ViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val v = LayoutInflater.from(parent.context).inflate(itemLayout, parent, false) return ViewHolder(v) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val order = items[position] holder.orderNumber.text = order.orderNumber.toString() holder.orderTotal.text = order.getOrderTotal().toString() // Move the calculation and formatting elsewhere holder.itemView.tag = order } override fun getItemCount(): Int { return items.size } class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var orderNumber: TextView var orderTotal: TextView init { orderNumber = itemView.findViewById<View>(R.id.order_number) as TextView orderTotal = itemView.findViewById<View>(R.id.order_total) as ImageView } } }
Адаптер занимается инфлэйтом UI-представления, мапит данные заказа к UI – представлению, создает ViewHolder и т. д. Этот класс имеет несколько обязанностей. Следует ли разделить эти обязанности? В конечном итоге это зависит от того, как приложение меняется с течением времени. Если приложение меняется таким образом, что это влияет на UI-представление и его логику, то, как утверждает дядя Боб, архитектура будет отдавать жесткостью, потому что одно изменение требует изменения другого. Изменение UI требует и изменения самого адаптера, из-за чего конструкция становится жесткой. Однако можно также утверждать, что если приложение не меняется таким образом, чтобы разные функции менялись в разное время, то нет необходимости разделять их. В этом случае их разделение добавило бы ненужной сложности. Так что же нам делать?
Пример, иллюстрирующий жесткость
Предположим, появилось новое требование к продукту, в котором говорится, что, когда общая сумма заказа равна нулю, в ячейки списка на экране должно отображаться ярко-желтое изображение «БЕСПЛАТНО» вместо общей суммы. Куда пойдет эта логика? В одном случае вам нужен TextView, а в другом — ImageView. Код необходимо изменить в двух местах: в UI и логике. Как и в большинстве приложений, которые я видел, это применялось на уровне адаптера. К сожалению, это приводит к необходимости изменения адаптера при изменении вашего UI-представления. Если логика для этого находится в адаптере, то это приводит к изменению логики в адаптере, а также UI. Это накладывает на адаптер еще одну ответственность.
Это именно тот момент, когда что-то вроде шаблона MVP предлагает необходимое разделение, чтобы классы не становились слишком жесткими, но при этом обеспечивали гибкость для расширения, компоновки и тестирования. Например, UI – представление будет реализовывать интерфейс, определяющий, как с ним будет взаимодействовать, а презентатор будет выполнять необходимую логику. Презентер в шаблоне MVP отвечает только за логику представления/отображения, не более того.
Перенос этой логики из адаптера в презентер поможет адаптеру в большей степени придерживаться принципа единой ответственности. Хотя это еще не все…
Если вы когда-либо внимательно изучали какой-либо адаптер RecyclerView, вы, вероятно, замечали, что адаптер делает множество вещей. Что адаптер все еще делает:
- Inflating the View
- Создание View Holder
- Переиспользование View Holder
- Подсчет количества элементов в списке
- etc.
Поскольку SRP предполагает единую ответственность, вы, вероятно, задаетесь вопросом, следует ли извлечь некоторую логику для соблюдения SRP. Я еще раз передам слово дяде Бобу Мартину, поскольку его объяснение соответствует действительности:
Хотя Адаптер по-прежнему выполняет различные действия, это, собственно, то, для чего он и предназначен. В конце концов, адаптер для RecyclerView — это просто реализация шаблона адаптера. В этом случае сохранение перечисленной логики и является ответственностью этого класса, и это то, что он делает лучше всего. Однако введение дополнительного поведения (например, логики UI-представления) нарушает SRP, и этого можно избежать, используя например MVP или что-то другое.
Заключение
Принцип единой ответственности, вероятно, является одним из самых простых для понимания принципов SOLID, поскольку он просто гласит (еще раз для полноты картины):
У класса должна быть только одна причина для изменения.
Тем не менее, это также один из самых сложных в применении. Легко переанализировать код, что может заставить вас подумать, что вам нужно придерживаться SRP, но затем обнаружить, что если вы это сделаете, вы усложните свое приложение.
Мой совет — попытаться отойти от кода и посмотреть на него объективно. Вы можете осознать, что вам необходимо применить SRP, или вы можете осознать, что уже проделали отличную работу. В любом случае, не торопитесь и все обдумайте.
