Help us understand the problem. What is going on with this article?

SwiftUI Tutorialsを読み解く

この記事は何?

SwiftUI Tutorials: Building Lists and Navigationをフルスクラッチからハンズオンするにあたって、Data.swiftファイルのソースコードを調べました。

Data.swift

チュートリアルはサンプルコードが用意されていて、SwiftUIの要点を学習できるようになっています。ただ、実践していると序盤にImageStoreという不明なクラスが突如、現れます。
このImageStoreを定義しているファイルが、Data.swiftです。
ちなみに、ナビゲータエリアのModelsフォルダに入っています。
ImageStoreは単なる「画像データを管理してくれるクラス」と理解すれば、チュートリアル自体は問題なく読み進めることができますが、ソースコードを見てみるとSwift初学者にはなかなかちょうどいい難易度の教材かと思いました。
忘備録がわりに解説しておきます。

実行環境

  • macOS 10.15 Catalina bata7
  • Xcode11 bata7

コードを読む

SwiftUIチュートリアルのプロジェクトに含まれているData.swiftですが、このファイル自体にSwfitUIの概念はほとんど含まれていません。
以下のようなSwiftプログラミングのちょっと高度な概念を見ることができます。
- ジェネリクス
- プロトコル
- タイプエイリアス
- guard-Let 構文
- do-catch 構文
- fileprivate キーワード
- ...

ソースコード全体

大まかな構成として、次の4つに分けて読むことができます。
1. フレームワークのインポート
2. landmarkData 定数
3. load<T>(_:as:) 関数
4. ImageStore クラス

Data.swift
import UIKit
import SwiftUI
import CoreLocation

let landmarkData: [Landmark] = load("landmarkData.json")

func load<T: Decodable>(_ filename: String, as type: T.Type = T.self) -> T {
    let data: Data

    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
        else {
            fatalError("Couldn't find \(filename) in main bundle.")
    }

    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }

    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}

final class ImageStore {
    typealias _ImageDictionary = [String: CGImage]
    fileprivate var images: _ImageDictionary = [:]

    fileprivate static var scale = 2

    static var shared = ImageStore()

    func image(name: String) -> Image {
        let index = _guaranteeImage(name: name)

        return Image(images.values[index], scale: CGFloat(ImageStore.scale), label: Text(verbatim: name))
    }

    static func loadImage(name: String) -> CGImage {
        guard
            let url = Bundle.main.url(forResource: name, withExtension: "jpg"),
            let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
            let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
        else {
            fatalError("Couldn't load image \(name).jpg from main bundle.")
        }
        return image
    }

    fileprivate func _guaranteeImage(name: String) -> _ImageDictionary.Index {
        if let index = images.index(forKey: name) { return index }

        images[name] = ImageStore.loadImage(name: name)
        return images.index(forKey: name)!
    }
}

1. フレームワークのインポート

3つのフレームワークをインポートしています。
このうち、UIKitの機能はこのファイル内で使用されていないので、記述を削除してもビルドに成功します。

Data.swift
import UIKit
import SwiftUI
import CoreLocation

...

2つ目のSwiftUIフレームワークは、以下のコードで使用されています。

ImageStoreクラス
func image(name: String) -> Image {
    ....
}

-> Image の部分です。
この Image がいわゆるSwiftUIビューです。

3つ目の CoreLocation は座標を扱う機能として、プロジェクト全体にわたって随所に使用されています。
ただし、このファイル内に限っては使用されていないので、削除してもビルドに成功します。

2. landmarkData 定数

[Landmark]型のコレクションです。
Landmarkは観光名所を表現した構造体で、名前や画像ファイル名、座標、所在する州名、カテゴリなどの情報を持っています。

Data.swift
let landmarkData: [Landmark] = load("landmarkData.json")

このコレクションを返す load() 関数は、直後に定義されています。

3. load < T >(_: as: ) 関数

この関数は、プロジェクトリソースのJSON形式データから[Landmark]型のコレクションを返します。

Data.swift
func load<T: Decodable>(_ filename: String, as type: T.Type = T.self) -> T {
    let data: Data

    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
        else {
            fatalError("Couldn't find \(filename) in main bundle.")
    }

    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }

    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}

