スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

サクッとわかる SwiftUI in WWDC 2020

こんにちは。4月に入社したiOSエンジニアの中村(@nkmrh)です。 東京もそろそろ梅雨が明けて夏がやってきますね。 さて、先月は WWDC 2020 がオンラインで開催されました。SwiftUI の新機能も発表され、いよいよ実戦投入の気運が高まってきているのではないでしょうか!

以下の2つのセッションで SwiftUI の新機能が紹介されていましたので、本稿ではこれらのセッションで、特にポイントとなりそうな項目をピックアップしてご紹介したいと思います。

Data Essentials in SwiftUI

このセッションでは、ビューとデータのバインドの方法について説明されていました。 新しく追加された機能の中に以下のものがありました。

  • Property Wrappers
    • @StateObject
    • @SceneStorage
    • @AppStorage
  • Modifier
    • onChange

順番に見ていきましょう。

@StateObject

class Store: ObservableObject {
    @Published var count = 0
}

struct ParentView: View {
    @StateObject private var store = Store()

    var body: some View {
        ChildView()
    }
}

上記のコードは @StateObject の使用例です。はじめに ParentView の body メソッドが呼ばれる前に store プロパティがインスタンス化されます。(ParentView がインスタンス化されるタイミングではなく)。それ以降 ParentView が再インスタンス化される場合でも、store プロパティの状態は保持され続けます。

@ObservedObject を使用して Store オブジェクトを ParentView にバインドした場合、ParentView が再インスタンス化されるタイミングで Store オブジェクトも初期化されてしまう為、状態を保持しておくには Store オブジェクトを外から注入させる必要がありました。

また、@ObservedObject は ParentView が再インスタンス化される度にインスタンス化され、ヒープメモリを圧迫しパフォーマンスの悪化原因となる為、@StateObject を使用することが推奨されていました。@StateObject のライフサイクルは SwiftUI が自動的に管理してくれるようです。

文章での説明だと分かりづらいと思いますので、サンプルコードで @StateObject と @ObservedObjectを利用した場合での挙動の違いを確認してみて下さい。

@SceneStorage

@SceneStorage("selection") var selection: String?

@SceneStorage を使うと Scene 単位でデータを永続化できます。 @State プロパティのように View にバインドして使います。Scene 単位なので、保存されたデータは Scene 間で共有されません。 内部的に UserDefaults は使われていないそうです。セッション内では Scene-Wide Source of Truth と紹介されていました。

@AppStorage

@AppStorage("updateArtwork") private var updateArtwork = true

@AppStorage はこれまでの UserDefaults と同じです。UserDefaults を View にバインドできるようになったので便利そうですね。

onChange modifier

struct ContentView: View {
    @State var count = 0

    var body: some View {
        VStack {
            Button("Increment count") {
                count += 1
            }
            Text("count \(count)")
                .onChange(of: count) { newCount in
                    print(newCount)
                }
        }
    }
}

onChange modifier を使用すると @State プロパティ等の値の変化を監視することができます。

以上が Data Essentials in SwiftUI セッションの中からキャッチアップしておきたい内容だと思います。

App Essentials in SwiftUI

このセッションでは SwiftUI における新しいアプリのライフサイクルの書き方が紹介されていました。

Xcode12以降から SwiftUI アプリケーションを新規作成すると、新規作成ダイアログの Life Cycle の項目から SwiftUI App と UIKit App Delegate のどちらかを選択できるようになっており、後者は従来の AppDelegate と SceneDelegate を使用したボイラープレートで、View を表示する為に UIHostingController を使いますが、前者は SwiftUI のコードだけで作成されます。

次のコードは UIKit App Delegate 選択時に生成される従来のボイラープレートです。UIHostingController を使い View を表示しています。

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let contentView = ContentView()
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

そして、次のコードは SwiftUI App 選択時に作成されるボイラープレートです。たったの8行で驚きました。セッション内では、"It's 100% functional app" と紹介されていたのが印象的でした。

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

上記のコードを見ていきましょう。

@main

まずはじめに目に付くのが @main です。これは、 @main 属性で Swift5.3 で導入されたものです。この属性を struct, class, enumration に適用すると、プログラムのエントリポイントを含むことを示します。適用するには main 関数を提供する必要があり、SwiftUI では App プロトコルが main 関数を提供しています。

App プロトコル

次に、App プロトコルです。App プロトコルはアプリの構造や動作を表すタイプです。App プロトコルに準拠するには、1つ以上の Scene を返す body プロパティを実装する必要があります。

WindowGroup

WindowGroup は macOS と iPadOS のマルチウィンドウに対応し、WindowGroup 以下の View 階層がマルチウィンドウ起動時のテンプレートとなります。iOS watchOS tvOS の場合は、1つのフルスクリーンウィンドウとなります。これにより、プラットフォームが違っても1つのコードで対応できるようになると解説されていました。

App Scene View の関係

App, Scene, View の関係は以下のようになり、WindowGroup が Scene を管理してくれます。

App Scene View の関係図

従来の Delagete プロトコルへの対応方法

App プロトコルで従来の UISceneDelegate や UIApplicationDelegate に対応するにはどうしたら良いのでしょうか。この点についてはセッション内では解説されていませんでしたが、以下のようにすることで対応できるようです。

UISceneDelegate に対応するには、ScenePhase 列挙子を Environment から取得し、onChange() メソッドの引数に設定することで、ScenePhase の値を監視することで対応できるようです。onChange() メソッドは上述の Data Essentials in SwiftUI で紹介されていましたね。

@main
struct MyApp: App {
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: scenePhase) { newScenePhase in
            switch newScenePhase {
            case .active, .inactive, .background:
                print(newScenePhase)
            default:
                fatalError()
            }
        }
    }
}

※ Xcode beta2 の時点では、まだ beta の為なのか background しか呼ばれていないようでした

UIApplicationDelegate に対応するには UIApplicationDelegateAdaptor PropertyWrappers を使います。初期化時に UIApplicationDelegate に準拠した型を渡すと、デリゲートメソッドが呼ばれるようになります。

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    ...
}

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        print(#function)
        return true
    }

    func applicationWillTerminate(_ application: UIApplication) {
        print(#function)
    }
}

macOS の場合は NSApplicationDelegateAdaptor が用意されています

App Essentials in SwiftUI セッションでは上記の内容が解説されていました。

まとめ

本稿では、上記2セッションの内容を解説しました。SwiftUI が使われていくにつれて、マルチウィンドウ対応や macOS 対応等も大事なポイントとなってくるのかもしれません。SwiftUI をキャッチアップしていく上で参考になれば幸いです。


こちらの記事を読まれて Quipper に興味をお持ちいただいた皆様、ぜひ Quipper に遊びに来ませんか? 以下 Wantedly ページよりお気軽にご連絡ください! https://www.wantedly.com/companies/quipper/projects