Swiftで遊ぼう! on Hatena

あしたさぬきblogでやってた初心者オヤジのiOSプログラミング奮闘記がHatenaに来ました

Swiftで遊ぼう! - 405 - Persist Data

チュートリアル索引に戻る→

2017年4月7日:Swift 3に対応*1

アップルのチュートリアル最後の課題に入ります。
developer.apple.com


やっとテーブルビュー(TableView)関連の基礎的な知識を習得しました。これから取り組む課題も全く知らないことなので習得には時間がかかるでしょう。

この課題で学ぶ項目です。

  • 構造体を作る
  • staticプロパティとinstanceプロパティの相違点の理解
  • NSCodingプロトコールを使ってデータの読み出しと書き込み

ここまでTableViewで表示するデータは、Mealクラスとして扱い、データ保持は、MealTableViewControllerにアレー型の変数「meals」として保持させていました。このやり方はチュートリアルで常套手段になっています。まず初心者はデータの扱いよりも他に知るべき項目があるからです。MVCモデルの基本的な理解がができた今こそデータの扱いを勉強する時期です。

MVCモデルに準拠してプロジェクトを作るのなら、データはすべてM(モデル)に保持させるべきです

しかし! まあ当然と言えば当然ですが、「データモデル」は、サードパーティ製を含め色々バリエーションがあります。データモデルの理解も一筋縄ではいかないんです(T_T) 実は以前取り組んだNSUserDefaultsも簡単なデーターベースモデルなんです。

yataiblue.hatenablog.com

チュートリアルの冒頭の説明を読めば、その重要性と難しさが読み取れ、説明しきれない問題が横たわっていることが示唆されます。

Data persistence is one of the most important and common problems in iOS app development.
「データ保持は、iOSアプリ開発において最も重要で一般的な"問題"のひとつ」というように「問題」として表現しているところが重要だということが明らかです。

今回取り組むNSCodingも唯一の方法ではないということを頭の片隅に置いておかなければいけません。

データの書き込みや読み出しに文字列の「キー(key)」を使うのが一般的なようです。Dictionary型のキー(key)と値(value)の関係に似てますね。「name」キーを使って、nameプロパティの値を管理するということです。

このキーは何度も繰り返し使用されるので、コンスタント(定数)として設定します。

Meal.swiftファイルを開きます。そして「// MARK: Properties」の下に次のコードを加えます。

// MARK: Types

struct PropertyKey {

}

この構造体にグローバル定数を持たせます。

struct PropertyKey {
    static let nameKey = "name"
    static let photoKey = "photo"
    static let ratingKey = "rating"
}

「static」が付くと、このプロパティがタイプ(Type)プロパティになります。static無しのプロパティは、宣言したクラス内で生成されて、そのクラス・インスタンス内でしか使えないinsutanceプロパティですが、staticが付くことで、インスタンスを跨いで、当にグローバルに使用できます。

NSCodingの機能を使うために、NSCodingプロトコールに準拠させます。

NSCodingに準拠させるためにNSObjectクラスも継承していないと使えないため、Mealクラス定義を変更します。

class Meal: NSObject, NSCoding {

NSCodingプロトコールを組み込むとやらなければならない作業があります。必須メソッドの具体的なコーディングです。メソッドは自分自身で完結しているので、クラス間をまたぐ「デリゲーション-プロトコール」の関係性はないので「delegate = self」は要りません。

NSCodingプロトコールで必要な2つのメソッド

