炊きたてのご飯が食べたい

定時に帰れるっていいね。自宅勤務できるっていいね。子どもと炊きたてのご飯が食べられる。アクトインディでは積極的にエンジニアを募集中です。

Swift2 - 画像アップロードと通常のリクエストを分けてユーザー体験を向上させる


リクエスト回数は極力減らした方が良いのでは?といった方針から、画像をアップロードする処理と通常のテキスト情報を送信する処理を一つのリクエストで行っていたのですが、実際に実装してみたところ、リクエストが完了するまで画面にローディングを表示して、操作を中断させる処理を入れていたため、えらく待たされる感が漂ってました。非同期で行われる API の良さも活かしきれていない状況で、リクエスト回数を減らすよりか、より早くユーザーに操作できる環境を提供する方が、操作していて気持ちいいだろうということで、通常のリクエストと画像のリクエストを分けて実装することになったので、その方法について紹介したいと思います。口コミを投稿する機能をサンプルとしたいと思います。

サンプル例

  1. ある宿泊施設に対して口コミを投稿する機能がある
  2. 口コミで投稿する内容は、感想( content ), 評価 ( rating ) , 画像 ※複数可 ( photos ) とする

実装内容

  1. テキスト情報のリクエストを定義する ( /facilities/:facility_id/reviews )
  2. 画像アップロードのリクエストを定義する ( /reviews/:review_id/photos )
  3. 口コミ投稿する際には、画像以外をまずは投稿する。レスポンスに口コミ ID を返すようにし、画像はレスポンスの口コミ ID にアップロードするようにする
  4. 複数枚の画像のアップロードが想定されるので、画像のアップロード中は画面にアップロード中の View を表示する

通常のリクエストはすぐに完了する処理なので、ローディング画面を表示して、操作を中断。画像アップロードは時間がかかるので、ローディング画面ではなく、ページ上部にアップロード中のお知らせを出して、画面を操作は制御しないようにします。画像のアップロードが失敗しても、口コミのテキスト情報の送信は commit されているので、ロールバックはできません。画像のアップロード失敗時は、適切にユーザーに知らせることに注力し、編集から再度画像をアップロードしてもらう形にしようといった割り切り方をしました。

f:id:t-namikata:20160523233117p:plain

1. テキスト情報のリクエストを定義する

(Swift2) Alamofire の upload 用 API クライアントを作成する の記事で作成した API クライアントを利用して、実装したいと思います。リクエストパスは /facilities/:facility_id/reviews とし POST するものとします。

  1. Review 情報のプロパティを定義する
  2. API を call する為の Endpoint を定義する

1. Review 情報のプロパティを定義する

まずは要件に沿って エンティティとなる ReviewEntity クラスを作成します。

  • ReviewEntity.swift
import ObjectMapper

class ReviewEntity: Mappable {
    var id: Int?
    var content = ""
    var rating = 0
    var photoList: [String] = []

    required init?(_ map: Map) {}    
    
    func mapping(map: Map) {
        id <- map["id"]
        content <- map["content"]
        rating <- map["rating"]
        photoList <- map["photos"]
    }
    
    var parameters: [String: AnyObject] {
        return [
            "content": content,
            "rating": rating,
        ]
    }
}

parameters は POST リクエストに利用するプロパティを定義しています。

2. API を call する為の Endpoint を定義する

  • Endpoint.swift
enum Review: RequestProtocol {
    typealias ResponseType = ReviewEntity
    
    case Post(facilityID: Int, parameters: [String: AnyObject])
    
    var method: Alamofire.Method {
        return .POST
    }
    
    var path: String {
        switch self {
        case .Post(let facilityID, _):
            return "/facilities/\(facilityID)/reviews"
        }
    }
    
    var parameters: [String: AnyObject]? {
        switch self {
        case .Post(_, let parameters):
            return parameters
        }
    }        
}

リクエストを投げる際は以下のように記述します。

let facilityID = 1
let parameters = ["content": "楽しかった", "rating": 3]
API.call(Endpoint.Review.Post(facilityID: facilityID, parameters: parameters)) { response in
    switch response {
    case .Success(let result):
        // 成功時の処理を書く
    case .Failure(let error):
        // エラー時の処理を書く
    }
}

2. 画像アップロードのリクエストを定義する ( /reviews/:review_id/photos )

通常のリクエストと同じように、画像のアップロードのリクエストを定義します。

  • Endpoint.swift
class Endpoint {
    class UploadPhoto: UploadProtocol {
        typealias ResponseType = AnyObject
        var id: Int
        var imageData: NSData
        
        init(id: Int, imageData: NSData) {
            self.id = id
            self.imageData = imageData
        }
        
        var path: String {
            return "/reviews/\(self.id)/photos"
        }

        lazy var multipartFormData: (MultipartFormData) -> () = { [weak self](data: MultipartFormData) in
            guard let weakSelf = self else {
                return
            }
            data.appendBodyPart(
                data: weakSelf.imageData,
                name: "photo",
                fileName: "image",
                mimeType: "image/jpeg"
            )
        }
    }
}

リクエストを投げる際は以下のように記述します

let reviewID = 1
let image = UIImage(named: "test")
let humanQuality: CGFloat = 0.8
let imageData = UIImageJPEGRepresentation(image, humanQuality)

