티스토리 뷰
안녕하세요 Diana 입니다.
어느덧 앨범 만들기도 6편까지 왔습니다.
이제 제일 재밌어보이는 부분만 남았는데요.
앨범에서 제일 중요한건 리스트에서 원하는 이미지와 동영상을 선택해서 확인하는거죠.
이미지의 경우는 화면전환을 하며 PinchZoom을 같이 구현해놨기 때문에 마지막으로 동영상 재생을 구현해보려고 합니다.
✅ AVFoundation이란?
동영상 재생을 구현하기 전에 AVFoundation이라는 프레임워크가 무엇인지 먼저 이해할 필요가 있습니다.
공식 문서에 따르면, AVFoundation은 시청각 미디어를 다루는 데 필요한 핵심 기능을 제공하는 프레임워크로
카메라 제어, 오디오 처리, 그리고 시스템 오디오와의 상호작용까지 폭넓은 영역을 담당합니다.
AVFoundation을 사용하면 미디어 파일을 생성·편집·재인코딩할 수 있으며,
비디오의 실시간 재생 제어나 디바이스로부터 입력 스트림을 받아 처리하는 작업도 가능합니다.

AVFoundation은 기능이 매우 방대하고 깊은 프레임워크입니다.
고급 미디어 처리나 커스텀 동영상 기능을 구현하려면 AVFoundation을 직접 사용해야 하지만,
단순히 비디오·오디오를 재생하는 목적이라면 더 고수준의 프레임워크인 AVKit을 사용하는 것이 훨씬 간단합니다.
✅ AVAsset, AVPlayerItem, AVPlayer
우리는 앨범에서 불러온 미디어 데이터를 PHAsset 형태로 가지고 있지만 실제로 재생을 위해 사용되는 객체는 AVAsset입니다.
AVAsset은 미디어의 제목, 재생 시간, 해상도, 메타데이터 등 다양한 정보를 제공한다는 점에서 PHAsset과 유사한 역할을 한다고 볼 수 있습니다.
하지만 AVAsset 자체는 곧바로 재생할 수 있는 객체가 아닙니다.
AVAsset은 재생 준비를 담당하는 AVPlayerItem으로 감싸져야 하며, 트랙별 상태 정보 역시 AVPlayerItemTrack을 통해 관리됩니다.
최종적으로, 이렇게 구성된 AVPlayerItem을 AVPlayer에 전달해야 비로소 영상·오디오 재생이 가능합니다.
✅ 구현
이제 실제 구현을 해보도록 하겠습니다.
저는 동영상이 재생되며 아래 재생버튼과 Slider바로 시점을 이동할 수 있는 간단한 기능을 구현할 생각이였으므로 AVKit을 사용하였습니다.
func getVideo(asset: PHAsset, options: PHVideoRequestOptions) async -> AVPlayerItem {
await withCheckedContinuation { continuation in
imageManager.requestPlayerItem(forVideo: asset, options: options) { [weak self] playerItem, info in
guard let _ = self, let playerItem = playerItem else { return }
continuation.resume(returning: playerItem)
}
}
}
우선 이전에 가져온 PHAsset를 통해 가지고 오고자 하는 비디오 데이터를 요청한 뒤 리턴해주었습니다.
class DetailVideoViewModel: BaseViewModel {
private var albumFetcher = AlbumFetcher()
var albumInfo: AlbumInfo
var isPlayingRelay = BehaviorRelay<Bool>(value: false)
init(albumInfo: AlbumInfo) {
self.albumInfo = albumInfo
}
func isPlaying(_ bool: Bool) {
isPlayingRelay.accept(bool)
}
func fetchOriginalVideo() async -> AVPlayerItem {
let options = PHVideoRequestOptions()
options.deliveryMode = .automatic
options.isNetworkAccessAllowed = true
let avPlayerItem = await albumFetcher.getVideo(asset: albumInfo.asset, options: options)
return avPlayerItem
}
}
이 과정을 viewModel에서 진행하였으며 viewModel에서는 이 외에도 동영상이 재생중임을 알 수 있는 isPlayingRelay의 상태 관리를 담당하였습니다.
이렇게 가지고 온 데이터를 통해 ViewController에서는 AVPlayer을 통해 비디오 재생을 진행하였습니다.
private func playVideo() {
loadTask = Task { [weak self] in
guard let self = self else { return }
let avPlayerItem: AVPlayerItem = await self.viewModel.fetchOriginalVideo()
if Task.isCancelled { return }
await MainActor.run {
self.configureInitialZoom()
self.avPlayer.replaceCurrentItem(with: avPlayerItem)
self.avPlayerLayer.player = self.avPlayer
self.avPlayerLayer.frame = self.videoView.bounds
self.avPlayer.play()
self.viewModel.isPlaying(true)
}
}
동영상 재생을 완료 한 뒤 Sliderbar을 구현하여 동영상을 탐색할 수 있도록 구현하였습니다.
func bind() {
...
playButton.rx.tap
.withLatestFrom(viewModel.isPlayingRelay.asObservable())
.map { !$0 }
.bind(to: viewModel.isPlayingRelay)
.disposed(by: disposeBag)
observePlayerTime(interval: 0.1)
.observe(on: MainScheduler.instance)
.withUnretained(self)
.subscribe(onNext: { vc, seconds in
if vc.slider.isTracking { return }
vc.slider.value = seconds
if seconds >= Float(vc.viewModel.albumInfo.asset.duration) {
vc.viewModel.isPlaying(false)
vc.avPlayer.pause()
vc.avPlayer.seek(to: .zero)
vc.slider.value = .zero
}
})
.disposed(by: disposeBag)
viewModel.isPlayingRelay
.distinctUntilChanged()
.withUnretained(self)
.subscribe(onNext: { vc, isPlaying in
if isPlaying {
vc.avPlayer.play()
vc.playButton.setImage(
UIImage(systemName: "pause.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
),
for: .normal
)
} else {
vc.avPlayer.pause()
vc.playButton.setImage(
UIImage(systemName: "play.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
),
for: .normal
)
}
})
.disposed(by: disposeBag)
slider.rx.controlEvent([.touchDown])
.withUnretained(self)
.subscribe(onNext: { vc, _ in
vc.avPlayer.pause()
vc.viewModel.isPlaying(false)
})
.disposed(by: disposeBag)
slider.rx.controlEvent([.touchUpOutside, .touchUpInside])
.withUnretained(self)
.subscribe(onNext: { vc, _ in
vc.avPlayer.play()
vc.viewModel.isPlaying(true)
})
.disposed(by: disposeBag)
slider.rx.value
.distinctUntilChanged()
.withUnretained(self)
.subscribe(onNext: { vc, value in
let time = CMTime(seconds: Double(value), preferredTimescale: 600)
vc.avPlayer.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
})
.disposed(by: disposeBag)
}
이렇게 길었던 앨범 구현이 완료되었습니다.
앨범의 모든 기능을 구현하지는 않았지만 미디어를 확인하고, 이미지와 비디오 를 확인할 수 있는 핵심 기능 구현을 위주로 진행해보았습니다.
이전에 사용해보았던 기술들을 복기하는 차원에서 진행했는데 오랫만에 큰 데이터를 어떻게 효율적으로 처리할지 고민해보는 시간이 되었던 것 같습니다.
긴 글을 읽어주셔서 감사합니다.
'Swift' 카테고리의 다른 글
| 앨범 만들기 5편 - Transitioning Delegate 를 통한 화면전환 애니메이션 구현편 (0) | 2025.11.12 |
|---|---|
| 앨범 만들기 4편 - Transitioning Delegate 를 통한 화면전환 애니메이션 개념편 (1) | 2025.11.07 |
| 앨범 만들기 3편 - 무한 스크롤 구현하기 (0) | 2025.11.06 |
| 앨범 만들기 2편 - PHCachImageManager의 requestImage 알아보기 (0) | 2025.11.04 |
| 앨범 만들기 1편 - 앨범에 접근하여 사진 가져오기 (0) | 2025.11.03 |
- Total
- Today
- Yesterday
- 코어데이터
- boxedprotocoltype
- swift
- 스위프트
- ios
- BoxedType
- boxedprotocol
- SwiftUI
- opaque
- capsulation
- ViewBuilder
- Algorithm
- iphone
- 빅세타표기법
- tipview
- kakaomapssdk
- tuist v4
- Concurrency
- 알고리즘
- Tuist
- private(set)
- Xcode
- 개발자코테
- coredata
- 팁킷
- PHCachImageManager
- asymptoticnotation
- reusablelist
- opaquetype
- UIViewTransitioningDelegate
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |