「Start Developing iOS Apps (Swift)」でFoodTracker Appを実際に作ってみる その10[データを保持する]

「Start Developing iOS Apps (Swift)」でFoodTracker Appを実際に作ってみる その10[データを保持する]

公式にある「Start Developing iOS Apps (Swift)」を使って実際にFoodTracker Appを作ってみる、というシリーズの最終回です。前回はこちら

「Start Developing iOS Apps (Swift)」でFoodTracker Appを実際に作ってみる その9[編集機能を実装する]
公式にある「Start Developing iOS Apps (Swift)」を使って実際にFoodTracker Appを作ってみる、と...

前回は既存のデータ(料理の情報)を編集したり、削除する機能を実装しました。今回はデータの保存です。(シミュレータで)アプリを起動して、一旦閉じた後もデータを保持する、という機能を追加します。

この記事を書いている時点でのXcodeのバージョンは8.1です。

データを保存する | Persist Data

いつものLearning Objectives(学習目的)です。この回が終わったら、

  • 構造体を作る
  • 型プロパティとインスタンスプロパティの違いを理解する
  • NSCodingプロトコルを使ってデータを読み書きする

ことが出来るようになるのが目標です。

料理情報を保存したり読み込む | Save and Load the Meal

キー(key)を使ってセーブとロード

Part9までに追加した機能で、新しく料理を追加したり、追加した料理を編集したり、削除することが出来るようになりました。ただし、シミュレータを一度閉じるとデフォルトの料理情報に戻ってしまいます。

そこで、今回は入力した情報を保存したり、それを読み込むような機能を実装していきます。保存と読み込み機能を実装するのにNSCodingというプロトコル(protocols)を使います。

仕組みとしては辞書コレクション型(Dictionary)と同じような構造で、Mealクラスが持つプロパティ1つ1つに対して対応するkeyを割り当てます。データの保存や読み込みは、このkeyを通じて行うという仕組みになります。

構造体を作ってkeyをまとめる

早速keyを作りますが、NSCodingではkeyに文字列Stringを使います。文字列をハードコードしても良いですが、使い回しが面倒になるのと、打ち間違えて変なバグを発生させる可能性もあるので、ここでは構造体(structures)を作ってkeyをひとまとめに管理します。

Meal.swiftを開いて、// MARK: Propertiesセクションの下に、構造体PropertyKeyを追加します。

// MARK: Types
    
struct PropertyKey {

}

構造体を定義したら、プロパティ(properties)を3つ追加します。

static let name = "name"
static let photo = "photo"
static let rating = "rating"

それぞれのプロパティが、Mealクラスの持つプロパティに対応しています。staticキーワードが付いているので、これらは型プロパティ(type properties)です。上記3つのプロパティは、型プロパティ(static)で、かつ定数(let)なので、PropertyKey構造体が共通で持つ不変の定数という取扱いになります。

一応まとめると、構造体PropertyKey

struct PropertyKey {
    static let name = "name"
    static let photo = "photo"
    static let rating = "rating"
}

このようになっているはずです。

公式ページではプロパティの名前がnameKeyなどになっていますが、keyが構造体の名前と重複するので取り除きました。

次は、コーディング機能(エンコードとデコード)を追加するために、NSCodingプロトコルを採用し、必要なメソッド(methods)を実装していきます。

NSObjectのサブクラス、NSCodingに準拠

プロトコルNSCodingに準拠したいので、

class Meal {

class Meal: NSCoding {

と書き換え、プロトコルNSCodingを採用しておきます。また、NSCodingに準拠した場合、同時にNSObjectサブクラスである必要があります。

NSCodingに準拠しただけでも、以降のソースコードをビルドしてアプリを実行することは出来ます。しかしNSObjectを継承しておかないと、データをエンコード(デコード)する時に実行時エラーをおこしてクラッシュします。

クラスの継承と、プロトコル採用を同時に行う場合は、クラス継承を先に書く決まりなので、

class Meal: NSObject, NSCoding {

としておきます。

NSCodingは2つのメソッド(1つは初期化子)を実装するのが必須で、その2つのメソッドが

func encode(with aCoder: NSCoder)
init?(coder aDecoder: NSCoder)

です。メソッドencode(with:)は名前から分かるように、データをエンコードして保存する場所です。初期化子init?(coder:)は逆にデコード処理を実装する場所になります。

以下、順番にメソッドを実装していきます。

NSCodingのencodeメソッドを実装する

Meal.swiftの一番下、}の手前に、

// MARK: NSCoding

いつもの// MARKコメントを追加しておきます。

コメントの下に

func encode(with aCoder: NSCoder) {
}

メソッドの外枠を実装し、メソッドの中に以下のようなコードを追加します。

aCoder.encode(name, forKey: PropertyKey.name)
aCoder.encode(photo, forKey: PropertyKey.photo)
aCoder.encode(rating, forKey: PropertyKey.rating)

メソッドencode(_:forKey:)は、1つ目のパラメータに入れたプロパティをエンコードし、2つ目のパラメータで指定したkeyを使って保存するメソッドです。

全て同じメソッドを呼び出しているように見えますが、最後の1つだけは別のメソッドです。最初の2つのメソッドは1つ目のパラメータがAny?型で、オブジェクトを取る場合に使います。最後のメソッドは1つ目のパラメータが整数Intになっています。

Swift 2.xでは、encodeObject(_:forKey:)encodeInteger(_:forKey:)という風に、明示的に別メソッドと分かる仕様でした。Swift 3以降では、パラメータの型で分けるような仕様で使いやすくなっています。

メソッド全体を改めて書いておくと、

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

このようになっているはずです。

初期化子を実装して保存されたデータをロードする

requiredとconvenienceキーワード

次は、エンコードし保存されたデータを、デコードして読み込む初期化子を実装します。

init?(coder aDecoder: NSCoder) {
}

initの後ろに?が付いているのは、初期化子がfailable initializerであるということを指しています。Failable initializerは初期化に失敗した場合、nilになる可能性があります。

Xcodeが補完する初期化子をそのまま追加してビルドすると分かりますが、

//Initializer requirement 'init(coder:)' can only be satisfied by a `required` initializer in non-final class 'Meal'

と怒られます。required修飾子が必要なようなので、忘れずに付けておきます。

また、この初期化子は「保存されたデータを読み出す時」という限定的な状況で使うので、convenienceキーワードを付けてconvenience initializerにしておきます。最終的に初期化子は

required convenience init?(coder aDecoder: NSCoder) {
}

このようになっているはずです。

この時点でビルドしてもコンパイルエラーになりますが、コードを追加していくと解消するので無視して先に進みます。

保存されたデータをデコードして読み出す

先にコードを書くと、

let name = aDecoder.decodeObject(forKey: PropertyKey.name) as! String

という感じで、保存されたデータをデコードします。decodeObject(forKey:)は対象が何かしらのオブジェクトの場合に使うメソッドです。

このメソッドの返り値Any?型ですから、該当する型にダウンキャスト(dowcasting)する必要があります。ただし、Mealクラスが持つnameプロパティは通常の型(オプショナル型ではないという意味)で定義されているので、as!でキャストします。もしこれが実行時にクラッシュした場合は、何かが根本的に間違っている可能性があります。

同様に、写真のデータphotoと評価値ratingもデコードすると、

// Because photo is an optional property of Meal, use conditional cast.
let photo = aDecoder.decodeObject(forKey: PropertyKey.photo) as? UIImage

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

という感じになります。写真のデータもdecodeObject(forKey:)でデコードするのですが、photoはオプショナル型(UIImage?)なので、ダウンキャストはas?で行います。また、ratingは整数値なので、デコードにはdecodeInteger(forKey:)を使います。decodeInteger(forKey:)の返り値はIntなので、ダウンキャストする必要はありません。

スーパークラスの初期化子の呼び出しをdesignated initializerに実装する

元々実装していた初期化子

init?(name: String, photo: UIImage?, rating: Int)

がありますが、これはdesignated initializerなので、継承元であるスーパークラスNSObjectの初期化子を呼び出さなければいけません(初期化子の委譲)。

スーパークラスの初期化子を呼び出すのは、サブクラスの格納プロパティを全て初期化した後なので、

init?(name: String, photo: UIImage?, rating: Int) {        
    // Initialize stored properties
    self.name = name
    self.photo = photo
    self.rating = rating

    super.init()
        
    // Initialization should fail if there is no name or if the rating is negative
    if name.isEmpty || rating < 0 {
        return nil
    }
}

のように初期化子を変更します。

データを保存するファイルのパスを作成する

次は、データを保存したり読み込んだりするファイルのパス(ファイル名)を指定します。Meal.swiftの// MARK: Propertiesセクションの下に、以下のコードを追加します。

// MARK: Archiving Paths

static let documentsDirectory = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first!
static let archiveURL = documentsDirectory.appendingPathComponent("meal")

コードを追加したらビルド(⌘B)して、コンパイルが通ることを確認しておきます。これらの変数はstaticで定義されているので、型プロパティです。繰り返しになりますが、型プロパティは型自体に付属しているインスタンスに依らない共通の値になります。

少し複雑なので順番に見ていきます。

FileManagerクラスとurls(for:in:)メソッド

FileManagerは、ファイルシステムにアクセスしてファイル操作(コピーや削除などなど)を行う重要なクラスのようです。

FileManager().urls(for: ..., in: ...)

という構文は、クラスインスタンスを作りつつ、FileManagerが持つメソッドurls(for:in:)を呼び出しています。

次にurls(for:in:)メソッドですが、メソッドの定義を見てみると

urls(for:in:)
Returns an array of URLs for the specified common directory in the requested domains.

Declaration

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

Parameters

directory The search path directory. The supported values are described in FileManager.SearchPathDirectory.
domainMask The file system domain to search. The value for this parameter is one or more of the constants described in FileManager.SearchPathDomainMask.

となっています。一見ややこしそうですが、メソッドurls(for:in:)の取るパラメータは2つで、どちらもFileManager内部で定義されている列挙型です。また、返り値はURLという構造体(後述)の配列になっています。

FileManagerのSearchPathDirectoryとSearchPathDomainMask

次に、2つのパラメータの型を見ていきます。1つ目のパラメータの型はFileManager.SearchPathDirectoryという列挙型ですが、Macのファイルシステムの様々なディレクトリが用意されています。詳しくはマニュアルを参考にして頂きたいですが、今回指定している.documentDirectoryというのは、FoodTrackerが置かれているディレクトリ(パス)です。

また、2つ目のパラメータの型はFileManager.SearchPathDomainMaskですが、この列挙型には値(case)が5つしかありません。今回し指定している.userDomainMaskというのは、ユーザのホーム(~)を指すようです。

返り値は配列[URL]だが最初の要素のみ必要?

urls(for:in:)の返り値の説明を読むと、

An array of NSURL objects identifying the requested directories. The directories are ordered according to the order of the domain mask constants, with items in the user domain first and items in the system domain last.

と書いてあります。が、「items in the system domain last」の部分が今いち分かりません。必要なのはユーザドメインのパスで、それが配列要素の先頭に来るのでfirstで取り出す?もしくはそもそも最初の要素にしか値が存在していないのかもしれません。

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

のコードを2段階に分けて書くと、

let urlArray = FileManager().urls(for: .documentDirectory, in: .userDomainMask) // [URL]
let directory = urlArray.first! // 型はURL。firstはオプショナル型なのでforce unwrap

という感じになります。結局documentsDirectoryの型はURLということになります。

appendingPathComponentでパスの後ろに名前を追加

static let archiveURL = documentsDirectory.appendingPathComponent("meal")

は単にdocumentsDirectoryのパス名の後ろに、"meal"という名前を付けているだけです。

例えばdocumentsDirectoryが、

/AAA/BBB/CCC/

だったら、documentsDirectory.appendingPathComponent("meal")を実行することで、パス名が

/AAA/BBB/CCC/meal

になります。

料理リストの保存と読み込み | Save and Load the Meal List

ここまでで、個々の料理を保存したり読み込んだりすることが出来るようになりました。次は、ユーザが新規料理を追加したり、既存の料理を編集・削除した場合に、料理のリストをセーブまたはロードする機能を追加していきます。

先ず料理のリストを保存するメソッドから実装していきます。

料理リストを保存するメソッドを実装

MealTableViewController.swiftを開き、クラス宣言の一番下の}の手前に

// MARK: NSCoding

func saveMeals() {

}

を追加します。いつもの// MARKコメントと、空のメソッドです。

次にメソッドsaveMeals()の中に、

let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(meals, toFile: Meal.archiveURL.path)

を加えます。NSKeyedArchiverはエンコードするためのクラス(NSCoderのサブクラス)のようです。NSKeyedArchiver.archiveRootObject(_:toFile:)は、meals配列(1つ目のパラメータ)を特定の場所(Meal.archiveURL.path)に保存しようとするメソッドで、保存に成功するとtrueを返します。

保存が成功したかどうかは返り値を見れば分かるので、先程のコードの下に

if !isSuccessfulSave {
    print("Failed to save meals ...")
}

を追加しておきます。もし保存に失敗した場合は"Failed to save meals ..."がコンソールに表示されます。成功した場合にも何かしら表示しても良いかもしれません。

最終的にメソッド全体は

func saveMeals() {
    let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(meals, toFile: Meal.archiveURL.path)
    if !isSuccessfulSave {
        print("Failed to save meals ...")
    }
}

このようになっているはずです。

料理リストを読み込むメソッドを実装

同様に、料理リストを読み込むメソッドも実装します。メソッド全体は

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

となります。これをクラス宣言のカッコ}の直前に追加します。返り値は[Meal]?なので、このメソッドはMealの配列を返すかもしれないし、nilを返すかもしれない、ということになります。

