본문 바로가기

학습 노트/iOS (2021)

214. Background Download

Background Download


UploadTask와 DownloadTask는 기본적으로 Foreground에서 실행되도록 구현돼있다.
Background로 전환했던 앱이 Foreground로 돌아왔을 때 중지된 Task를 이어서 하도록 구현하는 것도 가능하지만,
Background에서도 문제 없이 동작하도록 하는 것이 자연스럽다.

BackgroundSession은 다른 Session과 다르게 별도의 Process에서 실행되고, 데이터의 전송도 OS가 관리하도록 설계돼있다.
이 때문에 App이 Background로 전환돼도 주체는 변하지 않기 때문에 동작이 유지될 수 있다.

BackgroundDownload가 완료되면 Delegate로 Notification을 전송한다.
기능의 원리는 복잡하지만 구현 자체는 단순하다.
따라서 파일 전송을 구현하는 경우 BackgroundSession을 사용하지 않을 이유가 없다.

 

Session 생성

BackgroundDownloadView.swift

 

lazy var session: URLSession = { [weak self] in

  let config = URLSessionConfiguration.background(withIdentifier: "SampleSession")

  let session = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue.main)
  return session
}()

BackgroundSession을 생성하는 것은 간단하다.
SessionConfiguration을 background로 지정하고 Session을 생성한다.

 

Task 생성

BackgroundDownloadView.swift

var task: URLSessionDownloadTask?

lazy var session: URLSession = { [weak self] in
  let config = URLSessionConfiguration.background(withIdentifier: "SampleSession")

  let session = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue.main)
  return session
}()


@IBAction func startDownload(_ sender: Any) {
  do {
     let hasFile = try targetUrl.checkResourceIsReachable()
     if hasFile {
        try FileManager.default.removeItem(at: targetUrl)
        print("Removed")
     }
  } catch {
     print(error.localizedDescription)
  }
  updateRecentDownload()

  guard let url = URL(string: smallFileUrlStr) else {
     fatalError("Invalid URL")
  }

  task = session.downloadTask(with: url)
  task?.resume()
}

Task 생성도 마찬가지다.
생성한 Session을 사용해 이전의 방식대로 Task를 생성하고 resume 메서드를 호출해 작동시키면 된다.

지금도 Foreground - Background 간의 전환은 정상적으로 처리 되지만
앱 종료, 오류 등의 이유로 Task 자체가 종료되는 경우는 대응하지 못한다.

예를 들어 Background Download를 시작한 상태로 앱이 종료되면
Task가 종료되고, 해당하는 임시파일도 함께 삭제된다.
따라서 이 경우 Download가 정상적으로 종료된다 해도 임시 파일을 찾을 수 없고, 충돌이 발생하게 된다.

이를 해결하기 위해 앱이 종료되는 경우 임시파일을 저장하도록 조치해야 한다.

AppDelegate.swift

func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
    NSLog(">> %@ %@", self, #function)
    
    _ = BackgroundDownloadManager.shared.session
    BackgroundDownloadManager.shared.completionHandler = completionHandler
}

iOS는 앱이 실행 중이지 않은 상태에서 BackgroundTask가 완료되면
앱을 실행하고 해당 메서드를 호출한다.

application(_:handleEventsForBackgroundURLSession:completionHandler:)

 

Apple Developer Documentation

 

developer.apple.com

BackgroundSession 생성 시 전달했던 identifier를 사용해 Session을 다시 생성하고,
Delegate를 설정한 다음 Delegate에서 파일을 복사해 CompletionHandler를 호출한다.

Delegate 메서드는 BackgroundDownloadViewController 파일에 존재하지만
이 메서드가 호출된 시점에는 접근할 수가 없다.
따라서 Singletone 객체에서 BackgroundSession을 생성하는 것이 좋다.

BackgroundDownloadManager.swift

class BackgroundDownloadManager: NSObject {
   static let shared = BackgroundDownloadManager()
	
   private override init() {
      super.init()
   }
   
   static let didWriteDataNotification = Notification.Name(rawValue: "BackgroundDownloadManager.didWriteDataNotification")
   static let totalBytesWrittenKey = "totalBytesWritten"
   static let totalBytesExpectedToWriteKey = "totalBytesExpectedToWrite"
   
   var completionHandler: (()->())?
   
   var targetUrl: URL {
      guard let targetUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("backgroundFile.mp4") else {
         fatalError("Invalid File URL")
      }
      
      return targetUrl
   }
   
   lazy var session: URLSession = {
      let config = URLSessionConfiguration.background(withIdentifier: "SampleSession")
      
      let session = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue.main)
      return session
   }()
}

해당 클래스는 BackgroundDownloadView 클래스에서 선언했던 것과 동일한 코드를 작성한다.
Singletone 객체로 사용하기 위해 생성자를 override 해 주고,
Appdelegate에서 전달된 CompletionHandler를 받기 위한 인스턴스도 하나 만들어 준다.

BackgroundDownloadManager.swift

extension BackgroundDownloadManager: URLSessionDownloadDelegate {   
   func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
      print(totalBytesWritten, totalBytesExpectedToWrite)
   }
   
   func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
      NSLog(">> %@ %@", self, #function)
      print(error ?? "Done")
   }
   
   func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
      NSLog(">> %@ %@", self, #function)
      
      guard (try? location.checkResourceIsReachable()) ?? false else {
         return
      }
      
      do {
         _ = try FileManager.default.replaceItemAt(targetUrl, withItemAt: location)
      } catch {
         fatalError(error.localizedDescription)
      }
   }
}

Session의 생성이 BackgroundDonwloadView 클래스가 아닌 BackgroundDownloadManager 클래스에서 이뤄지므로,
Delegate 구현도 옮겨준다.

BackgroundDownloadView.swift

var targetUrl: URL {
   return BackgroundDownloadManager.shared.targetUrl
}

targetUrl을 구현하는 부분은 BackgroundDonwloadManager 클래스에서 구현됐다.
이를 사용할 수 있도록 수정한다.

BackgroundDownloadView.swift

var session = BackgroundDownloadManager.shared.session

Session도 이제는 해당 클래스에서 생성하지 않는다.
Singletone 객체의 session을 사용하도록 수정한다.

SessionDelegate 구현에 대한 부분도 이제는 필요가 없다.

BackgroundDownloadView.swift

class BackgroundDownloadViewController: UIViewController {
   
   @IBOutlet weak var sizeLabel: UILabel!
   @IBOutlet weak var recentDownloadLabel: UILabel!
   
   lazy var sizeFormatter: ByteCountFormatter = {
      let f = ByteCountFormatter()
      f.countStyle = .file
      return f
   }()
   
   lazy var dateFormatter: DateFormatter = {
      let f = DateFormatter()
      f.dateFormat = "MM/dd HH:mm:ss.SSS"
      return f
   }()
   
   var token: NSObjectProtocol?
   
   var targetUrl: URL {
	   return BackgroundDownloadManager.shared.targetUrl
   }
   
   var task: URLSessionDownloadTask?
   var session = BackgroundDownloadManager.shared.session
   
   @IBAction func startDownload(_ sender: Any) {
      do {
         let hasFile = try targetUrl.checkResourceIsReachable()
         if hasFile {
            try FileManager.default.removeItem(at: targetUrl)
            print("Removed")
         }
      } catch {
         print(error.localizedDescription)
      }
      updateRecentDownload()
      
      guard let url = URL(string: bigFileUrlStr) else {
         fatalError("Invalid URL")
      }
      
      task = session.downloadTask(with: url)
      task?.resume()
   }
   
   @IBAction func stopDownload(_ sender: Any) {
      session.invalidateAndCancel()
   }
   
   override func viewDidLoad() {
      super.viewDidLoad()
      
      _ = session
      updateRecentDownload()
   }
   
   override func viewWillDisappear(_ animated: Bool) {
      super.viewWillDisappear(animated)
      
      session.invalidateAndCancel()
      
      if let token = token {
         NotificationCenter.default.removeObserver(token)
      }
   }
}

extension BackgroundDownloadViewController {
   func updateRecentDownload() {
      do {
         let hasFile = try targetUrl.checkResourceIsReachable()
         if hasFile {
            let values = try targetUrl.resourceValues(forKeys: [.fileSizeKey, .creationDateKey])
            if let size = values.fileSize, let date = values.creationDate {
               recentDownloadLabel.text = "\(dateFormatter.string(from: date)) / \(sizeFormatter.string(fromByteCount: Int64(size)))"
            }
         }
      } catch {
         recentDownloadLabel.text = "Not Found / Unknown"
      }
   }
}

구현 코드를 Singletone 객체로 옮긴 덕분에 한 결 간단해진 모습이다.

BackgroundDownloadManager.swift

extension BackgroundDownloadManager: URLSessionDownloadDelegate {   
   func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
      print(totalBytesWritten, totalBytesExpectedToWrite)
   }
   
   func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
      NSLog(">> %@ %@", self, #function)
      print(error ?? "Done")
   }
   
   func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
      NSLog(">> %@ %@", self, #function)
      
      guard (try? location.checkResourceIsReachable()) ?? false else {
         return
      }
      
      do {
         _ = try FileManager.default.replaceItemAt(targetUrl, withItemAt: location)
      } catch {
         fatalError(error.localizedDescription)
      }
   }
   
    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        NSLog(">> %@ %@", self, #function)
        DispatchQueue.main.async {
            self.completionHandler?()
        }
    }
}

마지막으로 Delegate 구현에 메서드를 추가해 준다.

urlSessionDidFinishEvents(forBackgroundURLSession:)

 

Apple Developer Documentation

 

developer.apple.com

BackgroundSession이 종료되는 경우 호출되어 저장된 CompletionHandler를 실행해야 한다.
단 이 경우 completionHandler의 모체가 UIKit이므로 mainThread에서 실행될 수 있도록 초지해야 한다.

이렇게 코드를 수정하고 나면 BackgroundTask의 진행상황이 UI에 반영이 되지 않는다.
SingleTone 객체를 사용하도록 코드를 수정했지만 Singletone 객체는 UI 업데이트 코드가 존재하지 않는다.

 

데이터 주고받기

BackgroundDownloadManager.swift

 

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
    print(totalBytesWritten, totalBytesExpectedToWrite)

    let userInfo = [BackgroundDownloadManager.totalBytesWrittenKey: totalBytesWritten, BackgroundDownloadManager.totalBytesExpectedToWriteKey: totalBytesExpectedToWrite]
    NotificationCenter.default.post(name: BackgroundDownloadManager.didWriteDataNotification, object: nil, userInfo: userInfo)
}

Delegate의 메서드에 Singletone에서 발생한 데이터를 BackgroundDownloadView에 전달하도록 notification을 생성한다.

BackgroundDownloadView.swift

override func viewDidLoad() {
  super.viewDidLoad()
   _ = session

  updateRecentDownload()

   token_write = NotificationCenter.default.addObserver(forName: BackgroundDownloadManager.didWriteDataNotification, object: nil, queue: OperationQueue.main, using: { (noti) in
       guard let userInfo = noti.userInfo else {
           return
       }
       guard let downloadSize = userInfo[BackgroundDownloadManager.totalBytesWrittenKey] as? Int64 else {
           return
       }
       guard let totalSize = userInfo[BackgroundDownloadManager.totalBytesExpectedToWriteKey] as? Int64 else {
           return
       }

       self.sizeLabel.text = "\(self.sizeFormatter.string(fromByteCount: downloadSize))/\(self.sizeFormatter.string(fromByteCount: totalSize))"
   })
}

BackgroundDownloadView에서 notification을 감지할 수 있도록 observer를 추가한다.
이제 singletone 객체에서 data가 갱신되면 notification이 발생하고,
해당 notification을 BackgroundDonwloadView가 감지해 UI에 반영한다.

BackgroundDownloadView.swift

override func viewWillDisappear(_ animated: Bool) {
  super.viewWillDisappear(animated)
   print("willDisappear")

  if let token_write = token_write {
     NotificationCenter.default.removeObserver(token_write)
  }
}

Observer는 항상 Scene이 종료될 때 초기화하는 것을 잊지 않도록 한다.

 

데이터 주고받기 𝛂


Download가 완료된 경우에도 같은 방식으로 구현한다.

BackgroundDownloadManager.swift

static let didFinishDownloadNotification = Notification.Name(rawValue: "BackgroundDownloadManager.didFinishDownloadNotification")

singletone 객체에서 발생시킬 notification을 하나 추가한다.

BackgroundDownloadManager.swift

func notiRecentDownload() {
    do {
        let hasFile = try targetUrl.checkResourceIsReachable()
        if hasFile {
            NotificationCenter.default.post(name: BackgroundDownloadManager.didFinishDownloadNotification, object: nil, userInfo: nil)
        }
    } catch {
        fatalError(error.localizedDescription)
    }
}

notification을 발생시킬 메서드를 하나 정의하고

BackgroundDownloadManager.swift

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
  NSLog(">> %@ %@", self, #function)

  guard (try? location.checkResourceIsReachable()) ?? false else {
     return
  }

  do {
     _ = try FileManager.default.replaceItemAt(targetUrl, withItemAt: location)
  } catch {
     fatalError(error.localizedDescription)
  }
   notiRecentDownload()
}

BackgroundDownload가 완료되면 해당 메서드를 호출해 notification을 발생하도록 한다.

BackgroundDownloadView.swift

override func viewDidLoad() {
  super.viewDidLoad()
   _ = session

  updateRecentDownload()

   token_write = NotificationCenter.default.addObserver(forName: BackgroundDownloadManager.didWriteDataNotification, object: nil, queue: OperationQueue.main, using: { (noti) in
       guard let userInfo = noti.userInfo else {
           return
       }
       guard let downloadSize = userInfo[BackgroundDownloadManager.totalBytesWrittenKey] as? Int64 else {
           return
       }
       guard let totalSize = userInfo[BackgroundDownloadManager.totalBytesExpectedToWriteKey] as? Int64 else {
           return
       }

       self.sizeLabel.text = "\(self.sizeFormatter.string(fromByteCount: downloadSize))/\(self.sizeFormatter.string(fromByteCount: totalSize))"
   })

   token_done = NotificationCenter.default.addObserver(forName: BackgroundDownloadManager.didFinishDownloadNotification, object: nil, queue: OperationQueue.main, using: { noti in
       self.updateRecentDownload()
   })
}

다시 BackgroundDownloadView로 돌아와 해당 notification을 감지할 observer를 추가하고
updateRecentDownload를 호출해 UI를 업데이트하도록 구현한다.

BackgroundDownloadView.swift

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    print("willDisappear")

    if let token_write = token_write {
     NotificationCenter.default.removeObserver(token_write)
    }

    if let token_done = token_done {
       NotificationCenter.default.removeObserver(token_done)
    }
}

마찬가지로 Scene이 종료되는 경우 Observer를 해제해 준다.

 

BackgroundDownload 𝛂


제대로 동작하는 것처럼 보이지만 BackgroundDownloadTask를 중지했다가 다시 시작하는 경우,
Scene에 다시 진입해 새로 시작하는 경우 충돌이 발생해 앱이 종료되는 문제가 생긴다.

이는 한 번 InvalidateAndCancel을 진행한 Session은 재사용이 불가능한 특징 때문에 발생하는 문제로,
해당 Session이 삭제될 때 Session을 미리 생성해 대체하는 것으로 해결이 가능하다.

BackgroundDownloadManager.swift

var sessionID: String?

BackgroundDownloadManager에 새로운 변수를 생성한다.
해당 변수는 Session의 ID로 사용될 예정이다.

BackgroundDownloadManager.swift

func setupNewSession() -> URLSession {
    let id = sessionID ?? UUID().uuidString
    sessionID = id
    let config = URLSessionConfiguration.background(withIdentifier: id)
    session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
    return session
}

대체할 Session을 생성하는 메서드를 하나 정의한다.
이전에 사용했던 것과 같이 단일 id가 아닌 UUID를 사용하는 동적 id를 사용하는 것이 특징이다.
이전에 사용됐던 id가 아닌 늘 다른 id를 공급할 수 있어 무작위 값이 필요한 경우 종종 사용되는 패턴인 듯하다.

BackgroundDownloadManager.swift

lazy var session: URLSession = {
   setupNewSession()
}()

기존의 Session을 초기화하는 코드를 새롭게 생성한 메서드로 대체한다.

BackgroundDownloadManager.swift

func invalidateSession() -> URLSession {
    session.invalidateAndCancel()
    sessionID = nil
    return setupNewSession()
}

Session을 삭제하는 방식도 메서드로 대체한다.
InvalidateAndCancel을 호출하는 것은 동일하지만 이것과 동시에 대체할 세션을 함께 생성한다.

BackgroundDownloadView.swift

@IBAction func stopDownload(_ sender: Any) {
   session = BackgroundDownloadManager.shared.invalidateSession()
}

BackgroundDownloadView.swift

override func viewWillDisappear(_ animated: Bool) {
  super.viewWillDisappear(animated)
   print("willDisappear")
   BackgroundDownloadManager.shared.invalidateSession()

  if let token_write = token_write {
     NotificationCenter.default.removeObserver(token_write)
  }
  
   if let token_done = token_done {
       NotificationCenter.default.removeObserver(token_done)
   }
}

이제 Session을 삭제하는 코드를 해당 메서드로 대체하기만 하면 된다.

 

'학습 노트 > iOS (2021)' 카테고리의 다른 글

216. Reachability  (0) 2022.09.06
215. Response Caching  (0) 2022.09.06
213. Download Task  (0) 2022.08.30
212. Upload Task  (0) 2022.08.26
211. Post Request  (0) 2022.08.25