apple/DesignPattern & Architecture

[Swift] State Pattern

lgvv 2022. 5. 30. 19:26

State Pattern

 

✅ State Pattern

 

아래의 문서를 구입하여 영어 문서를 번역하고 이해한 것을 바탕으로 글을 작성하고 있습니다.

https://www.raywenderlich.com/books/design-patterns-by-tutorials/v3.0/chapters/15-state-pattern

 

 

 

스테이트 패턴은 런타임 시에 객체가 동작을 변경할 수 있도록 하는 패턴입니다. 그것은 현재 상태를 변경함으로써 동작합니다. 여기서 "state"는 주어진 시간에 주어진 객체가 어떻게 행동하는지를 설명하는 data의 집합을 의미합니다.

 

이 패턴은 3가지로 구성됩니다.

 

state pattern에 대한 설명

1. context는 현재 상태가 있고 동작이 변경되는 객체입니다.

2. State Protocol은 필요한 메소드와 속성읠 정의하고 있습니다. 일반적으로 개발자들은 base state class로 프로토콜을 대체합니다. 이렇게 함으로써 base state에 프로토콜에서는 사용이 불가능한 저장 프로퍼티들을 정의할 수 있습니다. 심지어 클래스는 base 클래스를 사용하더라도 직접 인스턴스화 할수는 없습니다.오히려 subclassed을 위한 단일 목적으로 정의됩니다. 다른 언어에서는 이것을 abstract class라고 합니다. 하지만 swift에서는 abstract가 없으므로 이 클래스는 꼭 완벽한 규칙을 따라야 하는 것은 아닙니다.

( * 개인 첨언: protocol로 구현해야 하나, abstract가 없고 실무에서는 기본 상태에 대해 base class로 정의하고 기본값을 주므로, 이게 더 편리하니이론을 완벽히 따르는 것은 아니다.)

3. concrete state는 state 프로토콜을 상속받아 따르거나, 기본 클래스가 대신 사용되는 경우 하위에 분류합니다. context는 현재 상태를 유지하지만 구체적인 상태의 타입을 알지는 못합니다. 대신 context는 다형성을 사용하여 동작을 변경합니다. 구체적인 상태는 context가 작동되는 방식을 정의합니다. 새로운 동작이 필요한 경우에는 concrete state를 정의합니다.

 

여기서 중요한 질문이 남아 있습니다!! 실제 코드에서 현재 상태가 변하는 것은 어디에 코드를 작성해야 할까요? context일까요? concrete state일까요? 아니면 어디든지 okay?

 

state 패턴에서는 상태가 변경되는 로직을 어디에 넣어야 하는지 정해져 있지 않습니다. 그것은 개발자가 스스로 알맞게 결정해야 합니다. 이 패턴의 강점이자 약점입니다. 디자인을 유연하게 만들지만 동시에 이 패턴을 구현하는 방법에 대해 완전환 지침이 없습니다.

 

playground에서는 context 내에서 논리를 배치하고 tutorial project에서는 구체적인 상태 자체를 변경하도록 처리해봅니다.

 

 

When should you use it?

 

상태 패턴을 사용하여 주어진 수명 동안에 두 개 이상의 상태가 있는 시스템을 만듭니다. 상태는 갯수에 제한이 있거나 또는 무제한 일 수 있습니다. 예를 들어서 신호등은 닫힌 상태(갯수에 제한)이 있습니다. 애니메이션은 "애니메이션 상태"로 열집 집한(무제한)으로 정의할 수 있습니다. 왜냐하면 애니메이션이 작동하는 동안에 회전, 번역 및 기타 애니메이션 등 작업할 수 있는 것이 무제한이기 때문입니다.

 

상태 패턴의 개방형 및 폐쇠형 구현은 모둔 다형성을 사용하여 동작을 변경합니다. 결과적으로 이 패턴을 사용한다면 switch if-else문을 제거할 수 있습니다.

( * 개인 첨언: enum으로 처리하는 기술을 연습해봅시다.)

 

context내에서 복잡한 조건을 다 걸어주는 대신에 현재 상태에 대한 호출을 전달합니다. 