メソッドの中を見ると

return NSKeyedUnarchiver.unarchiveObject(withFile: Meal.archiveURL.path) as? [Meal]

となっていますが、NSKeyedUnarchiverはデコードするためのクラスです。

NSKeyedUnarchiver.unarchiveObject(withFile:)は、NSKeyedArchiverでエンコードされファイルに落とされたデータをデコードするメソッドです。パラメータに指定するのはファイル名ですから、NSKeyedArchiver.archiveRootObject(_:toFile:)で指定したのと同じパスMeal.archiveURL.pathを指定しておきます。

メソッドNSKeyedUnarchiver.unarchiveObject(withFile:)の返り値はAny?なので、[Meal]にダウンキャストしています。取り出した配列は中身がない(nil)可能性があるので、ダウンキャストにはas?を使っています。

ユーザが料理を追加、削除、または編集した時に料理リストを保存する

これで料理のリストを保存、または読み込むためのメソッドが実装できたので、次はこれらのメソッドを必要な場所に追加していきます。

引き続きMealTableViewController.swiftを編集していきます。先ずはsaveMeals()メソッドからですが、このメソッドを追加する場所は

@IBAction func unwindToMealList(_ sender: UIStoryboardSegue)
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath)

の2箇所です。

unwindToMealList(_:)にsaveMeals()を追加する

