うるおいらんど
アイキャッチ画像

【Swift】NSData/NSFileWrapperを利用してデータを保存する

SwiftUIDocument

追記があります。

お久しぶりになってしまった。

CoreDataを用いてデータを保存しようとすること約5日。謎のエラーが出まくり上手くいかない。なんとか保存できるようになったがrelationshipがうまく付けられない。少しコードを変えれば別のところでエラー。原因も教えてくれずただただアプリが固まる。

 

なんてことを繰り返して結局CoreDataを使わずNSData/NSFileWrapperを使う方法で、ファイルの書き込み・読み出しに成功しました。

私を助けてくれたのはこの本。

UIKit&Swiftプログラミング 優れたiPhoneアプリ開発のためのUI実装ガイド

この本によれば、 NSData/NSFileWrapperを用いるのが標準方法と書いてあったんですが、調べてもあまりでてこない。CoreDataのことならいっぱいでてくるのに。

とにかくドキュメントの保存、読み出しの仕方だけざっくりと。

UIDocumentのサブクラスを作成する

File>New>FileよりiOSのCocoaTouchClassを選択>Subclass OfをUIDocumentにして作成

そこにこのようなコードを書いていきます。

import UIKit

class USDocument: UIDocument {
    //各データに対応したStatic定数を宣言
    struct USFileWrapperKeys{
        static let IMG = "USDocument.img"
        static let SUBJECT = "USDocument.subject"
        static let DATE = "USDocument.date"
        static let IMGARRAY = "USDocument.imgarray"
    }
    
    //記録したい内容を保持するメンバ変数を宣言
    var img:UIImage!
    var subject:String!
    var date:NSDate!
    var imgArray:NSMutableArray!
    
    //上記の内容をひとまとめにするNSFileWrapper
    var fileWrapper:NSFileWrapper?
    
    //読み出し/書き出し用のメソッド
    override func loadFromContents(contents: AnyObject, ofType typeName: String?) throws {
        //....
    }
    override func contentsForType(typeName: String) throws -> AnyObject {
       //.....
     }
}

 

今回は画像と文字と日付と画像の入った配列を保存・表示します。

loadFromContentsの中身を書いていきます。

override func loadFromContents(contents: AnyObject, ofType typeName: String?) throws {
        //引数で渡されたcontentsをダウンキャストし、fileWrapperにつめる。
     if let fileWrapper = contents as? NSFileWrapper {
            self.fileWrapper = fileWrapper
            let dict = fileWrapper.fileWrappers
            
            if let fw = dict![USFileWrapperKeys.IMG] as NSFileWrapper?{
                self.img = UIImage(data:fw.regularFileContents!)
            }
            if let fw = dict![USFileWrapperKeys.SUBJECT] as NSFileWrapper? {
                self.subject = NSString(data:fw.regularFileContents!,encoding:NSUTF8StringEncoding) as String?
            }
            if let fw = dict![USFileWrapperKeys.DATE] as NSFileWrapper?{
                self.date = NSKeyedUnarchiver.unarchiveObjectWithData(fw.regularFileContents!) as? NSDate
            }
            if let fw = dict![USFileWrapperKeys.IMGARRAY] as NSFileWrapper?{
                self.imgArray = NSKeyedUnarchiver.unarchiveObjectWithData(fw.regularFileContents!) as? NSMutableArray
            }
            
        }
        
}

 

続いてcontentsForTypeメソッドを書いていきます。

override func contentsForType(typeName: String) throws -> AnyObject {
        if self.fileWrapper == nil{
            //空のディクショナリを渡す
            self.fileWrapper = NSFileWrapper(directoryWithFileWrappers:[:])
        }
        if let subject = self.subject
            ,data  = subject.dataUsingEncoding(NSUTF8StringEncoding){
               let fw = NSFileWrapper(regularFileWithContents: data)
                fw.preferredFilename = USFileWrapperKeys.SUBJECT
                self.fileWrapper?.addFileWrapper(fw)
        }
        if let img = self.img
            ,data  = UIImageJPEGRepresentation(img, 1.0){
                let fw = NSFileWrapper(regularFileWithContents: data)
                fw.preferredFilename = USFileWrapperKeys.IMG
                self.fileWrapper?.addFileWrapper(fw)
        }
        if let date = self.date{
                let data = NSKeyedArchiver.archivedDataWithRootObject(date)
                let fw = NSFileWrapper(regularFileWithContents: data)
                fw.preferredFilename = USFileWrapperKeys.DATE
                self.fileWrapper?.addFileWrapper(fw)
        }
        if let imgArray = self.imgArray{
            let data = NSKeyedArchiver.archivedDataWithRootObject(imgArray)
            let fw = NSFileWrapper(regularFileWithContents:data)
            fw.preferredFilename = USFileWrapperKeys.IMGARRAY
            self.fileWrapper?.addFileWrapper(fw)
        }
        return self.fileWrapper!
}

 

