상세 컨텐츠

본문 제목

토스트 메시지 싱글톤으로 관리하기

iOS

by 쑤야. 2023. 12. 19. 23:39

본문

 

A/B 프로젝트에서 알림 UI로 토스트 메시지를 사용한다.

 

문제를 해결해 나가는 과정에서 운영체제 개념들이 많이 생각났고, 고민이 꽤 많았었는데 요구사항을 충족해 나가는 과정, 그리고 문제 상황에서 고민하고 해결한 과정들을 기록해 본다.

 

요구사항 1. 토스트 메시지는 한 번에 하나씩만 보여준다. 


한 번에 하나씩 처리하기 위해서, 싱글톤 인스턴스를 사용해야겠다는 생각을 가장 먼저 했다. 

 

 

방법 1. semaphore → 실패


 

방법 2. 뮤텍스 → 성공


isAnimating을 임계 구역에 진입할 수 있는 키로 사용하였다. 

public final class ToastMessage {

     private init() { }

     private static var isAnimating = false
     private static let toastMessageView = ToastMessageView()

     public static func show(message: String) {

         if isAnimating { return }

         isAnimating = true

         setMessage()
         setLayout()
         startAnimation()
	}
    
    //애니메이션이 종료될 때 isAnimating = false 처리
 }

 

이 코드는 애니메이션 중, 새로운 메시지가 도착할 경우 무시해 버린다는 문제점이 있다. 

회의 때 로직을 어떻게 처리할지 의논했는데, 방출된 메시지는 모두 보여주기로 했다. 

이 부분을 구현하는 과정은 요구사항 2에서 정리하겠다. 

 

 

요구사항 2. 애니메이션 도중에 새로운 메시지가 도착한다면, 저장해 놨다가 하나씩 보여줘야 한다. 


메시지를 보관하는 것은 큐를 사용하면 된다. 

내가 고민했던 부분은 애니메이션이 끝난 후, 큐에 아직 보여주지 않은 메시지가 있다면 어떻게 처리할 것인가? 였다. 

 

방법 1. 반복문 → 실패


가장 먼저 생각한 방법은 큐가 비어있는지 여부를 조건으로 하는 반복문을 사용하는 것이었다. 

 

아래와 같이 isAnimating으로 lock을 거는 것은 busy waiting이 될 수 있기에 다른 방식을 생각했다.

(busy waiting이 발생하는 경우, 로그가 무한히 찍히다 보니 프로그램이 맛탱이가 간다. 이 현상이 너무 싫기 때문에.. ^^ 루프를 돌더라도 최대한 적은 횟수가 되도록 하는 것을 목표로 했다.)

while isAnimating {

}

 

isAnimating이 아닌 경우, 애니메이션을 실행할 수 있는데 애니메이션 실행이 끝나고 나서 남아있는 메시지가 있는 경우에 다시 애니메이션을 실행시키도록 로직을 작성했다.

private static var isAnimating = false
private static var messageQueue: [String] = []
private static let toastMessageView = ToastMessageView()

public static func show(message: String) {
    
    messageQueue.append(message)
    
    if isAnimating { return }

    let topViewController = topViewController()
    
    repeat {
    
        isAnimating = true
        
        setMessage()
        setLayout()
        startAnimation()
        
    } while !messageQueue.isEmpty
    
    func startAnimation() {
            
        startShowingAnimation()
            
        func startShowingAnimation() {
                ...
          //completion에서 startHidingAnimation 호출
        }
            
        func startHidingAnimation() {
		    ...
        }
    }
}

메시지 1,2,3을 1-2초 정도씩 시간차가 나도록 하여 테스트해 보았다. 

 

메시지 3 애니메이션이 실행되지 않았다.

각 단계에 로그를 넣어 출력해 보았는데, 이를 통해 제대로 동작하지 않는 이유를 알 수 있었다.

메시지: 2
startShowingAnimation 호출
애니메이션 블럭1 시작
애니메이션 블럭1 종료
반복문 블록 종료
애니메이션 블럭2 시작
애니메이션 블럭2 종료
메시지: 3
애니메이션 블럭2 completion 시작 
애니메이션 블럭2 completion 종료 //메시지 2에 대한 종료

 

내가 기대한 것은 애니메이션이 모두 끝난 후, startAnimation 메서드가 종료되는 것이었다.

하지만 애니메이션 블록 1이 종료되고, repeat - while 문이 종료된 것이다. 

 

이유는 비동기 프로그래밍 때문이었다. 

 

첫 번째 애니메이션에서 completion으로 두 번째 애니메이션이 시작하도록 코드를 구현하였다. 

하지만 animate 메서드는 비동기다. 그리고 completion도 비동기다.

