SwiftUIで使用されているSwift5.1の新機能

こんにちは。会員事業部の岡村 (@iceman5499) です。 普段はクックパッドアプリ(iOS)を開発しています。 先日San Joseで開催されたWorldwide Developers Conference 2019 (WWDC19)に参加し、そこでSwiftUIの発表をうけていくつか調べたことがあるので簡単にまとめておきたいと思います

SwiftUIの登場

今年のKeynoteの最後に、SwiftUIという新たなUIフレームワークが発表されました。 SwiftUIはReactやFlutterのような形式でViewを宣言して画面を構築できる、これまで使用されてきたUIKitとは全く異なる形式のフレームワークです (AppleのSwiftUI紹介ページ )

この発表をうけてKeynoteはとても盛り上がっていました。期間中もSwiftUIの話題でもちきりで、セッションも多く開かれていました

SwiftUIでできるようになること

  • DSLでViewを宣言的に適宜できるようになりUIの構成要素を簡単に表現できるようになった
  • コード編集中にリアルタイムにUIプレビューを利用できる *1
  • 余白調整やアクセシビリティ・ダークモード対応などがある程度自動で行われ、Human Interface Guidelinesに則った画面を作成しやすい
  • スムーズなアニメーションが簡単に設定できるようになった

UIKitではよくあるリスト形式の画面を作るだけでも TableViewDelegateTableViewDataSource のメソッドを多数実装したり、ラベルを上下に並べるのに

label.constraint(equalTo: otherLabel.topAnchor, constant: 16).isActive = true

などといった長いコードを書いていく必要がありましたが、SwiftUIではそれがすっきりして

 List(contents) { content in
    VStack {
      Text(content.title)
      Text(content.subtitle)
    }
 }

のような形でシュッと書けるようになりました 🎉

f:id:iceman5499:20190624145219j:plain
Introducing SwiftUI: Building Your First App

(Introducing SwiftUI: Building Your First App より)

実際にさわってみた感想

2019年6月現在。macOS Catalina 10.15 Beta 2 と Xcode 11 Beta2 を使用しています

プレビューめちゃくちゃ使いやすい!?

  • 起動して目的の画面にたどり着くための操作をしなくてもいい
  • その場でタップフィードバックなども試せる
  • モックデータを簡単に挿せる

あたりの機能は非常に便利で、今後のプロトタイピング開発やエラー表示のテスト、デザインドキュメントとしての利用など様々な場面での活用が予想されます

新規プロジェクトならいい感じに動いたのですが、一方で既存プロジェクトで動かそうとした場合にいくつかの問題点に遭遇しました

  • ビルドターゲットがiOS13未満に設定されているとプレビュービルドができない *2

Swiftでは @available を用いることで指定コードが有効化されるiOSバージョンを制御することができます。これを用いてビルドターゲットがiOS13未満のプロジェクトにおいては

@available(iOS 13.0, *)
struct ContentView : View {
    var body: some View {
        Text("Hello World")
    }
}

のように記述をすることでビルド及び実行することができるようになります(iOS13未満ではSwiftUIを使用できないため代わりの実装を用意する必要があります)

こちらの書き方を使用して既存プロジェクトからSwiftUIを利用する場合、Xcode11Beta2時点ではプレビュービルドはエラーとなり利用することができませんでした

  • Objective-C製ライブラリ(Firebaseなど)を使ってるとたびたびそれらのビルドが走る

これはSwiftUIの問題ではなくビルドシステムの都合だと思うのですが、プレビューを使用する際は変更部分のみがリビルドされるはずがObjective-Cを利用している場合にそれらのビルドが走ることがあり、現実的な待ち時間でプレビューを使用することが難しいことがありました

  • 新規プロジェクトでも急に止まったり調子悪くなったりしがち

こちらはシンプルにプレビューの描画が止まったり明らかにおかしくなったり、ビルドが長くなったりなどです

と、このような障害もあり、まだBetaであるため安定してないのはしょうがないですが、安定しないままリリースされる可能性も十分にあり得るため今のところプレビューはあくまで補助的なものと捉えています。

個人的にあるだろうと思った機能がないこともある

触っていくとだんだんと気づくのですが、Betaということもあって個人的に必要だと思った機能が実は存在していないといったケースがあります

  • HStack などを用いて複数のViewを等幅で配置できない *3
  • ボタンハイライト時の挙動を設定できない *4
  • 画面を閉じる or 戻るボタンを配置できない

コンポーネントはどんどん拡充されていくはずですので、リリース時点やその後のコンポーネントの拡充に期待です

SwiftUIで使用されているSwift5.1の新機能

SwiftUIにはSwift5.1で新規追加される機能がふんだんに使用されていました

  • @propertyDelegate
  • @_functionBuilder
  • Opaque Result Type
  • @_dynamicReplacement(for: )
  • KeyPathに対する @dynamicMemberLookup

順にみていきます

@propertyDelegate

Proposal: SE-0258

