こんにちは、R&Dチームの中村です。最近 iOSアプリでファイルをバックグラウンドでアップロードしたい という場面に出くわしたので、その時に調べたこと・実際に手を動かして確認するために作った簡素なアプリをまとめます。意外とバックグラウンドダウンロードに関してはそこそこあるものの、アップロードに関してまとまったものが少なかったというのも動機の1つです。確認している手元の環境は以下のようになります。
- MacBook Pro 2012 Mid
- macOS Mojave 10.14.6(18G3020)
- Xcode Version 11.3 (11C29)
- Swift 5.1
- iOS Simulator Version 11.3
- iPhone 8 (iOS 13.3)
- 2019年のiOSバックグラウンド事情
- 目標
- バックグランドアップロードの下調べ
- やってみる
- UIを足してアプリにまとめる
- 試してみる
- 所感
- 謝辞
2019年のiOSバックグラウンド事情
本題からは少しそれますが、iOS・Androidのようなモバイル端末上でバックグラウンド動作するアプリには、ネットワーク帯域・バッテリーなどのリソースを効率的につかうための制約がつきものになります。そのためアプリがフォアグラウンドでない状態になっても処理を継続させておきたかったり、定期的に処理をさせたい場合はOSの規則や挙動に沿った記述が必要になります。
iOS13.0以前のバックグラウンドでの定期的な処理はBackground fetch
を使うことが一般的でした。これを置き換えるような形でiOS13.0からBackgroundTasks Frameworkが新しく導入されました。従来の定期的なバックグラウンド処理に加えて、遅延可能で時間のかかる処理(データのバックアップやCoreMLでの学習作業等)を実行するためのBGProcessingTaskが用意されました。CoreMLでの学習作業も視野に入っているあたり、リソースをそれなりに使うようなケースでも問題なさそうです。従来と同じような定期的なバックグラウンド処理に関しては、BGAppRefreshTaskが用意されました。30秒以内で完了するようなアプリの状態を最新に保つため処理が想定されています。ただし 電源に接続されている状態が必須 や ネットワークに接続されている状態が必須 およびその他の制約により実行タイミングはOS側が決めるので、きっちりとした頻度では実行されないという点もあります。BackgroundTasks Frameworkを使うには、Xcode上で以下のCapabilitiesの設定を行います。
- プロジェクトファイルを選択
Signing & Capabilities
タブを選択- ~ 4.
+ Capability
ボタンを押してBackground Mode
を選択 Background App Refresh Tasks (BGAppRefreshTask)
を利用する場合には、Background fetch
へチェックを入れるBackground Processing Tasks (BGAppRefreshTask)
を利用する場合には、Background processing
へチェックを入れる- info.plistを編集して
Permitted background task scheduler identifiers
にタスクのidentifierを設定する
identifier
は一位である必要があるため、逆ドメイン(com.example.app.taskのような形式)が推奨されます。
アプリのバックグラウンドの挙動周りはモバイルデバイスのハードウェア・OSの進化で変わることが多いので、継続的にウォッチしておきたいですね。
※ 今回のテーマのバックグラウンドアップロード(Background Transfar)に関しては特にこういったCapability周りの設定は必要ありません。
目標
今回は以下の目標の達成を条件に進めていきたいと思います。
- アプリ
- iPhoneのフォトギャラリーから画像を選んでアップロードすることができる
- アップロードする画像は1つとする
- アップロードを始めた後にアプリをバックグランドにしても継続される
- アップロードが完了するとローカル通知を出す
- サーバー
[POST] /photo
のエンドポイントでアップロードできるmultipart/form-data
リクエストでContent-Disposition: form-data; name=photo
のファイルを受け取るとカレントディレクトリへ保存する- 正常系のレスポンスとして
{"filename": "アップロード時に指定したファイル名"}
のjsonを返す
バックグランドアップロードの下調べ
バックグランドでのファイルダウンロードはURL Loading Systemの Downloading Files in the Background項に記載されている通りです。ファイルのアップロードの具体的な方法に関してはこのページ内には記載がありませんでしたが、Only upload tasks from a file are supported (uploads from data instances or a stream fail after the app exits). という記載があるため基本的にバックグラウンド状態でのアップロードは ファイルから に限られるようです。バックグラウンドでのファイルダウンロードと、Uploading Data to a Websiteを踏まえると以下の手順を踏む必要がありそうです。
UploadTask
の状態の変更を受け取るためのDelegateを用意する- バックグランドタスク用の
URLSessionConfiguration
を生成する - 2.の
URLSessionConfiguration
を引数に入れてURLSession
を生成する - アップロード対象のファイルを
multipart/form-data
に沿うような形でエンコードする - 4.でエンコードしたデータをファイルへ出力し
URLRequest
を組み立てる URLSession
のメソッドuploadTask(with:fromFile:)
でアップロードタスクを開始する- レスポンスを受け取る
やってみる
iOSアプリのコードが断片的になっていますが、最後にまとめたものを準備しています。
アップロード先サーバーの準備
ファイルのアップロード先のサーバーはPython + FastAPIで準備しました。以下のコードで目標のサーバーの項を満たすサーバーとして動作します。
# server.py from fastapi import FastAPI, File, UploadFile app = FastAPI() @app.post('/photo') async def uploadPhoto(photo: UploadFile = File(...)): filename = str(photo.filename) photo_data = await photo.read() with open(f"./{filename}", mode="wb") as f: f.write(photo_data) return {"filename": filename}
あとはuvicorn --host 0.0.0.0 --port 3000 server:app
でサーバーが立ち上がります。server
の部分はファイル名の拡張子を取り除いたファイル名になります。細かいインストール方法や挙動・設定はFastAPIのチュートリアルを参考にしてください。
ImagePickerでフォトライブラリから画像を選択する
UIImagePickerControllerDelegate
を継承させたViewControllerで以下の2つのメソッドを実装することでフォトライブラリから画像を選択できるようになります。このときのimageURL
は選択した画像がテンポラリディレクトリへコピーされた後のパスが入っているのでそのまま使います。
// ViewController.swift func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { if let imageURL = info[.imageURL] as? URL { // imageURL = tmpフォルダへコピーされた画像のパス } picker.dismiss(animated: true, completion: nil) }
// ViewController.swift func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true, completion: nil) }
1. UploadTaskの状態の変更を受け取るためのDelegateを用意する
バックグランドでのアップロード・ダウンロードを行う際の失敗・成功やサーバーからのレスポンスはDelegateを通して処理しなければいけないのでそれらを準備します。今回ViewControllerではURLSessionTaskDelegate
とURLSessionDataDelegate
を継承させ、メソッド3つ実装しています。AppDelegateではUIApplicationDelegate
を継承させてメソッドを1つ実装しています。
urlSessionDidFinishEvents(forBackgroundURLSession:)
ドキュメントにも記載がありますがアプリがバックグランドかつ停止状態でデータ転送が完了した際に、iOSが自動的にアプリを再起動させapplication(_:handleEventsForBackgroundURLSession:completionHandler:)
を呼び出します。completionHandler
が呼び出されるとアプリは再度停止状態になってしまう(=以降Delegateが呼ばれなくなる)ため、urlSessionDidFinishEvents(forBackgroundURLSession:)
ですべてのバックグラウンドタスクが終了したタイミングでcompletionHandler
を呼び出すようにします。
// AppDelegate.swift var completionHandler: (() -> Void)? func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { completionHandler = completionHandler }
// ViewController.swift func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { DispatchQueue.main.async { guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } if let completionHandler = appDelegate.completionHandler { completionHandler() } } }
urlSession(_:dataTask:didReceive:)
サーバーからデータを受け取った時 に呼ばれるDelegateです。今回の場合ではクライアントがサーバーからデータを受け取るタイミングは アップロード後のレスポンス のみになるため、1リクエスト中に1回呼ばれることになります。またこのDelegateはあくまで サーバーからデータを受け取った時に呼ばれる ものなので、リクエストの成功・失敗に関わらず呼び出されます。複数回に分けてデータを受け取るようなタスクでは複数回呼び出されます。
// ViewController.swift func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { guard let response = dataTask.response as? HTTPURLResponse else { fatalError("No response") } // StatusCodeの正常・異常に関わらずレスポンスがあれば呼び出される // ResponseBodyは`data`に格納されています }
urlSession(_:task:didCompleteWithError:)
データの転送が完了した際に呼ばれるDelegateです。didCompleteWithError
にあるように、正常・異常に関わらず呼び出されるため、このDelegateが呼ばれたから正常に終わっているとは限りません。ドキュメントにある通りDNS周りのエラーや、そもそもホストに接続できない等のクライアント側のエラーでサーバーへデータ送れなかった場合に、ここで判断することができます。
// ViewController.swift func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let _error = error { fatalError(_error) } }
2. ~ 3. URLSessionを生成するまで
ドキュメントのURLSessionConfigurationを参考に生成します。必要に応じてタイムアウトまでの時間や、クッキーなどの設定ができます。
let sessionConfig = URLSessionConfiguration.background(withIdentifier: "upload-sample") sessionConfig.isDiscretionary = false // OSのタイミングにまかせてアップロードさせる場合はコメントアウト session = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
4. アップロード対象のファイルをmultipart/form-data
に沿うような形でエンコードする
URLSessionにはuploadTask(with:fromFile:)のようにファイルをアップロードするメソッドが用意されています。一見このメソッドを使ってアップロードすればいいように思いますが、multipart/form-data
コンテンツタイプの場合はデータの境界を示すためのboundary
を付与したりする必要があるので、データの整形を行う必要があります。(自動的に合うようにエンコードしたりはしてくれません)
multipart/form-data
コンテンツタイプに関する情報はMDN - POSTや、MDN - Content-Typeに書かれています。
以下のmultipart/form-data
用のデータを生成するメソッドを作成しました。
func createMultipartFormdataBody(_ files: [(path: URL, name: String, dispositionName: String)], boundary: String) -> Data { let contentDisposition = {(url: URL, name: String) -> Data in return "Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(url.lastPathComponent)\"\r\n".data(using: .utf8)! } let contentType = {(path: URL) -> Data in switch path.pathExtension.lowercased() { case "jpeg", "jpg": return "Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)! case "png": return "Content-Type: image/png\r\n\r\n".data(using: .utf8)! case "gif": return "Content-Type: image/gif\r\n\r\n".data(using: .utf8)! case "bmp": return "Content-Type: image/bmp\r\n\r\n".data(using: .utf8)! default: fatalError("Unknown extension") } } var data = Data() for (path, dispositionName) in files { data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) data.append(contentDisposition(path, dispositionName)) data.append(contentType(path)) let content = try! Data(contentsOf: path) data.append(content) } data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) return data }
余談ですがAlamofireを使うとこのあたりをやらなくて済みます。
5. ~ 6. URLRequest
を組み立ててuploadTask(with:fromFile:)
でアップロードタスクを開始する
createMultipartFormdataBody(files:boundary:)
を用いて生成されたデータを一度ファイルへ書き込んで、そのファイルをuploadTask(with:fromFile:)
でアップロードさせます。前述した通りBackground TransfarではuploadTask(with:from:)
で直接データをアップロードすることができないため一時的にファイルへ書き込みます。
func upload(_ filePath: URL){ let boundary = UUID().uuidString var req = URLRequest(url: self.serverURL, timeoutInterval: 3000) req.httpMethod = "POST" req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") let uploadName = filePath.lastPathComponent let data = self.createMultipartFormdataBody([(filePath, uploadName, "photo")], boundary: boundary) let uploadData = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(boundary) try! data.write(to: uploadData) let task = session.uploadTask(with: req, fromFile: uploadData) task.resume() }
7. レスポンスを受け取る
1.で実装した中でurlSession(_:dataTask:didReceive:)
のDelegateでレスポンスを受け取ります。一応フォアグラウンド状態でも通知を受け取るようにしておきたいので、AppDelegateにも変更を入れます。
// ViewController.swift func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { guard let _response = dataTask.response as? HTTPURLResponse else { fatalError("No response") } var responseBody: String? switch _response.statusCode { case 200: responseBody = String(data: data, encoding: .utf8) default: break } // ローカル通知を出す let content = UNMutableNotificationContent() content.title = "アップロード完了" content.body = "Response statusCode: \(_response.statusCode)" content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest(identifier: "upload-sample", content: content, trigger: trigger) let center = UNUserNotificationCenter.current() center.add(request) { (error) in if let error = error { print(error.localizedDescription) } } }
// AppDelegate.swift // 通知の許可ダイアログ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let center = UNUserNotificationCenter.current() center.delegate = self center.requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in if !granted { print("Didn't allowed notification") } } return true } // フォアグラウンドで通知を受け取るためのメソッド(フォアグラウンドで試す時以外はコメントアウト) func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: (UNNotificationPresentationOptions) -> Void) { completionHandler([.alert, .badge, .sound]) }
UIを足してアプリにまとめる
Single View Appでプロジェクトを作成し、AppDelegateとViewControllerの編集に加えて、StoryBoardでButtonを2つ、LabelとTextFieldを1ずつを作成しています。
AppDelegate.swift(クリックして展開します)
// AppDelegate.swift import UIKit import UserNotifications @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { var backgroundCompletionHandler: (() -> Void)? func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { backgroundCompletionHandler = completionHandler } func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let center = UNUserNotificationCenter.current() center.delegate = self center.requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in if !granted { print("Didn't allowed notification") } } return true } // MARK: UISceneSession Lifecycle func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { // Called when a new scene session is being created. // Use this method to select a configuration to create the new scene with. return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) { // Called when the user discards a scene session. // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } /* func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: (UNNotificationPresentationOptions) -> Void) { completionHandler([.alert, .badge, .sound]) } */ }
ViewController.swift(クリックして展開します)
// ViewController.swift import Foundation import UIKit import Photos import UserNotifications class ViewController: UIViewController, URLSessionTaskDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate, URLSessionDataDelegate { @IBOutlet var selectButton :UIButton! @IBOutlet var uploadButton :UIButton! @IBOutlet var uploadImageName :UITextField! @IBOutlet var message :UILabel! let serverURL = URL(string: "http://[YOUR HOST]/photo")! var session :URLSession! var uploadImageSource: URL? required init?(coder: NSCoder) { super.init(coder: coder) let sessionConfig = URLSessionConfiguration.background(withIdentifier: "upload-sample") sessionConfig.isDiscretionary = false // OSのタイミングにまかせてアップロードさせる場合はコメントアウト self.session = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil) } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let _error = error as NSError? { DispatchQueue.main.sync { self.message.textColor = .red self.message.text = """ \(_error.localizedDescription) task: \(task.taskIdentifier) code: \(_error.code) """ } return } } func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { DispatchQueue.main.async { guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, let backgroundCompletionHandler = appDelegate.backgroundCompletionHandler else { return } backgroundCompletionHandler() } } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { guard let _response = dataTask.response as? HTTPURLResponse else { fatalError("No response") } DispatchQueue.main.sync { var responseBody: String? switch _response.statusCode { case 200: responseBody = String(data: data, encoding: .utf8) self.message.textColor = .green default: self.message.textColor = .red } self.message.text = """ Response \(_response.statusCode) taskID: \(dataTask.taskIdentifier) response: \(responseBody ?? "nil") """ let content = UNMutableNotificationContent() content.title = "アップロード完了" content.body = "Response statusCode: \(_response.statusCode)" content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest(identifier: "upload-sample", content: content, trigger: trigger) let center = UNUserNotificationCenter.current() center.add(request) { (error) in if let error = error { print(error.localizedDescription) } } } } @IBAction func selectFile(_ sender : Any) { let picker = UIImagePickerController() picker.delegate = self picker.sourceType = .photoLibrary self.present(picker, animated: true) } @IBAction func uploadFile(_ sender : Any) { guard let souce = self.uploadImageSource else { return } self.upload(souce) self.message.textColor = .black self.message.text = "Uploading" } func upload(_ filePath: URL){ let boundary = UUID().uuidString var req = URLRequest(url: self.serverURL, timeoutInterval: 3000) req.httpMethod = "POST" req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") let uploadName = "\(uploadImageName.text ?? UUID().uuidString).\(filePath.pathExtension)" let data = self.createMultipartFormdataBody([(filePath, uploadName, "photo")], boundary: boundary) let uploadData = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(boundary) try! data.write(to: uploadData) let task = session.uploadTask(with: req, fromFile: uploadData) task.resume() } func createMultipartFormdataBody(_ files: [(path: URL, name: String, dispositionName: String)], boundary: String) -> Data { let contentDisposition = {(name: String, dispositionName: String) -> Data in return "Content-Disposition: form-data; name=\"\(dispositionName)\"; filename=\"\(name)\"\r\n".data(using: .utf8)! } let contentType = {(path: URL) -> Data in switch path.pathExtension.lowercased() { case "jpeg", "jpg": return "Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)! case "png": return "Content-Type: image/png\r\n\r\n".data(using: .utf8)! case "gif": return "Content-Type: image/gif\r\n\r\n".data(using: .utf8)! case "bmp": return "Content-Type: image/bmp\r\n\r\n".data(using: .utf8)! default: fatalError("Unknown extention") } } var data = Data() for (path, name, dispositionName) in files { data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) data.append(contentDisposition(name, dispositionName)) data.append(contentType(path)) let content = try! Data(contentsOf: path) data.append(content) } data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) return data } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { if let imageURL = info[.imageURL] as? URL { self.uploadImageSource = imageURL self.uploadImageName.text = imageURL.deletingPathExtension().lastPathComponent } picker.dismiss(animated: true, completion: nil) } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true, completion: nil) } }
StoryBoard(スクリーンショット)
- アップロード名編集用UITextField
- 画像選択用UIButton
- アップロード用UIButton
- レスポンスメッセージ表示用UILabel
フォアグラウンドで通知が出る状態で動かしてみると、このような動作になります。(正常系と404の場合)
試してみる
実際にバックグラウンドアップロードを試してみます。この画像*1をアップロードします。
結果以下のような操作を行った結果、無事にアップロードされました。
- アップロードを始める
- ホームボタンを押してアプリをバックグラウンド状態にする
- 電源ボタンを押してシュミレータのiPhoneをスリープ状態にする
- じっと待つ
実際にQuickTimeで画面録画を用いて計測したアップロード所要時間は約2分59秒でした。
サーバーにも無事アップロードされていました。
ちなみにURLSession
にはアップロードの進捗が取れるDelegateや、バックグラウンドアップロードに失敗したタスクを取れるDelegate等ほかにもよりアプリを便利にしてくれるものがあるのでぜひリファレンスを見てみてください。
所感
オプティムは事業領域が広いため実装環境もサービスごとに多種多様です。企業全体の体験としても、個人の体験としても新しいことをやってみることが多い中でドキュメントを読んでもピンと来ないことが少なくとも私にはちょくちょくあります。 そんなときはやっぱり初心に返って試しに手を動かして実装してみることが一番の近道だなと今回 も 思わされました。
そんな自分の未知の領域を楽しく一緒に開拓していくメンバーを絶賛募集しております!こちらをご一読いただければ幸いです。
謝辞
アイキャッチ画像素材: いらすとや 様
*1:私が昨年ソロ旅行したときのものです