func startShowingAnimation() {
    UIView.animate(
        withDuration: 0.5,
        delay: 0,
        options: .curveEaseOut,
        animations: {
            self.topViewController?.isUserInteractionEnabled = false
            self.toastMessageView.transform = CGAffineTransform(translationX: 0, y: self.toastMessageView.bounds.height)
        },
        completion: { _ in
            startHidingAnimation()
        }
    )
}

completion을 비동기라고 표현하는 것이 맞는지 조금 애매하긴 한데, 포인트는 animate가 종료된 이후 @escaping인 completion이 실행된다는 것이다. 즉, animations의 종료 후 바로 completion이 실행되지 않고 다른 실행 흐름을 처리하다 실행될 수 있으며, 위의 경우가 이에 해당한다고 볼 수 있다. 

 

방법 2. 실행 함수 재호출 → 성공


방법 1의 실패를 통해 completion의 실행 이후 또는 completion 내에서 재실행 할 수 있는 방법에 대해 생각해 보았다. 

 

A. isAnimating을 Published 프로퍼티로 설정

@Published private var isAnimating = false

$isAnimating
    .dropFirst()
    .filter{ !$0 }
    .receive(on: DispatchQueue.main)
    .sink{ _ in
        if self.messageQueue.isEmpty { return }
        self.show()
    }
    .store(in: &cancellable)

 

애니메이션 2가 끝난 후, isAnimating을 해제하는데 isAnimating 값이 false이고, 큐에 남아있는 메시지가 있는 경우 show 메서드를 재호출 한다. 

 

B.  completion에서 restart 메서드 호출

 

A의 방식의 경우 이미 사용하고 있는 isAnimating 값을 publisher로 사용한다는 점에서 효율적(?)이지만, 반대로 completion 내에 상태를 점검하고 재호출하는 로직이 없어서 코드 이해나 가독성 측면에서 조금 떨어질 수 있겠다는 생각을 했다. 

 

개선 방안으로는 restart 메서드를 isAnimating 값을 해제한 이후 호출해 주는 것이다. 

private func startHidingAnimation() {
    UIView.animate(
        withDuration: 0.5,
        delay: 2,
        options: .curveEaseOut,
        animations: {
            self.toastMessageView.transform = .identity
        }, completion: { _ in
            defer {
                self.isAnimating = false
                self.restart()
            }
            self.topViewController = nil
            self.toastMessageView.removeFromSuperview()
            self.topViewController?.isUserInteractionEnabled = true
        }
    )
}

private func restart() {
    if !messageQueue.isEmpty {
        show()
    }
}

 

 

최종 코드는 아래 첨부한다. 

더보기
public final class ToastMessage {
    
    public static let shared: ToastMessage = ToastMessage()
    
    private init() { }
    
    private var isAnimating = false
    private var topViewController: UIWindow?
    private var messageQueue: [String] = []
    private let toastMessageView = ToastMessageView()
    private var cancellable: Set<AnyCancellable> = []
    
    public func register(message: String) {
        messageQueue.append(message)
        show()
    }
    
    private func show() {
        
        if isAnimating {
            return
        } else {
            isAnimating = true
        }
        
        prepare()
        start()
        
        func prepare() {
            
            self.topViewController = topViewController()
            setMessage()
            setLayout()
            
            func topViewController() -> UIWindow? {
                UIApplication
                    .shared
                    .connectedScenes
                    .compactMap{ ($0 as? UIWindowScene)?.keyWindow }
                    .last
            }
            
            func setMessage() {
                toastMessageView.messageLabel.text = messageQueue.removeFirst()
            }
            
            func setLayout() {
                self.topViewController?.addSubview(self.toastMessageView)
                self.toastMessageView.snp.makeConstraints{
                    $0.bottom.equalTo(self.topViewController!.snp.top)
                    $0.leading.trailing.equalToSuperview()
                }
            }
        }
        
        func start() {
            
            startShowingAnimation()
            
            func startShowingAnimation() {
                UIView.animate(
                    withDuration: 0.5,
                    delay: 0,
                    options: .curveEaseOut,
                    animations: {
                        self.topViewController?.isUserInteractionEnabled = false
                        self.toastMessageView.transform = CGAffineTransform(translationX: 0, y: self.toastMessageView.bounds.height)
                    },
                    completion: { _ in
                        startHidingAnimation()
                    }
                )
            }
            
            func startHidingAnimation() {
                UIView.animate(
                    withDuration: 0.5,
                    delay: 2,
                    options: .curveEaseOut,
                    animations: {
                        self.toastMessageView.transform = .identity
                    }, completion: { _ in
                        defer {
                            self.isAnimating = false
                            restart()
                        }
                        self.topViewController = nil
                        self.toastMessageView.removeFromSuperview()
                        self.topViewController?.isUserInteractionEnabled = true
                    }
                )
            }
        }
        
        func restart() {
            if !messageQueue.isEmpty {
                show()
            }
        }
    }
}

관련글 더보기