Archive/패캠(올인원)

ch12 애플뮤직st 음악앱 코드리뷰

lgvv 2021. 6. 24. 15:47

✅ 이번 시간에는 주된 내용은 AVFoundation을 활용한 미디어 객체를 사용하는 작업이야.

확실히 코드리뷰를 하고 지나가야 온전히 내것으로 만드는 느낌이 있어서 진도가 조금 느려지더라도 꼭 하고 지나가는걸로..!

 

AVFoundation에 대해 사용해본 경험이 적어서 이번에는 짚고 넘어가야할 코드가 많은 것 같다..!

익숙하지 않을때는 역시나 애플 개발자 문서를 보면서 지나가보자!

 

애플 공식문서의 AVFoundation에 대한 프레임워크

애플 개발자 문서

 

https://developer.apple.com/documentation/avfoundation/

 

Apple Developer Documentation

 

developer.apple.com

 

그럼 코드 리뷰 시작해보자!!

 

(목차)

1. 컬렉션 뷰 헤더

2. AVFoundation 메타 데이터 추출

3. TrackManager에 대한 설명

4. SimplePlayer와 싱글톤에 대한 설명

5. CMTime에 대해서 알아보자.

6. 버튼의 시스템이미지 사용

7. 다크모드 대응 

 

✅ 우선 첫번째로 컬렉션 뷰 헤더에 대해서 알아보도록 하자

컬렉션 뷰 헤더와 관련한 이미지들

 

저번 시간에도 보았듯이 컬렉션 뷰에는 테이블 뷰와 비슷한 기능에 추가로 레이아웃을 잡아주는 메소드가 있었어.

그러면 헤더는 어떻게 표현할까?

컬렉션 뷰 헤더를 추가하기 위한 라이브러리

저기 파란 부분으로 된 것을 컬렉션 뷰에 추가해야 사용할 수 있어.

Collection Reusable View 설명을 보면 footer에도 사용할 수 있다고 나와있음!

 

그럼 헤더 뷰를 적용하기 위한 메소드를 한번 보도록 할까?

    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        switch kind {
        case UICollectionView.elementKindSectionHeader:
            guard let item = trackManager.todaysTrack else {
                return UICollectionReusableView()
            }
            
            guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "TrackCollectionHeaderView", for: indexPath) as? TrackCollectionHeaderView else {
                return UICollectionReusableView()
            }
            
            header.update(with: item)
            header.tapHandler = { item in
                let playerStoryboard = UIStoryboard.init(name: "Player", bundle: nil) // 스토리보드 파일 객체 생성
                guard let playerVC = playerStoryboard.instantiateViewController(identifier: "PlayerViewController") as? PlayerViewController else { return } // 플레이어스토리보드에서 ID에 해당하는 ViewController 찾기
                playerVC.simplePlayer.replaceCurrentItem(with: item) // 싱글톤 패턴을 이용해서 갈아끼기
                self.present(playerVC, animated: true, completion: nil)
            }
            
            return header
        default:
            return UICollectionReusableView()
        }
    }

헤더뷰는 조금 다르지? viewForSupplementaryElementOfKind 를 포함하고 있는 메소드로 구성해야해.

그리고 헤더뷰는 헤더를 가져오는 것도 조금 다른데 dequeueReusableSupplementaryView 를 사용해야해.

❗️dequeueReusableCell 을 사용하면 안돼!

그리고 switch문을 보면 kind를 설정할 수 있게 해두었는데, 여기서 섹션 헤더나 푸터를 선택할 수 있어.

 

그럼 이제 헤더에 대한 코드를 처리라는 내용을 볼까?

class TrackCollectionHeaderView: UICollectionReusableView {
    @IBOutlet weak var thumbnailImageView: UIImageView!
    @IBOutlet weak var descriptionLabel: UILabel!
    
    var item: AVPlayerItem?
    var tapHandler: ((AVPlayerItem) -> Void)?
    
    override func awakeFromNib() {
        super.awakeFromNib()
        thumbnailImageView.layer.cornerRadius = 4
    }
    
    func update(with item: AVPlayerItem) {
        // TODO: 헤더뷰 업데이트 하기
        self.item = item
        guard let track = item.convertToTrack() else { return } // 변환안되면 그냥 리턴
        
        self.thumbnailImageView.image = track.artwork
        self.descriptionLabel.text = "Today's pick is \(track.artist)'s album - \(track.albumName), Let listen."
    }
    
    @IBAction func cardTapped(_ sender: UIButton) {
        // TODO: 탭했을때 처리
        guard let todaysItem = item else { return }
        tapHandler?(todaysItem)
        
        
    }
}