switch문 if-else문 대신에 상태 패턴을 사용하여 클래스를 정의하고 사용해 보세요! 결과적으로 더 유연하고 더 쉬운 유지관리 시스템을 만들 수 있습니다:)

 

 

Playground Example

 

 

여기서는 신호등 시스템을 구현합니다. 특히 Core Graphics를 사용하여 신호등을 "현재 상태"에서 녹색에서 노란색 빨간색에서 다시 녹색으로 변경합니다.

 

(참고) CoreGraphics에 대해서 기본적인 이해가 필요합니다. CALayer와 CAShapeLayer에 대해서 적어도 약간은 알고 있어야 합니다. 만약 Core Graphics가 완전히 새롭다면 아래 링크로 가셔서 무료 튜토리얼을 읽어보면 더욱 좋습니다:)

https://www.raywenderlich.com/10317653-calayer-tutorial-for-ios-getting-started

 

CALayer Tutorial for iOS: Getting Started

In this article, you’ll learn about CALayer and how it works. You’ll use CALayer for cool effects like shapes, gradients and particle systems.

www.raywenderlich.com

 

context를 우선 정의합니다.

import UIKit
import PlaygroundSupport

// MARK: - Context
public class TrafficLight: UIView {

  // MARK: - Instance Properties
  // 1
  public private(set) var canisterLayers: [CAShapeLayer] = []

  // MARK: - Object Lifecycle
  // 2
  @available(*, unavailable,
    message: "Use init(canisterCount: frame:) instead")
  public required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) is not supported")
  }

  // 3
  public init(canisterCount: Int = 3,
              frame: CGRect =
                CGRect(x: 0, y: 0, width: 160, height: 420)) {
    super.init(frame: frame)
    backgroundColor =
      UIColor(red: 0.86, green: 0.64, blue: 0.25, alpha: 1)
    createCanisterLayers(count: canisterCount)
  }

  // 4
  private func createCanisterLayers(count: Int) {

  }
}

 

1. 먼저 프로퍼티를 먼저 정의합니다. 이것은 신호등 캐니스터 레이어에 고정됩니다. 이 레이어는 빨강/노랑/초록 상태를 하위 레이어로 유지합니다.

2. 플레이 그라운드를 심플하게 유지하기 위해서 init(coder: )를 지원하지 않습니다.

3. init(canisterCount:frame:)를 지정된 이니셜라이저로 선언하고, 기본값으로 제공합니다.

 

아래 메소드를 추가합니다. 

// 1
let paddingPercentage: CGFloat = 0.2
let yTotalPadding = paddingPercentage * bounds.height
let yPadding = yTotalPadding / CGFloat(count + 1)

// 2
let canisterHeight = (bounds.height - yTotalPadding) / CGFloat(count)
let xPadding = (bounds.width - canisterHeight) / 2.0
var canisterFrame = CGRect(x: xPadding,
                           y: yPadding,
                           width: canisterHeight,
                           height: canisterHeight)

// 3
for _ in 0 ..< count {
  let canisterShape = CAShapeLayer()
  canisterShape.path = UIBezierPath(
    ovalIn: canisterFrame).cgPath
  canisterShape.fillColor = UIColor.black.cgColor

  layer.addSublayer(canisterShape)
  canisterLayers.append(canisterShape)

  canisterFrame.origin.y += (canisterFrame.height + yPadding)
}

 

1, 2번은 신호등의 겉면을 만들기 위한 코드입니다. 3번은 해당 1,2에서 정의된 것들을 가지고 실제로 신호등을 만듭니다.

 

코드가 작동하는지 확인하려면 아래의 코드를 추가하세요!

let trafficLight = TrafficLight()
PlaygroundPage.current.liveView = trafficLight

 

 

만약에 출력이 표시되지 않는다면 liveView를 누르세요

신호등

 

조명 상태를 표시하기 위해서 state protocol을 정의해야 합니다. 

// MARK: - State Protocol
public protocol TrafficLightState: class {

  // MARK: - Properties
  // 1
  var delay: TimeInterval { get }

  // MARK: - Instance Methods
  // 2
  func apply(to context: TrafficLight)
}