ここでひっかかったのが、NSMutableArrayの読み出しです。

本には書いてなかったので、dataのところに何を書けばいいのかがわからず結構迷ってしまいました。

結局

let data = NSKeyedArchiver.archivedDataWithRootObject(imgArray)

という書き方で成功。

 

Documentを使う側の実装

続いてデータを保存します。

class ViewController:UIViewController{
    //Documentに関する情報
    struct USDocInfo{
        static let NAME = "document"
        static let ECTENSION = "doc"
        static var LOCAL_DOCUMENTS_PATH:String? = nil
    }
    //アプリのサンドボックスのパスを格納する変数
    var localDocumentsPath:String{
        if let dir = USDocInfo.LOCAL_DOCUMENTS_PATH{
            return dir
        }else {
            let dir = NSSearchPathForDirectoriesInDomains(.DocumentDirectory,.UserDomainMask, true)[0] as! String + "/"
            USDocInfo.LOCAL_DOCUMENTS_PATH = dir
            return dir
        }
    }
    var document:USDocument!
    var isFileExists = false

    override func viewDidLoad() {
        super.viewDidLoad()
        let filePath = localDocumentsPath + USDocInfo.NAME + "." + USDocInfo.ECTENSION
        let fileUrl = NSURL(fileURLWithPath:filePath)
        //ファイルの存在チェック
        isFileExists = NSFileManager.defaultManager().fileExistsAtPath(filePath)
        document = USDocument(fileURL:fileUrl)
        if isFileExists{
            document.openWithCompletionHandler({ (success:Bool) -> Void in
                if success{
                    print("Documentを開きましたplace:\(self.document.subject)")
                }else{
                    print("Documentを開けませんでした。")
                }
            })
        }else{
            //ここで保存したい内容を書く
            document.subject = "お題"
            document.saveToURL(fileUrl, forSaveOperation: .ForCreating, completionHandler: {(success:Bool) -> Void in
                if success{
                    print("Documentを保存しました。subject\(self.document.subject)")
                }else{
                    print("Documentを保存できませんでした")
                }
            })
        }
    }

}

 

ファイルがあればコンソールに表示。なければsubjectにお題を保存する。

これを2回実行すれば1回目は「Documentを保存しました。」と表示され、2回目は「お題」と表示されるはずです。

実際の私のコードでは、保存したい内容のところにわさわさ書いてあります。ファイルもお題ごとに作成してほしいので、filePathをこのようにしています。

let subjectTitle = "おもち"
let filePath = localDocumentsPath + USDocInfo.NAME + "." + USDocInfo.ECTENSION + "." + subjectTitle

実際はsubjectTitleは毎回異なる値が入ります。そうすることでsubjectTitleごとにファイルを作成してくれます。

 

読み出しだけを別に行う場合も同様に、ファイルがあれば読みこむ、なければコンソールにファイルがないと表示〜という風にしてください。

 

今回配列で保存した画像をコレクションビューに配置する、ということがしたかったんですがなかなかにはまってしまったのが、openWithCompletionHandlerメソッドです。

userDefaultsを使用してsubjectTitleの値だけ別に配列で保存して、そのファイルを1つずつ探してきて対応した画像を表示させる、ということをしたかったのですが、どうも上手くいかない。

let subjectTitle = ["おもち" ,"ごはん","パン"]
let imgArray:NSMutableArray = []
for var x = 0; x < subjectTitle.count ; x++ {
      let filePath = localDocumentsPath + USDocInfo.NAME + "." + USDocInfo.ECTENSION + "." + subjectArray[x]
      let fileUrl = NSURL(fileURLWithPath:filePath)
      //ファイルの存在チェック
      isFileExists = NSFileManager.defaultManager().fileExistsAtPath(filePath)
      document = USDocument(fileURL:fileUrl)
      if isFileExists{
          document.openWithCompletionHandler({ (success:Bool) -> Void in
             if success{
                  self.imgArray.addObject(self.document.img)
             }else{
                        print("Documentを開けませんでした。")
                    }
                })
            }else{
                print("Documentがありませんぬ")
            }
      }
}

 