우선 탭 핸들러 변수는 탭 했을때, 액션을 관리하기 위한 변수야.

awakeFromNib() 코드가 있지? 이 코드는 스토리보드에 있는 아이템에서 실제로 앱 안의 어떤 UICollectionView로 로드될때, 호출되는 메소드야.

아까 헤더뷰에 대한 업데이트가 어떻게 진행되는지 그럼 보자.

convertToTrack() 코드는 AVFoundation의 데이터 타입을 우리가 흔히 사용하는 타입으로 변환해주는 함수를 구현한건데, 잠시뒤에 따로 설명하도록 할게.

 

 

✅ 그럼 AVFoundation에서 메타 데이터를 추출하는 코드를 알아보자

extension AVPlayerItem {
    func convertToTrack() -> Track? {
        let metadatList = asset.metadata // AVPlayItem 객체 안에 asset이라는 프로퍼티가 있고, 거기에는 메타데이터라고 해갖고, 어떤 곡이란 파일이 있을때, 곡 음원도 있고, 곡 음원의 메타데이터라고 해가지고 아티스트 정보, 타이블, 곡의 썸네일 등의 정보도 들어있는데, 이런 정보들을 가져올 수가 있다.
        
        var trackTitle: String?
        var trackArtist: String?
        var trackAlbumName: String?
        var trackArtwork: UIImage?
        
        // 음악파일에서 곡의 메타데이터를 추출해내서 트랙을 만들수가 있다. - 직접 구현할 필요보다 검색해서 쓰는걸로 해요.
        for metadata in metadatList {
            if let title = metadata.title {
                trackTitle = title
            }
            
            if let artist = metadata.artist {
               trackArtist = artist
            }
            
            if let albumName = metadata.albumName {
                trackAlbumName = albumName
            }
            
            if let artwork = metadata.artwork {
                trackArtwork = artwork
            }
        }
        
        guard let title = trackTitle,
            let artist = trackArtist,
            let albumName = trackAlbumName,
            let artwork = trackArtwork else {
                return nil
        }
        return Track(title: title, artist: artist, albumName: albumName, artwork: artwork)
    }
}
 
extension AVMetadataItem {
    var title: String? {
        guard let key = commonKey?.rawValue, key == "title" else {
            return nil
        }
        return stringValue
    }
    
    var artist: String? {
        guard let key = commonKey?.rawValue, key == "artist" else {
            return nil
        }
        return stringValue
    }
    
    var albumName: String? {
        guard let key = commonKey?.rawValue, key == "albumName" else {
            return nil
        }
        return stringValue
    }
    
    var artwork: UIImage? {
        guard let key = commonKey?.rawValue, key == "artwork", let data = dataValue, let image = UIImage(data: data) else {
            return nil
        }
        return image
    }
}

extension AVPlayer {
    var isPlaying: Bool {
        guard self.currentItem != nil else { return false }
        return self.rate != 0
    }
}

 

 

우선 convertToTrack에 대해서 알아보자.

asset을 이용하여 메타데이터에 접근한 뒤, 반복문을 통해 메타데이터를 가져온다.

메타데이터에는 코드에 주석으로 작성해둔 것과 같은 정보가 들어있는데, 만들어도 좋으나 찾아쓰는 것도 하나의 좋은 방법!

AVPlayerItem의 하나의 ViewModel의 형식을 띈다.

isPlaying 변수는 현재 아이템이 실행중인지 아닌지를 확인하는 코드이다. 

 

 

✅ 다음은 TrackManager에 대한 설명이다.

class TrackManager {
    // TODO: 프로퍼티 정의하기 - 트랙들, 앨범들, 오늘의 곡
    var tracks : [AVPlayerItem] = [] // AVPlayerItem으로 구현할 수 있다고 한다.
    var albums : [Album] = []
    var todaysTrack : AVPlayerItem?
    
    
    // TODO: 생성자 정의하기
    init() {
        let tracks = loadTracks()
        self.tracks = tracks
        self.albums = loadAlbums(tracks: tracks)
        self.todaysTrack = self.tracks.randomElement()
    }

    // TODO: 트랙 로드하기
    func loadTracks() -> [AVPlayerItem] {
        // 1. 파일들 읽어서 AVPlayerItem 타입의 형태로 만들기.
        let urls = Bundle.main.urls(forResourcesWithExtension: "mp3", subdirectory: nil) ?? [] // .mp3 파일 가져오는데 없으면 깡통들 세팅해주기 - bundle은 앱 안을 이야기함.
        let items = urls.map { url in
            return AVPlayerItem(url: url)
        }
        return items
    }
    