  1. encode(with aCoder: NSCoder)
  2. init?(coder aDecoder: NSCoder)

またまた理解しがたいメソッドが出てきました。データを記録するエンコードはなんとなく分かります。しかし、記録を読み出すデコードメソッドがイニシャライザになっていて、クラス生成がデータ読み込みになるというのは理解しづらいです。「クラス生成=データ読み込み」という概念に疑問を持たず受け入れないといけませんね*2

func encode(with aCoder: NSCoder) {

}

データを記録するメソッドの実装から見ていきます。

func encode(with aCoder: NSCoder) {
    aCoder.encode(name, forKey: PropertyKey.name)
    aCoder.encode(photo, forKey: PropertyKey.photo)
    aCoder.encode(rating, forKey: PropertyKey.rating)
}

お手上げの暗号状態です(笑)。しかし、なんどなく読み取れます。aCoderの中にforKeyを使ってオブジェクトを埋め込んでいるようなイメージのようです。

init?(coder aDecoder: NSCoder)

今日は2つめの読み出しメソッドを考えます... と言った矢先から悩んでいました。

プロトコールでイニシャライザを定義するんですね。こういう発想の展開になかなか付いていけないのが親父頭です(^^;)

プロトコールでイニシャライザ... Swift本にそんな解説があったのだろうか?

確かにイニシャライザもメソッドも似たようなものかもしれません。

実装しなければならないイニシャライザですが、実際のコーディングは次のようになります。

required convenience init?(coder aDecoder: NSCoder) {

}

イニシャライザに関して補足

required」というキーワードが付きます。これは「必須イニシャライザ」と呼ばれるもので、必ず実装しなければならないイニシャライザです。スーパークラスに「required」のキーワードを付けてイニシャライザを書きます。これを継承したサブクラスは、この「required」を付けたままこのイニシャライザを必ず書かなければならないということです。

しかし、プロトコールに記述されているイニシャライザは例外です。プロトコール内でイニシャライザに「required」を書く必要は無いのですが、プロトコールに準拠するクラスは「required」付きのイニシャライザを書く必要があるということです。

ということでNSCodingプロトコールのイニシャライザなので、それを実装する場合「required」のキーワードが必要になります。

そして「convenience」キーワードは、「簡易イニシャライザ」の事で、オプション扱いのイニシャライザになります。そりゃそうですよね。このMealクラスのコードをみて下さい。既に「指定イニシャライザ」が存在するからです。

そして「?」が付いているので、これは「失敗許容イニシャライザ」ということになります。このイニシャライザが理解できたらクラス初期化の意味がすべて理解できているということになります。

Swiftで遊ぼう! - 407 - Initializer イニシャライザ - Swiftで遊ぼう! on Hatena

この実装すべきイニシャライザで何をするのか? 当然このクラス特有のプロパティに値を入れていく作業に入ります。

その前にエラー処理のためにクラス冒頭に次のフレームワークを組み込みます。

import os.log

イニシャライザーでこのクラス特有のプロパティを実装していきます。

そこでオブジェクトから呼び出して定数に入れる作業を組み込みます。まずプロパティの1つ「name」の場合次のようにします。

guard let name = 
    aDecoder.decodeObject(forKey: PropertyKey.name) 
        as? String else {
    os_log("Unable to decode the name for a Meal object.", 
                 log: OSLog.default, type: .debug)
    return nil
}

decodeObject(forKey:)メソッドの戻り値は「Any?」なのでキャストする必要があるうえにオプショナル型が許されないので「guard」を使って安全にアンラップします。

次にphotoをデコードするのですが次のようにします。

let photo = 
    aDecoder.decodeObject(forKey: PropertyKey.photo) as? UIImage

photoの場合はオプショナル型なんで「guard」を使ってアンラップする必要がないんです。そしてratingをデコードするdecodeInteger(forKey:)メソッドの戻り値は「Int」なんで次のようにします。

let rating = aDecoder.decodeInteger(forKey: PropertyKey.rating)

そして、簡易イニシャライザは必ず同じクラスの指定イニシャライザを呼ばないといけないルールがあります

required convenience init?(coder aDecoder: NSCoder) {
        
    guard let name = 
        aDecoder.decodeObject(forKey: PropertyKey.name) 
                                      as? String else {
        os_log("Unable to decode the name for a Meal object.", 
                    log: OSLog.default, type: .debug)
        return nil
    }
    let photo = 
        aDecoder.decodeObject(forKey: PropertyKey.photo) 
                                              as? UIImage
    let rating = 
        aDecoder.decodeInteger(forKey: PropertyKey.rating)
        
    self.init(name: name, photo: photo, rating: rating)
        
}

最後に自分自身の指定イニシャライザを呼んで初期値をパラメーターとして与えています。

イニシャライザはこれだけで不十分です。というのもMealクラスがNSObjectクラスから継承を受け(サブクラス化)ているからです。指定イニシャライザは必ずスーパークラスの指定イニシャライザを呼ばないといけないというルールがあります。それでもアップルのドキュメントを読むと、NSObjectクラスのイニシャライザは「init()」1つだけなのでSwiftコンパイラが自動的に付与してくれるので書かなくても問題はないそうです。しかしながら初心者は指定イニシャライザを呼ばなければならないというルールを覚えるためにも書きましょう。

という事で、Mealクラスの指定イニシャライザに「super.init()」を加えます。

init?(name: String, photo: UIImage?, rating: Int) {
        
    guard !(name.isEmpty) else {
        return nil
    }
        
    guard (rating >= 0) && (rating <= 5) else {
        return nil
    }
        
    self.name = name
    self.photo = photo
    self.rating = rating
        
    super.init()
}

これでプロトコールのイニシャライザの設定が終わりました。

NSCodingはかなり強敵です。初めて出てくるプロパティやメソッドのオンパレード... 納得できる理屈もないので理解できません(^^;)

それでも、「こういうもんだ!」と理屈を考えないで雰囲気で理解していきます。

Mealクラス構造のイメージがはっきりしませんが、Mealクラスは、NSCodingを組み込んだことで、Mealクラスのデータをグループにして外部にオブジェクトとして保存(save)したり、外部のデータをオブジェクトして読み込んで(load)でMealクラスとして復活させる機能が実装された感じです。

Mealクラスのデータを どこに探しに行くべきか、どこに保存したらいいのかグローバル定数としてパス値を設定します。

「// MARK: Properties」の下に「// MARK: Archiving Paths」を作ります。

//MARK: - Archiving Paths
    
static let DocumentsDirectory = 
    FileManager().urls(for: .documentDirectory, 
                        in: .userDomainMask).first!
static let ArchiveURL = 
    DocumentsDirectory.appendingPathComponent("meals")

このチュートリアルに最初取り組んだときに全く理解できなかったコーディングでした。実はこれのデザイン・パターンの1つ「シングルトン(Singleton)」ですね。

そして外部ファイルの操作ができるFileManagerも知らないと全く理解できないため少し考察を入れます。

FileManager

まずFileManagerを勉強するためにQiitaってサイトを使います。

qiita.com

今までiOSアプリのファイル構造なんて気にしたことが無かったので少し考察を加えます。

ここに書かれているまま、「Xcodeメニュー」 -> 「Window」 -> 「Devices」 -> 実機(iOS 9をインストールしたiPhone6s Plus)を選んで、「Installed Apps」から「FoodTracker」をダブルクリックするとアプリのディレクトリ構成をみることができます。

f:id:yataiblue:20170406215147j:plain

今回、ユーザーが作成するコンテンツ(Mealクラスのデータ)は「Documents」の中に保存するのが望ましいですね。

次にディレクトリパスを習得(場所の特定ですよね)をする方法に色々あることを知りました。「MyApp.app」「temp」と「その他」でメソッドは違うんですね。同じようなメソッドでも古いものや新しいものがありできるだけ推奨メソッドを使用して開発した方がいいでしょう。

