アプリ開発録

iPhoneアプリやその他開発で得られた知見をメモ

iPhoneアプリ『妄想通知』開発録

開発したアプリ

t.co

処女作『妄想通知』アプリ開発の記録。

自分がそうだったように、これから初めてアプリ開発をする人の助けや参考になる記事になるように、自分が直面した課題やつまづいた所、解決策等について記します。

開発動機

本アプリが処女作。

アプリの開発から販売までの流れと、App Store市場の動向の把握が目的。

最初のアプリは、上に挙げたように、流れと動向の把握が目的なので簡単に作れて販売まで短期間で到れることが必要。

妄想の通知画面の壁紙を作るアプリはあるものの、通知そのものを発行するものは意外となかったので、すぐできそうだったので作ることに。

構想

OneNoteを使用し以下のような、最低限盛り込みたい機能やUIのデザインの構想を練る

f:id:tak-dev:20170208134143p:plain

わからなかったこと

  • Swift
  • Xcode
  • AutoLayout
  • どうやって販売するのか

等々、まるで初めてだったのでわからないことだらけだった。

ただし、以前にPlayStation Mobileのアプリの開発・販売をしたことがあり、その経験が役に立った。

学習

まずはじめに、Udemyの以下のビデオ講座を購入し受講。

www.udemy.com

定価は1万数千円だったが、セールで2400円で購入。

このビデオを一通りみて、おおよその開発の流れと、GUIパーツの仕様や使用方法、Xcodeの使用方法を摑む。

ただしAutoLayoutには一切触れられていなかったので別途、ドットインストールのプレミアム会員に1ヶ月だけなり、iOSレイアウト入門 の動画を全て視聴しオートレイアウトを学習。

その他に、書籍を2冊購入するなど。

直面した課題

Swiftで困惑した記法 

Swiftは以前にも実践抜きで座学だけしたことあるが、今まで触ったどの言語とも違う感じで難解だという印象だった。

今回、数年越しで実践に踏み込めて触った印象は、やはりややこしい!

オプショナル型、アンパック

オプショナル型という概念は、Swift特有のもので、今まで触って切った言語にはなかったので、いまいち理解できずにいた。

オプショナル型やアンラップの概念については下記の記事がもっとも理解しやすかった。

qiita.com

_という記法、ワイルドカード

Swiftでは_(アンダーバー、アンダースコア)という記号が使える。

これはワイルドカードを意味していて、使い捨ての匿名の変数のような形で使うことができる。

blog.skipbit.jp

外部引数名

Swiftには外部引数名という概念があり、これも他の言語にはみられない特徴だ。

外部引数名とは、メソッドの等の定義で内部で使う引数名とは別に、メソッドを呼び出す側からはどう見えるかということを扱う概念。

func bmi(weight kg:Double, height cm:Double) -> Double {
  // ...
}

weight、heightのところが外部引数名である。

一般的に見慣れている形は以下のような感じなので、上の記法を初めてみると、一体なんのこっちゃ???とかなり面を喰らう。

func bmi(kg:Double, cm:Double) -> Double {
  // ...
}

この外部引数名によって、呼び出す側からは、kgとcmではなく、wightとheightを指定することになる。

ルーセルみたいなパーツの作り方

f:id:tak-dev:20170210135752p:plain

『妄想通知』では、上図の赤枠の部分をWebサイトでいうカルーセルのように左右に指でスワイプできるようになっている。

構想で、そういう機能にしようと決めたものの、iOSアプリでそれをどのように実現するかはわかっていなかった。

iOSアプリでカルーセルビューの作り方

f:id:tak-dev:20170210140222p:plain

これをiOSアプリで簡単に実現するためには、「Scroll View」というUIパーツを使う。

StoryBoard上でScrollView(以下SV)をビュー上に配置する。

だが、このままだとWebサイトを見る時のような区切りのないスクロールになってしまう。

f:id:tak-dev:20170210140353p:plain

Paging Enabledというプロパティにチェックを入れるだけで、ページ単位でスワイプできるようになる。

後はこのSVの子としてSVと同じ幅と高さの要素を3つ、重ならないように追加していく。

その追加する要素はコードで生成しているが、構想した機能として、名前背景画像を持つオブジェクトが3つと規定していたので、それぞれ独立した名前と背景画像を3つずつ配置するのではなく、名前と背景画像がワンセットとなったパーツとしてのオブジェクトを3つ、しかもその見た目はインターフェースビルダー(IB)で設定したいと考えていた。

PlayStation Mobile(以下PSM)の時は、そういったサブビュー、あるいはカスタムビューを、親のメインビューの子として、配置できたので、Xcodeではどのように実現すればいいのか探ることにした。

文章ではわかりづらいが、自分がやりたかったのは以下のようなことである。