こんな感じのコードを描いたのですが、openWithCompletionHandlerでは一番最後の画像しか入りません。subjectTitleを配列に入れていくとすれば["パン","パン","パン"]になってしまう。

色々コンソールに表示させてみてみると、ファイルの存在チェックをした後、ファイルを開くまでに時間差があり、isFileExistsに入っているdocumentのファイルURLが全て一番最後のものになってしまっていました。

 

解決策としては配列を用意して、そこにdocumentを格納しておき、(ここではdocumentArrayに格納)

    .....
 if isFileExists{
      let doc:USDocument = self.documentArray[self.x] as! USDocument
      doc.openWithCompletionHandler({ (success:Bool) -> Void in 
       ......

このif isFileExists とopenWithCompletionHandlerメソッドの間で取り出すことで、それぞれのファイルを取り出すことができました。

 

そしてこのopenWithCompletionHandlerメソッドより読み込んだ値をコレクションビューに表示させたいのに、先にコレクションビューのメソッドが呼ばれるので値が入れられない・・・となってしまったので値を入れた後にコレクションビューをreloadしておきます。

self.myCollectionView.reloadData()

 

おそらく綺麗なコードではなさそうですが、動いたのでとりあえず良しとしています。。

 

なんとかiPhoneにもともと入っている写真アプリのような感じの仕組みが作れた気がします。

データが膨大になったら〜みたいなことは頭の片隅にはありますが、まだわからないので後々デバッガーになって頑張ります・・・。

 

感想

なんだ感想ってとか言わないで!とにかくいい感じに動くようになって思ったこと。

根気よく調べればなんとかなる。ということと自分で色々とコードをいじってみることもすごく大切だということ。結構なんでも調べれば出てくるけれど、今回は本当に出てこなくて困った。なんて調べればいいのかもよくわからなかったけれど、それこそXcodeではメソッド名が出てくるわけで、それを頼りになんとかしてみることも意外とできるなーってこと。

しかしCoreDataが結局使えなかったのがムムムといった感じ。また機会があれば。

 

あとSwiftだけじゃなくObjective-Cの記事も読んでみるのが大事だと思います。メソッド名は同じだったりするので結構ありがたい。一番問題なのはリファレンスが一番読めないということ(笑)もうちょっとわかりやすく書いて欲しい。みるところが悪いのかなぁ・・・・

 

とにかくもかくにも頑張ったよ!!!!!生きてるよ!!

ちなみにアプリ内のコードはもうちょい複雑でごちゃごちゃしてます。配列の中に配列入れちゃったりしてるよ。配列超便利。

 

とりあえず今日でこの部分を終わらせようと思います(まだ終わってない・・・)

ではでは

Additional Notes追記

Swift4に対応してみた...

(-ω-; ムム…

とりあえずこんな感じで。ほぼ警告を修正していっただけだけれども。

ファイルの読み出しと書き出しはDataManagerにまとめてみました。

現状だと~hogehoge.hoge/hogeというフォルダ内にテキスト・日付を一度だけ保存するという処理になっています。なので毎回上書きされちゃってます。

さらにそれらをまとめるWrapperを用意するのが良いと思います。 一意のテキスト(id)に必ず1つだけ「テキスト・日付・画像・画像の配列」を割り振る場合は今回のようにしてpathの部分だけ

let filePath = localDocumentsPath + MyDocInfo.NAME + "." + MyDocInfo.ECTENSION + id

といった形にして、idはuserDefaults等やもう1つ別のフォルダを作ってまとめておくといいと思います。

例えばMyAppというフォルダに(pathが~/myAppとする) アプリ名(appとする) / アプリのアイコン / 更新日 / アプリに関する画像の一覧 を突っ込むとした場合は今回のような形にして、主な操作は

~/myAppのファイルを読み込む appのドキュメントからアプリに関する画像の一覧を取得する 画像の一覧に新しい画像を追加して保存する

という感じかなぁ。。。。

個人的にはデータ保存はもうCoreDataかRealmを使っちゃいます。 この方法は敷居が低いようで結構メンドイので・・・。

とりあえずすごくザックリですがSwift4対応をしてみたので、何か参考になれば嬉しいです。

Comments

わかりにくいswiftのファイル保存がちょっとわかったような気がしました。感謝。 2年ぶりにアプリ開発を再開したら、様子が変わっていて、戸惑うことばかり。 定年後の爺の頭にはかなりきつい。また助けてください。

小田 収 (@OdaOsam)

少しでも役に立てたなら、私も嬉しい限りです。 こちらこそありがとうございます。

Reo(管理人)