    // TODO: 인덱스에 맞는 트랙 로드하기
    func track(at index: Int) -> Track? {
        let playerItem = tracks[index] // AVPlayerItem 타입이라서 Track타입으로 바꿔야 해
        return playerItem.convertToTrack()
        
    }

    // TODO: 앨범 로딩메소드 구현
    func loadAlbums(tracks: [AVPlayerItem]) -> [Album] {
        let trackList : [Track] = tracks.compactMap { $0.convertToTrack()}
        let albumDics = Dictionary(grouping: trackList) { (track) in track.albumName } // 트랙들을 이용해 딕셔너리를 만들건데, 각각의 이름 별로 트랙들을 나눌건데 이런식으로 그룹핑해서 쓸 수 있다.
        var albums : [Album] = []
        for (key, value) in albumDics {
            let title = key
            let tracks = value
            let album = Album(title: title, tracks: tracks)
            albums.append(album)
        }
        return albums
    }

    // TODO: 오늘의 트랙 랜덤으로 선책
    func loadOtherTodaysTrack() {
        self.todaysTrack = self.tracks.randomElement()
    }
}

다음은 TrackManager에 대해서 알아볼건데, MVVM에서 보았듯이 tracks는 AVPlayerItem을 받아서 사용한다.

여기서 조금 주목할만 한 것은 트랙을 로드하기 위한 부분이다.

Bundle은 우선 내 앱 안의 있는 파일에 대한 정보를 가져오는 것이다.

swift map에 대한 것은 처음 보았는데 글의 맨 아래에 참고문서를 넣어두었으니 개념을 가서 따로 확인하기로..!

url을 해당하는 인덱스에 맞게 아이템에 넣는다!

앨범 로딩 메소드 쪽에는 compactMap이 있는데, 이건 참고쪽을 보면 된다.

또한 딕셔너리에서 grouping도 있는데, 이 부분도 참고쪽을 보면 된다. 트랙리스트를 인덱스로 그룹핑한다. 

 

✅ SimplePlayer와 싱글톤에 대한 간략설명

class SimplePlayer {
    // TODO: 싱글톤 만들기, 왜 만드는가?
    static let shared = SimplePlayer() // 싱글톤은 static 키워드 붙임
    
    private let player = AVPlayer()

    var currentTime: Double {
        // TODO: currentTime 구하기
        return player.currentItem?.currentTime().seconds ?? 0.0
    }
    
    var totalDurationTime: Double {
        // TODO: totalDurationTime 구하기
        return player.currentItem?.duration.seconds ?? 0.0
    }
    
    var isPlaying: Bool {
        // TODO: isPlaying 구하기
        return player.isPlaying
    }
    
    var currentItem: AVPlayerItem? {
        // TODO: currentItem 구하기
        return player.currentItem
    }
    
    init() { }
    
    func pause() {
        // TODO: pause구현
        player.pause()
    }
    
    func play() {
        // TODO: play구현
        player.play()
        
    }
    
    func seek(to time:CMTime) {
        // TODO: seek구현
        player.seek(to: time)
    }
    
    func replaceCurrentItem(with item: AVPlayerItem?) {
        // TODO: replace current item 구현
        player.replaceCurrentItem(with: item)
    }
    
    func addPeriodicTimeObserver(forInterval: CMTime, queue: DispatchQueue?, using: @escaping (CMTime) -> Void) {
        player.addPeriodicTimeObserver(forInterval: forInterval, queue: queue, using: using)
    }
}

single톤 패턴에 대해서 알아보자. 간략하게 말하자면 쓰던 객체를 여러번 돌려쓴다는 의미이다.

싱글톤은 객체는 static을 붙인다고 한다.

currentTime은 현재시간 duration은 토탈시간을 의미한대 seek은 우리가 슬라이더 바를 움직일 때 조정하는 메소드

 

✅ CMTime에 대해서 알아보자.

@IBAction func seek(_ sender: UISlider) {
        // TODO: 시킹 구현
        guard let currentItem = simplePlayer.currentItem else { return }
        
        let position = Double(sender.value) // 0~1 사이의 값
        let seconds = position * currentItem.duration.seconds // 전체시간을 곱해주면 어느정도 위치에 있어야하는지 알 수 있다.
        let time = CMTime(seconds: seconds, preferredTimescale: 100) // double 타입이라 길어질 수 있는데, 우리는 소수점 2자리까지만 쓰겠다.
        simplePlayer.seek(to: time)
}

