こんにちは。会員事業部の岡村 (@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ではよくあるリスト形式の画面を作るだけでも TableViewDelegate
・TableViewDataSource
のメソッドを多数実装したり、ラベルを上下に並べるのに
label.constraint(equalTo:otherLabel.topAnchor, constant:16).isActive =true
などといった長いコードを書いていく必要がありましたが、SwiftUIではそれがすっきりして
List(contents) { content in
VStack {
Text(content.title)
Text(content.subtitle)
}
}
のような形でシュッと書けるようになりました 🎉
(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, *) structContentView:View { varbody:some View { Text("Hello World") } }
のように記述をすることでビルド及び実行することができるようになります(iOS13未満ではSwiftUIを使用できないため代わりの実装を用意する必要があります)
こちらの書き方を使用して既存プロジェクトからSwiftUIを利用する場合、Xcode11Beta2時点ではプレビュービルドはエラーとなり利用することができませんでした
- Objective-C製ライブラリ(Firebaseなど)を使ってるとたびたびそれらのビルドが走る
これはSwiftUIの問題ではなくビルドシステムの都合だと思うのですが、プレビューを使用する際は変更部分のみがリビルドされるはずがObjective-Cを利用している場合にそれらのビルドが走ることがあり、現実的な待ち時間でプレビューを使用することが難しいことがありました
- 新規プロジェクトでも急に止まったり調子悪くなったりしがち
こちらはシンプルにプレビューの描画が止まったり明らかにおかしくなったり、ビルドが長くなったりなどです
と、このような障害もあり、まだBetaであるため安定してないのはしょうがないですが、安定しないままリリースされる可能性も十分にあり得るため今のところプレビューはあくまで補助的なものと捉えています。
個人的にあるだろうと思った機能がないこともある
触っていくとだんだんと気づくのですが、Betaということもあって個人的に必要だと思った機能が実は存在していないといったケースがあります
コンポーネントはどんどん拡充されていくはずですので、リリース時点やその後のコンポーネントの拡充に期待です
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
を宣言してみます
@propertyDelegateenumLazy<Value> { case uninitialized(() ->Value) case initialized(Value) init(initialValue:@autoclosure@escaping () ->Value) { self= .uninitialized(initialValue) } varvalue:Value { mutatingget { switchself { case .uninitialized(letinitializer):letvalue= initializer() self= .initialized(value) return value case .initialized(letvalue):return value } } set { self= .initialized(newValue) } } }
このような @propertyDelegate
が宣言されているとき、次のようにその宣言された型の名前でattributeを宣言できるようになります
@Lazyvarfoo=1738
これは実際のコンパイル時に以下のように展開されます(イメージのための疑似コードです)
var_foo:Lazy<Int>= Lazy<Int>(initialValue:1738) varfoo: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ため、
@StatevarinputText:String...varbody:some View { // ↓ TextField.init(_ text: Binding<String>) に対して// $inputText.delegateValue: Binding<String> を $inputText という記法で取り出して渡している TextField($inputText) }
次のようなコードがある場合に $inputText
は Binding<String>
を返します。
TextField
は自身への入力を Binding<String>
を経由して別のところへ渡すというインターフェースをしています
ややこしいですが、これによってSwiftUIはViewへのデータバインディングのためのプロパティの更新検知を実現しています
@_functionBuilder
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の構造体は
structButton<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と説明しています
varbody:some View { List { Section { ForEach(names.identified(by: \.self)) { name in Text(name) } } } }
実際のコードはこうなので、上のような複雑な型を書く必要がなくなっています。 この機能によって実装中に複雑な型の存在を意識せずともViewを取り扱えるようになっています
@_dynamicReplacement(for: )
これは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でアクセスできるようになりました。 これが具体的にどういうことか、以下のコードをみてみましょう
@dynamicMemberLookupstructBox<T> { varvalue:Tsubscript<U>(dynamicMember keyPath:WritableKeyPath<T, U>) ->U { return value[keyPath:keyPath] } } structUser { varname:String="taro"varage:Int=42 } letboxedUser= Box(value:User()) print(boxedUser.age) // → 42
boxedUser.age
はいかにも Box
に生えているように見えますが、実際にアクセスする先は User
の持つ age
となっています。
このようにして、 @dynamicMemberLookup
を使用することで subscript
で指定されてる型に適合するkeyPathを \.age
などの記法を使わずに取り出してあたかもプロパティ呼び出しであるかのようにsubscriptに流し込んで呼び出せるようになっています
これはSwiftUIではprotocolの BindableObject
で活用されており、
structViewModel:BindableObject { varname:String... } @ObjectBindingvarviewModel:ViewModel
として宣言されている viewModel
に対して、
TextField($viewModel.name)
のように $viewModel.name
を Binding<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/などで詳しく解説されています