f:id:tak-dev:20170210144337p:plain

クラスのように、ひとつの機能がまとまったオブジェクトを簡単にインスタンスとして配置、なおかつそのパーツの配置や設定は、メインのビューとは独立したインターフェース上でしたかった。

カスタムビュー(xib)を作成しそれを本体ビューで使う

PSMの時は、それが簡単手軽にできたので、Xcodeでもきっとできるはずと探ると、xibという概念が浮上した。

f:id:tak-dev:20170210144802p:plain

CustomView.xibと命名したこの、メインビューとは独立したパーツ用として作成したこのビューでは、文字が重なっていてわかりにくいが背景画像用のImageViewと名前用のLabelを配置している。

ここで作成した小パーツをメインビュー上で、配置しようというわけである。

StoryBoard上でもこれを見た目を反映した上で配置できるらしいが、妄想通知ではコード上で動的に生成している。

ちなみに、SB上で見た目を反映させた上で配置するためには

@IBDesignable
class Hoge: UIView {

このように、xibファイルと対にしたソースファイルのクラスの上に@IBDesignableというデコレーションをする必要がある。

xibファイルとソースコードを関連づけるためには、File's Ownerを設定してあげる必要がある。

f:id:tak-dev:20170210145548p:plain

参考

サブビューの初期化コード

    override init(frame: CGRect) {
        super.init(frame: frame)
        loadXib()
    }
    
    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
        loadXib()
    }

    // 初期化
    fileprivate func loadXib() {
        
        let bundle = Bundle(for: type(of: self))
        let nib = UINib(nibName: "CustomView", bundle: bundle)
        let view = nib.instantiate(withOwner: self, options: nil).first as! UIView
        addSubview(view)
        
        view.translatesAutoresizingMaskIntoConstraints = false
        let bindings = ["view": view]
        addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options:NSLayoutFormatOptions(rawValue: 0), metrics:nil, views: bindings))
        addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options:NSLayoutFormatOptions(rawValue: 0), metrics:nil, views: bindings))

        // ...

Swiftの配列

スクリプト言語に慣れてると扱いが難しい
ここが一番わかりやすかった

www.webopixel.net

データの保存(自作クラスのシリアライズ

ビルトインの型であれば、簡単な手順で保存できるが、独自にクラス定義したデータの保存は、シリアライズ、デシリアライズ処理が必要となり少々面倒くさい。

妄想通知は以下のようになっている。

書き込み

var dataArray:[SendToData] = [SendToData]()

// ...

// シリアライズして書き込み
let encodeData = NSKeyedArchiver.archivedData(withRootObject: dataArray)
userDefaults.set(encodeData, forKey: "DataStore")
userDefaults.synchronize()

読み込み

let decoded  = UserDefaults.standard.object(forKey: "DataStore") as! Data
dataArray = NSKeyedUnarchiver.unarchiveObject(with: decoded) as! [SendToData]

クラス

// データ保存用のクラス
class SendToData: NSObject, NSCoding {
    var image: UIImage?
    var name: String
    
    init(image: UIImage?, name: String) {
        self.image = image
        self.name = name
    }
    
    // MARK: - NSCoding
    public required init(coder aDecoder: NSCoder){
        name  = aDecoder.decodeObject(forKey: "name") as! String
        image = aDecoder.decodeObject(forKey: "image") as? UIImage
    }
    
    public func encode(with aCoder: NSCoder){
        aCoder.encode(name, forKey: "name")
        aCoder.encode(image, forKey: "image")
    }
    
}

このクラスでは通知される宛名var name: Stringと、背景画像var image: UIImage?を保存する構造になっている。

データのシリアライズとデシリアライズを行いたい場合、NSObjectNSCodingという物を継承したクラスを作る必要があるらしい。

参考

stackoverflow.com

shoboshobopg.hatenablog.com

背景写真の登録

妄想通知では、宛名の背景部分をタップすると背景写真の登録を行える。

カメラロールへアクセスするためには、info.plistPrivacy - Photo Library Usage Descriptionを登録する必要がある。
valueは任意の文字列で審査が通った。

f:id:tak-dev:20170222150231p:plain

コード

class CustomView: UIView, UITextFieldDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
    @IBOutlet var backImageView: UIImageView!
    @IBOutlet var toSendLabel: UILabel!
    @IBOutlet var nameInputField: UITextField!
    
    var index: Int?
    var sv: UIViewController?
    var dataArray = [SendToData]()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        loadXib()
    }
    
    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
        loadXib()
    }

    // 初期化
    fileprivate func loadXib() {

        // ...        

        // タップジェスチャーの登録
        backImageView.isUserInteractionEnabled = true
        let aSelector2 : Selector = #selector(self.setImage)
        let tapGesture2 = UITapGestureRecognizer(target: self, action: aSelector2)
        tapGesture.numberOfTapsRequired = 1
        backImageView.addGestureRecognizer(tapGesture2)

        // ...

    }

    // 画像の登録
    func setImage() {
        // フォトライブラリを使用できるか確認
        if UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.photoLibrary) {
            let picker = UIImagePickerController()
            picker.modalPresentationStyle = UIModalPresentationStyle.popover
            picker.delegate = self
            picker.sourceType = UIImagePickerControllerSourceType.photoLibrary
            
            sv!.present(picker, animated: true, completion: nil)
        }
    }
    
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : Any]) {
        // 選択した画像・写真を取得し、imageViewに表示
        if let pickedImage = info[UIImagePickerControllerOriginalImage] as? UIImage {
            backImageView.image = pickedImage
            saveData(value: pickedImage, type: .image)
        }
        // フォトライブラリの画像・写真選択画面を閉じる
        picker.dismiss(animated: true, completion: nil)
    }