(※ Proposalでは Property Wrappers という命名になっていますが、Xcode11Beta2上ではまだ @propertyDelegate が使用されているため本記事ではこちらで表記します)

この修飾子をつけるとプロパティに対して新しいattributeを宣言できるようになります

例えば次のような Lazy を宣言してみます

@propertyDelegate enum Lazy<Value> {
  case uninitialized(() -> Value)
  case initialized(Value)

  init(initialValue: @autoclosure @escaping () -> Value) {
    self = .uninitialized(initialValue)
  }

  var value: Value {
    mutating get {
      switch self {
      case .uninitialized(let initializer):
        let value = initializer()
        self = .initialized(value)
        return value
      case .initialized(let value):
        return value
      }
    }
    set {
      self = .initialized(newValue)
    }
  }
}

このような @propertyDelegate が宣言されているとき、次のようにその宣言された型の名前でattributeを宣言できるようになります

@Lazy var foo = 1738

これは実際のコンパイル時に以下のように展開されます(イメージのための疑似コードです)

var _foo: Lazy<Int> = Lazy<Int>(initialValue: 1738)
var foo: Int {
  get { return _foo.value }
  set { _foo.value = newValue }
}

foo へのアクセスが _foo に移譲される形となり暗黙に Lazy<Int> の機能を利用できるようになります。Lazy では遅延初期化の実装がされているため、lazy var と同じような機能が @Lazy をつけることによって利用できるようになりました

Property Delegateを使用したSwiftUIの型は多数存在しています。 例えば @State ではViewに使用される値の更新検知をしており、View.body の中で @State つきの変数にアクセスするとその変数の監視が始まり、その値が変化したときに自動的にViewが更新されるといった挙動をみせています

また $ をつけることによってラップしている本来の型のオブジェクトにアクセスすることができます

$foo // → Lazy<Int>

このとき、さらにラップしている型に var delegateValue: T { get } が定義されていれば delegateValue を取り出すことになります

例えば @State では var delegateValue: Binding<Value> { get } が定義されている*5ため、

@State var inputText: String

...

var body: some View {
  // ↓ TextField.init(_ text: Binding<String>) に対して
  //   $inputText.delegateValue: Binding<String> を $inputText という記法で取り出して渡している
  TextField($inputText)
}

次のようなコードがある場合に $inputTextBinding<String> を返します。 TextField は自身への入力を Binding<String> を経由して別のところへ渡すというインターフェースをしています

ややこしいですが、これによってSwiftUIはViewへのデータバインディングのためのプロパティの更新検知を実現しています

@_functionBuilder

Forum、 Proposal: SE-XXXX

VStack {
  Text("Hoge")
  Text("Fuga")
}

このコードを見たときSwiftのエンジニアは当然 🤔となると思います。クロージャが返り値を持っておらず、途中で評価しただけの Text が何らかの形でクロージャの外に現れています

VStackのイニシャライザ(一部省略)はこうなっていて

init(@ViewBuilder content: () -> Content)

なるほど怪しい @ViewBuilder が生えてることがわかります

これは新たに追加された @_functionBuilder による機能で、どこかに @_functionBuilder struct ViewBuilder {} が宣言されているときクロージャ引数に @ViewBuilder を付与できるようになり、そのクロージャの中で評価された式は ViewBuilder が持つ各種build関数の中を通って出力されます

例えば中で2つのViewが評価されていたときはViewBuilderの public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View 関数の中を通ります(1つ目に評価されたViewがc0、2つ目がc1として引数が与えられます)。結果、クロージャは TupleView という型のインスタンスを返します

Swiftにこのような言語機能が搭載されたことによって、Swiftの型検査を有効にしたままDSLが記述できるようになっています

Opaque Result Type

Proposal: SE-0244

これまで関数の返り値にprotocolを指定した場合はexistential type *6 にラップされて返され、ラップやアンラップの処理にオーバヘッドが発生していました。 またprotocolがassociated typeを持っていた場合はprotocolはGenericsの型パラメータを持てないので AnyHashable など型消去のテクニックを用いて返却する必要がありました。これは元の型の情報を失っているために本来比較可能でないもの同士を比較できてしまうなどのコードを記述できてしまいました

Swift5.1からその問題を解決するために、 "protocol P を満たすある一つの型" を返すという意味で some P という表現ができるようになりました。これによってPを満たす任意の型を実際の型を知らずとも扱えるようになりました

これがどのように作用しているかというと、 多くのSwiftUIの構造体は

