멋쟁이사자처럼 iOS 앱 스쿨에서 최종 프로젝트로 단어장 앱을 만들면서,
AVSpeechSynthesizer를 활용하여 TTS 기능을 구현하려고 했다.
하지만 구현 과정에서 예상치 못한? 오류를 만났고 생각보다 골머리를 썩였다.
코드 상으로는 전혀 문제가 없었는데 말이다. (진짜로? 문제 없는 거 맞아?)
그렇게 고민하던 중, 결국 View Memory Graph Hierarchy를 통해 오류를 해결할 수 있었다.
처음 써봤는데 정말 유용한 친구라는 걸 깨달았다.
아무튼 오늘은 메모리 관련 이슈가 있을 때 사용하면 정말 유용한 친구를 소개하고자 한다.
그 전에, AVSpeechSynthesizer는 어떻게 동작하는지에 대해 알아보도록 하자.
AVSpeechSynthesizer의 동작 과정
코드로는 이렇게 표현할 수 있다.
이 코드가 실제로 어떻게 동작할까?
우선 AVSpeechSynthesizer에는 AVSpeechUtterance를 담을 수 있는 그릇(Queue)이 존재한다.
이곳에 AVSpeechUtterance 인스턴스가 담기고,
speak 메서드를 호출하면 Queue에서 Pop되면서 TTS가 작동하게 된다.
애니메이션으로는 이렇게 표현할 수 있다.
문제 상황 🧐
뭐, 정말 별 거 없다.
위 애니메이션만 보면 '아... 이렇게 동작하는구나...' 정도로 생각할 수도 있다.
간단한 애니메이션처럼 실제로 코드 상으로도 간단하게 구현할 수 있다. (간단할 줄 알았다...)
우선, 우리 앱에서의 단어 듣기 모드에는 세 가지 타입이 존재한다.
1. 선택한 단어 듣기(1개)
2. 선택한 단어 듣기(여러 개)
3. 전체 단어 듣기
1개의 단어만 들을 때(1번)는 TTS가 실행, 종료된 이후에 다시 TTS를 실행했을 때 아무 문제가 없었다.
하지만 2번과 3번처럼, TTS가 온전히 종료하기 전에 NavigationItems에 있는 "취소" 버튼으로 TTS를 강제 종료하는 순간 문제가 발생했다.
이렇게 TTS를 강제로 종료하게 되면 이후에 TTS가 실행되지 않는 치명적인 오류였다.
아래와 같은 오류를 내뿜으며 TTS가 작동하지 않았다.
아, 이거다!
구글링을 해봐도 내가 원하는 답변을 찾을 수 없었다.
그러던 중, 멘토님께서 하신 말씀이 머리를 스쳐 지나갔다.
'Xcode에서 메모리와 관련된 디버그 툴을 제공한다...'
아, 이거다!
평소에 자주 사용하던 View Hierarchy와 같은 곳에 존재했다.
아무튼 이 친구를 통해서 메모리에 어떤 인스턴스들이 올라가있나 직접 확인해봤고,
문제가 발생할 때와 발생하지 않을 때를 비교해봤다.
왼쪽 사진은 정상적으로 TTS가 종료됐을 때의 모습이고,
오른쪽은 취소 버튼을 눌러서 TTS를 강제 종료됐을 떄의 모습이다.
빨간 밑줄 친 저 놈, AVSpeechUtterance가 생생하게 살아있는 것을 목격했다.
그래서 얘를 메모리에서 제거하면 TTS가 정상적으로 실행될 거라는 확신이 생겼다.
그래서, 어떻게 제거할건데?
ARC를 활용하고자 했다.
wordUtterance를 nil로 만들어 줌으로써,
ARC에 의해 메모리에서 자동으로 제거되는 방향으로 접근했다.
하지만 아까 위에서 동작 원리에서 봤던 것처럼
AVSpeechSynthesizer에서 wordUtterance에 대한 참조를 갖고 있었고,
이로 인해 RC가 발생하여 ARC가 해당 인스턴스를 제거하지 않았다.
그래서 다른 방식으로 접근했다.
wordUtterance를 제거하는 것이 아니라 이를 담고 있는 AVSpeechSynthesizer 자체를 제거하는 방향으로 말이다.
결과는 성공적이었다.
앞서 발생한 문제를 말끔하게 해결할 수 있었다! 😀
디버깅이 아니였다면 해결하지 못한 이슈로 남았을 것이다...
앞으로도 자주 활용해서, 메모리 관련 이슈를 말끔하게 해결해보자! 💪
직접 부딪히고, 찾아보면서 작성한 글이라 잘못된 정보가 있을 수 있습니다.
피드백은 언제든지 환영입니다. 감사합니다. 😇
전체 코드
import AVFoundation
import Foundation
enum SpeechType {
case single
case many
}
protocol TTSProtocol {
/// 단어 하나를 읽어주는 메서드
func speakWordAndMeaning(_ word: Word, to language: String, _ type: SpeechType)
/// 여러개의 단어를 읽어주는 메서드
func speakWordsAndMeanings(_ words: [Word], to language: String)
/// 단어 읽기를 멈추는 메서드
func stopSpeaking()
}
final class SpeechSynthesizer: NSObject, ObservableObject, TTSProtocol {
// 싱글톤으로 정의
private var instance: AVSpeechSynthesizer? = AVSpeechSynthesizer()
// 단어와 의미 사이의 간격 (같은 단어 내에서)
private let intervalOfWordAndMeaning = 0.3
// 의미와 의미 사이의 간격 (같은 단어 내에서)
private let intervalOfMeaningAndMeaning = 0.1
// 단어와 단어 사이의 간격 (다른 단어끼리)
private let intervalOfWordAndWord = 0.5
// TODO: - 사용자가 설정한 언어에 따라 동적으로 뱌뀌는 코드 추가
private let meaningUtteranceLanguage = "ko-KR"
// 재생할 단어의 개수를 저장
private var totalWordsCount: Int = 0
private var spokenWordsCount: Int = 1
// 단어 듣기의 재생 여부를 확인
@Published var isPlaying = false
override init() {
super.init()
instance?.delegate = self
}
/// 단어 하나를 읽어주는 메서드
func speakWordAndMeaning(_ word: Word, to language: String, _ type: SpeechType) {
if instance == nil {
instance = AVSpeechSynthesizer()
instance?.delegate = self
}
let wordUtterance = AVSpeechUtterance(string: word.word ?? "") // TTS로 들려줄 단어 설정
wordUtterance.voice = AVSpeechSynthesisVoice(language: language) // 언어 설정
wordUtterance.postUtteranceDelay = intervalOfWordAndMeaning // 다음 단어와의 시간 간격 설정
instance?.speak(wordUtterance) // TTS 작동
if type == SpeechType.single { return } // contextMenu로 접근한 경우
// meaning 타입이 [String?]라서 순회하는 방식으로 구현
word.meaning?.forEach { meaning in
let meaningUtterance = AVSpeechUtterance(string: meaning)
meaningUtterance.voice = AVSpeechSynthesisVoice(language: meaningUtteranceLanguage)
meaningUtterance.postUtteranceDelay = intervalOfMeaningAndMeaning
instance?.speak(meaningUtterance)
}
}
/// 여러 개의 단어를 읽어주는 메서드
/// 단어 하나를 읽어주는 메서드를 재사용하는 방식으로 구현
func speakWordsAndMeanings(_ words: [Word], to language: String) {
totalWordsCount = calculateWordsAndMeanings(words) // 단어 개수 카운트
spokenWordsCount = 1
isPlaying = true // 재생 상태로 변경
words.forEach { word in
self.speakWordAndMeaning(word, to: language, .many)
}
}
/// 단어 읽기를 멈추는 메서드 (onDisapper)
/// TTS가 작동 중인 상태였으면 (선택 듣기, 전체 듣기 도중 종료) AVSpeechSynthesizer를 초기화하여 오류를 방지
func stopSpeaking() {
instance?.stopSpeaking(at: .immediate)
instance = nil // 인스턴스 초기화
isPlaying = false // 정지 상태로 변경
}
/// 재생할 단어의 개수를 계산하는 메서드
private func calculateWordsAndMeanings(_ words: [Word]) -> Int {
var cnt = 0
for word in words {
cnt += 1
for _ in word.meaning ?? [] {
cnt += 1
}
}
return cnt
}
}
extension SpeechSynthesizer: AVSpeechSynthesizerDelegate {
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
// 재생이 완료된 단어의 개수가 총 단어의 개수와 일치하면 정지
if totalWordsCount == spokenWordsCount {
isPlaying = false
} else {
spokenWordsCount += 1
}
}
}
광고
기본 앱스럽지만(의도한 디자인입니다 ㅎㅎ) 숨겨진 기능이 많습니다.
많이 사용해주시고, 저희가 발견하지 못한 버그가 있을 경우에 언제든지 제보해주시면 감사하겠습니다. 😽
TheVoca
## 주요기능 손쉽게 단어를 추가하고, 단어장으로 묶어서 관리하세요. - 내가 공부하고 있는 단어와 의미를 직접 하나하나 등록할 수 있습니다. - 또한 많은 단어를 한 번에 등록하기 위해 엑셀
apps.apple.com
'💻 개발 > iOS' 카테고리의 다른 글
[iOS / SwiftUI] MapKit, 실시간으로 도로명 주소 변환하기 (2) | 2022.12.20 |
---|---|
[iOS / SwiftUI] OnAppear, OnDisappear는 언제 호출될까? (1) | 2022.12.01 |
[iOS / SwiftUI] 스크롤, 무한으로 즐겨요~ (LazyVStack으로 무한 스크롤 구현하기) (0) | 2022.11.28 |
[iOS / Swift] Swift 문자열 정복하기 (aka 'Character') (0) | 2022.11.03 |
[iOS / SwiftUI] 다양한 상태 프로퍼티들을 알아보자! (0) | 2022.10.24 |