fbpx

Blog

Шаблонный метод на примере формирования списков в Android

В этой статье мы рассмотрим один из паттернов проектирования, который существенно поможет вам сделать код читаемым, облегчая повторное использование кода. В этом нам поможет поведенческий паттерн, который называется Шаблонный метод или Template.

Давайте сразу рассмотрим пример из жизни разработчика. Нам необходимо создать список, который будет отображать информацию о разном медиаконтенте. Например, в одном списке у нас могут быть: информация о фильме/сериале, книге или игре. При этом часть информации (например рейтинг или обложка) есть у всех элементов, но например может меняться иконка (будет зависеть от типа), будет меняться жанр (у сериалов и игры жанры отличаются) и может отличаться краткая информация — например у сериала кол-во серий, а у книги кол-во страниц. Чтобы было понятней, взгляните на макет:

Ячейка для медиаконтента. Красным отмечены те части, которые изменяются в зависимости от типа контента.

На скриншоте красными блоками выделены части UI которые могут незначительно меняться. Например:

1. Иконка зависит от типа контента (Книга/Фильм/Игра)

2. Жанр тоже может меняться (У игры и фильма могут быть разные жанры)

3. Информация о количестве серий тоже меняется. Если это сериал — то у него есть серии, а если книга — то количество страниц

При этом есть повторяющиеся элементы интерфейса:

1. Обложка

2. Название

3. Рейтинг

Плохое решение

В самом простом случае, решение могло бы выглядеть так:

package ru.androidschool.template_pattern_demo

import android.view.View
import android.widget.ImageView
import androidx.annotation.DrawableRes
import com.squareup.picasso.Picasso
import com.xwray.groupie.viewbinding.BindableItem
import ru.androidschool.template_pattern_demo.MediaContentType.*
import ru.androidschool.template_pattern_demo.databinding.ItemMediaBinding

class MediaContentItem(
    private val content: Media,
    private val onClick: (p: Media) -> Unit
) : BindableItem<ItemMediaBinding>() {

    override fun getLayout() = R.layout.item_media

    override fun initializeViewBinding(view: View): ItemMediaBinding {
        return ItemMediaBinding.bind(view)
    }

    override fun bind(viewBinding: ItemMediaBinding, position: Int) {
        // Общая часть
        viewBinding.title.text = content.title
        viewBinding.imagePreview.loadImage(
            content.coverUrl
        )
        viewBinding.rating.rating = content.rating

        // Зависит от типа
        viewBinding.bage.setImageResource(getBageIcon())
        viewBinding.info.text = getInfo()
        viewBinding.genre.text = getGenre()

        viewBinding.content.setOnClickListener {
            when (content.type) {
                BOOK -> openBookStore()
                MOVIE -> openMovieStore()
                GAME -> openGameStore()
            }
        }
    }

    @DrawableRes
    fun getBageIcon(): Int {
        return when (content.type) {
            BOOK -> R.drawable.ic_book
            MOVIE -> R.drawable.ic_movie
            GAME -> R.drawable.ic_game
        }
    }

    fun getInfo(): String {
        return when (content.type) {
            BOOK -> "Кол-во страниц: " + content.mediaContentSize
            MOVIE -> "Кол-во серий: " + content.mediaContentSize
            GAME -> "Время прохождения:" + content.mediaContentSize
        }
    }

    fun getGenre(): String {
        return when (content.type) {
            BOOK -> getGeneralGenre()
            MOVIE -> getGeneralGenre()
            GAME -> getGameSpecificGenre()
        }
    }

    fun getGeneralGenre(): String {
        return "Фантастика"
    }

    fun getGameSpecificGenre(): String {
        return "Файтинг"
    }

    fun openBookStore() {
        // TODO
    }

    fun openGameStore() {
        // TODO
    }

    fun openMovieStore() {
        // TODO
    }
}

fun ImageView.loadImage(imgUrl: String?) {
    if (!imgUrl.isNullOrEmpty()) {
        Picasso.get()
            .load(imgUrl)
            .into(this)
    }
}

