Стейт машина и паттерн Состояние

В данной заметке я опишу использование стейт машины (Машину состояний), покажу простую реализацию, реализацию с использованием паттерна Состояние. Стоит оговориться, что использовать паттерн Состояние нежелательно при наличии меньше трех состояний, т.к. обычно это приводит к излишнему усложнению читаемости кода, сопутствующими проблемам в поддержке – все должно быть в меру.

MEAACT PHOTO / STUART PRICE.

Повелитель флажков

Допустим мы разрабатываем экран видеоплеер для медиасистемы гражданского самолета, плеер должен уметь загружать видео поток, проигрывать его, позволять пользователю останавливать процесс загрузки, перематывать и производить прочие операции обыкновенные для плеера.
Допустим плеер закэшировал очередной чанк видеопотока, проверил что чанков достаточно для воспроизведения, начал воспроизводить пользователю фрагмент и одновременно продолжает загрузку следующего.
В этот момент пользователь перематывает в середину ролика, тоесть теперь нужно остановить проигрывание текущего фрагмента, и начать загрузку с новой позиции. Однако существуют ситуации в которых такое делать нельзя – пользователь не может управлять проигрыванием видеопотока, пока ему показывают видеоролик о безопасности в воздухе. Заведем флажок isSafetyVideoPlaying для проверки данной ситуации.
Также система должна уметь ставить на паузу текущее видео и транслировать оповещение от капитана судна и экипажа через плеер. Заведем еще один флажок isAnnouncementPlaying. Плюс ко всему появилось требование не приостанавливать воспроизведение во время вывода справки по работе с плеером, еще один флажок isHelpPresenting.

Псевдокод примера медиаплеера:


class MediaPlayer {

    public var isHelpPresenting = false
    public var isCaching = false
    public var isMediaPlaying: Bool = false
    public var isAnnouncementPlaying = false
    public var isSafetyVideoPlaying = false

    public var currentMedia: Media = null

    fun play(media: Media) {

        if isMediaPlaying == false, isAnnouncementPlaying == false, isSafetyVideoPlaying == false {

            if isCaching == false {
                if isHelpPresenting == false {
                    media.playAfterHelpClosed()
                }
                else {
                    media.playAfterCaching()
                }
            }
    }

    fun pause() {
        if isAnnouncementPlaying == false, isSafetyVideoPlaying == false {
            currentMedia.pause()
        }
    }
}

Приведенный выше пример плохо читаем, такой код сложно поддерживать из-за большой вариативности (энтропии) Данный пример основывается на моем опыте работы с кодовой базой *многих* проектов, где не использовалась стейт-машина.
Каждый флажок должен по особенному “управлять” элементами интерфейса, бизнес-логики приложения, разработчик, добавляя очередной флажок, должен уметь жонглировать ими, проверяя и перепроверяя все по нескольку раз со всеми возможными вариантами.
Подставляем в формулу “2 ^ количество флажков” можно получить 2 ^ 6 = 64 варианта поведения приложения для всего 6 флажков, все эти сочетания флажков нужно будет проверять и поддерживать вручную.
Со стороны разработчика добавление нового функционала при такой системе выглядит так:
– Нужно добавить возможность показывать страницу браузера авиакомпании, при этом она должна сворачиваться как с фильмами, если члены экипажа что-то объявляют.
– Ок, сделаю. (Ох чёрт придется добавить еще один флаг, и перепроверить все места где пересекаются флажки, это же сколько всего надо поменять!)

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

Enter The Machine

Если хорошо присмотреться к флажкам, то можно понять что на самом деле мы пытаемся обработать конкретные процессы происходящие в реальном мире. Перечислим их: обычный режим, показ видео безопасности, трансляция сообщения от капитана или членов экипажа. Для каждого процесса известен свод правил, которые меняют поведение приложения.
По правилам паттерна стейт машины (машины состояний), перечислим все процессы как состояния в enum, добавим такое понятие как стейт в код плеера, реализуем поведение на основе стейта, удалив комбинации на флажках. Таким образом мы снизим варианты для тестирования ровно до количества состояний.

Псевдокод:


enum MediaPlayerState {
	mediaPlaying,
	mediaCaching,
	crewSpeaking,
	safetyVideoPlaying,
	presentingHelp
}

class MediaPlayer {
	fun play(media: Media) {
		media.play()
	}

	func pause() {
		media.pause()
	}
}

class MediaPlayerStateMachine {
	public state: MediaPlayerState
	public mediaPlayer: MediaPlayer
	public currentMedia: Media

	//.. init (mediaPlayer) etc

	public fun set(state: MediaPlayerState) {
		switch state {
			case mediaPlaying:
				mediaPlayer.play(currentMedia)
			case mediaCaching, crewSpeaking,
			safetyVideoPlaying, presentingHelp:
				mediaPlayer.pause()
		}
	}
}

Огромная разница между флажковой системой и стейт-машиной заключается в воронке переключения логического состояния в методе set(state: ..), оно позволяет транслировать человеческое понимание состояния в программный код, без необходимости играть в логические игры по конвертации флажков в состояния при дальнейшей поддержке кода.

Паттерн Состояние

Далее я покажу разницу между наивной имплементацией стейт машины и паттерном состояние. Представим что понадобилось добавить 10 стейтов, в итоге класс стейт-машины разрастется до размеров godobject, его будет сложно и затратно поддерживать. Конечно данная реализация лучше флажковой, (при флажковой системе застрелится сначала разработчик, а если и нет, то увидев 2 ^ 10 = 1024 вариаций повесится QA, однако если они оба *не заметят* сложности задачи, то ее заметит пользователь, у которого приложение просто откажется работать при определенном сочетании флажков)
При большом количестве состояний необходимо использовать паттерн Состояния.
Вынесем свод правил в протокол Состояния:


protocol State {
    func playMedia(media: Media, context: MediaPlayerContext)
    func shouldCacheMedia(context: MediaPlayerContext)
    func crewSpeaking(context: MediaPlayerContext)
    func safetyVideoPlaying(context:MediaPlayerContext)
    func presentHelp(context: MediaPlayerContext)
}

Вынесем реализацию свода правил в отдельные состояния, для примера код одного состояния:


class CrewSpeakingState: State {
	func playMedia(context: MediaPlayerContext) {
		showWarning(“Can’ t play media - listen to announce!”)
	}

	func mediaCaching(context: MediaPlayerContext) {
		showActivityIndicator()
	}

	func crewSpeaking(context: MediaPlayerContext) {
		set(volume: 100)
	}

	func safetyVideoPlaying(context: MediaPlayerContext) {
		set(volume: 100)
	}

	func presentHelp(context: MediaPlayerContext) {
		showWarning(“Can’ t present help - listen to announce!”)
	}
}

Далее создадим контекст с которым будет работать каждый стейт, интегрируем стейт-машину:


final class MediaPlayerContext {
	private
	var state: State

	public fun set(state: State) {
		self.state = state
	}

	public fun play(media: Media) {
		state.play(media: media, context: this)
	}

	…
	остальные возможные события
}

Компоненты приложения работают с контекстом через публичные методы, объекты стейты сами решают из какого стейта в какой осуществлять переход с помощью стейт-машины внутри контекста.
Таким образом мы реализовали декомпозицию God Object, поддерживать изменяющееся состояние будет гораздо проще, благодаря отслеживанию изменений в протоколе компилятором, уменьшения сложности понимания состояний из-за уменьшения количества строк кода и фокусе на решении конкретной задачи состояния. Также теперь можно разделять работу в команде, отдавая реализацию конкретного состояния членам команды, не беспокоясь о необходимости “разруливать” конфликты, что происходит при работе с одним большим классом стейт-машины.

Источники

https://refactoring.guru/ru/design-patterns/state