【Swift】iOSで放置型育成ゲームを作るよ(8) ~資源をUserDefaultsに保存してデータを永続化させる その1~

  • 3
    いいね
  • 4
    コメント

今日のゴール

  • userDefaultを使って増えた資源を永続化する
  • アプリ終了時やバックグラウンドからの復帰でUserDefaultから値を読み込む

唐突な宣伝

  • 今まで(エンジニア人格で)やっていなかったTwitter始めました ナスてんと呼んでください これから界隈に出没するかもしれません
    • @nastengood です
    • なんか技術的に困ったこと呟いたりゲームのシステム考えたりなんか諸々を呟こうかなと思います
  • iOSDC 2017のトークに応募しました どちらも未経験や経験の浅いエンジニア向けです
    • 15分トーク枠
      • 「エンジニアが情報発信を続けると何がいいのか身を以て経験したのでそれを今あなたに伝えたい」
    • LT枠
      • 「エンジニアリングはエンジニアのためにあるのか」
    • どっちも技術的な内容はほぼ喋りません。エモい系の話になる予定です。
  • 以上、宣伝終わり! 閉廷!

やったこと

データの永続化とは

  • まずアプリにおいてデータを保存しておくには(ワイの理解では)主に下記パターンがあるよ
    • 変数に格納
      • 一番基本かつ簡単 インスタンスを用意して値を代入するだけ
      • インスタンスを参照すればいつでも値を利用することができる
      • インスタンスが解放されるとデータは利用できなくなる
      • なので「保存」ではあるが「永続」ではない
    • plist
      • ○○.plistというファイルを作ってアプリにぶち込む
      • ArrayやDictionaryなどを格納しておける
      • アプリがアンスコされるまで利用できる
      • 記述内容を変えたい場合にはアプリを再申請する必要がある
    • UserDefaults
      • iOSで用意されている簡単なデータ永続化の仕組み
      • Keyを指定して値を保存しておく
      • Keyを指定してデータを読み出す
      • アプリがアンスコされるまで使える
      • 記述内容を変えたい場合にはアプリを再申請する必要がある
    • アプリ内データベース
      • 今回は省略
    • サーバー
      • 今回は省略
  • 最初は資源クラスのquantityだけ保存して読みだせばいいかと思ったけど、Resourceインスタンス生成時の情報も保存しておけると便利そうなので、生成したインスタンスをシリアライズしてそれごとUserDefaultに入れてみるよ
    • ちなみにシリアライズ・デシリアライズは今までやったことないので調べながらやるよ

やりたいこと

どういう実装にするか考えずにとりあえずコードを書き始めるのは愚の骨頂(流派東方不敗感)
ということでまず永続化のフローを考えるよ

アプリ起動時にResourceクラスからインスタンスを生成

一定時間ごとに資源を生産する

アプリがバックグラウンド状態になる or アプリが終了される

現在の資源インスタンスをシリアライズ化、UserDefaultに保存する

アプリがバックグラウンドから復帰 or アプリキル状態から復帰

UserDefaultからシリアライズされたインスタンスをデシリアライズして値を読み込む

こんな感じでできたらええんちゃうかな