Здесь видно что, в зависимости от типа контента мы изменяем отображение и формирование информации. Теперь, представим, что у нас появилось еще несколько новых типов контента с похожим поведением, но немного отличающейся логикой. Например, для некоторых элементов (для предстоящих концертов) — нужно скрывать рейтинг (так как они только будут в будущем), а для музыкальных альбомов нужно обложку загружать не из поля content.coverUrl а из musicAlbumUrl. Кроме этого для новых типов надо скрывать иконку-бэйджик. Обновим нашу логику байндинга данных для ячейки:

package ru.androidschool.template_pattern_demo

import android.view.View
import android.widget.ImageView
import androidx.annotation.DrawableRes
import com.squareup.picasso.Picasso
import com.xwray.groupie.viewbinding.BindableItem
import ru.androidschool.template_pattern_demo.MediaContentType.*
import ru.androidschool.template_pattern_demo.databinding.ItemMediaBinding

class MediaContentItem(
    private val content: Media,
    private val onClick: (p: Media) -> Unit
) : BindableItem<ItemMediaBinding>() {

    override fun getLayout() = R.layout.item_media

    override fun initializeViewBinding(view: View): ItemMediaBinding {
        return ItemMediaBinding.bind(view)
    }

    override fun bind(viewBinding: ItemMediaBinding, position: Int) {
        // Общая часть
        viewBinding.title.text = content.title
        // Появились новые типы данных - для них url обложки меняется
        
        if (content.type==MUSIC){
            viewBinding.imagePreview.loadImage(
                // Для музыкальных альбомов загружаем картинку из другого поля
                content.musicAlbumUrl
            )
        } else {
            viewBinding.imagePreview.loadImage(
                content.coverUrl
            )
        }
        viewBinding.rating.rating = content.rating

        // Зависит от типа
        viewBinding.bage.setImageResource(getBageIcon())
        viewBinding.info.text = getInfo()
        viewBinding.genre.text = getGenre()
        
        // Появились новые типы данных - для них скрываем часть UI
        if (content.type==CONCERT_LIVE){
            viewBinding.rating.visibility = View.GONE
        } else {
            viewBinding.rating.visibility = View.VISIBLE
        }
        
        

        viewBinding.content.setOnClickListener {
            when (content.type) {
                BOOK -> openBookStore()
                MOVIE -> openMovieStore()
                GAME -> openGameStore()
                else -> openWebSite()
            }
        }
    }

    @DrawableRes
    fun getBageIcon(): Int {
        return when (content.type) {
            BOOK -> R.drawable.ic_book
            MOVIE -> R.drawable.ic_movie
            GAME -> R.drawable.ic_game
            // Пришлось добавить новую иконку
            else -> R.drawable.ic_default
        }
    }

    fun getInfo(): String {
        return when (content.type) {
            BOOK -> "Кол-во страниц: " + content.mediaContentSize
            MOVIE -> "Кол-во серий: " + content.mediaContentSize
            GAME -> "Время прохождения:" + content.mediaContentSize
            else -> "Описание"
        }
    }

    fun getGenre(): String {
        return when (content.type) {
            BOOK -> getGeneralGenre()
            MOVIE -> getGeneralGenre()
            GAME -> getGameSpecificGenre()
            // Для других типов контента подставляем жанр из модели данных
            else -> content.genre
        }
    }

    fun getGeneralGenre(): String {
        return "Фантастика"
    }

    fun getGameSpecificGenre(): String {
        return "Файтинг"
    }

    fun openBookStore() {
        // TODO
    }

    fun openGameStore() {
        // TODO
    }

    fun openMovieStore() {
        // TODO
    }

    fun openWebSite() {
        // TODO
    }
}

fun ImageView.loadImage(imgUrl: String?) {
    if (!imgUrl.isNullOrEmpty()) {
        Picasso.get()
            .load(imgUrl)
            .into(this)
    }
}

Получается нам пришлось обновить:

Все методы где есть when(type)-так как появились новые типы. Получается мы должны менять уже рабочую логику из-за появления новых типов контента. Что не соответствует принципам SOLID

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

Наша ячейка MediaContentItem.kt стала еще больше и сложнее для понимания — хотя у нас всего 5 типов контента и простой UI.

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

Паттерн Шаблон или Шаблонный метод

Давайте отрефакторим данный класс, применяя паттерн шаблонный метод.

Шаблонный метод — это поведенческий паттерн проектирования, который определяет скелет алгоритма, перекладывая ответственность за некоторые его шаги на подклассы. Паттерн позволяет подклассам переопределять шаги алгоритма, не меняя его общей структуры.

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