宣言を見ると、以下のことがわかります。

  • 1つめのパラメータは、String型(ラベルなし、引数名 filename
  • 2つめのパラメータは、任意の型 T(ラベル as、引数名 type
  • 任意の型 T を返す
関数の宣言
func load<T: Decodable>(_ filename: String, as type: T.Type = T.self) -> T {
    ...
}

<T: Decodable> は、Decodableプロトコルに準拠したジェネリクスです。
Decodableに準拠していれば、あらゆる型を引数に受け取ることができることになります。
また、2つ目のパラメータには既定値として、= T.self が割り当てられています。
このため、呼び出し時には省略可能です。

次に、リソースからJSONデータを取得して定数 file に割り当てています。

load()関数
guard let file = Bundle.main.url(forResource: filename, withExtension: nil) else {
    fatalError("Couldn't find \(filename) in main bundle.")
}

guard-let 構文によって、早期エラー検出して安全に記述しています。
この後、do-try-catch エラーハンドリングをしながら、ファイル情報をデータ化します。

load()関数
do {
    data = try Data(contentsOf: file)
} catch {
    fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
}

この時点で、定数 data にはJSONファイルのバイナリデータが割り当てられていると考えられます。
最後に、バイナリデータをコレクション型にデコードして返します。

このメソッド全体が、JSONファイルをコードで扱えるコレクションに変換するお手本になりそうな関数になっているので、覚えておいてもいいかもしれません。

load()関数
do {
    let decoder = JSONDecoder()
    return try decoder.decode(T.self, from: data)
} catch {
    fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
}

JSONDecoder型のインスタンスを生成して、decoder(_:from:)メソッドを呼び出すことでこの作業を行なっています。
ここで、T.self という値が使われていますが、これはパラメータ type の既定値と同じです。
type ではなく、T.self とわざわざ記述している理由はわかりません...。

ともかく、このファイルのグローバル定数 landmarkData に、JSONデータから取り出したコレクションが適切な型で割り当てられました。

4. ImageStore クラス

ここまでの1~3は、ImageStore クラスを構築するための準備と言えます。
ImageStore型は final キーワードによって、これ以上は継承できないように制限されています。
このクラスには、3つの変数と3つのメソッドが定義されています。

  • 変数 images
  • 変数 scale
  • 変数 shared
  • image(name:) メソッド
  • loadImage(name:) メソッド
  • _guaranteeImage(name:) メソッド

また、typealias によって [String: CGImage] 型を _ImageDictionary という名前で呼び出せるように定義しています。

Data.swiftに定義されているImageStore型
final class ImageStore {
    typealias _ImageDictionary = [String: CGImage]
    fileprivate var images: _ImageDictionary = [:]

    fileprivate static var scale = 2

    static var shared = ImageStore()

    func image(name: String) -> Image {
        let index = _guaranteeImage(name: name)

        return Image(images.values[index], 
                     scale: CGFloat(ImageStore.scale),
                     label: Text(verbatim: name))
    }

    static func loadImage(name: String) -> CGImage {
        guard
            let url = Bundle.main.url(forResource: name, withExtension: "jpg"),
            let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
            let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
        else {
            fatalError("Couldn't load image \(name).jpg from main bundle.")
        }
        return image
    }

    fileprivate func _guaranteeImage(name: String) -> _ImageDictionary.Index {
        if let index = images.index(forKey: name) { return index }

        images[name] = ImageStore.loadImage(name: name)
        return images.index(forKey: name)!
    }
}

変数

まず、宣言されている変数の型と役割を確認しておきます。

ImageStore型
fileprivate var images: _ImageDictionary = [:]    
fileprivate static var scale = 2    
static var shared = ImageStore()

変数 images は、_ImageDictionary 型と記述されていますが、実際には [String: CGImage] という辞書コレクションです。ここには観光地名と画像が割り当てられます。
初期値はカラの状態です。
filepribete キーワードによって、ファイル外からのアクセスが制限されています。

変数 scale は、画像データを表示するときの倍率と考えてください。実際には、SwiftUIビューである Image の初期化メソッドを呼び出す際、パラメータとして渡されます。
初期値は 2 になっています。
こちらもファイル外からのアクセスを制限しています。

変数 shared は、割り当てられている値が自身の型 ImageStore であることからも分かるように、シングルトンというデザインパターンを実現するために利用されています。
シングルトンは簡単にいうと、共有される単一のインスタンスです。
ImageStore が構造体ではなく、クラスによって定義されている理由もシングルトンパターンを実現するためだと思われます。構造体だと暗黙的コピーが発生する Copy on Write によって、インスタンスが1つではなくなるのでシングルトンの要件を満たせません。

メソッド

続いて、メソッドを見ていきます。

最初に、loadImage(name:) メソッドです。
宣言に loadImage(name: String) -> CGImage と記述されていることから、「画像名を渡すと、適切な画像データを返す」機能を果たすことがわかります。
また、このメソッドは static キーワードがあるので、静的メソッド (いわゆるタイプメソッド)です。つまり、インスタンス化せずに直接、型から呼び出せます。

ImageStore型
static func loadImage(name: String) -> CGImage {
    guard let url = Bundle.main.url(forResource: name, withExtension: "jpg"),
          let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
          let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
    else {
        fatalError("Couldn't load image \(name).jpg from main bundle.")
    }
    return image    
}

guard-let 構文を使って、早期エラー回避しながら以下の処理を行なっています。

  1. パラメータとして受け取った画像名を使って、パスが割り当てられた定数 urlを生成
  2. url を辿って、読み取る画像ソースを割り当てた定数 imageSource を生成
  3. imageSourceの先頭インデックスに関連付けられた画像データを image に割り当てる

要は、プロジェクトに含まれている Resource フォルダ内にある画像を取得する手順です。

ここで使用されている CGImageSourceCreateWithURL() メソッドとCGImageSourceCreateImageAtIndex() メソッドは Image I/O フレームワークで定義されている画像処理APIです。

特にインポート宣言をしていないことから、Swift標準ライブラリに内容されていると考えられます。
最後に、CGImage型の画像データを1つだけ返しています。

次に、_guaranteeImage(name:) メソッドです。
宣言は _guaranteeImage(name: String) -> _ImageDictionary.Index と記述されています。
このメソッドは、パラメータとして受け取った「画像名に基づいて辞書を検索し、対応する画像のインデックスを返す」機能を果たします。

ImageStore型
fileprivate func _guaranteeImage(name: String) -> _ImageDictionary.Index {
    if let index = images.index(forKey: name) { return index }

    images[name] = ImageStore.loadImage(name: name)        
    return images.index(forKey: name)!    
}

if-let 構文によって、辞書コレクション内のキーに画像名が見つかった場合は、その index を返すようにしています。

辞書でキーがヒットせず、nil になった場合は、パラメータ name と対応する画像データをResource フォルダから取得して、変数 images コレクションに追加します。
最後に、images コレクションにある追加済み画像のインデックスを返します。

残るは、image(name) メソッドです。
宣言は、image(name: String) -> Image と記述されています。
このメソッドの機能が「画像名を渡すと、適切な画像を返す」ことだとわかります。
返り値の型は、SwiftUIビューの Image 型です。

ImageStore型
func image(name: String) -> Image {
    let index = _guaranteeImage(name: name)

    return Image(images.values[index], 
                 scale: CGFloat(ImageStore.scale), 
                 label: Text(verbatim: name))    
}

パラメータとして受け取った画像名 name をそのまま、_guaranteeImage(name:) に渡しています。
帰ってきたインデックスを基に、images コレクションから画像データを取得できます。
実際には、Image型イニシャライザにパラメータとして渡され、そのまま返り値になっています。

ImageStore_Concept.png

考察

Apple謹製のサンプルコードだけあって、いろんなテクニックが詰め込まれているのに、短く分割されて、理解しやすいと感じました。

ただ、辞書コレクションである images 変数を操作するのにインデックスを多用する理由はなんでしょう?
images.values[index]のようにキーを使って値を取り出さず、わざわざインデックスを指定しています。

もしかすると、チュートリアルを進めていく途中で理解できるのかもしれません。

続き

Modelグループにあるもう1つのファイルLandmark.swiftの解説はこちら

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away