天狗会議録 Posts Pages About

SwiftUIを学習した

#swift #swiftui
2024/9/2

概要

9月4日から始まるインターンでSwiftを用いる。 それにもかかわらず、1ミリとしてSwiftに触れたことがなかった。 そのため、予習として、macOS及びiOS向けの日誌記録アプリ開発を通してSwift(というよりはSwiftUI)を学習した。 学習した内容やSwift及びSwiftUIに対する所感を本記事にまとめる。

ソースコードはこちら

開発環境

M2 MacBook Air (macOS Sonoma 14)+Visual Studio Codeで開発した。 曰く、AppleはXcodeでの開発を推奨しているようであるが、私はCLI信者であるため、これを拒否した。 残念ながら(あるいは当然ながら)、CLIでのビルド方法に関する文献は少ない。 まともにビルドできるようになるまで苦労を要した。

まず、Homebrewをインストールした時点で次のような状態であった。 幾つかエラーが見える。 このエラーのために、Swiftをビルドできない。

$ swift --version
swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0

$ xcode-select -p
/Library/Developer/CommandLineTools

$ xcrun --show-sdk-path
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk

$ xcrun --show-sdk-platform-version
xcrun: error: unable to lookup item 'PlatformVersion' from command line tools installation
xcrun: error: unable to lookup item 'PlatformVersion' in SDK '/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk'

$ ls -la /Library/Developer/CommandLineTools/SDKs
total 0
drwxr-xr-x  7 root  wheel  224  7 21 18:46 .
drwxr-xr-x  5 root  wheel  160  7 21 18:46 ..
lrwxr-xr-x  1 root  wheel   14  7 21 18:46 MacOSX.sdk -> MacOSX14.4.sdk
drwxr-xr-x  7 root  wheel  224  5 12  2023 MacOSX13.3.sdk
lrwxr-xr-x  1 root  wheel   14  7 21 18:46 MacOSX13.sdk -> MacOSX13.3.sdk
drwxr-xr-x  7 root  wheel  224  2 20 09:28 MacOSX14.4.sdk
lrwxr-xr-x  1 root  wheel   14  7 21 18:46 MacOSX14.sdk -> MacOSX14.4.sdk

$ ls -la /Library/Developer/CommandLineTools/SDKs/MacOSX14.4.sdk
total 40
drwxr-xr-x  7 root  wheel   224  2 20 09:28 .
drwxr-xr-x  7 root  wheel   224  7 21 18:46 ..
-rw-r--r--  1 root  wheel   127  2 10  2024 Entitlements.plist
-rw-r--r--  1 root  wheel  6564  2 10  2024 SDKSettings.json
-rw-r--r--  1 root  wheel  4871  2 10  2024 SDKSettings.plist
drwxr-xr-x  5 root  wheel   160  2 20 09:25 System
drwxr-xr-x  7 root  wheel   224  2 20 09:25 usr

$ sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
xcode-select: error: invalid developer directory '/Applications/Xcode.app/Contents/Developer'

$ ls /Applications/X*
zsh: no matches found: /Applications/X*

Homebrewインストール時にXcode Command Line for Toolsが自動でインストールされる。 しかし、どうやらこのCLTでは不十分であり、Xcodeをインストールしなければならないらしい。 さて、私はCLI信者であるため、xcodesというXcodeのバージョン管理アプリを用いてXcodeをインストールした。 ただし、どうやらXcodeを運用するにはApple Developerに登録する必要があるらしく、登録済みのAppleアカウントへのログインが要求された。

$ brew install xcodesorg/made/xcodes
$ brew install aria2
$ xcodes install 15.4
$ cd /Applications
$ ln -s Xcode-15.4.0.app Xcode.app
$ sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
$ xcrun --show-sdk-platform-version
14.5

以上でswiftコマンドが利用できるようになった。 しかし、SwiftUIを用いる場合、swiftコマンドでは不十分であり、xcodebuildコマンドを用いてビルドする必要がある。 xcodebuildを用いるにはXcodeプロジェクトが必要である。 幸いにもxcodegenというCLI操作でYAMLからそれを作成できるアプリがあるため、これを用いる。 簡単なYAMLファイルは次のようである。

name: ProjectName

settings:
  base:
    MARKETING_VERSION: 1.0.0
    CURRENT_PROJECT_VERSION: 1

targets:
  TaskRecorder:
    type: application
    platform: [macOS, iOS]
    sources:
      - Sources
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: product.bundle.identifier