CMTime에 대해서 알아보면

position은 슬라이더 위치인데 슬라이더의 위치는 sender를 통해 받아오고 0~1사이의 값으로 나온다.

seconds는 전체 시간에 슬라이더의 값을 곱해주면 현재 시간을 찾을 수 있다.

다음 time 부분에는 0.xxx * (전체시간) 을 곱했을때, 결과가 소수점 0.xxxxx... 으로 길게 나올 수가 있는데, CMTime을 사용하면 시간을 100조각으로 나누겠다는 의미로 즉, 소수점 2번째 자리까지만 사용하겠다는 말이다.

CMTime의 seconds에는 시간, preferredTimescale에는 몇 조각으로 나눌건지에 대해서 작성하면 된다.

0.1초의 경우 각각의 파라미터로 1과 10을 넣어주면 된다.

 

 

✅ 버튼의 시스템 이미지사용

버튼의 시스템이미지 사용

시스템 이미지를 사용할 수도 있다. play.fill로 설정되어 있는데, 아이콘의 크기가 작게 나올 수 있다.

시스템 이미지의 크기를 바꿔주기 위해서는 Default Symnbol Configuration - Configuration 을 Pont Size로 바꾼 후 Pont-Size의 크기를 조정해줘야 한다.

또한 코드를 통해서도 이미지를 바꿀 수 있는데,

  func updatePlayButton() {
        // TODO: 플레이버튼 업데이트 UI작업 > 재생/멈춤
        if simplePlayer.isPlaying {
            let configuration = UIImage.SymbolConfiguration(pointSize: 40) // 아이콘 이미지
            let image = UIImage(systemName: "pause.fill", withConfiguration: configuration)
            playControlButton.setImage(image, for: .normal)
        } else {
            let configuration = UIImage.SymbolConfiguration(pointSize: 40) // 아이콘 이미지
            let image = UIImage(systemName: "play.fill", withConfiguration: configuration)
            playControlButton.setImage(image, for: .normal)
            
        }
    }

이 방법을 활용하면 코드를 통해서도 이미지를 바꿀 수 있다. 이때는 꼭, 폰트 사이즈를 따로 주어야함을 잊지 말자.

 

✅ 다크모드 대응 

 

class DefaultStyle {
    public enum Colors {
        public static let tint : UIColor = {
            if #available(iOS 13.0, *) {
                return UIColor { traitCollction in
                    if traitCollction.userInterfaceStyle == .dark {
                        return .white
                    } else {
                        return .black
                    }
                }
            } else {
                return .black
            }
        }()
    }
}

iOS 13 이후에는 다크모드 대응이 상당히 중요해졌다.

    func updateTintColor() {
        playControlButton.tintColor = DefaultStyle.Colors.tint
        timeSlider.tintColor = DefaultStyle.Colors.tint
    }

다음과 같이 코드로도 적용할 수 있다..!

다크모드는 시스템 컬러를 사용할 경우 애플에서 다크모드 대응을 알아서 해주기 때문에 이 기능을 잘 활용한다면 더욱 좋지 않을까 싶다.

 

 

그럼 이만...!

 

 

참고

- http://minsone.github.io/mac/ios/swift-map-filter-reduce-and-inference 

 

[Swift]Map, Filter, Reduce 그리고 추론

우선 Swift의 Map, Filter, Reduce에 설명하기 앞서 Closure에서 사용될 추론에 대해 먼저 설명하고자 합니다. 추론(Inference) 애플 문서에도 나와있지만 Swift에서 추론은 아주 강력하며, 코드의 양을 줄여

minsone.github.io

https://jinshine.github.io/2018/12/14/Swift/22.%EA%B3%A0%EC%B0%A8%ED%95%A8%EC%88%98(2)%20-%20map,%20flatMap,%20compactMap/ 

 

[Swift] 고차함수(2) - map, flatMap, compactMap - jinShine

map map은 배열 내부의 값을 하나씩 mapping한다고 생각하면 쉽게 다가올껍니다. 각 요소에 대한 값을 변경하고자 할때 사용하고, 그 결과들을 배열의 상태로 반환합니다. Declaration 1func map (_ transform:

jinshine.github.io

https://poisonf2.tistory.com/m/75

 

[Swift] Dictionary - init, grouping, by

Dictionary의 init인 grouping by을 써보자! 먼저 단순한 Int 배열들로 grouping을 보여 드리겠습니다. 출력 Dictionary의 init으로 value 값으로 grouping의 sequnce가 들어가고 키 값으로는 by파라미터의 값이..

poisonf2.tistory.com