struct Button<Label> where Label : View {

のようにGenericsでそのViewの内部にあるViewの型を指定して受け取ります。existentialはそれ自身のprotocolに適合しないのでexistential経由でGenericsへ型パラメータをわたすことはできず、この型パラメータのためにはconcreteな型を知る必要があります

ただしSwiftUIの型は非常に複雑で、例えば上の

VStack {
  Text("Hoge")
  Text("Fuga")
}

VStack<TupleView<(Text, Text)>> 型です。 これはまだマシですが、

List {
    Section {
        ForEach(names.identified(by: \.self)) { name in
            Text(name)
        }
    }
}

だと List<Never, Section<EmptyView, ForEach<IdentifierValuePairs<Array<String>, String>, Text>, EmptyView>> 型になります。 こんな複雑な型をいちいち返り値に記述することは人間には難しいですし、変更に弱すぎます

そこで、それらをまるごとひっくるめて some View として表現できるようになっています。 このような表現をSwift proposalでは、Opaque Result Typeと説明しています

var body: some View {
    List {
        Section {
            ForEach(names.identified(by: \.self)) { name in
                Text(name)
            }
        }
    }
}

実際のコードはこうなので、上のような複雑な型を書く必要がなくなっています。 この機能によって実装中に複雑な型の存在を意識せずともViewを取り扱えるようになっています

@_dynamicReplacement(for: )

Forum

これはXcodeでのプレビュー用に使われている属性で、dynamic 修飾子がついた関数などにこの属性がついたモジュールをロードしてあげるとその関数の実装を入れ替えることができるようになります。 SwiftUIのPreviewではこれを用いて実行中のシミュレータが持つバイナリの実装を動的に差し替えてリアルタイムなプレビューを実現しています

ちなみにこの挙動の存在は、Preview機能がクラッシュしたときのエラーログからXcodeがプレビュー対象のコードに @_dynamicReplacement(for: ) をつけて回っていてあとから差し替えてる様子が確認できたことから確認しました

さながらObjective-C時代のMethod Swizzlingですね

KeyPathに対する @dynamicMemberLookup

Proposal: SE-0252

@dynamicMemberLookup は以前からSwiftに実装されている機能ですが、今回新たにKeyPathに対してsubscriptできるようになりました。 具体的な定義はこんな感じです

// BindingConvertibleの例
subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Self.Value, Subject>) -> Binding<Subject> { get }

任意のKeyPathをdynamicMemberLookupできるようになったため、プロパティアクセスのふりをしつつ型安全にsubscriptでアクセスできるようになりました。 これが具体的にどういうことか、以下のコードをみてみましょう

@dynamicMemberLookup struct Box<T> {
    var value: T

    subscript<U>(dynamicMember keyPath: WritableKeyPath<T, U>) -> U {
        return value[keyPath: keyPath]
    }
}

struct User {
    var name: String = "taro"
    var age: Int = 42
}

let boxedUser = Box(value: User())
print(boxedUser.age) // → 42

boxedUser.age はいかにも Box に生えているように見えますが、実際にアクセスする先は User の持つ age となっています。 このようにして、 @dynamicMemberLookup を使用することで subscript で指定されてる型に適合するkeyPathを \.age などの記法を使わずに取り出してあたかもプロパティ呼び出しであるかのようにsubscriptに流し込んで呼び出せるようになっています

これはSwiftUIではprotocolの BindableObject で活用されており、

struct ViewModel: BindableObject {
  var name: String
  ...
}

@ObjectBinding var viewModel: ViewModel

として宣言されている viewModelに対して、

TextField($viewModel.name)

のように $viewModel.nameBinding<String> として取り出す操作を可能にしています。 (ObjectBindingのdelegateValueは ObjectBinding<BindableObjectType>.Wrapper 型であり、それはKeyPathのdynamicMemberLookupで Binding<T> を返す) 一見viewModelに生えてるStringのプロパティを取り出しているように見せかけてBindingを返せているのでpropertyDelegateの恩恵をぶら下がってるプロパティにも適用できるようになっています

まとめ

SwiftUIで使用されているSwift5.1で追加された新機能について調べてみました。 マイナーアップデートでありながら大胆な機能が多数追加されてコードの様子が一気に様変わりしましたね。見た目は大きく変わりつつも中身は型の効いてるSwiftらしさがあり挙動や実装を調査していくのはとても楽しいですね

クックパッドアプリ(iOS)は1年前のiOSバージョンまでサポートする運用をしており、なんとあと1年とちょっと待てばSwiftUIが実用段階になる予定です。 また新規アプリを作成する際は最初からSwiftUIでやっていけるかもしれません。 クックパッドではSwiftUIを使ってすばやくサービス開発していくエンジニアや、SwiftやXcodeに詳しく開発環境を改善していけるエンジニアを募集しています

https://info.cookpad.com/careers/

*1:正確にはXcode11&macOS Catalinaの機能

*2:Beta2時点

*3: 内部を HStack { Spacer(); content(); Spacer() } で囲む、GeometryReader でframe直打ちなどのやり方はありますがすっきりするものではありません

*4:longPressAction や DragGesture を使うという裏技もありますがすっきりするものではありません

*5:https://developer.apple.com/documentation/swiftui/state/3287851-delegatevalue

*6:https://blog.waft.me/2017/10/27/swift-type-system-08/ などで詳しく解説されています

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/