  1. NSHomeDirectory()
  2. NSTemporaryDirectory()
  3. NSSearchPathForDirectoriesInDomains() or FileManager().urls(for:in:)/url(for:in:)

ここで「Documents」のパスを求めるメソッドは3番目になります。そしてNSFileManagerクラスのFileManager().urls(for:in:)がチュートリアルで使用されているのでこのメソッドが推奨されていると考えていいでしょう

urls(for:in:)メソッドのパラメーターをみます。

func urls(for directory: FileManager.SearchPathDirectory, 
            in domainMask: FileManager.SearchPathDomainMask) -> [URL]

最初のdirectoryはイーナム型で探すディレクトリを設定します。

enum SearchPathDirectory : UInt {
    case applicationDirectory
    case demoApplicationDirectory
    case developerApplicationDirectory
    case adminApplicationDirectory
    case libraryDirectory
    case developerDirectory
    case userDirectory
    case documentationDirectory
    case documentDirectory
    case coreServiceDirectory
    case autosavedInformationDirectory
    case desktopDirectory
    case cachesDirectory
    case applicationSupportDirectory
    case downloadsDirectory
    case inputMethodsDirectory
    case moviesDirectory
    case musicDirectory
    case picturesDirectory
    case printerDescriptionDirectory
    case sharedPublicDirectory
    case preferencePanesDirectory
    case applicationScriptsDirectory
    case itemReplacementDirectory
    case allApplicationsDirectory
    case allLibrariesDirectory
    case trashDirectory
}

そして探し求めているファイルシステムの範囲を指定します。これは構造体になっています。

struct SearchPathDomainMask : OptionSet {

    init(rawValue: UInt)