1. 먼저 delay상태가 표시 되어야 하는 시간 간격을 정의하는 속성을 선언합니다.

2. 그리고 apply(to: )를 정의합니다. 이건 concrete state에 대한 구현입니다.

 

다음으로는 컴파일 오류를 무시하고 TrafficLight를 바로 뒤에 추가합니다.

public private(set) var currentState: TrafficLightState
public private(set) var states: [TrafficLightState]

 

이름에서 알 수 있듯이 현재 상태와, 상태들에 대한 정보입니다.

 

 

init(canisterCount: frame: )을 바꿉니다.

public init(canisterCount: Int = 3,
            frame: CGRect =
              CGRect(x: 0, y: 0, width: 160, height: 420),
            states: [TrafficLightState]) {

  // 1
  guard !states.isEmpty else {
    fatalError("states should not be empty")
  }
  self.currentState = states.first!
  self.states = states

  // 2
  super.init(frame: frame)
  backgroundColor =
    UIColor(red: 0.86, green: 0.64, blue: 0.25, alpha: 1)
  createCanisterLayers(count: canisterCount)
}

1. state 초기화 되게끔 추가하였습니다. states가 비어 있다는 것은 논리적으로 이해가 되지 않는 문제이므로 fatalError를 냅니다.

2. 그 이후는 기존과 마찬가지로 init을 합니다.

 

public func transition(to state: TrafficLightState) {
  removeCanisterSublayers()
  currentState = state
  currentState.apply(to: self)
}

private func removeCanisterSublayers() {
  canisterLayers.forEach {
    $0.sublayers?.forEach {
      $0.removeFromSuperlayer()
    }
  }
}

 

다음으로는 아래의 코드를 추가합니다.

transition(to: currentState)

 

이렇게 한다면 currentState가 초기화 될 때 뷰가 추가됩니다.

 

이제는 concrete state를 만들어야 합니다. 

// MARK: - Concrete States
public class SolidTrafficLightState {

  // MARK: - Properties
  public let canisterIndex: Int
  public let color: UIColor
  public let delay: TimeInterval

  // MARK: - Object Lifecycle
  public init(canisterIndex: Int,
              color: UIColor,
              delay: TimeInterval) {
    self.canisterIndex = canisterIndex
    self.color = color
    self.delay = delay
  }
}

SolidTrafficLightState를 "solid light" state를 나타내기 위해서 선언합니다. 예를 들어 녹색불이 켜졌다는 것을 나타낼 수 있습니다. 

 

SolidTrafficLightState가 TrafficLightState를 상속받아 구현합니다.

extension SolidTrafficLightState: TrafficLightState {

  public func apply(to context: TrafficLight) {
    let canisterLayer = context.canisterLayers[canisterIndex]
    let circleShape = CAShapeLayer()
    circleShape.path = canisterLayer.path!
    circleShape.fillColor = color.cgColor
    circleShape.strokeColor = color.cgColor
    canisterLayer.addSublayer(circleShape)
  }
}

apply(to: ) 내에서 상태에 대한 새 CAShapeLayer를 생성합니다. 지정된 캐니스터에 대한 캐니스터 Layer와 일치하도록 경로를 설정합니다. 캐니스터Layer와 일치하도록 경로를 설정합니다. 

 

// MARK: - Convenience Constructors
extension SolidTrafficLightState {
  public class func greenLight(
    color: UIColor =
      UIColor(red: 0.21, green: 0.78, blue: 0.35, alpha: 1),
    canisterIndex: Int = 2,
    delay: TimeInterval = 1.0) -> SolidTrafficLightState {
    return SolidTrafficLightState(canisterIndex: canisterIndex,
                                  color: color,
                                  delay: delay)
  }

  public class func yellowLight(
    color: UIColor =
      UIColor(red: 0.98, green: 0.91, blue: 0.07, alpha: 1),
    canisterIndex: Int = 1,
    delay: TimeInterval = 0.5) -> SolidTrafficLightState {
    return SolidTrafficLightState(canisterIndex: canisterIndex,
                                  color: color,
                                  delay: delay)
  }