多言語対応

PSMの時の多言語対応はビューを作るUI上でできたのだが、Xcodeでの多言語対応は少々手順がややこしかった。

コード上で出てくる部分については以下を参考にし

qiita.com

StoryBoard上のパーツの多言語化は以下を参考にした。

qiita.com

通知の登録

通知には、発信はデバイス内アプリから出してデバイス上だけで完結するローカル通知と、サーバーから発信しデバイスで受信するPush通知の2種類がある。

Push通知では、鍵や認証局等のサーバーのややこしい登録が必要だが(サーバーといっても主にFirebaseが使用されている)、
ローカルだけで完結する場合の通知は大した手間がいらない。

ただし、iOS10からは、この通知のフレームワークが一新されたらしく、それ以前の情報は役に立たないことがあるので注意されたい。

AppDelegete.swift

AppDelegete.swiftファイルにUNUserNotificationCenterDelegateと以下のコードを実装する。

class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    var window: UIWindow?


    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        
        // ...

        // ローカル通知
        if #available(iOS 10.0, *) {
            // iOS 10
            let center = UNUserNotificationCenter.current()
            center.requestAuthorization(options: [.badge, .sound, .alert], completionHandler: { (granted, error) in
                if error != nil {
                    return
                }
                if granted {
                    debugPrint("通知許可")
                    center.delegate = self
                } else {
                    debugPrint("通知拒否")
                }
            })
            
        } else {
            // iOS 9
            let settings = UIUserNotificationSettings(types: [.badge, .sound, .alert], categories: nil)
            UIApplication.shared.registerUserNotificationSettings(settings)
        }
        return true
    }

ViewController.swift

class ViewController: UIViewController, UITextViewDelegate, UNUserNotificationCenterDelegate, UITextFieldDelegate, UIScrollViewDelegate {

    // ...

    // 通知登録
    @IBAction func send(_ sender: Any) {
        
        // もしメッセージが空なら通知を登録しない
        if validation() {
            let _ = showAlert(isValidSuccese: true)
        } else {
            let _ = showAlert(isValidSuccese: false)
            return
        }
        
        let trigger: UNNotificationTrigger
        trigger = UNTimeIntervalNotificationTrigger(timeInterval: Double(picker.placementAnswer.value!), repeats: false)
        
        let content = UNMutableNotificationContent()
        content.title = cvArray[getCurrentPageNumber()].toSendLabel.text!
        content.body = messageInputField.text
        content.sound = UNNotificationSound.default()
        let request = UNNotificationRequest(identifier: NSUUID().uuidString,
                                            content: content,
                                            trigger: trigger)
        
        UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
        
    }

    // フォアグラウンドで通知受信する場合は実装
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([.alert, .sound])  // 通知バナー表示、通知音の再生を指定
    }
    
    // 通知開封
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -> Void) {
        debugPrint("opened")
        completionHandler()
        
        // 通知センターへストックされているこのアプリからの全通知を削除
        // center.removeAllDeliveredNotifications()
    }

func userNotificationCenterを実装しない場合は、フォアグラウンド中に動作中のアプリの通知が発火されても通知が出現しない。

参考

iOS10

qiita.com

swift.swift-studying.com

iOS9以前の通知

dev.classmethod.jp

以下随時更新

  • ピッカービューを下から出す
  • オートレイアウト
  • オブジェクトの一辺にだけボーダーを引く、IB上でも編集できるようにする
  • レビューを促すダイアログを出すライブラリの導入
  • 実機での動作確認
  • AppStoreへの申請
  • 今回使用したツール、ライブラリ
  • 妄想通知のソースコード一式(Github

今後試してみたいこと

  • 他アプリ起動中の共有パネルでのアクション登録
  • Open In xxxx
  • DLCコンテンツ、課金システムの実装

随時更新。