    static var userDomainMask: 
                  FileManager.SearchPathDomainMask { get }
    static var localDomainMask: 
                  FileManager.SearchPathDomainMask { get }
    static var networkDomainMask: 
                  FileManager.SearchPathDomainMask { get }
    static var systemDomainMask: 
                  FIleManager.SearchPathDomainMask { get }
    static var allDomainsMask: 
                  FileManager.SearchPathDomainMask { get }
}

チュートリアルの「シングルトン」パターンのグローバル定数を見ます。

static let DocumentsDirectory = 
    FileManager().urls(for: .documentDirectory, 
                        in: .userDomainMask).first!

この2つのパラメーターは上記で説明したものです。

urls(for:in:)メソッドは[URL]を戻り値として返します。URLタイプのアレー型です。そして、アレー型には標準メソッド「first」を持っています。このメソッドはオプショナル型の最初の項目を返すので、最初の項目に値があると、その値を返し、無ければnilを返します。最初の項目は必ず存在するので「!」で強制アンラップさせます。

最初のコードでURL型のデータを取り出してDocumentsDirectoryという定数に入れました。

URL型って何?

また知らないことなのでちょっと調べて見ると、URL Loading System Programming Guideなんてのがありました。いやはや。「iOS Frameworks」って超巨大ですね。太平洋を一人ヨットで横断している気分です(T_T)

少なくともこのクラスの概要は理解していた方がいいですね。
URL - Foundation | Apple Developer Documentation

日本語でまとめられているのはここかな?
NSURLクラス | Second Flush

URLクラスというのは、URLファイルシステムで管理するファイルの位置情報とそのコンテンツとなるオブジェクトを内包して管理するクラスという理解でいいのでしょう。

2015年10月28日:自分でも少し勉強しました。
Swiftで遊ぼう! - 455 - Swift 2 NSURLSessionまだ全然わかっていない...ちょっと分かったかな(ATS) - Swiftで遊ぼう! on Hatena

URLの作成

指定のURL文字列、このURL文字列というのが「file://localhost/Users/ytai/Documents/file.txt」こういうファイルの位置情報ですが、これを使って、convenience init?(string URLString: String)というイニシャライザで生成させます。Objective-Cには「URLWithString」メソッドを使ってインスタン生成もできたようですがSwiftには用意されていません。

このチュートリアルでは、FileManager()クラスのメソッド、「urls(for:in:)」メソッドを使って作りました。

このURLオブジェクトで指定されたでディレクトリに「meals」というコンポーネントを作ってURLオブジェクトを作りかえるメソッドが「appendingPathComponent()」ということです。

最終的にこのステップで「meals」ファイルが組み込まれたURLファイリング情報を含んだURLクラスのグローバル定数ArchiveURLが作られたということです。

やっとMealクラスの内部環境は整ってきました。しかし、外部からアクセスしなければ使えないのでその準備をしていきます。

データをセーブしたりロードするのはMealTableViewControllerなので、MealTableViewControllerを開きます。

「// MARK: - Private Methods」の下に次のメソッドを加えます。

private func saveMeals() {
}

データをURLクラスにして指定場所に保存する機能をMealクラスに実装しました。

このデータですが、このMealTableViewControllerでシリアライズ(アーカイブ化)して保存させます。

f:id:yataiblue:20150816155626j:plain

NSKeyedArchiverクラスを使って、オブジェクトのバイナリ化させることをシリアライズと呼びます。バイナリ化したファイルは保存しやすいだけでなくネットワークで送受信できるというメリットもあります。

シリアライズ化というのはオブジェクトをバイナリ化させることのみを指し、保存は含みません

しかし、チュートリアルで使っている次のメソッドを使ったら、シリアライズ化とファイル保存を一緒にできます。そして成功したら「true」を返すという優れものです。

class func archiveRootObject(_ rootObject: AnyObject,
toFile path: String) -> Bool

これを使って次のコードをseveMeals()メソッドに加えます。

private func saveMeals() {
let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(meals,
                                toFile: Meal.ArchiveURL.path)
}

そしてこれが成功したかどうか確認するためのコードを加えます。

private func saveMeals() {
    let isSuccessfulSave = 
           NSKeyedArchiver.archiveRootObject(meals,
                            toFile: Meal.ArchiveURL.path!)
    if isSuccessfulSave {
        os_log("Meals successfully saved.", 
                log: OSLog.default, type: .debug)
    } else {
        os_log("Failed to save meals...", 
                log: OSLog.default, type: .error)
    }
}

ここでデータがセーブされたかどうかコンソールで確かめることができます。

mealsのセーブはできるようになりました。ポイントは「シリアライズ化」と「URLクラス」でした。

次はバイナリ化(シリアライズ化したこと)しているデータをデシリアライズ(アンアーカイブ)するメソッドですが、UIKeyedUnarchiverクラスメソッドを使います。

class func unarchiveObject(withFile path: String) -> Any?

パス情報を与えることでバイナリデータをオブジェクトに変更するのですが、戻り値がAnyなので必ずダウンキャストしないと使えません。ということは期待するオブジェクトは「[Meal]」です。

ということでロードするメソッドをsaveMeals()メソッドの下にコードします。

private func loadMeals() -> [Meal]? {
    return 
      NSKeyedUnarchiver.unarchiveObject(withFile: 
                          Meal.ArchiveURL.path) as? [Meal]

こででmealsのデータをセーブしてロードするメソッドができました。

どのタイミングでセーブとロードをする?

  1. mealデータを加える
  2. mealデータを消去
  3. mealデータを編集

ここで(1)と(3)は、同じメソッドの切り替えで対応します。cellが選択されたら「if let」ブロック内のコードが実行され、それ以外(「+」ボタンが押される)は「else」ブロック内のコードが実行されて、外に出てきた時にsaveMeals()メソッドを実行させることで変更を即座に記録させます。

@IBAction func unwindToMealList(sender: UIStoryboardSegue) {
    if let sourceViewController = sender.source as? 
        MealViewController, let meal = sourceViewController.meal {
            
        if let selectedIndexPath = tableView.indexPathForSelectedRow {
                
            // Updating an existing meal.
            meals[selectedIndexPath.row] = meal
            tableView.reloadRows(at: [selectedIndexPath], with: .none)
                
        } else {
                
            // Add a new meal.
            let newIndexPath = IndexPath(row: meals.count, section: 0)
            meals.append(meal)
            tableView.insertRows(at: [newIndexPath], with: .automatic)
                
        }
            
        // save the meals.
        saveMeals()

    }
}

TableViewControllerに組み込まれているデリゲートメソッドの1つ、Cellを消去するメソッドにもsaveMeals()メソッドを組み込む必要があります。

override func tableView(_ tableView: UITableView, 
    commit editingStyle: UITableViewCellEditingStyle, 
        forRowAt indexPath: IndexPath) {
    if editingStyle == .delete {
        meals.remove(at: indexPath.row)
        saveMeals()
        tableView.deleteRows(at: [indexPath], with: .fade)
            
    } else if editingStyle == .insert {
        // Create a new instance of the appropriate class, 
        // insert it into the array, and add a new row to 
        // the table view
    }
}

これでsaveMeals()メソッドの実装はできました。次はloadMeals()メソッドを何処に実装するか考えます。じゃあ、いつセーブされているデータをロードすべきか? だいたい予想はできますね。画面が最初に表示される時ですね。ということはviewDidLoad()メソッド内です。

loadMeals()メソッドの戻り値は「[Meal]?」なので、オプショナルもアンラップしてやらないといけません。ということは「if let」を使うのが望ましいですね。

if let savedMeals = loadMeals() {
 meals += savedMeals
} 

もしセーブされたファイルが無ければサンプルをロードするというやり方でいいですね。

override func viewDidLoad() {
    super.viewDidLoad()
        
    navigationItem.leftBarButtonItem = editButtonItem
        
    if let savedMeals = loadMeals() {
        meals += savedMeals
    }
    else {
        // Load the sample data.
        loadSampleMeals()
    }    
}

これでアップルのチュートリアルが終了です。TableViewControllerを使ってデータは別ファイルとして保存するシステムです。完璧に理解しているとは言いかねますが、やっとチュートリアルらしきものを1つ最後までやり遂げる事ができました。

このシステムを拡張していけばもう少しアプリらしいものができます。

このチュートリアルを何度も繰り返して、練習をします。何も見ないでコードできるようになったら身についたと言えるでしょう。

私は2度このチュートリアルに取り組みましたが、まだまだです(T_T)

索引に戻る→Swiftで遊ぼう! - 480 - Start Developing iOS Apps (Swift):チュートリアル - Swiftで遊ぼう! on Hatena

*1:2015年10月25日改訂:環境環境:Xcode7.1Swift2.1

*2:何度も繰り返して勉強をしていると何となく受け入れられるようになってきました(^_^;) データはバイナリデータで保存されているので、読み出すときは必ずデシリアライズしてクラスインスタンスとして復活させないと使えないからでしょう。