今日のゴール
- userDefaultを使って増えた資源を永続化する
- アプリ終了時やバックグラウンドからの復帰でUserDefaultから値を読み込む
唐突な宣伝
- 今まで(エンジニア人格で)やっていなかったTwitter始めました ナスてんと呼んでください これから界隈に出没するかもしれません
- @nastengood です
- なんか技術的に困ったこと呟いたりゲームのシステム考えたりなんか諸々を呟こうかなと思います
-
iOSDC 2017のトークに応募しました どちらも未経験や経験の浅いエンジニア向けです
- 15分トーク枠
- 「エンジニアが情報発信を続けると何がいいのか身を以て経験したのでそれを今あなたに伝えたい」
- LT枠
- 「エンジニアリングはエンジニアのためにあるのか」
- どっちも技術的な内容はほぼ喋りません。エモい系の話になる予定です。
- 15分トーク枠
- 以上、宣伝終わり! 閉廷!
やったこと
データの永続化とは
- まずアプリにおいてデータを保存しておくには(ワイの理解では)主に下記パターンがあるよ
- 変数に格納
- 一番基本かつ簡単 インスタンスを用意して値を代入するだけ
- インスタンスを参照すればいつでも値を利用することができる
- インスタンスが解放されるとデータは利用できなくなる
- なので「保存」ではあるが「永続」ではない
- 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)とすると呼ばれたとこのメソッド名をプリントしてくれるので地味に便利なのを最近知った
~省略~
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)
}
~省略~
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
}
~省略~
}
デバッグコンソールよく見てみると・・
- なんかdidEnterBackgroundとWillEnterForegroundが2回呼ばれてるんですけどぉぉぉおぉぉ!
- なんでや・・ なんでなんや・・・
- ブレイクポイント貼って調べてみたらAppDelegateでPostした通知をVCで受け取って1回メソッド実行、その後なんかVCのviewDidLoadでもう一回メソッド実行してるよ・・
- 小一時間調べてもよく分からなかったので放置してとりあえず先に進むよ(動いてるしね)
インスタンスをシリアライズ・デシリアライズできるようにする
- 検索ワード「swift シリアライズ」
- この記事がすごく参考になったよ
- とりあえずResourceクラスをシリアライズ・デシリアライズできるようにするよ
- NSCodingに準拠するようにして、encodeとdecode処理を書いてくよ
~省略~
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から読み込み、表示までをやるよ
~省略~
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
// 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()を使うようにする
- なので実装をちょっと変更する
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に処理を移すよ
~省略~
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回ずつ呼ばれるの解明できず
- そこはかとない敗北感
次回
終わりに
ご指摘・リファクタ大歓迎です