saveMeals()を追加した後のメソッドをいきなり載せると、

@IBAction func unwindToMealList(_ sender: UIStoryboardSegue) {
    if let source = sender.source as? MealViewController, let meal = source.meal {
        if let selectedIndexPath = tableView.indexPathForSelectedRow {
            // Update 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: .bottom)
        }

        // Save the meals.
        saveMeals()
    }
}

このようになっています。追加した場所は、外側のif文の最後、内側else {}の直後です。入れ子になっているif else構文の外側にあるので、既存の料理を編集しても、新規料理を追加しても保存されるようになっています。saveMeals()を追加した場所が、一番外側のif文(if let sourceから始まるif文)の中にあることを確認しておきます。

tableView(_:commit:forRowAt:)にsaveMeals()を追加する

同様にtableView(_:commit:forRowAt:)にもsaveMeals()を追加します。

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
    if editingStyle == .delete {
        // Delete the row from the data source
        meals.remove(at: indexPath.row)

        // Save the meals.
        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
    }
}

追加する場所は、meals.remove(at: indexPath.row)の直後です。

これで料理のリストが

  • 既存料理の編集、または新規料理の追加
  • 既存料理の削除

という適切なタイミングで保存されます。

適切なタイミングで料理リストをロードする

次は料理リストをロードする(読み込む)メソッドloadMeals()を「適切なタイミング」で実行します。

公式ページにも

This should happen every time the meal list scene loads, which means the appropriate place to load the stored data is in viewDidLoad.

と書いてあるように、保存されたデータを読み込むのは、料理リストsceneが常にロードされる場所であるviewDidLoad()が最適のようです。

viewDidLoad()

override func viewDidLoad() {
    super.viewDidLoad()
        
    // Use the edit button item provided by the table view controller.
    navigationItem.leftBarButtonItem = editButtonItem

    // Load the sample data.
    loadSampleMeals()
}

のようになっているはずですが、先ずloadSampleMeals()の手前に

// Load any saved meals, otherwise load sample data.
if let savedMeals = loadMeals() {
    meals += savedMeals
}

を追加します。loadMeals()Mealの配列を返した場合は、if文の中身が実行され、既存のmeals配列に、読み込んだsavedMeals配列が追加されます。もしloadMeals()nilの場合、読み込む料理がないということなのでif文は実行されません。

ただし、このままだとその次に実行されるloadSampleMeals()でリストが上書きされてしまうので、elseで囲んでおきます。

} else {
    // Load the sample data.
    loadSampleMeals()
}

最終的にviewDidLoad()は、

override func viewDidLoad() {
    super.viewDidLoad()
        
    // Use the edit button item provided by the table view controller.
    navigationItem.leftBarButtonItem = editButtonItem

    // Load any saved meals, otherwise load sample data.
    if let savedMeals = loadMeals() {
        meals += savedMeals
    } else {
        // Load the sample data.
        loadSampleMeals()
    }
}

このようになっているはずです。

最後にシミュレータを起動して、適当に料理を追加し、一旦アプリを終了した後に起動しても、先程追加した料理が追加されていることを確認します。

まとめ

これでめでたくFoodTrackerアプリが完成しました。ここからどうするか?ということも(簡単に)公式ページに書かれていますので、参考になるかもしれません。

スポンサーリンク
広告1
広告1

シェアする

フォローする