次のようにxcodegenを用いる。 尚、2個あるxcodebuildについて、1個目がmacOS向け、2個目がiOS向けである。 ただし、$TEAM_IDはデベロッパーチームのIDのことであるが、無料のApple Developerにおいてはそれを取得する正規の手段が提供されていない。 そのため、Xcodeでプロジェクトを作成し、その.xcodeprojectディレクトリから発見するしかない。 また、$SDKは後述する方法でインストールし、「xcodebuild -sdk -version」を実行して確認できる。 xcodebuildの結果、build/(macOS|iOS)/Build/Products/(Debug|Release)/ProjectName.appが生成される。

$ brew install xcodegen
$ xcodegen
$ xcodebuild \
    -project ProjectName.xcodeproj \
    -scheme ProjectName_macOS \
    -derivedDataPath ./build/macOS \
    -configuration (Debug | Release) \
    GENERATE_INFOPLIST_FILE=YES \
    build
$ xcodebuild \
    -project ProjectName.xcodeproj \
    -scheme ProjectName_iOS \
    -sdk $SDK \
    -derivedDataPath ./build/iOS \
    -configuration (Debug | Release) \
    GENERATE_INFOPLIST_FILE=YES \
    DEVELOPMENT_TEAM=$TEAM_ID \
    build

iPhoneやiPad等のシミュレーションを行うためには、次のように実行する。 ただし、予めApple Developer websiteからランタイムをダウンロードし、「xcrun simctl runtime add "/path/to/runtime-file.dmg"」によってインストールしておく必要がある。 伴ってSDKとシミュレータがインストールされる。 また、$DEVICE_TYPEと$SIMULATORは「xcrun simctl list」を実行して確認できる。

$ xcrun simctl create $DEVICE_NAME $DEVICE_TYPE $SIMULATOR
$ xcrun simctl boot $DEVICE_NAME
$ xcrun simctl install iPad8 $PATH_TO_APPLICATION
$ open -a /Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/Contents/MacOS/Simulator

以上で開発環境を構築できた。 尚、この開発環境はマゾCLI信者向けである。 こんな不毛な環境構築作業をしないために、また特にプレビューの恩恵を受けるために、素直にXcodeを用いるのが良い。

Swiftについて

初見の印象は「Rustみたいだなあ」だった。 実際、Swiftのパラダイムはオブジェクト指向ではなくプロトコル指向なるものらしく、このプロトコルがRustのトレイトのようなものなので、ある程度の馴染みがあった。 しかし、主要パラダイムではないとしながらオブジェクト指向をサポートしていることや・下記の要素から、Rustのような言語だと言うのは少々過言かと思う。

nilの可能性があることを表すオプショナル型には、TypeScriptや新しいC#等のモダンな言語の雰囲気を感ぜられる。 nilや例外の除去のためのguard文もまた、そのシンタックス自体が独立しているながら、矢張りTypeScriptのif文による型推論のような機能である。

一方で、1モジュール内において名前空間の概念がなく、トップレベルに宣言を連ねる点には、CやJavaScriptのような古い言語を想起させる。 グローバル変数を定義できる点も、これに拍車を掛けている。 スクリプト言語のように書ける開発用言語といった印象である。

個人的に面白いと思ったのは、名前付き引数とクロージャのシンタックスである。 まず、名前付き引数を半ば強要するような言語は珍しいと思った。 これまで扱ったことのある言語の中ではPythonにも名前付き引数があったが、強制ではなかったと記憶している。 この機能のおかげか、構造体の初期化を関数呼出しのように書ける。 エレガントである。 また、クロージャのシンタックスは次のようであり、なんか、こう……、なんというか、不気味で面白いなと思った。

["foo", "bar"].forEach({ print($0) })
["foo", "bar"].forEach({ n in print(n) })
["foo", "bar"].forEach { n in print(n) }
["foo", "bar"].forEach { (n: String) in print(n) }
["foo", "bar"].forEach { (n: String) -> Void in print(n) }

SwiftUI

まず、宣言的UIである点が好印象である。 私は必要最小限の依存で開発する・所謂フルスクラッチ厨であり、そのためReactもVueも業務でもない限りはまず使わない。 しかし、だからこそ、DOMをJavaScriptで操作したり・.NETのUIを直書きすることの面倒臭さを理解している。 そこで、宣言的にUIを記述できるというのはありがたい仕様であった。

次は発展的な知識である。