API.call(Endpoint.UploadPhoto(id: reviewID, imageData: imageData)) { response in
    switch response {
    case .Success(let result):
        print("success \(result)")
    case .Failure(let error):
        print("failure \(error)")
    }
}

3. 口コミ投稿する際には、画像以外をまずは投稿する。レスポンスに口コミ ID を返すようにし、画像はレスポンスの口コミ ID にアップロードするようにする

簡易的に ViewController で API のリクエストを行いますが、きちんと実装する場合は Model 等で非同期のリクエストは行うようにしてください。

  1. Endpoint.Review.Post でテキスト情報のリクエストを投げる
  2. Endpoint.Review.Post リクエストの Success 処理内で画像をアップロードする Endpoint.UploadPhoto を呼び出す

これでテキスト情報のリクエストと画像アップロードを一連の流れの中で別処理として行うことができるようになりました。

ViewController.swift

import SVProgressHUD

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let facilityID = 1
        let parameters = ["content": "楽しかった", "rating": 3]
        // テキスト情報の投稿中はユーザーの画面操作を制御する
        SVProgressHUD.showWithStatus("投稿中", maskType: .Black)
        API.call(Endpoint.Review.Post(facilityID: facilityID, parameters: parameters)) { response in
            // API の結果が返ってきたら、ローディングを解除する。このタイミングでユーザーは画面を操作可能となる。
            SVProgressHUD.dismiss()
            switch response {
            case .Success(let result):
                // Endpoint.Review.Post は Endpoint.swift の ResponseType に ReviewEntity を指定しているので
                // result の型は ReviewEntity となる
                self.upload(result.id)
            case .Failure(let error):
                // エラー時の処理を書く
            }
        }
    }

    private upload(reviewID: Int) {
        let imageList = [UIImage(named: "test"), UIImage(named: "test2"), UIImage(named: "test3")]
        let humanQuality: CGFloat = 0.8
        let imageData = UIImageJPEGRepresentation(image, humanQuality)

        for image in imageList {
            // API.call リクエストは非同期処理の為、一つ一つの処理を待たずに画像のアップロードは 3並列で行われる。
            API.call(Endpoint.UploadPhoto(id: reviewID, imageData: imageData)) { response in
                switch response {
                case .Success(let result):
                    print("success \(result)")
                case .Failure(let error):
                    print("failure \(error)")
                }
            }        
        }
    }
}
  1. 複数枚の画像のアップロードが想定されるので、画像のアップロード中は画面にアップロード中の View を表示する

最後に、このままでは画像のリクエストの進捗がユーザーに分からないので、進捗が分かるように View を画面に表示します。

f:id:t-namikata:20160523233117p:plain

(Swift2) Alamofire の upload の進捗を通知で受け取ってプログレスバーを表示するの内容を拡張すれば容易に行えるかと思います。ここでは、処理の流れと、変更を加えた箇所だけ説明します。具体的な実装方法については記事を参照してください。

処理の流れ

  1. 画像アップロードの開始に合わせてアップロード中の View を表示する
  2. 画像アップロードの終了に合わせてアップロード終了の View を表示する
  3. アップロードに失敗した画像があればアップロード失敗のトーストを表示する

最終的に ViewController の実装は以下のようになります。

import SVProgressHUD

class ViewController: UIViewController {
    // 画像アップロードの通知用 View
    private let uploadProgressView = UploadProgressUIView()

    override func viewDidLoad() {
        super.viewDidLoad()

        let facilityID = 1
        let parameters = ["content": "楽しかった", "rating": 3]
        SVProgressHUD.showWithStatus("投稿中", maskType: .Black)
        API.call(Endpoint.Review.Post(facilityID: facilityID, parameters: parameters)) { response in
            SVProgressHUD.dismiss()
            switch response {
            case .Success(let result):
                self.upload(result.id)
            case .Failure(let error):
                // エラー時の処理を書く
            }
        }
    }

    private upload(reviewID: Int) {
        let imageList = [UIImage(named: "test"), UIImage(named: "test2"), UIImage(named: "test3")]
        let humanQuality: CGFloat = 0.8
        let imageData = UIImageJPEGRepresentation(image, humanQuality)

        // 画像のリクエストに合わせて View を表示する
        self.addUploadProgressView()
        // アップロード回数を計測する。API のリクエストは並列で行われる為 for 文の index は利用できない
        var uploadPhotoCount = 0
        for image in imageList {
            API.call(Endpoint.UploadPhoto(id: reviewID, imageData: imageData)) { response in
                // リクエストの回数をインクリメントする
                uploadPhotoCount += 1
                switch response {
                case .Success(let result):
                    // No-op
                case .Failure(let error):
                    // トースト表示なので dismiss() しなくても一定時間が経過すると表示は消える
                    SVProgressHUD.showErrorWithStatus("画像のアップロードに失敗しました")
                }
            }
            if uploadPhotoCount == imageList.count {
                self.removeUploadProgressView()
            }
        }
    }

    private func addUploadProgressView() {
        // ...省略
    }
    
    private func removeUploadProgressView() {
        // ...省略
    }

    private func settingProgressView() {
        // ...省略
    }
}