fbpx

Blog

SOLID в Android. Single Responsibility

Это первая часть из серии статей, состоящей из пяти частей, посвященных принципам 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, или вы можете осознать, что уже проделали отличную работу. В любом случае, не торопитесь и все обдумайте.

Если вы хотите прокачать свои знания до middle-уровня, обратите внимание на интенсивный курс по Android-разработке с code-review и консультациями. <<<<👉 Записаться>>>>