宣言的UIはありがたい、と言ったものの。 UI再描画の発火条件に非常に悩まされた。 とにかく、配列が悪さをする。 例えば、次のコードである。 Observableなクラスの要素である配列について、メソッドを介して取得すると変更が反映されない。

struct Element: Identifiable {
    var text: String = "foo"
}
class Elements: Observable {
    private var privateElements: [Element] = []
    var elements: [Element] = []
    func get() -> [Element] {
        return self.privateElements
    }
    func append() {
        self.privateElements.append(Element())
        self.elements.append(Element())
    }
}

/* ... */

List(elements.get(), id: \.self) { element in
    Text(element.text) // 追加が反映されない
}
List(elements.elements, id: \.self) { element in
    Text(element.text) // 追加が反映される
}
Button("Add") {
    elements.append()
}

また、Listの中のNavigationLinkによって遷移した別画面での配列の要素の変更は反映されない。

import SwiftUI

struct ModelData {
    var array = [false]
}

struct MyButton: View {
    @Binding var value: Bool
    var body: some View {
        Button(value ? "1" : "0") {
            value.toggle()
        }
    }
}

@main
struct MainApp: App {
    @State var modelData: ModelData = ModelData()
    var body: some Scene {
        WindowGroup {
            NavigationStack {
                Text("Array")
                NavigationLink {
                    // 再描画されて変更が反映される
                    MyButton(value: self.$modelData.array[0])
                } label: {
                    MyButton(value: self.$modelData.array[0])
                }
                
                List {
                    Text("Array")
                    NavigationLink {
                        // 再描画されず、変更が反映されない
                        MyButton(value: self.$modelData.array[0])
                    } label: {
                        MyButton(value: self.$modelData.array[0])
                    }
                }
            }
        }
    }
}

他にも、次の例がある。 真理値を持つモデルの配列から、真偽判定によるフィルタリングによって2個の配列を出し分けるプログラムである。 しかし、各モデルの真理値は変更され、その外見も再描画されるが、フィルタリングの結果が変わらない。 そして、配列に要素を追加することでフィルタリングの結果が正常になる。

import SwiftUI

class Model: ObservableObject {
    @Published var value: Bool = false
}
@Observable
class ModelData {
    var models = [Model()]
}

struct MyButton: View {
    @ObservedObject var value: Model
    var body: some View {
        Button(value.value ? "1" : "0") {
            value.value.toggle()
        }
    }
}

struct ContentView: View {
    @Environment(ModelData.self) var modelData
    var falses: [Model] {
        return modelData.models.filter({ n in !n.value })
    }
    var trues: [Model] {
        return modelData.models.filter({ n in n.value })
    }
    var body: some View {
        // 配列に要素を追加すると下のForEachの内容が変わる。
        Button("add") {
            modelData.models.append(Model())
        }
        Text("Falses")
        ForEach(falses.indices, id: \.self) { i in
            // 反映されない!
            MyButton(value: falses[i])
        }
        Text("Trues")
        ForEach(trues.indices, id: \.self) { i in
            // 反映されない!
            MyButton(value: trues[i])
        }
    }
}

@main
struct MainApp: App {
    @State var modelData = ModelData()
    var body: some Scene {
        WindowGroup {
            ContentView().environment(modelData)
        }
    }
}

SwiftUIに対する不可解な挙動は再描画だけではない。 まず、macOS向けのビルドにおいては、.editMode()を使うことができない。 そのため、同様の機能を持たせるには.onMove()や.onDelete()を使うことになる。 しかし、まず、List>ForEach(.onMove())>TextFieldとしたとき、TextFieldのフォーカスが著しく遅くなる。 これはQiitaにも報告が上がっている。 また、その.onDelete()はmacOS向けのビルドに利用できない。 そのため、.onDeleteCommand()を用いてキーバインドするか、削除UIを別途追加する必要がある。

macOSにおいてウィンドウを閉じたときにプロセスを終了するには、次のようにしなければならない。

import SwiftUI

#if os(macOS)
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
        return true
    }
}
#endif

@main
struct MainApp: App {
#if os(macOS)
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#endif

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

細かいところでは、次のようなものがある。

これまでの人生で何度も仕様に殺されたことはあったが、SwiftUIは中でもトップクラスの殺意を感じた。

とはいえ、慣れてしまえば簡単にそれっぽいものが作れるので、悪い体系とも言い難い。 インターンで経験を積んで、もっと理解を深めたい。

参考文献