일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
Tags
- weatherkit
- 문법
- WebSocket
- embedded-swift
- EventLoop
- iot
- builder
- Architecture
- LifeCycle
- dart
- tuist
- raspberrypi5
- designpattern
- AppleDeveloper
- philipshue
- chartsorg
- WWDC24
- dgcharts
- network
- Xcode
- URLSession
- OpenAI
- flutter
- dartz
- isolate
- SwiftUI
- SampleApp
- swift
- uikit
- GIT
Archives
- Today
- Total
Jaebi의 Binary는 호남선
[Swift] 대용량 파일 다운로드 (3) - 다운로드 진행상황 확인 및 View 연결 본문
목차
목표
- 다운로드 진행상황 확인 (Byte와 퍼센트 분율)
- View에 연동하여 UI로 확인
구현 내용
다운로드 진행상황 확인을 위한 Delegate 구현
- `urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)` delegate 함수내부 구현
- `streamContinuation`에서 진행상황 퍼센트를 `yield`
...
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
print("Downloading: \(totalBytesWritten/1024/1024)MB of \(totalBytesExpectedToWrite/1024/1024)MB, \(String(format: "%.0f", progress*100))%")
streamContinuation?.yield(.progress(progress))
}
...
ViewModel 구현
- `@MainActor`를 달아 메인 스레드에서 실행되도록 보장
- 다운로드 진행상황 및 다운로드가 완료된 파일의 저장 경로를 가지고 있는 `@Published` 속성 구현
- 비동기 작업으로 다운로드 스트림을 받고 `for try await event in stream`으로 받아오는 `DownloadStatus` 처리
import Foundation
@MainActor
final class FileDownloadViewModel: ObservableObject {
@Published var progress: Double = 0.0
@Published var downloadedFilePath: String?
// Sample 500 MB 파일
let fileURL = "https://jsoncompare.org/LearningContainer/SampleFiles/PDF/sample-500mb-pdf-download.pdf"
func startDownload() {
downloadedFilePath = nil
progress = 0.0
Task {
do {
let stream = FileDownloadService.shared.downloadFile(urlString: fileURL)
for try await event in stream {
switch event {
case .progress(let value):
await MainActor.run {
self.progress = value
}
case .finished(let location):
await MainActor.run {
self.progress = 1.0
self.downloadedFilePath = location.absoluteString
}
}
}
} catch {
print("다운로드 에러: \(error)")
}
}
}
}
View 구현
- `@StateObject`로 `FileDownloadViewModel` 관리
import SwiftUI
struct FileDownloadView: View {
@StateObject private var viewModel = FileDownloadViewModel()
var body: some View {
VStack(spacing: 20) {
Text("Download Large File")
.font(.title)
.padding()
Button(action: {
viewModel.startDownload()
}) {
Text("Start Download")
.font(.headline)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
VStack {
ProgressView(value: viewModel.progress, total: 1.0)
.progressViewStyle(LinearProgressViewStyle())
.frame(width: 250)
Text("\(Int(viewModel.progress * 100))% completed")
.font(.caption)
.padding()
}
if let filePath = viewModel.downloadedFilePath {
Text("Downloaded File: \(filePath)")
.font(.footnote)
.padding()
}
}
.padding()
}
}
실행 화면
파일 다운로드 시작 | 파일 다운로드 종료 및 완료 위치 표시 | Files 앱으로 다운로드 받은 파일 확인 |
![]() |
![]() |
![]() |
전체 코드
- FileDownloadService.swift
import Foundation
import UniformTypeIdentifiers
enum DownloadStatus {
case progress(Double) // 0.0 ~ 1.0 사이의 진행률 값
case finished(URL) // 다운로드 완료 후 파일이 위치한 URL
}
final class FileDownloadService: NSObject {
static let shared = FileDownloadService()
private lazy var session: URLSession = {
let config: URLSessionConfiguration = URLSessionConfiguration.background(withIdentifier: "testDownload")
config.isDiscretionary = false // 베터리, 네트워크 상태, 전원 연결 상태등을 무시하고 즉시 실행
config.sessionSendsLaunchEvents = true // 앱이 종료되었거나 백그라운드 상태일 경우에도 URLSession 이벤트 발생시 시스템이 앱 자동 실행
return URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue.main)
}()
private override init() {
super.init()
}
private var streamContinuation: AsyncThrowingStream<DownloadStatus, Error>.Continuation?
func downloadFile(urlString: String) -> AsyncThrowingStream<DownloadStatus, Error> {
guard let url = URL(string: urlString) else {
return AsyncThrowingStream { continuation in
continuation.finish(throwing: NSError(domain: "Invalid URL", code: -1))
}
}
return AsyncThrowingStream { continuation in
self.streamContinuation = continuation
let task = self.session.downloadTask(with: url)
task.resume()
}
}
}
// 가독성을 위해 Delegate 함수들은 extension으로 분리
extension FileDownloadService: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
print("Download finished at: \(location)")
moveDownloadedFile(downloadTask: downloadTask, downloadedLocation: location)
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
print("Downloading: \(totalBytesWritten/1024/1024)MB of \(totalBytesExpectedToWrite/1024/1024)MB, \(String(format: "%.0f", progress*100))%")
streamContinuation?.yield(.progress(progress))
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {
print("Download Resume: \(fileOffset/1024/1024)MB of \(expectedTotalBytes/1024/1024)MB")
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print("Download Error: \(error.localizedDescription)")
streamContinuation?.finish(throwing: error)
streamContinuation = nil
}
}
}
// 파일 Management 관련 코드 분리
private extension FileDownloadService {
func moveDownloadedFile(downloadTask: URLSessionDownloadTask, downloadedLocation: URL) {
let fileManager: FileManager = FileManager.default
// 앱의 Documents 디렉토리를 가져옴, 실패시 에러 던지기
guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
streamContinuation?.finish(throwing: NSError(domain: "Invalid File Document Directory", code: -1))
return
}
// 파일명 결정: 서버 response의 suggestedFilename이 있으면 사용하고, 없으면 UUID로 생성
var fileName = downloadTask.response?.suggestedFilename ?? UUID().uuidString
// 만약 파일명에 확장자가 없다면, response의 MIME 타입을 통해 확장자를 추론해 추가
if (fileName as NSString).pathExtension.isEmpty,
let mimeType = downloadTask.response?.mimeType,
let utType = UTType(mimeType: mimeType),
let ext = utType.preferredFilenameExtension {
fileName += ".\(ext)"
}
// 사용자가 지정한 Documents(또는 Library, Cache) 내의 폴더 (예제에서는 "MyDownloads")
let folderURL = documentsDirectory.appendingPathComponent("MyDownloads", isDirectory: true)
try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
let destinationURL = folderURL.appendingPathComponent(fileName)
do {
// 기존에 같은 이름의 파일이 있다면 삭제
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
// 임시 파일을 지정한 destination으로 이동
try fileManager.moveItem(at: downloadedLocation, to: destinationURL)
streamContinuation?.yield(.finished(destinationURL))
} catch {
// 파일 이동 및 실패시 에러 던지기
streamContinuation?.finish(throwing: error)
return
}
streamContinuation?.finish()
streamContinuation = nil
}
}
- FileDownloadViewModel.swift
import Foundation
@MainActor
final class FileDownloadViewModel: ObservableObject {
@Published var progress: Double = 0.0
@Published var downloadedFilePath: String?
// Sample 500 MB 파일
let fileURL = "https://jsoncompare.org/LearningContainer/SampleFiles/PDF/sample-500mb-pdf-download.pdf"
func startDownload() {
downloadedFilePath = nil
progress = 0.0
Task {
do {
let stream = FileDownloadService.shared.downloadFile(urlString: fileURL)
for try await event in stream {
switch event {
case .progress(let value):
await MainActor.run {
self.progress = value
}
case .finished(let location):
await MainActor.run {
self.progress = 1.0
self.downloadedFilePath = location.absoluteString
}
}
}
} catch {
print("다운로드 에러: \(error)")
}
}
}
}
- FileDownloadView.swift
import SwiftUI
struct FileDownloadView: View {
@StateObject private var viewModel = FileDownloadViewModel()
var body: some View {
VStack(spacing: 20) {
Text("Download Large File")
.font(.title)
.padding()
Button(action: {
viewModel.startDownload()
}) {
Text("Start Download")
.font(.headline)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
VStack {
ProgressView(value: viewModel.progress, total: 1.0)
.progressViewStyle(LinearProgressViewStyle())
.frame(width: 250)
Text("\(Int(viewModel.progress * 100))% completed")
.font(.caption)
.padding()
}
if let filePath = viewModel.downloadedFilePath {
Text("Downloaded File: \(filePath)")
.font(.footnote)
.padding()
}
}
.padding()
}
}
'Swift' 카테고리의 다른 글
[Swift] 대용량 파일 다운로드 (2) - FileManager (0) | 2025.03.13 |
---|---|
[Swift] 대용량 파일 다운로드 (1) - FileDownloadService (0) | 2025.03.08 |
[Swift] 대용량 파일 다운로드 (0) - 계획 (0) | 2025.03.08 |
[Swift] WebSocket + STOMP 연동 (0) | 2025.02.04 |
[Swift] 실시간 데이터 표현 (0) | 2025.02.04 |