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:)
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:)
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 |