はじめに
CyberAgent Developers Advent Calendar 2018の11日目の投稿となります。
昨日はshiftkyさんの「Kubebuilder で Operator を作ってプライベートクラウドの操作を自動化してみる」です。
明日はbaba5246さんの「OpenPose はどこまでの画像に耐えられるのか ~ Pose Estimation の紹介~」です。
11日目は、AbemaTVのiOSアプリ開発メンバーのmarty-suzukiが担当します。
業務でやっていることや関連していることは、Speaker Deckなどでアウトプットしているので、個人で開発したiOSアプリのArtShredder(バンクシーシュレッダー)について書きます。
全体として、思い立ってから1時間でアプリの初回申請版をつくり、幾度のリジェクトを乗り切り、運用のためにFlux+MVPでリファクタしたという流れで書いていきます。
アニメーションモード | ARモード |
---|---|
10月某日の出来事
あるオークションで1.5億円で落札された絵画がシュレッダーで切り刻まれるという出来事は記憶に新しいのではないでしょうか。
YouTubeより Sotheby's, October 5th 2018 by banksyfilm
https://youtu.be/iiO_1XRnMt4?t=40
さまざまなメディアで取り上げられたこの出来事ですが、数日後にはオマージュしたWebサービスが登場しています。
Banksy Shredder by Lee Martin
https://codepen.io/leemartin/pen/pxNvod
この頃、iOSアプリ設計パターン入門の執筆をしていて個人でアプリをつくる時間が減っていました。
面白そうな出来事であり、iOSアプリを探してみたところ同じコンセプトのものがなかったため、執筆の息抜きとして空き時間でサクッとつくってしまおうと思い立ちました。
初回申請までに
アプリをつくろうと思い立ったその日に、初回申請をしました。
なぜ初回申請を思い立ったその日にできたかというと、アプリ自体を1時間でつくったからです。
1時間でつくったアプリ (10月10日)
初回申請時につくったアプリは、以下の3つの機能のみを実装したシンプルなものです。
- 画像をカメラロールから選択
- 選択した画像をシュレッダーアニメーション
- アニメーション後のスクリーンを画像として保存
- 保存完了のアクションシートを表示
ちなみにリリース最優先だったため、ViewControllerにすべてベタ書きしています。
- 94行のViewController.swift
- Main.storyboard
夜中の疲れからでしょうか、プロジェクト名がShurettaという謎の単語になってしまっています...
画像をカメラロールから選択
画像をカメラロールから選択は、UIImagePickerController
を使うことで実装を簡易化しています。
ボタンがタップされたらUIImagePickerControllerを表示し、UIImagePickerControllerDelegateで選択された画像を受け取るという実装だけで済みます。
final class ViewController: UIViewController {
...
@IBAction private func selectPicture(_ sender: UIButton) {
let picker = UIImagePickerController()
picker.delegate = self
present(picker, animated: true, completion: nil)
}
}
extension ViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
let image = info[.originalImage] as? UIImage
...
}
}
選択した画像をシュレッダーアニメーション
ViewのレイアウトはStoryboard上で完結しています。
以下の図のように
- 黒背景のView
- ベースとなる白背景のView
- 選択した画像を表示するImageView
- 黒と透明のViewを交互に配置したStackView
- 額縁のImageView
という階層でViewが重なっています。
黒と透明のViewを交互に配置したStackView
と黒背景のView
の間に、選択した画像を表示するImageView
配置するだけで、あたかも画像が裁断されたかのような見た目になります。
つまり、上記の階層でViewを配置し、選択した画像を表示するImageView
をアニメーションさせるだけで、バンクシーシュレッダーのアニメーションが完成します。
ちなみに、Constraintは一箇所変えるだけで以下のようなアニメーションをするようになっています。
containerView.layoutIfNeeded()
imageViewCenterYConstraint.constant = me.bottomView.frame.size.height
UIView.animate(withDuration: 5, delay: 2, options: .curveEaseInOut, animations: {
self.containerView.layoutIfNeeded()
}) { _ in
...
}
アニメーション後の画面を画像として保存
UIGraphicsを利用して、ViewControllerのviewのスクリーンショットを取得しています。
そして、UIImageWriteToSavedPhotosAlbumを利用してカメラロールにその画像を保存しています。
UIGraphicsBeginImageContextWithOptions(view.bounds.size, false, 0.0)
guard let context = UIGraphicsGetCurrentContext() else {
UIGraphicsEndImageContext()
return
}
view.layer.render(in: context)
guard let image = UIGraphicsGetImageFromCurrentImageContext() else {
UIGraphicsEndImageContext()
return
}
UIGraphicsEndImageContext()
UIImageWriteToSavedPhotosAlbum(image, self, #selector(self.image(_:didFinishSavingWithError:contextInfo:)), nil)
保存完了のアクションシートを表示
カメラロールに画像が保存完了後、アクションシートを表示するようにしています。
保存完了を受け取り、その後にUIAlertControllerを表示するシンプルな実装です。
@objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
let alert = UIAlertController(title: "Save done", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Close", style: .default, handler: nil))
present(alert, animated: true, completion: nil)
}
このように、Storyboard上でのレイアウトを工夫し、最低限のコードを書くことで1時間で実装を終えました。
初回申請に最短で出すためにViewControllerにすべてのコードがベタ書きなっているので、普段はこの書き方はしません。
初回申請の審査結果 (10月11日)
20時間が経過したころに、審査の結果が届きました。
Changes needed.
どうやら、メタデータリジェクトされたようでした。
アプリの説明文に「とあるオークションの場面を再現しています」という文言があったので
「もしかするとオークションアプリじゃないのにオークションという文字が入っているということが不適切ということなのかもしれない」
と考えてオークションという文字を削除して、審査を続行してもらいました。
メタデータリジェクトの審査結果 (10月14日)
In Reviewのまま3日が経過し、ようやく審査が終わりましたが...
2.1 App Completeness
iPadの考慮が漏れていてUIAlertControllerの表示時にクラッシュが起きてしまっていたため、リジェクトされました。
このリジェクトは、10日後にアプリをリリースできるようになるまでの序章にすぎなかったのです。
リリースするまで
ここからは、アプリをリリースするまでにどんなリジェクトになって、それに対してどのような改善をしていったかを書いていきます。
2度目の申請 (10月14日)
申請までの実装
機能が少ないという理由でリジェクトされた場合に備えて、初回審査中も機能の追加実装をしていました。
- GIF画像での出力機能
- 設定画面の追加
- 日本語と英語の2カ国語対応
- 広告の追加
上記に加え、プロジェクト名をShurettaという謎の単語から、ArtShredderに変更しました。
それに伴い、アイコンやLaunchScreenの画像の差し替え、App Store Connect上の名前も変更しました。
GIF画像での出力機能
動きがあるものを吐き出せた方が、ユーザーはSNSにシェアしてくれるかもしれないと思ったのでGIF画像の出力機能を追加しました。
あまり良い実装ではないですが、Timerを利用して画面のスクリーンショットを撮り、その画像をGIF化しています。
本当はReplayKitを使って画面をキャプチャしたかったのですが、早く機能追加をしたかったのでその実装方法は一旦諦めて、既存の実装を活かすことにしました。
final class ViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
private var timer: Timer?
private var images: [UIImage] = []
...
private func snapshot(size: CGSize? = nil, scale: CGFloat = 0.0) -> UIImage? {
let _size = size ?? view.bounds.size
let _rect = size.map { CGRect(x: 0, y: 0, width: $0.width, height: $0.height) } ?? view.bounds
UIGraphicsBeginImageContextWithOptions(_size, false, scale)
view.drawHierarchy(in: _rect, afterScreenUpdates: false)
guard let image = UIGraphicsGetImageFromCurrentImageContext() else {
UIGraphicsEndImageContext()
return nil
}
UIGraphicsEndImageContext()
return image
}
@objc private func timerHandler(_ timer: Timer) {
DispatchQueue.main.async {
let _size = self.view.bounds.size
let size = CGSize(width: _size.width / 2, height: _size.height / 2)
guard let image = self.snapshot(size: size, scale: 1.0) else { return }
self.images += [image]
}
}
private func createGIF() {
let url = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("sample.gif")!
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, kUTTypeGIF, images.count, nil) else { return }
let properties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]]
CGImageDestinationSetProperties(destination, properties as CFDictionary)
let frameProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFDelayTime as String: 0.2]]
for image in images {
guard let cgImage = image.cgImage else { continue }
CGImageDestinationAddImage(destination, cgImage, frameProperties as CFDictionary)
}
guard CGImageDestinationFinalize(destination) else { return }
let controller = UIActivityViewController(activityItems: [url], applicationActivities: nil)
controller.completionWithItemsHandler = { [weak self] activityType, isCompleted, returnedItems, error in
...
}
present(controller, animated: true, completion: nil)
}
...
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
...
dismiss(animated: true) { [weak self] in
guard let me = self else { return }
...
me.images.removeAll()
me.timer = Timer.scheduledTimer(timeInterval: 0.05, target: me, selector: #selector(me.timerHandler(_:)), userInfo: nil, repeats: true)
...
}
}
}
設定画面の追加
画面を追加するだけでも機能を追加したことになり、審査を通過できることもあるため設定画面を追加しました。
設定画面と言いつつも、機能としてはアプリ内で使っている額縁の画像のソース元をSFSafariViewController
を使って表示するだけになっています。
レイアウトはStoryboard上で組んでいるため、実装は以下のようにシンプルになっています。
import UIKit
import SafariServices
final class SettingViewController: UIViewController {
@IBOutlet private(set) weak var frameDescriptionLabel: UILabel! {
didSet {
let localized = NSLocalizedString("frame_description", comment: "")
frameDescriptionLabel.text = String(format: localized, Const.urlString)
}
}
@IBAction private func openFrameWebSite(_ sender: UIButton) {
let url = URL(string: "https://www.freeiconspng.com/img/24597")!
let vc = SFSafariViewController(url: url)
present(vc, animated: true, completion: nil)
}
}
日本語と英語の2カ国語対応
ダウンロード数を少しでも伸ばせればなという淡い期待をしていたので、多言語対応することにしました。
まず、プロジェクトのLocalizationsに対応する言語を追加します。
そして、Localizable.stringsに対応したい言語の文言を追記し、NSLocalizedStringで呼び出すだけで対応ができます。
"frame_description" = "This photo frame is provided by %@";
"select_image_name" = "add_image_en";
"save_gif_button" = "Save as GIF";
"save_image_button" = "Save as Image";
"did_save_to_camera_roll_title" = "Save Image Finished";
"did_save_to_camera_roll_message" = "Finished saving Image to Camera Roll!";
"close_action" = "Close";
"image_source_select_title" = "Image Source";
"image_source_select_message" = "Select image source";
"image_source_select_camera" = "Camera";
"image_source_select_camera_roll" = "Camera Roll";
"cancel_action" = "Cancel";
"frame_description" = "このフレームは %@ で提供されています";
"select_image_name" = "add_image_jp";
"save_gif_button" = "GIFを保存";
"save_image_button" = "画像を保存";
"did_save_to_camera_roll_title" = "画像の保存完了";
"did_save_to_camera_roll_message" = "カメラロールに画像を保存しました!";
"close_action" = "閉じる";
"image_source_select_title" = "画像の取得先";
"image_source_select_message" = "画像の取得先をえらんでください";
"image_source_select_camera" = "カメラ";
"image_source_select_camera_roll" = "カメラロール";
"cancel_action" = "キャンセル";
@IBOutlet private(set) weak var saveImageButton: UIButton! {
didSet {
saveImageButton.isEnabled = false
let title = NSLocalizedString("save_image_button", comment: "")
saveImageButton.setTitle(title, for: .normal)
}
}
広告の追加
万が一ダウンロード数が伸びたときのためという淡い期待で、Google-Mobile-Ads-SDKを使って広告の実装をしました。
GIFアニメーションを作成するときにInterstitial ADを表示し、画面には常にバナーを表示するようにしています。
審査結果 (10月16日)
それなりに機能を追加して挑みましたが...
Guideline 4.2.2 - Design - Minimum Functionality
機能が少ないということでリジェクトされました。
つまり、GIF画像の出力や多言語対応をしても不十分だったのです。
3度目の申請 (10月16日)
申請までにやったこと
「BundleIDが変わるとレビュアーも変わるため、同じバイナリを提出しても審査を通過することがある」という噂がありました。
BundleIDにはまだ、Shurettaという謎の単語が入ってしまっていました。
そのためBundleIDを変更し、App Store Connect上のShurettaアプリも削除して、ArtShredderアプリを新規に追加しました。
そして、ArtShredderにShurettaと同じバイナリをアップロードして再申請をしました。
まさか、この変更が後々に厄介なことになるとは想像もしていませんでした。
審査結果 (10月17日)
Guidline 4.3 Spam
同じような内容の他のアプリが、App Storeに存在するという内容のリジェクトでした。
同じようなアプリがあるのであれば、独自の機能を実装してしまえば通るだろうと思って、4度目の申請に向けた準備をはじめました。
4度目の申請 (10月18日)
申請までの実装
ストアを探してみたのですが同じようなアプリを見つけることができず、どんな機能を追加するかを悩んでいました。
また、似たようなアプリがあるという理由でリジェクトされた場合、レビュアーに問い合わせてもどんなアプリか教えてもらえないようです。
そこで頭をよぎったのが、ARです。
WWDCでARKit 2が発表されたこともあり、AR機能をアプリに実装することで審査が通りやすくなるのではないかと考えました。
ARKitを触ったことすらなかったので、良い機会だし面白しろそうだと思ったので実装してみました。
もともとあったUIViewの処理を転用しようと考えました。
しかしUIViewベースでアニメーションを行うと、AR上で表示しているWindowの参照が残り続けてしまって、他のViewControllerを表示すると画面操作ができなくなるという現象に遭遇しました。(なぜ参照が残るのかの原因は未だに未解決)
またもともとの実装では、背景色と裁断される部分の色が同じだったために、あたかも裁断されたかのように見えていただけでした。
そこで、CALayerを利用する実装に変更しつつ、画像もマスクして背景が実際に透けるように修正しました。
今回のLayerの構成は、額縁の画像、等間隔に透過済みの画像と元画像になります。
等間隔に透過済みの画像に対して、額縁内の範囲に対してマスクをかけます。
そして元画像に対して、額縁外の範囲に対してマスクをかけます。
それらのLayerを同時に同じポジションに向かってアニメーションさせることで、額縁から裁断されて画像が出てくるアニメーションが完成します。
画像を等間隔に透過する実装は、CAShapeLayerとUIBezierPathを組み合わせることで実現できます。
let maskLayer = CAShapeLayer()
maskLayer.path = {
let path = UIBezierPath()
(0..<20).forEach { num in
let _path = UIBezierPath(rect: CGRect(x: CGFloat(num) * 13.65, y: 0, width: 11, height: 564))
path.append(_path)
}
path.close()
return path.cgPath
}()
maskLayer.fillColor = UIColor.black.cgColor
maskLayer.frame = shredImageLayer.bounds
アニメーションはSCNodeのサブクラス内で、CABasicAnimationを利用しています。
final class ARShredderNode: SCNNode {
....
let shredderLayer = ARShredderLayer()
init(node: SCNNode) {
super.init()
let size = CGSize(width: 514, height: 743)
let scale: CGFloat = 0.3
let geometry = SCNPlane(width: size.width * scale / size.height, height: scale)
let material = SCNMaterial()
material.diffuse.contents = shredderLayer
geometry.materials = [material]
self.geometry = geometry
self.position = node.convertPosition(SCNVector3(x: 0, y: 0, z: -0.5), to: nil)
}
func startAnimation() {
[shredderLayer.baseImageLayer, shredderLayer.shredImageLayer].forEach {
$0.removeAnimation(forKey: Const.animationKey)
let animation = CABasicAnimation(keyPath: Const.animationKeyPath)
animation.duration = 5
animation.beginTime = CACurrentMediaTime() + 2
animation.fromValue = 0
animation.toValue = -232
animation.isRemovedOnCompletion = false
animation.fillMode = .forwards
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
animation.delegate = self
$0.add(animation, forKey: Const.animationKey)
}
}
}
審査結果 (10月21日)
Guidline 4.3 Spam
同じアイコンの他のアプリが、App Storeに存在するという内容のリジェクトでした。
ストア内を探してみても、同じアイコンのアプリなど見つかりませんでした。
しかし、あることを思い出しました。
App Store Connectでアプリを新しくつくり直し、ArtShredderとしてShurettaと同じバイナリをアップロードして再申請したことです。
審査内容の異議申立 (10月22日)
App Store Connectの消したアプリを、ArtShredderと同じものだと審査された可能性があったので、App Store Connectから異議申立をしました。
すると申立をしたその日のうちに返信がきました。
そして、違うレビューアによって再審査されることになったのです。
審査結果 (10月24日)
2日後に審査結果が届きました。
Welcome to the App Store.
ようやくリジェクトのスパイラルから抜け出しApp Storeでリリースすることができたのです。
リリース後のリファクタリング
リリースはできたので今後の運用のことも考慮し、いろいろと直していくことにしました。
StoryboardからXibに置き換える
後々テストすることを考慮し、外部からオブジェクトをViewControllerに対して注入できるようにします。
Storyboardのままだとinitializerを使ってオブジェクトを渡すことはできないため、暗黙的オプショナルを使うことになります。
Xibを利用することで、ViewControllerのinitializerが使えるようになるので、オブジェクトを渡すことができるようになります。
iOS11以降であれば、StoryboardからXibに移行するのは、それほど大変な作業ではありません。
StoryboardのViewをXibにそのままコピーしFile's Ownerのviewに紐付け、IBOutletに再度リンクし直すだけです。
(iOS10以下の場合、Top Layout GuideとSafe Areaのconstraintの張替えをする必要がある部分がでてきたりします。)
ReplayKitを利用する
Timerを利用してViewのスクリーンショットを撮る実装はあまりパフォーマンスが良くないので、ReplayKitを使って画面のキャプチャをするようにします。
RPScreenRecorder
のfunc startCapture(handler:completionHandler:)
を利用することで、handlerからCMSampleBuffer
を受け取ることができます。
受け取ったCMSampleBuffer
からCIImage → CGImage → UIImageと変換をして、画像を取得します。
let recorder = RPScreenRecorder.shared()
let handler: (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { buffer, type, error in
guard type == .video, let imageBuffer = CMSampleBufferGetImageBuffer(buffer) else { return }
CVPixelBufferLockBaseAddress(imageBuffer, [])
let ciImage = CIImage(cvImageBuffer: imageBuffer)
let context = CIContext(options: nil)
guard let cgImage = context.createCGImage(ciImage, from: ciImage.extent) else { return }
let image = UIImage(cgImage: cgImage, scale: 1.0, orientation: .right)
CVPixelBufferUnlockBaseAddress(imageBuffer, [])
}
let completionHandler: (Error?) -> Void = { error in
...
}
recorder.startCapture(handler: handler, completionHandler: completionHandler)
Flux + MVPを利用する
Fluxは単一方向のデータの流れが特徴のアーキテクチャです。
単体で利用するのも良いですが、他のアーキテクチャにデータの流れを単一方向にする目的で組み合わせます。
MVPと組み合わせることで、Storeの更新をPresenterからViewに対して更新することができます。
ArtShredderではPrexで、FluxとMVPの組み合わせを利用します。
Prexは、VueFluxやReactorKitのデータの流れをインスパイヤしつつ、FluxとMVPを組み合わせているFrameworkです。
Viewの操作をPresenterが受け取ります。
PresenterからActionをDispatcherに送信し、StoreはDispatcherからActionを受け取ります。
Storeで保持しているStateをMutationの内部でActionをもとに更新します。
更新されたStateはPresenterに伝達され、PresenterはViewに対して画面更新の処理を呼び出します。
ARを表示してるViewControllerでPrexを利用し、画像選択ボタンの表示/非表示を切り替える実装を説明します。
以下のように、Action
、State
、Mutation
を定義します。
StateはMutationと同じファイルに定義しPropertyをfileprivate(set)
とするとで、Mutationでのみ変更することができ外部からはRead-onlyにすることができます。
またMutationでStateを変更する場合は、受け取ったActionによってのみ変更することができます。
enum AR {
enum Action: Prex.Action {
case setIsImageSelectHidden(Bool)
}
struct State: Prex.State {
fileprivate(set) var isImageSelectHidden = true
}
struct Mutation: Prex.Mutation {
func mutate(action: Action, state: inout State) {
switch action {
case let .setIsImageSelectHidden(isHidden):
state.isImageSelectHidden = isHidden
}
}
}
}
次にPresenterを定義します。
Presenter内では暗黙的にStore
とDispatcher
の参照を持っています。
Presenterのfunc dispatch(_:)
にActionを渡すことで、Store内のStateを更新することができます。
final class ARPresenter: Presenter<AR.Action, AR.State> {
convenience init<T: View>(view: T) where T.State == AR.State {
self.init(view: view, state: AR.State(), mutation: AR.Mutation())
}
func didSelectImage() {
dispatch(.setIsImageSelectHidden(true))
}
}
ViewControllerはPresenter
を保持し、View
に準拠しています。
View
のfunc reflect(change:)
を実装することで、Storeに変更があった場合にPresenter経由でその変更を各UIに対して反映します。
StateChange
のfunc changedProperty(for:)
を利用することで、任意のKeyPathに変更があった場合にのみ値を取得することができます。
Presenterのfunc reflect()
は、StoreのStateを即座にViewに反映します。
func didSelectImage()
が呼び出されると、StoreのStateが変更されるため、その状態が選択ボタンに反映され非表示になります。
final class ARViewController: UIViewController {
@IBOutlet private(set) weak var selectImageButton: UIButton!
private lazy var presenter = ARPresenter(view: self)
override func viewDidLoad() {
super.viewDidLoad()
presenter.reflect()
}
private func didSelectImage() {
presenter.didSelectImage()
}
}
extension ARViewController: View {
func reflect(change: StateChange<AR.State>) {
if let isHidden = change.changedProperty(for: \.isImageSelectHidden)?.value {
selectImageButton.isHidden = isHidden
}
}
}
最後に
初回申請時のプロジェクトの状態は、ViewControllerにコードをすべてベタ書きした状態でした。
機能もそれほど多くはなく、今後どのように発展させていくのかも未定でした。
そういった状況で、最短で審査に出す
という目的があるのであれば、ViewControllerにコードをすべてベタ書きするという選択もありだと思います。
しかし、機能が増えていくと、その状態では辛くなってくる時期が来ます。
そのため、最短で審査に出す
という目的が達成された後に、新しく機能を加えつつも徐々に構成を改善する必要がでてきます。
どういった設計を使ってみるのが良さそうかは、iOSアプリ設計パターン入門が参考になるかもしれません。
時事ネタにのっかったこともあり、アプリのダウンロード数が伸びるかなという淡い期待をしていたのですが、現実はそう甘くはありませんでした。
ダウンロード数も伸びず、つくったものが埋もれてしまうのはもったいないなと感じているので、アプリのソースコードを公開してしまおうと思います!
GitHub: https://github.com/marty-suzuki/ArtShredder
AppStore: https://itunes.apple.com/JP/app/id1439126672?mt=8