[Swift]Value-type なモデルを使った UNDO の実装(その1: LucidDreams を Xcode12 でコンパイル)

Apple の WWDC ビデオを見て、感化されたので、Value-type を意識したアプリ で UNDO を作ってみます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Big Sur 11.2.2
  • Xcode 12.4
  • iOS 14.4

Protocol and Value Oriented ビデオ

いまさらかもしれませんが、以下の2本のビデオは、必聴 です。

Protocol-Oriented Programming in Swift

Protocol and Value Oriented programming in UIKit Apps

余裕があれば、以下をみると、Swift が Value-oriented をどのようにサポートしてくれるかも理解が進みます。

Understanding Swift Performance

LucidDreams を動かしてみる

まずは、"Protocol and Value Oriented programming in UIKit Apps" で例題となっていたアプリを動かしてみようと思いました。

サンプルは、Xcode 8 で Swift 3 で書かれています。現在の Xcode12, Swift5 環境では、そのままではコンパイルできません。

Xcode10.1 を使うと、migrate できるとアドバイスが表示されるのですが、macOS Big Sur では、Xcode10.1 が動きません・・・・

ということで、手で直すしかありません。

SWIFT_VERSION を変更する

ダウンロードしてきたプロジェクトを、Xcode12 を使ってコンパイルしようとすると表示されるエラーは、1つだけでした。エラーとしては、「SWIFT_VERSION 3.0 は、サポートされていない 」でした。

Xcode12 は、Swift 4 以降しかサポートしていません。せっかくなので、最新の Swift5 を使うことにしました。

ターゲットの "Build Settings" で "Swift Compiler - Language - Swift Language Version" に Swift5 を選択します。

この設定をすることで、実際にコンパイルが開始され エラーは26に増えます。大抵は、コンパイラーが修正方法も提示してくれるので、それに従って直していきます。

以下がエラーメッセージとそれぞれの修正方法です。

  • 'UIEdgeInsetsInsetRect' has been replaced by instance method 'CGRect.inset(by:)'
  • 'UITableViewCellStyle' has been renamed to 'UITableViewCell.CellStyle'
  • 'NSFontAttributeName' has been renamed to 'NSAttributedString.Key.font'
  • 'UICollectionElementKindSectionHeader' has been renamed to 'UICollectionView.elementKindSectionHeader'
  • 'UITextFieldTextDidChange' has been renamed to 'UITextField.textDidChangeNotification'
  • 'UIBarButtonSystemItem' has been renamed to 'UIBarButtonItem.SystemItem'
  • 'UITableViewCellSelectionStyle' has been renamed to 'UITableViewCell.SelectionStyle'
  • 'UITableViewCellAccessoryType' has been renamed to 'UITableViewCell.AccessoryType'
修正した方法:FIX に従って修正(FIX ボタンを押すと修正されます)
Cannot infer contextual base in reference to member 'UITextField'
修正した方法:.UITextField.textDidChangeNotification -> UITextField.textDidChangeNotification に変更(最初のドットを削除)
Type of expression is ambiguous without more context
修正した方法:activityViewController.completionWithItemsHandler = { _ in -> activityViewController.completionWithItemsHandler = { _,_,_,_ in に変更
Overriding non-@objc declarations from extensions is not supported
修正した方法:SKNode+Layout.swift を以下のように修正(layaout の定義に @objc を追加)
SKNode+Layout.swift

extension SKNode: Layout {
    typealias Content = SKNode

    @objc func layout(in rect: CGRect) {
        // `SKNode` has a flipped coordinate system, so invert our Y coordinates.
        let height = parent?.frame.size.height ?? 0
        position = CGPoint(x: rect.midX, y: height - rect.midY)
    }

    var contents: [Content] {
        return [self]
    }
}

extension SKSpriteNode {
    @objc override func layout(in rect: CGRect) {
        super.layout(in: rect)

        /*
            `SKSpriteNode`s have a settable size, so we'll update the node's size
            in addition to it's `position` (which is done in `SKNode`'s `layout(in:)`
            method).
        */
        size = rect.size
    }
}

特に最後のエラーは、extension で メソッドのオーバーライドができなくなっていることから来る問題で、エラーとして表示される箇所を直すだけでは解決できないのでしばらく悩みました。

上記以外にも、メソッドが Deprecated になっているというワーニングがでますが、とりあえず、先に進みます。

value-semantics, protocol-oriented として着目する点

これで動くようになるので、以下の箇所を見ていくことにします。よくわからなれけば、コードを動かしてみることができるので 理解が進むはずです。

以下の点について、見てみます。

  • Model と State の整合方法
  • UNDO/REDO

Protocol-oriented としての layout メソッドについては、ビデオの中で十分説明されているので、特に見なくても良いしました。

LucidDreams で参考にすべき箇所:Model - State

ビデオの中でも、「モデルと状態の整合性を取るのは大変だから、このアプリの実装を参考にすると良いよ」的なことを言及していましたので、見てみます。

Model

使われている Model は "Dream" という struct です。Dream.swift というファイルで定義されていて、Model の詳細要素として、Creature と Effect という enum を定義しています。その他に、description 等の stored property も Dream という struct 内に保持しています。

この Model は、モデルの構成要素を定義しているものでした。アプリケーションで扱うまとまりあるモデルは、次の ViewControllerModel で定義されています。

ViewModel

ViewModel( と呼んで良いかと思います) も定義しています。DreamListViewControllerModel として定義されています。この中で具体的に、FavoriteCreature や 複数の Dream を保持しています。
DreamListViewControllerModel も struct で定義されています。

全て struct で構成されていて、完全に value-type な Model となっています。

State

State は、State (という名称の enum) として、DreamListViewController 内部で定義しています。

State は、あくまで View/ViewController の持つものなので、この設計は妥当だと思います。(なにも考えないと、State 情報が Model を侵食しそうなイメージがありますので、ViewController の内部型にするのは FoolProof を兼ねていて良い設計です)

Associated type を持つ enum 定義となっていて、状態として持つ必要のある付加情報も状態と合わせて持つようになっています。

実際の付加情報としては、選択中のインデックス情報を 状態"selecting" に付加して保持し、共有中の Dream を 状態"sharing" の付加情報として、保持しています。

状態"viewing" と 状態"duplicating" は、付加情報を持っていません。

Model/ViewModel と State の整合

どのような工夫がなされているかをまとめると以下になります。

「ViewModel の変更を1箇所にまとめ、その最後に、State との整合を取ることで、不整合の発生する余地をなくしています。」

以下に、変更と整合を取るコードを抜粋します。

examwithValuesple

    func withValues(_ mutations: (inout Model, inout State) -> Void) {
        // (1)
        let oldModel = self.model
        // (2)
        mutations(&self.model, &self.state)
        /*  The model and state changes can trigger table view updates so we'll
            wrap both calls in a begin/end updates call to the table view.    */
        tableView.beginUpdates()
        // (3)
        let modelDiff = oldModel.diffed(with: self.model)
        // (4)
        modelDidChange(diff: modelDiff)
        /*  We don't need to worry about the old state in this example. In your
            app you might need perform different operations based on a combination
            of your old / new state values, so you'd pass the old state as a parameter
            here (similar to the `modelDidChange(...)` approach).      */
        // (5)
        stateDidChange()
        tableView.endUpdates()
    }
withValues は、新しいモデルデータと状態情報を引数にとって、モデルを変更する関数です。

# tableView.beginUpdates()/endUpdates() は TableView のアップデート用です。

コード解説
  1. 変更前のデータを oldModel にバックアップしています。
  2. この mutations が実際に行いたい変更を実行する関数です。model と state を inout で渡して、変更できるようにしています。
  3. 変更前のデータと変更後のデータの差分を計算しています。
  4. modelDidChange で UI の更新をおこない、UNDO の操作を登録しています。
  5. この stateDidChange で model に応じた state に変更しています。(コメントにもありますが、直前の state が 遷移先の state に影響するならば、引数に過去の state を渡す必要がありま

ポイントは、「モデルへの変更は、1箇所を必ず経由しておこなうようにし、その最後で、state を設定/調整 する」ということのようです。

LucidDreams で参考にすべき箇所: UNDO

UNDO の実装についても、ぜひ参考にした方が良い 的な説明をしていましたので、見てみます。

先ほども見た、1つしかないモデルを変更する関数 withValues のなかでのみ undoManager に undo を登録するようになっています。

UndoManager に登録する undo 操作

通常は、モデルを操作する関数が、UndoManager の registerUndo に 自分の操作 を 元に戻す操作 を登録することで UNDO/REDO が実現されています。

例えば、「要素追加 操作」ならば、「要素削除 操作」を 「元に戻す操作」として登録します。

通常の方法と比較して、「UNDO に登録する操作」が大きく異なりました。

LucidDreams では、registerUndo に登録される関数は、withValues という関数だけです。

もともと withValues は、引数に 新しい model と 新しい state を受け取ります。「(Model 変更時にコピーしておいた)古い model を withValues に渡す」ことを undo 操作として登録します。

ビデオの中で、ざっくり説明していたとおり、「操作前のモデルをコピーしておいて、UNDO 時には戻す」 という UNDO になっています。

Swift が value-type のコピーについては、copy on write という扱いをするから できる実装方法です。

アプリとしては、変更途中で Model の Diff を計算するのですが、あくまで UI の更新のための Diff であって、UNDO には、使用しない形の実装になっています。

# そうはいっても、コピー時に工夫が入っていたりするかと想定していたのですが、普通の「=」を使用してコピーしています。

# Model が完全に value-semantics で構成されていると問題ありませんが、うっかり(?) reference-semantics のデータが入ってしまうと、謎のバグになりそうです。

まとめ:LucidDreams のポイント

LucidDreams のポイント
  • モデル操作箇所は1つにして、モデルと状態の不整合を防ぐ
  • Model を value-type で構築し、UNDO は、copy を使ってのシンプルな実装

次回以降で、LucidDreams からの気づきを使って、TODO アプリを改めて作ってみます。

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です