まずはUserDefaultの使い方に慣れるために簡単なフローで確認するよ

  • AppDelegateのapplicationDidEnterBackground(アプリがバックグラウンド状態になると呼び出されるメソッド)とapplicationWillEnterForeground(アプリがフォアグラウンドになる時に呼び出されるメソッド)にnotificationをpostメソッドを実装する
  • ViewControllerでpostを受け取って、
    • applicationDidEnterBackgroundからのポストであればテスト用ラベルをUserDefaultsに保存する
    • applicationWillEnterForegroundでUserDefaultsから値を読み込んでキャラの名前に強引にはめ込む
  • こうすることでバックグラウンド → フォアグラウンドにした時にキャラの名前が変わるはず
  • ちなみにprint(#function)とすると呼ばれたとこのメソッド名をプリントしてくれるので地味に便利なのを最近知った
AppDelegate.swift
~省略~
    func applicationDidEnterBackground(_ application: UIApplication) {
        print(#function)
        let notification = Notification(name: .UIApplicationDidEnterBackground, object: self, userInfo: ["application": "DidEnterBackground"])
        NotificationCenter.default.post(notification)
    }

    func applicationWillEnterForeground(_ application: UIApplication) {
        print(#function)

        let notification = Notification(name: .UIApplicationWillEnterForeground, object: self, userInfo: ["application": "WillEnterForeground"])
        NotificationCenter.default.post(notification)

    }
~省略~
ViewController.swift
    func viewDidLoad() {
~省略~
        // バックグラウンド移行時の通知を受けた時の処理
        NotificationCenter.default.addObserver(forName: .UIApplicationDidEnterBackground, object: nil, queue: .main) { (_) in
            print("didEnterBackgound")
            let userDefault = UserDefaults.standard
            userDefault.set("UserDefaultTest", forKey: "UserDefault")
        }
        // フォアグラウンド移行時の通知を受けた時の処理
        NotificationCenter.default.addObserver(forName: .UIApplicationWillEnterForeground, object: nil, queue: .main) { (_) in
            let userDefault = UserDefaults.standard.object(forKey: "UserDefault") as? String

            self.name.text = userDefault
        }
~省略~
    }

お、とりあえずできてるな!
test.gif

デバッグコンソールよく見てみると・・

  • なんかdidEnterBackgroundとWillEnterForegroundが2回呼ばれてるんですけどぉぉぉおぉぉ! img1500134945469.png
  • なんでや・・ なんでなんや・・・
  • ブレイクポイント貼って調べてみたらAppDelegateでPostした通知をVCで受け取って1回メソッド実行、その後なんかVCのviewDidLoadでもう一回メソッド実行してるよ・・
  • 小一時間調べてもよく分からなかったので放置してとりあえず先に進むよ(動いてるしね)

インスタンスをシリアライズ・デシリアライズできるようにする

  • 検索ワード「swift シリアライズ」
  • この記事がすごく参考になったよ
  • とりあえずResourceクラスをシリアライズ・デシリアライズできるようにするよ
    • NSCodingに準拠するようにして、encodeとdecode処理を書いてくよ
Resource.swift
~省略~
final class Resource: NSObject, NSCoding {

    let name: String            // 資源名(ex: 光のオーブ)
    let image: UIImage          // 資源image
    private var quantity: Int           // 資源保有量 デフォルトでは10000
    private var incrementInterval: TimeInterval  // 増加間隔(秒)  デフォルトでは5秒
    private var increaseAmount: Int     // 増加量 デフォルトでは100
    private var increaseTimer: Timer

    init(name: String,
         image: UIImage,
         quantity: Int = 100000,
         incrementInterval: TimeInterval = 5.0,
         increaseAmount: Int = 100) {
        self.name = name
        self.image = image
        self.quantity = 100000
        self.incrementInterval = incrementInterval
        self.increaseAmount = increaseAmount
        self.increaseTimer = Timer()
        super.init()
    }

    required init?(coder aDecoder: NSCoder) {
        guard
            let name = aDecoder.decodeObject(forKey: "name") as? String,
            let image = aDecoder.decodeObject(forKey: "image") as? UIImage,
            let quantity = aDecoder.decodeObject(forKey: "quantity") as? Int,
            let incrementInterval = aDecoder.decodeObject(forKey: "incrementInterval") as? Double,
            let increaseAmount = aDecoder.decodeObject(forKey: "increaseAmount") as? Int
        else {
            print("coder失敗")
            return nil
        }

        self.name = name
        self.image = image
        self.quantity = quantity
        self.incrementInterval = incrementInterval
        self.increaseAmount = increaseAmount
        self.increaseTimer = Timer()
        super.init()
    }

    func encode(with aCoder: NSCoder) {
        aCoder.encode(self.name, forKey: "name")
        aCoder.encode(self.image, forKey: "image")
        aCoder.encode(self.quantity, forKey: "quantity")
        aCoder.encode(self.incrementInterval, forKey: "incrementInterval")
        aCoder.encode(self.increaseAmount, forKey: "increaseAmount")
    }
~省略~
  • バックグラウンドに入った時にData型にシリアライズしてUserDefaultsに保存、フォアグラウンド時にUserDefaultsから読み込み、表示までをやるよ
ViewController.swift
~省略~
        NotificationCenter.default.addObserver(forName: .UIApplicationDidEnterBackground, object: nil, queue: .main) { (_) in
            print("didEnterBackgound")

            let serializeResource = NSKeyedArchiver.archivedData(withRootObject: self.resource) as Data

            let userDefault = UserDefaults.standard
            userDefault.set(serializeResource, forKey: "resource")
        }

        NotificationCenter.default.addObserver(forName: .UIApplicationWillEnterForeground, object: nil, queue: .main) { (_) in
            print("willEnterForeground")
            guard let resource = UserDefaults.standard.object(forKey: "resource") as? Resource else {
                return
            }

            self.resource = resource
            self.resource.setUpTimer()
            self.resourceQuantityLabel.text = String(resource.getQuantity())
        }
~省略~
  • これで動くやろなあと思ってたら何度やってもcoder失敗でnilが返ってきて発狂しそうになったよ・・
  • 1時間ほど寝てからもう一回やってみたらどうやら間違い&考慮しなきゃいけない点が2つほどあったよ

問題点その① UIImageの扱い

  • どうやらSwiftではdecodeObjectでUIImageはそもそもdecodeできないっぽい(objective-cならできるっぽいんだが・・)
  • 一度Data型にしてから突っ込まないといけない様子
    • うーん・・・ 拡張するか!!(いいのかな・・🤔)

NSCoderを拡張するよ

NSCoder+UIImage.swift
//
//  NSCoder+UIImage.swift
//  Digikore
//
//  Created by nasteng on 2017/07/16.
//
//

import Foundation
import UIKit

extension NSCoder {
    func encode(_ image: UIImage, key: String) {
        self.encode(UIImagePNGRepresentation(image), forKey: key)
    }

    func decodeImage(forKey key: String) -> UIImage? {
        guard let imageData = self.decodeObject(forKey: key) as? Data else {
            return nil
        }

        return UIImage(data: imageData)
    }
}

問題点その② decodeObject

  • どうやらdecodeする際はそれぞれの型専用のメソッドを使わないとdecodeに失敗してnilが返ってくるっぽい
    • ex) quantityはInt型で決まっているのでdecodeInteger(), incrementIntervalはDouble型なのでdecodeDouble()を使うようにする
    • なので実装をちょっと変更する
Resource.swift
required init?(coder aDecoder: NSCoder) {
        guard
            let name = aDecoder.decodeObject(forKey: "name") as? String,
            let image = aDecoder.decodeImage(forKey: "image")
        else {
            print("coder失敗")
            return nil
        }

        let quantity = aDecoder.decodeInteger(forKey: "quantity"),
            incrementInterval = aDecoder.decodeDouble(forKey: "incrementInterval"),
            increaseAmount = aDecoder.decodeInteger(forKey: "increaseAmount")

        self.name = name
        self.image = image
        self.quantity = quantity
        self.incrementInterval = incrementInterval
        self.increaseAmount = increaseAmount
        self.increaseTimer = Timer()
        super.init()
    }

    func encode(with aCoder: NSCoder) {
        aCoder.encode(self.name, forKey: "name")
        aCoder.encode(self.image, forKey: "image")
        aCoder.encode(self.quantity, forKey: "quantity")
        aCoder.encode(self.incrementInterval, forKey: "incrementInterval")
        aCoder.encode(self.increaseAmount, forKey: "increaseAmount")
    }

よしできたぞ・・!(ハアハア ゼエゼエ)

と思ったけどよく考えたらAppDelegateからの通知受け取るのVCじゃなくてResourceクラスで受け取るべきじゃないか?
ということでResourceに処理を移すよ

Resource.swift
~省略~
    func setUpNotification() {
        NotificationCenter.default.addObserver(forName: .UIApplicationWillTerminate, object: nil, queue: .main) { (_) in
            print("willTernimate...")

            let serializeResource = NSKeyedArchiver.archivedData(withRootObject:Resource(name: "炎のオーブ", image: UIImage(named: "r_fire.png")!, quantity: self.getQuantity()))

            let userDefault = UserDefaults.standard
            userDefault.set(serializeResource, forKey: "resource")
        }

        NotificationCenter.default.addObserver(forName: .UIApplicationWillEnterForeground, object: nil, queue: .main) { (_) in
            print("willEnterForeground")
            guard let unarchivedObject = UserDefaults.standard.object(forKey: "resource") as? Data,
                let unarchiveResource = NSKeyedUnarchiver.unarchiveObject(with: unarchivedObject) as? Resource else {
                    return
            }

            self.quantity = unarchiveResource.quantity
        }
~省略~

だめだ、、、なんかバグってる・・・(gifが添付できないけど)
enterBackground, enterForegroundとwillTerminateの処理がごちゃごちゃになっちゃってるな・・・

なんかめっちゃ難易度上がってて草生える
1記事で終わらせるつもりだったけど長くなりそうなので2つに分けます

結果

  • ゴールが達成できなかった・・・!
  • 結局didEnterBackgroundとWillEnterForegroundが2回ずつ呼ばれるの解明できず
  • そこはかとない敗北感

次回

資源をUserDefaultに保存して永続化させる その2

終わりに

ご指摘・リファクタ大歓迎です:pray: