質問をすることでしか得られない、回答やアドバイスがある。

15分調べてもわからないことは、質問しよう!

ただいまの
回答率

87.34%

SwiftのカスタムクラスをUserDefaultsで保存して取り出したい

解決済

回答 2

投稿 編集

  • 評価
  • クリップ 0
  • VIEW 2,380

score 7

前提・実現したいこと

todoとtimerを組み合わせたアプリを作成しています。
登録したtodoに対してかかった時間を記録して、別の画面のtableViewで
todoを実施した日付でセクションを分けて表示させたいです。

発生している問題・エラーメッセージ

自作したサンプルデータを使ってtableViewに表示させることはできたのですが、
UserDefaultsを使ってtodoや時間を保存して受け渡そうとしたときに
取り出されるデータが空になってしまいます。

受け渡したいデータは、以下の4つで、これらを格納するカスタムクラスをUserDefaultsで取り扱いたいです。

1.todo : String
2.time1 : Int
3.time2 : Int
4.date : Date

該当のソースコード

import Foundation

class Content:NSObject, NSCoding {

    let todo: String
    let time1: Int
    let time2: Int
    let date: Date

    init(todo:String, time1:Int, time2:Int, date:Date) {
        self.todo = todo
        self.time1 = time1
        self.time2 = time2
        self.date = date
    }   

    func encode(with aCoder: NSCoder) {
        aCoder.encode(self.todo, forKey: "todo")
        aCoder.encode(self.estimate, forKey: "time1")
        aCoder.encode(self.actual, forKey: "time2")
        aCoder.encode(self.date, forKey: "date")
    }

    required init?(coder aDecoder: NSCoder) {
        todo = aDecoder.decodeObject(forKey: "todo") as! String
        time1 = aDecoder.decodeInteger(forKey: "time1")
        time2 = aDecoder.decodeInteger(forKey: "time2")
        date = aDecoder.decodeObject(forKey: "date") as! Date
    }
}
import UIKit

class ViewController: UIViewController, UITextFieldDelegate, UIPickerViewDelegate, UIPickerViewDataSource {

    // 保存するデータのオブジェクトとそれを格納する配列を用意
    var content = [Content]()
    var contents = [[Content]]()


       ~~~中略~~~


    // doneボタンをクリックした時の処理
    @IBAction func clickDoneButton(_ sender: Any) {
        timer.invalidate()

        let todoString:String = todo.text!
        var time1Int:Int = Int()
        var time2Int:Int = Int()
        let now:Date = Date()

        let index = timePickerOption.firstIndex(of: timeLabel.text!)
        time1Int = convertedTimePickerOption[index!]
        time2Int = time1Int - count

        // done!押下時の時間を"yyyy/mm/dd"に変換する
        let f = DateFormatter()
        f.timeStyle = .none
        f.dateStyle = .medium
        f.locale = Locale(identifier: "ja_JP")
        f.dateFormat = "yyyy/MM/dd"
        let s = f.string(from: now)

        // contentに書き込み、contentsにappend
        content = [Content(todo: todoString, time1: time1Int, time2: time2Int, date: f.date(from: s)!)]
        contents.append(content)

        // UserDefaultsに保存
        let encodedContents = try? NSKeyedArchiver.archivedData(withRootObject: contents, requiringSecureCoding: false)
        UserDefaults.standard.set(encodedContents, forKey: "contents")
        UserDefaults.standard.synchronize()

        // segueでRecordVCに遷移する
        performSegue(withIdentifier: "record", sender: nil)


    }
    override func viewDidLoad() {
        super.viewDidLoad()

        recordTableView.delegate = self
        recordTableView.dataSource = self

        prepare()   
    }

    private func prepare() {
        // 日付を用意して、"yyyy/MM/dd"に変換
        let f = DateFormatter()
        f.locale = Locale(identifier: "en_US_POSIX")
        f.dateFormat = "yyyy/MM/dd"

        var contents: [Content]!
        let contentsData = UserDefaults.standard.object(forKey: "contents") as? Data
        guard let t = contentsData else { return }
        let unArchiveData = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(t)
        contents = unArchiveData as? [Content] ?? [Content]()

        print(contents!) // -> 結果が空[]になってしまう

        todos = Dictionary(grouping: contents) { content -> Date in
            return content.date
        }
        .reduce(into: [Date: [Content]]()) {dic, tuple in
            dic[tuple.key] = tuple.value.sorted { $0.date < $1.date }
        }

        // 日付順を保持するための配列
        dateOrder = Array(todos.keys).sorted { $0 > $1 }

    }

試したこと

SwiftでAttempt to insert non-property list objectが出た時の対処について
swift4でカスタムオブジェクトをNSUserDefaultsに保存・読込する

こちらを参考にUserDefaultsへの保存を試みたのですが、うまく値を取り出せません。

contentsにappendするまでは値を入れられているようなのですが、
UserDefaultsへの保存、取り出しで空になってしまっているように思えます。
使い方が不適切なのでしょうか。

よろしくお願いいたします。

補足情報(FW/ツールのバージョンなど)

xcode Version: 11.1

  • 気になる質問をクリップする

    クリップした質問は、後からいつでもマイページで確認できます。

    またクリップした質問に回答があった際、通知やメールを受け取ることができます。

    クリップを取り消します

  • 良い質問の評価を上げる

    以下のような質問は評価を上げましょう

    • 質問内容が明確
    • 自分も答えを知りたい
    • 質問者以外のユーザにも役立つ

    評価が高い質問は、TOPページの「注目」タブのフィードに表示されやすくなります。

    質問の評価を上げたことを取り消します

  • 評価を下げられる数の上限に達しました

    評価を下げることができません

    • 1日5回まで評価を下げられます
    • 1日に1ユーザに対して2回まで評価を下げられます

    質問の評価を下げる

    teratailでは下記のような質問を「具体的に困っていることがない質問」、「サイトポリシーに違反する質問」と定義し、推奨していません。

    • プログラミングに関係のない質問
    • やってほしいことだけを記載した丸投げの質問
    • 問題・課題が含まれていない質問
    • 意図的に内容が抹消された質問
    • 過去に投稿した質問と同じ内容の質問
    • 広告と受け取られるような投稿

    評価が下がると、TOPページの「アクティブ」「注目」タブのフィードに表示されにくくなります。

    質問の評価を下げたことを取り消します

    この機能は開放されていません

    評価を下げる条件を満たしてません

    評価を下げる理由を選択してください

    詳細な説明はこちら

    上記に当てはまらず、質問内容が明確になっていない質問には「情報の追加・修正依頼」機能からコメントをしてください。

    質問の評価を下げる機能の利用条件

    この機能を利用するためには、以下の事項を行う必要があります。

回答 2

checkベストアンサー

+2

UserDefaultsに保存しているデータの型はArray<Array<Content>>ですが取り出すときに

contents = unArchiveData as? [Content] ?? [Content]()


と Array<Content>にキャストしようとしています。
当然キャストできないのでnilになります。


型の処理とかはSwiftではやらなくていいならやらないという方針でいったほうがいいです。
ですのでいまならJSONEncoder/JSONDecoderを使う方がいいかな

// NSCoding ではなく Codableにする
// NSCodingのためだけにNSObjectにしていたのならNSObjectは不要。 struct Content: CodableでもOK
class Content: NSObject, Codable {

    let todo: String
    let time1: Int
    let time2: Int
    let date: Date

    init(todo:String, time1:Int, time2:Int, date:Date) {
        self.todo = todo
        self.time1 = time1
        self.time2 = time2
        self.date = date
    }
}

UserDefaultsを拡張

extension UserDefaults {
    func setCodableObject<T: Encodable>(item: T, forKey defaultName: String) {
        guard let data = try? JSONEncoder().encode(item) else {
            print("Can not Encode to JSON.")
            return
        }

        set(data, forKey: defaultName)
    }

    func codableObject<T: Decodable>(forKey defaultName: String) -> T? {
        guard let data = data(forKey: defaultName) else { return nil }
        guard let object = try? JSONDecoder().decode(T.self, from: data) else {
            print("Can not Decode from JSON.")
            return nil
        }

        return object
    }
}

としておけば、

// 保存  Array<Content>のままでOK
UserDefaults.standard.setCodableObject(content , forKey: "contents")

// 読み出し Optionl<Array<Content>>で取得できる
let contents: [Content]? = UserDefaults.standard.codableObject(forKey: "contents")


と出来ます。


それと、現在はUserDefaults.standard.synchronize()は無意味なので使わないでください。


さらに、ここまで書いておいてなんですが、アプリケーション内でのデータの受け渡しにUserDefaultsを使うのは推奨されません。


GitHubなどにはKeyに型情報を持たせてさらにデータを扱いやすくしたUserDefaultsのラッパーなどもあるので調べてみるといいです。

投稿

編集

  • 回答の評価を上げる

    以下のような回答は評価を上げましょう

    • 正しい回答
    • わかりやすい回答
    • ためになる回答

    評価が高い回答ほどページの上位に表示されます。

  • 回答の評価を下げる

    下記のような回答は推奨されていません。

    • 間違っている回答
    • 質問の回答になっていない投稿
    • スパムや攻撃的な表現を用いた投稿

    評価を下げる際はその理由を明確に伝え、適切な回答に修正してもらいましょう。

  • 2019/10/16 16:22

    ご回答いただきありがとうございます!!
    冒頭ご指摘の通り、保存する型と取り出す型が異なっておりました。
    その点を修正したところ、とりあえずは期待する動きができました。

    また推奨方法まで詳しくコメントいただきありがとうございます。
    勉強不足で申し訳ありません、いただいた情報を調べてみます!

    >さらに、ここまで書いておいてなんですが、アプリケーション内でのデータの受け渡しにUserDefaultsを使うのは推奨されません。
    todo(というよりも実施事項というイメージですが)を保持しておきたかったのでUserDefaultsを使うと思ったのですが、受け渡し含め保存すること自体も他の方法が良いということでしょうか?
    お手隙でしたらご回答いただけますとより勉強になります。
    よろしくお願いいたします。

    キャンセル

  • 2019/10/16 17:15

    https://developer.apple.com/documentation/foundation/userdefaults
    リファレンスにある通り「設定」などで使用されるユーザーごとの設定を保存する場所です。
    例えばTodoですと日付は表示するけど時間は表示しないなどの情報が考えられます。
    なので汎用データストレージとして使用することは推奨されていません。
    ただし、汎用データストレージとして使ってもリジェクトされないですし爆発もしないのでそこはプログラマさんがどう考えるか次第です。
    ネットでよくUserDefaultsがそのように使われているのは、単にお手軽に使えるから、だけだと思われます。
    お手軽が第一と考える場合はUserDefaultsを使うのはありでしょう。

    キャンセル

  • 2019/10/16 17:44

    ありがとうございます!
    お手軽だから、というのがしっくりきました。
    Udemyの講座ではfirebaseを使っている事例があって、認証や画像等重いデータを扱うときは外部のDBやストレージを使うのかな〜くらいの飲み込み方をしていて、どういうケースでどういう保存方法を使うのかまだわかっておらず。。
    調べてみてやはりふに落ちない、解決方法がわからないときは改めて質問を立てさせていただきます!

    ご丁寧に解説いただきありがとうございました!

    キャンセル

+1

適当に以下のようなテストプログラムを書いて検証してみましたが、特に問題なく動作しているように見えます。

import UIKit

class Content:NSObject, NSCoding {

    let todo: String
    let time1: Int
    let time2: Int
    let date: Date

    init(todo:String, time1:Int, time2:Int, date:Date) {
        self.todo = todo
        self.time1 = time1
        self.time2 = time2
        self.date = date
    }

    func encode(with aCoder: NSCoder) {
        aCoder.encode(self.todo, forKey: "todo")
        aCoder.encode(self.time1, forKey: "time1")
        aCoder.encode(self.time2, forKey: "time2")
        aCoder.encode(self.date, forKey: "date")
    }

    required init?(coder aDecoder: NSCoder) {
        todo = aDecoder.decodeObject(forKey: "todo") as! String
        time1 = aDecoder.decodeInteger(forKey: "time1")
        time2 = aDecoder.decodeInteger(forKey: "time2")
        date = aDecoder.decodeObject(forKey: "date") as! Date
    }
}


class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    func save() {
        print("save")
        let contents = [
            Content(todo: "TEST00", time1: 1, time2: 2, date: Date()),
            Content(todo: "TEST01", time1: 1, time2: 2, date: Date()),
        ]

        let encodedContents = try? NSKeyedArchiver.archivedData(withRootObject: contents, requiringSecureCoding: false)

        UserDefaults.standard.set(encodedContents, forKey: "contents")
    }

    func load() {
        print("load")
        var contents: [Content]!
        let contentsData = UserDefaults.standard.object(forKey: "contents") as? Data
        guard let t = contentsData else { return }
        let unArchiveData = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(t)
        contents = unArchiveData as? [Content] ?? [Content]()

        print(contents.count)
        print(contents[1].todo)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        save()
        load()
    }
}

出力:

save
load
2
TEST01

載せてもらったソース以外の場所に原因があるのではないでしょうか?
(UserDefaultsを全消しするコードが入っているとか)

投稿

  • 回答の評価を上げる

    以下のような回答は評価を上げましょう

    • 正しい回答
    • わかりやすい回答
    • ためになる回答

    評価が高い回答ほどページの上位に表示されます。

  • 回答の評価を下げる

    下記のような回答は推奨されていません。

    • 間違っている回答
    • 質問の回答になっていない投稿
    • スパムや攻撃的な表現を用いた投稿

    評価を下げる際はその理由を明確に伝え、適切な回答に修正してもらいましょう。

  • 2019/10/16 16:12

    ご回答いただきありがとうございます!!
    自分のコードでも色々printしながら確認していたのですが
    いただいたコードを参考にprint(contents[0].todo)と打とうとしたところ
    todoがcontents[]の名前ではないよと出てきました。
    改めて確認したところ変数の型の指定が間違っていたようです。。。

    【before】
    var content = [Content]()
    var contents = [[Content]]()
    【after】
    var content = Content(todo: "", estimate: 0, actual: 0, date: Date())
    var contents = [Content]()

    と変更したところ無事動きました!!
    よくわからず色々な記事を継ぎ接ぎして作っていましたが少し理解が深まりました!
    ありがとうございました!!

    キャンセル

  • 2019/10/17 09:32

    MasakiHoriさんの方でいろいろ言ってもらっているのでほとんど言うことがないですが、選択肢としては、自分でDocumentフォルダにJSON等で保存・読み込みする、というものもあります。外部DB,ローカルDB,ローカルJSON(、ローカルバイナリ),どれにするか悩んでみてください。

    キャンセル

  • 2019/10/18 10:31

    追記いただきありがとうございます!!
    別の方法でできないかも検討してみます!

    キャンセル

15分調べてもわからないことは、teratailで質問しよう!

  • ただいまの回答率 87.34%
  • 質問をまとめることで、思考を整理して素早く解決
  • テンプレート機能で、簡単に質問をまとめられる

関連した質問

同じタグがついた質問を見る