  public class func redLight(
    color: UIColor =
      UIColor(red: 0.88, green: 0, blue: 0.04, alpha: 1),
    canisterIndex: Int = 0,
    delay: TimeInterval = 2.0) -> SolidTrafficLightState {
    return SolidTrafficLightState(canisterIndex: canisterIndex,
                                  color: color,
                                  delay: delay)
  }
}

여기서 신호등을 만들기 위해 해당 클래스 메소드를 추가합니다.

 

let greenYellowRed: [SolidTrafficLightState] =
  [.greenLight(), .yellowLight(), .redLight()]
let trafficLight = TrafficLight(states: greenYellowRed)
PlaygroundPage.current.liveView = trafficLight

결과

 

신호등은 상태가 전환되어야 합니다. 이 기능은 실제로 구현하지 않았습니다. 개발자가 적절히 넣으며 되는데, 실제로도 두가지 선택지가 있습니다. 첫번째로는 TrafficLight에 넣을 수도 있고 TrafficLightState에 넣을 수도 있습니다.

여기서는 TrafficLight에 넣는게 더 적절하다고 생각되어서 여기에 넣어 보겠습니다.

 

// MARK: - Transitioning
extension TrafficLightState {
  public func apply(to context: TrafficLight, 
                    after delay: TimeInterval) {
    let queue = DispatchQueue.main
    let dispatchTime = DispatchTime.now() + delay
    queue.asyncAfter(deadline: dispatchTime) { 
      [weak self, weak context] in
      guard let self = self, let context = context else { 
        return 
      }
      context.transition(to: self)
    }
  }
}

 

extension은 TrafficLightState를 준수하는 모든 유형에 "apply after" 기능을 추가합니다. apply(to:after:에 적용)에서 전달된 지연 후 DispatchQueue.main으로 디스패치하면 현재 상태로 전환됩니다. retain cycle을 막기 위해서 context도 self로 설정합니다.

 

다음으로는 아래 코드를 추가합니다.

public var nextState: TrafficLightState {
  guard let index = states.firstIndex(where: { 
      $0 === currentState 
    }), 
    index + 1 < states.count else {
      return states.first!
  }
  return states[index + 1]
}

 

이렇게 하면 다음 상태가 없으면 처음으로 돌아갑니다.

nextState.apply(to: self, after: currentState.delay)

 현재 상태가 지나간 후에 자체적으로 적용되도록 구현하였습니다!!

 

What should you be careful about?

 

context와 concrete state사이에 결합을 하는 것에 주의하죠! 다른쪽에서 재사용 하고 싶다면 특정 컨텍스트에서 메소드를 호추랗도록 하는 대신에 concrete state와 context사이세 protocol을 배치하는 것을 고려하세요!

 

대신 상태에서 다른 상태로 전환하고 싶다면 이 경우 초기화나 다른 속성을 통해 다음 상태로 전달하는 것을 고려하세요!

 

Tutorial project

스테이트 패턴은 상당히 실속있게 중요해 보입니다. 이론은 어렵지만 코드를 작성하다보면 엄청 어려운 것은 아닙니다.

 

https://github.com/lgvv/DesignPattern/tree/main/state-pattern

 

GitHub - lgvv/DesignPattern: ✨ 디자인 패턴을 공부합니다!

✨ 디자인 패턴을 공부합니다! Contribute to lgvv/DesignPattern development by creating an account on GitHub.

github.com

 

 

Key Points

이번 장에서는 상태 패턴에 대해서 공부했습니다. 핵심 사항을 정리해 보겠습니다.

1. 상태패턴을 사용하면 객체를 런타임시에 변경할 수 이씃ㅂ니다. 여기서에는 context, state protocol, concrete state 세가지가 포함됩니다.

2. context는 현재 상태를 가진 객체, state protocol은 필요한 메소드와 속성을 정의, concrete state는 상태프로토콜과 런타임에 변경되는 실제 동작을 구현

3. state패턴은 상태가 변경되는 논리를 배치할 곳을 정해주지 않음, 개발자가 알아서 적절한 곳에 배치

 

 

 

(참고)