Jaebi의 Binary는 호남선

[Swift] 대용량 파일 다운로드 (3) - 다운로드 진행상황 확인 및 View 연결 본문

Swift

[Swift] 대용량 파일 다운로드 (3) - 다운로드 진행상황 확인 및 View 연결

jaebijae 2025. 3. 16. 05:05

목차

    목표

    • 다운로드 진행상황 확인 (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()
        }
    }