Pieces of Paper(個人開発のiOSアプリ)をFull SwiftUI化したときに大変だったことを書きます

2022/09/11に公開

この記事はiOSDC 2022に採択されなかったプロポーザルの供養記事です。

Pieces of Paperとは

個人開発のiOSアプリです。
iPadとApple Pencilで、紙とペンのような体験を実現しようみたいなコンセプトのノートアプリです。
OSSです。

(App Store)
https://apps.apple.com/app/pieces-of-paper/id1511690088

(Github)
https://github.com/0si43/PiecesOfPaper

もともとはStoryboard x UIKitで実装してました。
2021年末にFull SwiftUIで書き直しました。

Full SwiftUIとは

ここでの「Full SwiftUI」は、AppDelegate がなく、UIKitの利用はSwiftUIで実現できない要件に対してのみ、UIViewRepresentable でラップして使っている状態を指します。
Full SwiftUIという用語が一般的なのかもわかりませんが、iOSDC 2021の下記セッションから引用させてもらいました。

https://fortee.jp/iosdc-japan-2021/proposal/04c4b5bc-b2ed-46c5-bdf7-6fbe35e72c22

既存アプリをFull SwiftUIに移行する意味

今、新規でiOSアプリをつくるときに、Full SwiftUIでつくるのはアリだと思うんですが、移行に関しては正直そんなに意味がないと思ってます。
ただ今回僕の個人開発アプリだと、AppDelegate に書いてる機能がそんなになくて、簡単に移行できそうだったので、
せっかくSwiftUI導入するなら、とことんやっちゃうかあくらいの軽い気持ちでした。

参考にした本

記事を書いている途中で、僕が解説を書くのが厳しいときがあったので、SwiftUIをやる上で参考にした2冊を紹介します。

「SwiftUI 徹底入門」
https://amzn.to/3DgqIi0

2019/12発売なので、内容は若干古いところもあるんですが、
教科書としてよくできてるので、最初の一冊としてはいい気がします。

「1人でアプリを作る人を支えるSwiftUI開発レシピ」
https://amzn.to/3eGXAGs

徹底入門よりはTips集感が強い本で、上手くカバー範囲がズレてるので、補足的に使えました。
iOS 14での追加要素が詳しく書かれています。

解説が十分でないところについては、この2冊の参照箇所を示そうと思います。

UIKitからFull SwiftUIに移行する方法

単にUIKit製のViewをSwiftUIで書き直すと、最初に呼び出されるViewに対して、UIKitへのラップが必要になります。

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    window = UIWindow(frame: UIScreen.main.bounds)
    window?.rootViewController = UIHostingController(rootView: RootView())
    window?.makeKeyAndVisible()
    return true
}

これだと真の意味でSwiftUIベースのアプリとは言えませんね。
SwiftUI->UIKitへのアクセス(UIViewRepresentable)は、UIKitでないと実現できない機能がある現状だと仕方ありませんが、
UIKit->SwiftUI(UIHostingController)は、極力廃していきたいです。

iOS 14から、AppDelegate 部分をSwiftUIで書けるように、App というprotocolが追加されました。

@main
struct PiecesOfPaperApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
    }
}

これで仕組み上はSwiftUIだけでアプリがつくれる基盤ができました。
ただ現実的にはUIKitやUIKitベースのSDKを使わないと実現できない部分があるので、そこはラップして対応する必要があります。

詳しく知りたい方は「1人でアプリを作る人を支えるSwiftUI開発レシピ」の第5章を読んでください。

ここから実践する中で発生した色々な問題と対応

ここから実践する中で発生した色々な問題とどう対応したかについて書いていきます。
あくまで経験ベースなので、あまり体系的な書き方にならないのはご容赦ください。

UIKitベースのライブラリ(たとえばPencilKit)とのブリッジをどうするか問題

上述の通り、Full SwiftUIであっても、UIKitへのブリッジは残っています。
SwiftUIに対応してくれてるライブラリもあって、たとえばMapKitはSwiftUIのViewを提供しています。

https://developer.apple.com/documentation/mapkit/

ただSwiftUI対応してくれてるライブラリはメジャーどころだけです。

SwiftUIはDelegateが受け取れない!

Pieces of Paperは、PencilKitがメイン機能というか、PencilKitのラッパーアプリと言ってもいいので、PencilKitはめちゃ大事なライブラリです。
SwiftUI化の前には、CanvasViewControllerPKCanvasView を保持して、PencilKitの各種Delegateメソッドの中で処理を書いていました。

// MARK: PKCanvasViewDelegate
extension CanvasViewController: PKCanvasViewDelegate {
    func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
        // 描画が更新されたときにさせたい処理
    }
}

// MARK: UIPencilInteractionDelegate
extension CanvasViewController: UIPencilInteractionDelegate {
    func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
        // Apple Pencilの2回タップしたときのツール切り替え処理
    }
}

はじめは「単にPKCanvasViewUIViewRepresentable でラップするだけでしょ?」と思って、ガリガリ書き直していったんですが、一つの問題に気づきます。

「あれ……? これDelegate受ける場所なくね?」

SwiftUIのViewは構造体なので、PKCanvasViewDelegate が継承している NSObjectProtocol を満たしません。
なんらかのクラスベースのオブジェクトに継承させる必要があります。
じゃあViewModelかな、と継承させてみたんですが、エラーが出ます。

final class CanvasViewModel: ObservableObject {
    // …
}

はい。ViewModelはNSObjectを継承してませんでした。
今思うと、別にNSObjectを継承させる道もあったかとは思うんですが、当時の僕はちょっと混乱していたこともあり、
CanvasDelegateBridgeObject というDelegate受けるためだけの専用クラスを爆誕させました。

https://github.com/0si43/PiecesOfPaper/blob/3.0.0/PiecesOfPaper/View/Canvas/CanvasDelegateBridgeObject.swift

ちょっとこれについては自分でももっと上手い手があるだろうなと思いつつ、その場しのぎで書いたものなので、解説はしません。

Coordinatorを使おう

その後、もっといい方法はないか調べていくうちに、UIViewRepresentable に Coordinator という仕組みがあることに気づきました。
この Coordinator、存在は知ってたんですが、よく理解できていませんでした。

SwiftUIは常にstructの再作成で処理が行われます。
が、UIKitベースだと参照型な処理が必要になるときがあり、そのインターフェイスが Coordinatorです。
要はDelegate受けたいときの受ける場所ですね。
僕はわざわざ CanvasDelegateBridgeObject というクソクラスで自作しましたが、こっちを使うのが正しいです。

https://github.com/0si43/PiecesOfPaper/blob/main/PiecesOfPaper/View/Canvas/PKCanvasViewWrapper.swift

Coordinatorを使うと、↑こんな感じで書けます。
詳しい使い方は「SwiftUI 徹底入門」の12章か、クラスメソッドのこの記事かをご参照ください。

@ObservedObject の意図しない初期化

SwiftUIの中で参照型を使うときに苦しんだことはまだあります。
Pieces of Paperのノートリスト画面はこんなUIになっています。

View構成としては、ざっくりこんな感じです。

RootView: ただのView

SideBarList: List を表示している。iPadだとサイドバーになる。スクショ画面の左側

Notes: ノートリスト部分。スクショ画面の右側

(以下子コンポーネント)

ちょっと今見ると名前がわかりづらいんですが、Notes はViewです。
サイドバーで Inboxを選ぶと、Inboxのノート一覧を出します。
Trashを押すと、ゴミ箱の中にあるノート一覧を出します。

SwiftUI化の終盤になって、この切り替えで致命的な不具合があって、頭を抱えることになりました。
切り替え自体はできてるように見えたんですが、
既存ノートの編集後に戻ったときの更新が思うようにいかなかったり、たまに明らかにデータがあるのにNo Dataになったりしました。

サイドバーでもListはあくまでList

このバグを修正したdiffがこちらなんですが、ポイントは @StateObject でViewModelを保持するようにしたところです。

https://github.com/0si43/PiecesOfPaper/commit/6015bb4bbe94763922ab877d829bad9ec9e375a0#diff-cd07ed1f57e9583c6f419588ba540ae8f85aa71654e2e242b278eb8556f29998

この修正の前まで、僕は一度表示した Notes は、サイドバーの切り替えがあっても、アプリ上で保持されている、と誤解していました。
しかしあくまでListはListで、iPad上だとサイドバーっぽく振る舞っていますが、
サイドバーの選択は内部的には画面遷移していて、@Stateが指定されていないViewは破棄されています。

つまり、この修正を入れる前は、サイドバー上でノート一覧を切り替えるたびに、新たなViewModelが再作成され、再ロードに行っていました。
宣言的UIは再描画されるときに、変更のない要素については再作成を賢く省略する、という認識でいましたが、この場合はそうではありませんでした。
この場合、再作成する必要がないというのは僕の勝手な思い込みで、コンパイラ的には判断できないということでしょう。

@ObservedObjectvs@StateObject

@ObservedObject@StateObject も似たような属性で、Viewの初回表示のときは全く同じ挙動をします。
ただ二度目以降の表示で、@StateObject はインスタンスが引き継がれるという違いがあります。
つまりViewModelの初期化は @StateObject でやって、保持する必要があります。

破棄されると困るから全部 @StateObject にしよう、も間違えで、
親コンポーネントからViewModelの参照を受け取るようなViewは、@ObservedObject を指定してあげるのが適切と思われます。

更に混乱させると、@EnvironmentObject という似たような属性もありまして、ここの使い分けは、
デバッグフェーズに入ったときに致命的になる可能性があるので、実装の早めに小さく検証しながら進むことをお勧めします。
使いわけの詳細は、「1人でアプリを作る人を支えるSwiftUI開発レシピ」の6.8以降を読むか、著者の方のほぼ同内容のWebページを参照してください。

https://blog.personal-factory.com/2021/01/23/how-to-use-propertywrapper-in-swiftui/

画面遷移のハンドリングをどうするか

これはまだ僕の中でも答えが出てないんですが、SwiftUIでRouter書くのどうするの問題というのがあります。
UIKitでは、画面遷移のタイミングをコードで指定できました。
一方、SwiftUIはプッシュ遷移の場合、 NavigationLink を使う必要があります。
モーダル遷移はさらに厄介で、.sheet というモディファイアで指定します。

隠しNavigationLinkという手もあるみたいなんですが、うーん、という感じ。

https://blog.studysapuri.jp/entry/2021/09/18/iosdc-swiftui-navigationlink-push-navigation

実装当初は頑張って、Routerクラスっぽいのつくってたんですが、だんだんSwiftUIの仕組みにそぐわないので、

var showActivityView = false {
    willSet {
        objectWillChange.send()
    }
}

みたいなプロパティをViewModelに仕込んで、画面遷移させるようにするようになりました。
Pieces of Paperぐらいの小さなアプリならそんなに問題になりませんが、規模が大きいと、この辺はスパゲッティになるんじゃないかという懸念があります。

.alert モディファイアが1つしか指定できない

.sheet とかは複数指定が効きますが、.alert は2つ以上の指定がサポートされていません。
なので、何種類かアラート出したいときは、AlertType みたいなんつくって、ハンドルしてやる必要があります。

SwiftUIベースのプロジェクトにしたら表示がなんか変だった

上手く問題を書けないんですが、開発の初期に、サイドバーの表示がこんな感じになっていた時期がありました。

プロジェクトのInfoに、UILaunchScreen が抜けてると、こういう表示になるみたいでした。

VStackHStackView が10個まで

SwiftUIでスタックできるView には10個までという制限があります。
問題はエラーメッセージで、 Argument passed to call that takes no arguments というメッセージが出ます。
この制限を知ってても、実際にハマるときって10個以上の要素をスタックした複雑なものをつくろうとしてるときだと思うんで、実際に遭遇すると結びつかなくて焦りました。

Pull to refreshが書けない(~iOS 14)

iOS 15から refreshable(action:) が追加されて、書けるようになったんですが、iOS 14までだと自作する必要がありました。

https://developer.apple.com/documentation/swiftui/view/refreshable(action:)

そろそろiOS 16出るから、iOS 14切ってもいいですかね?

SwiftUIのググラビリティが終わってる問題

これは改めて書くまでもないかもですが、SwiftUI関連で検索かけようとするとマジで終わってます。
App とかView とかList とかなので。
なので、検索でなんとかするというよりは、本か公式ドキュメントのトップから下っていくかとかがいい気がしました。

それと、「カピ通信」には結構お世話になりました。

https://capibara1969.com/

まとめ

長くなったのでまとめると、Full SwiftUI化大変だったよ、という記事です。
つらつら書いてきましたが、大きく苦しんだポイントは3つです。

  • UIKitとのブリッジで、Delegate受けるためにCoordinatorを使うこと
  • @ObservedObject の仕様わかってなかったこと
  • 画面遷移の正解がわかってないこと

SwiftUI自体は慣れれば書きやすいと思いますが、意図しない再作成を食らうと、デバッグしてるとき絶望的な気持ちになる、というのを感じました。
もしこの記事が何かの参考になれば幸いです。
(了)

Discussion