LoginSignup
68
48

More than 3 years have passed since last update.

動作原理から理解するSwiftUI

Last updated at Posted at 2020-07-29

前置き

  • まだ調べ中の部分があります。
  • 主にSwift UI 1の仕様で確認しているのでSwift UI 2だと違う部分があるかもしれないです。

SwiftUIの特徴

  • iOS 13から利用可能
  • UIKitの完全なリプレイス
    • Objective-Cとの決別
    • 今まで培ってきたUIKitの大部分の知見が失われたため、一から学ぶ必要があり学習コストが高い(UIViewControllerの階層構造の概念は変わっていないのでそういう部分はまだ活用できますが)。
  • MVCの廃止
    • SwiftUIのデータバインディング機構のおかげでUIViewControllerが不要になった
  • Storyboard, XibのXMLからプレビューのGUIを構築してUIの変更してXMLを書き換えていたのを、SwiftコードからプレビューのGUIを構築してUIを変更しSwiftコード自体を書き換えるように変わった
    • XMLのコンフリクト問題が解消!
  • Swift 5.1以前の言語仕様から見ると、いくつか魔法のような挙動がある
    • State, ObservedObject, @￰propertyWrapper, @￰_functionBuilder

役に立つ資料

以下は一通り目を通すことをおすすめします。

その他役立つ資料

SwiftUIの動作原理

検証環境

  • Xcode 11.5とXcode 12.0 beta3で検証

SwiftUIはどうやってUIの再レンダリングをコントロールしているか

  • StateObservedObject のみが値を変更したときに View の再レンダリングが行われる
    • 自前で定義する方法については後述
  • 値は View.body 内で変更しないと再レンダリングされない?
    • ドキュメント探し中

State

  • Stateをプロパティの属性として定義すると、そのプロパティが変更されたらViewのbodyが再計算される
struct ContentView: View {

  // @State属性のプロパティが変更されたらbodyを再生成している
  @State var flag = false

  var body: some View {
    print(#function)
    return List {
      Button(action: {
        self.flag.toggle()
      }) {
        Text("self.flagを変更: \(self.flag.description)")
      }
    }
  }
}
  • この挙動は以下のようにドキュメントに記載されている

State

SwiftUI manages the storage of any property you declare as a state. When the state value changes, the view invalidates its appearance and recomputes the body. Use the state as the single source of truth for a given view.

ObservableObjectとObservedObject

  • ObservableObjectプロトコルに準拠したクラス内の@￰Published属性があるプロパティが変更されると、そのクラスを@￰ObservedObject属性でプロパティを持つViewは変更のたびにbodyを再生成して画面を更新してくれるそのプロパティがバインディングされたSwiftUIのViewが更新される
final class Content: ObservableObject {

  // @Publshed属性はwillSetでobjectWillChangeのsend()メソッドを呼び出している
  @Published var flag = false

  // flagを@Published属性なしで実装すると以下
  var flag2 = false {
    willSet {
      print("manual objectWillChange.send()")
      // objectWillChangeはObservableObjectのメソッド
      // willSetのタイミングでsendしている
      objectWillChange.send() 
    }
  }
}
struct ContentView: View {

  // @ObservedObject属性はObservableObject.objectWillChangeを
  // sink (サブスクライブ)して、変更をreceiveしたタイミングでbodyを再生成している
  @ObservedObject var content = Content()
  private var cancellables = [AnyCancellable]()

  var body: some View {
    print(#function)
    return List {
      Button(action: {
        self.content.flag.toggle()
      }) {
        Text("@Publishedのflagを変更")
      }
      Button(action: {
        self.content.flag2.toggle()
      }) {
        Text("flag2を変更(@Publishedなし)")
      }
    }
  }

  init() {
    content
      .objectWillChange // objectWillChangeが呼び出されていることを確認
      .print("check")
      .sink(receiveValue: {})
      .store(in: &cancellables)
  }
}
  • この挙動は以下のようにドキュメントに記載されている

ObservableObject

By default an ObservableObject synthesizes an objectWillChange publisher that emits the changed value before any of its @Published properties changes.

ObservedObject

A property wrapper type that subscribes to an observable object and invalidates a view whenever the observable object changes.

  • @￰ObservedObject属性をつけられるプロパティはObservableObjectを準拠している必要がある(where ObjectType : ObservableObjectになっている)

自前実装でViewを再レンダリングをさせる方法

  • 現状は完全に独自実装でViewを再レンダリングさせることは不可で、StateとObservedObjectのみが値を変更したときにViewの再レンダリングが行われるので、それらをラップする方法で実現できる
  • Swift Forumsでも独自実装は不可と回答があった。
@propertyWrapper
struct Refreshing<Value> : DynamicProperty {
    let storage: State<Value>

    init(wrappedValue value: Value) {
        self.storage = State<Value>(initialValue: value)
    }

    public var wrappedValue: Value {
        get { storage.wrappedValue }

        nonmutating set { self.process(newValue) }
    }

    public var projectedValue: Binding<Value> {
        storage.projectedValue
    }

    private func process(_ value: Value) {
        // do some something here or in background queue
        DispatchQueue.main.async {
            self.storage.wrappedValue = value
        }
    }

}


struct TestPropertyWrapper: View {

    @Refreshing var counter: Int = 1
    var body: some View {
        VStack {
            Text("Value: \(counter)")
            Divider()
            Button("Increase") {
                self.counter += 1
            }
        }
    }
}

Is it correct to expect internal updates of a SwiftUI DynamicProperty property wrapper to trigger a view update?

bodyは変更のたびに再生成されるがパフォーマンス上の問題はないか

  • SwiftUIのレンダリングシステムが賢く差分でレンダリングしてくれるので問題なし

propertyWrapper宣言属性

  • propertyWrapperはDeclaration Attributes(宣言属性)
  • propertyWrapper宣言属性がある定義は wrappedValue というプロパティ名のgetterが必須になる(大抵はStored property)。
  • propertyWrapper宣言属性がある定義はプロパティの属性に定義可能で、暗黙的に以下の3つのプロパティに変換される
@State private var flag = false

↓ 変換

private var _flag: State<Bool> = State(initialValue: false)
private var $flag: Binding<Bool> { return _flag.projectedValue }
private var flag: Bool {
    get { return _flag.wrappedValue }
    nonmutating set { _flag.wrappedValue = newValue }
}

*※ Why I Can Mutate @State var? How Does @State Property Wrapper Work Inside?

@propertyWrapper
struct SomeWrapper {
  var wrappedValue: String
}

struct ContentView: View {
  @SomeWrapper var wrapper = ""

  init() {
    print(wrapper)
    print(_wrapper.wrappedValue) // wrapperと等価
  }
}

DynamicPropertyプロトコル

  • StateやObservedObjectはView.bodyが再生成されても前回のプロパティの値を保持している仕組みはDynamicPropertyプロトコルが処理している
  • View.bodyの再生成される直前にDynamicProperty.update()が呼ばれるので、そこで
@propertyWrapper
struct Wrapper: DynamicProperty {
  var wrappedValue: Int

  mutating func update() {
    // StateはどうにかしてwrappedValueに前回の値を入れている
  }
}

その他 Swift 5.1の機能

関数でのreturnを省略できるようになった

some 構文

// リバースジェネリクス(実際はこういう構文はないけど概念)
func makeAnimal() -> <A: Animal> A {
    return Cat()
}

// Opaque Result Type
func makeAnimal() -> some Animal {
    return Cat()
}

ViewBuilder

  • 以下のTextは複数ありreturnしてるわけでもないのに、なぜか複数のTextをVStackに渡せている
VStack {
  Text("Turtle Rock")  
  Text("Joshua Tree National Park")
}
  • VStackの定義は以下でクロージャーに@￰ViewBuilder属性がついている
@inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)
  • ViewBuilderの定義は以下で@￰_functionBuilder属性(宣言属性?)がついている
@_functionBuilder public struct ViewBuilder {

    /// Builds an empty view from an block containing no statements, `{ }`.
    public static func buildBlock() -> EmptyView

    /// Passes a single view written as a child view (e..g, `{ Text("Hello") }`) through
    /// unmodified.
    public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
}
  • @￰_functionBuilderはドキュメントに記載がない
  • @￰ViewBuilderは改行区切りの要素から、変数宣言と関数呼び出しを生成する
  • ViewBuilderの定義は @_functionBuilder public struct ViewBuilder
    • @￰_functionBuilderはクロージャーの中で複数の子Viewを生成できるパラメータ属性
    • ViewBuilderのextensionに1から10個までViewが渡せる関数が定義されている。以下は引数10個ある定義
public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View, C9 : View

State, ObservedObject, EnvironmentObject について

struct ContentView: View {
    @EnvironmentObject var settings: UserSettings

    var body: some View {
        NavigationView {
            VStack {
                // A button that writes to the environment settings
                Button(action: {
                    self.settings.score += 1
                }) {
                    Text("Increase Score")
                }

                NavigationLink(destination: DetailView()) {
                    Text("Show Detail View")
                }
            }
        }
    }
}

struct DetailView: View {
    @EnvironmentObject var settings: UserSettings

    var body: some View {
        // A text view that reads from the environment settings
        Text("Score: \(settings.score)")
    }
}

EinvironmentObjectとEnvironmentについて

  • スコープの違いがある
  • Environmentはグローバル
  • EinvironmentObjectはNavigationLinkでViewの子ならokだけど、sheetではアクセス不可なのでView階層内のみにスコープを限定
  • Environmentはアプリ全体で使う環境変数として使ったり、共通で使うService層のDIとして使うのがいいと思った。
protocol APIClientType {

  func request()
}

final class APIClient {}

extension APIClient: APIClientType {

  func request() {
    print(#function)
  }
}

struct APIClientKey: EnvironmentKey {

  static let defaultValue: APIClientType = APIClient()
}

extension EnvironmentValues {

  var apiClient: APIClientType {
    get { self[APIClientKey.self] }
    set { self[APIClientKey.self] = newValue }
  }
}
@Environment(\.apiClient) private var apiClient

@_dynamicReplacement(for: ) について

  • 調べ中

Appleが推奨するアーキテクチャはMVCから変わった?

data-flow.jpg

疑問

  • @￰Stateなどの変更がどういう仕組みでViewに対しての更新処理を行わせているのかが不明
    • ObservableObjectはobjectWillChangeがあるのまだ理解可能なのですが…
    • setNeedsBodyUpdateみたいなメソッドがあるのかなと想像(WKHostingControllerにはあります)

まとめ

  • Storyboard, XibのXMLベースでGUIからUIを構築していたものから、SwiftコードベースのGUIでUIを構築するものに変わった
  • CombineフレームワークのおかげでUIViewControllerが不要になった
  • Swift UIはまだ発展と途上っぽいので本格的に採用は難しそう
  • 現状はSwift UIよりCombineとApple純正Fluxに期待したい

参考

68
48
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
68
48