SwiftUI) MVI 패턴

1. MVI 패턴이란?

MVI (Model-View-Intent)는 단방향 데이터 흐름을 강조하는 아키텍처 패턴입니다. 주로 UI 상태 관리 및 사용자 상호작용을 보다 명확하고 예측 가능하게 만드는 데 사용됩니다. 이 패턴은 데이터를 화면에 표시하고, 사용자 입력을 처리하며, 모델을 업데이트하는 모든 과정이 일방향으로 흐르는 구조입니다.

MVI는 모델을 중심으로 데이터를 관리하고, 뷰(View)는 모델의 상태에 따라 UI를 그리며, 인텐트(Intent)는 사용자의 상호작용을 받아 모델에 액션을 전달합니다. 이 모든 과정은 단방향으로 연결되므로 데이터 흐름을 추적하고 관리하기가 용이합니다.

2. MVI 패턴의 핵심 요소

Model: 상태 관리의 중심

모델은 앱의 상태를 관리하는 역할을 합니다. 이 상태는 UI와 사용자 상호작용을 기반으로 변경되며, 모델은 이 데이터를 모든 UI 구성 요소에 전달합니다. SwiftUI에서 모델은 ObservableObject로 구현될 수 있습니다.

View: UI 업데이트 처리

뷰는 모델의 상태를 UI에 반영하는 역할을 합니다. SwiftUI에서 뷰는 모델에서 제공된 상태를 바탕으로 사용자에게 UI를 렌더링합니다. SwiftUI의 선언형 구조는 상태 기반 UI 업데이트에 최적화되어 있어, MVI 패턴과 자연스럽게 어우러집니다.

Intent: 사용자 상호작용과 비즈니스 로직

Intent는 사용자 상호작용을 받아 처리하고, 이를 통해 모델의 상태를 변경하는 역할을 합니다. 예를 들어 버튼 클릭이나 텍스트 입력과 같은 상호작용을 감지한 후, 그에 맞는 액션을 모델에 전달합니다. SwiftUI에서는 Action이나 Binding을 통해 Intent를 구현할 수 있습니다.

3. SwiftUI와 MVI 패턴 적용 방법

SwiftUI) SFSpeechRecognizer를 통해 오디오 파일 STT 구현 에서 구현한 내용을 MVI 패턴으로 리펙토링하며 공부했습니다.

Model 구현

enum STTFileResult {
    case success(url: URL?)
    case failure
}

protocol STTModelActionsProtocol {
    func changeFileResultState(_ result: STTFileResult)
    func changeIsOpenFileImporterState()
    func changeSTTText(text: String)
    func getUrlPath(url: (URL?) -> ())
}

final class STTTestModel: ObservableObject {
    @Published var fileResult: STTFileResult = .failure
    
    @Published var isOpenFileImporter: Bool = false
    
    @Published var sttText: String = ""
}

extension STTTestModel: STTModelActionsProtocol {
    
    
    func changeFileResultState(_ result: STTFileResult) {
        self.fileResult = result
    }
    
    func changeIsOpenFileImporterState() {
        isOpenFileImporter.toggle()
    }

    func changeSTTText(text: String) {
        self.sttText = text
    }
    
    func getUrlPath(url: (URL?) -> ()) {
        if case let .success(fileURL) = fileResult {
              url(fileURL)
          } else {
              url(nil)
          }
    }
}

모델은 ObservableObject를 채택하여 SwiftUI와 자연스럽게 상태를 공유할 수 있습니다. 또한, STTModelActionsProtocol이라는 프로토콜을 통해 모델의 상태를 변경하는 액션을 명시적으로 정의하여 다른 클래스가 모델의 세부 사항을 알지 않고도 상태를 조작할 수 있게 합니다.

View 구현

struct STTTestView: View {
    @StateObject var model: STTTestModel
    private var intent: STTTestIntent
    
    @Binding var isPresented: Bool
    
    init(isPresented: Binding<Bool>) {
        _isPresented = isPresented
        let model = STTTestModel()
        _model = StateObject(wrappedValue: model)
        self.intent = STTTestIntent(model: model)
    }
    
    var body: some View {
        VStack{
            
            Text("변환된 값: \(model.sttText)")

            
            switch model.fileResult {
            case .success(let url):
                Text("값 존재 \(url?.absoluteString ?? "URL 없음")")
            case .failure:
                Text("에러")
            }

            Button {
                intent.startSTT()
            } label: {
                Text("변환 시작")
            }
            
            
            Button {
                intent.openFileImpoter()
            } label: {
                Text("파일 열기")
            }
            .fileImporter(isPresented: $model.isOpenFileImporter, allowedContentTypes: [.item]) { result in
                switch result {
                case .success(let url):
                    let gotAccessURL =  url.startAccessingSecurityScopedResource()
                    if !gotAccessURL { return }
                    
                    intent.getFileResult(result: .success(url))
                case .failure(let failure):
                    intent.getFileResult(result: .failure(failure))
                }
            }
        }
    }
}

뷰는 SwiftUI의 선언적 문법을 사용하여 모델의 상태를 UI에 반영합니다. 버튼 클릭과 같은 사용자 상호작용은 Intent를 통해 모델에 전달되어 상태를 변경하게 됩니다.

Intent 구현

protocol STTTestIntentProtocol {
    func openFileImpoter()
    func getFileResult(result: Result<URL, Error>)
    func startSTT()
}

final class STTTestIntent {
    private var model: STTModelActionsProtocol?
    
    init(model: STTModelActionsProtocol) {
        self.model = model
    }
}

extension STTTestIntent: STTTestIntentProtocol {
    func getFileResult(result: Result<URL, Error>) {
        switch result {
        case .success(let url):
            model?.changeFileResultState(.success(url: url))
        case .failure(_):
            model?.changeFileResultState(.failure)
        }
    }
    
    func openFileImpoter() {
        model?.changeIsOpenFileImporterState()
    }
    
    func startSTT() {
        SFSpeechRecognizer.requestAuthorization { [weak self] authStatus in
            DispatchQueue.main.async {
                if authStatus == .authorized {
                    print("음성 인식 권한이 부여되었습니다.")
                    
                    self?.model?.getUrlPath(url: { url in
                        self?.transcribeFile(url: url) { result in
                            switch result {
                            case .success(let success):
                                self?.model?.changeSTTText(text: success)
                            case .failure(let failure):
                                self?.model?.changeSTTText(text: failure.localizedDescription)
                            }
                        }
                    })
                    
                } else {
                    print("음성 인식 권한이 거부되었습니다.")
                }
            }
        }
    }
    
    private func transcribeFile(url: URL?, completion: @escaping (Result<String, Error>) -> ()) {
        guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "ko_KR")) else {
            return
        }
        guard let url = url else { return }
        
        if !recognizer.isAvailable {
            return
        }
        
        let request = SFSpeechURLRecognitionRequest(url: url)
        recognizer.recognitionTask(with: request) { (result, error) in
            
            guard let result = result else {
                completion(.failure(error!))
                return
            }
            
            if result.isFinal {
                completion(.success(result.bestTranscription.formattedString))
            }
        }
    }
}

Intent는 모델과의 상호작용을 처리하는 중요한 역할을 담당합니다. 여기서는 모델을 조작할 수 있는 프로토콜에 의존하므로, 유연하고 테스트 가능한 구조를 유지할 수 있습니다.

Leave a Comment