Шаги реализации:

1. Выделить общие шаги. Продумать какие шаги будут у всех одинаковыми, а какие отличаться.

2.Создать базовый абстрактный класс. Определите в нём шаблонный метод. Этот метод должен состоять из вызовов шагов алгоритма.

3. Добавьте реализацию по умолчанию в базовом классе, а методы которые отличаются оставьте абстрактными для дальнейшей реализации. Таким образом все подклассы должны будут реализовать нужные методы общего алгоритма, а методы по умолчанию будут переиспользоваться.

4. Создайте конкретные классы, унаследовав их от абстрактного класса. Реализуйте в них все недостающие шаги.

Создание абстрактного класса с базовым алгоритмом.

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

  1. Проставить заголовок. Метод bindTitle()
  2. Проставить рейтинг
  3. Проставить картинку и иконку
  4. Проставить доп. информацию
  5. Проставить жанр.

Часть этих шагов (например заголовок) подходит для всех ячеек и не изменяется. А часть незначительно отличается. Например формирование кол-ва страница или серий в зависимости от типа.

После рефакторинга, базовый класс выглядит так:

abstract class BaseMediaContentItem(
    private val content: Media,
    private val onClick: (p: Media) -> Unit
) : BindableItem<ItemMediaBinding>() {

    protected lateinit var viewBinding: ItemMediaBinding

    override fun getLayout() = R.layout.item_media

    override fun initializeViewBinding(view: View): ItemMediaBinding {
        viewBinding = ItemMediaBinding.bind(view)
        return viewBinding
    }

    private fun bindTitle() {
        viewBinding.title.text = content.title
    }

    open fun bindRating() {
        viewBinding.rating.rating = content.rating
        viewBinding.rating.visibility = View.VISIBLE
    }

    // Так как эта логика отличается - то делаем метод абстрактным
    abstract fun bindCoverImage()
    abstract fun bindBage()
    abstract fun bindInfo()
    abstract fun bindGenre()
    abstract fun navigate()


    override fun bind(viewBinding: ItemMediaBinding, position: Int) {
        // Общая часть
        bindTitle()
        bindRating()

        // Появились новые типы данных - для них url обложки меняется
        // Но логика находится в подклассах
        bindCoverImage()

        // Логика зависит от типа
        // каждый подкласс реализует по-своему
        bindBage()
        bindInfo()
        bindGenre()

        viewBinding.content.setOnClickListener {
            navigate()
        }
    }


    fun getGeneralGenre(): String {
        return "Фантастика"
    }

    fun getGameSpecificGenre(): String {
        return "Файтинг"
    }
}

fun ImageView.loadImage(imgUrl: String?) {
    if (!imgUrl.isNullOrEmpty()) {
        Picasso.get()
            .load(imgUrl)
            .into(this)
    }
}

Согласитесь, теперь этот код выглядит гораздо лучше:

После рефакторинга

Каждый подкласс реализуют только специфичную для него логику:

Каждая ячейка реализует специфичную для нее логику. При этом переиспользует общую логику абстрактного класса.

Вот так выглядит код для ячейки с логикой для концертов:

Ячейка для отображения концертов реализует специфичную для такого вида контента логику

Таким образом мы добились:

Независимость ячеек друг от друга. Изменение логики ячейки для книги никак не затрагивает ячейку с игрой.

Избежали дублирования, так как вынесли общую логику в базовый абстрактный класс.

Плюсы такого подхода:

1. Облегчает повторное использование кода.

2. Ячейки не зависимы, проще модифицировать код.

Минусы такого подхода:

1. Вы жёстко ограничены скелетом существующего алгоритма.

2. Вы можете нарушить принцип подстановки Барбары Лисков, изменяя базовое поведение одного из шагов алгоритма через подкласс. Например мы скрываем рейтинг для ячейки с концертом.

3. Большое количество подклассов.

Надеюсь, в этой статье вы уловили суть паттерна Template и сможете писать более читабельный и эффективный код на Kotlin. Ну а если вы хотите прокачаться в Android-разработке, то приглашаю вас на следующий поток интенсива по Android-разработке на Kotlin. Узнать подробности можно по ссылке 👈: