The Pragmatic Ball boy

iOSを中心にやってる万年球拾いの老害エンジニアメモ

2023振り返り

2023年の振り返りです。

仕事

アウトプット

会社ブログ

tech.connehito.com

tech.connehito.com

プライベート

超がんばってiPhoneアプリをリリースしました。星5お願いします!

apps.apple.com

Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考 https://www.amazon.co.jp/gp/product/B0BSW72QKZ

単体テストの考え方/使い方 https://www.amazon.co.jp/gp/product/B0BLTG8Z9K

アジャイルリーダーシップ 変化に適応するアジャイルな組織をつくる https://www.amazon.co.jp/gp/product/B0BT747481

一冊でマスター!Swift Concurrency入門 https://www.amazon.co.jp/gp/product/B0B76V1HNC

ちょうぜつソフトウェア設計入門――PHPで理解するオブジェクト指向の活用 https://www.amazon.co.jp/gp/product/B0BNH1J2W2

スタッフエンジニア マネジメントを超えるリーダーシップ https://www.amazon.co.jp/gp/product/B0C231J7FC

ユーザーの問題解決とプロダクトの成功を導く エンジニアのためのドキュメントライティング https://www.amazon.co.jp/gp/product/B0BXSYF2N4

プライベート

子供が7歳、3歳になりました。 小学校は宿題やらなんやらたいへんですね・・

スクラムガイド輪読会で捕捉したりしてること

この記事はコネヒトアドベントカレンダー22日目の記事です。

adventar.org

スクラムガイド輪読会をこれまで社内で何度となくやってきて、スクラムガイドを読み進める上で各章のポイントや捕捉していることについてまとめてみました。

スクラムガイドを読んでもなんだかちょっとわかんないなというスクラム初心者のかたなどの助けになると幸いです。

わたしは認定スクラムマスターではありますが2日研修受けてテスト通ったくらいのあれなのと、 個人的な見解も含んでますので、 もしここ間違ってるよみたいなところがもしあれば教えていただけると幸いです。

また、ここに書いてあることだけ読んでもなんだこれって感じだと思いますので、スクラムガイド2020年版の該当章読んでそれからみていただけるとよいかなと思っております。

スクラムの定義のところ

スクラムフレームワークかつ、意図的に不完全にしてあるのでこの通りにやればうまくいくわけではないです。 スクラムはまずは書いてある通りに試してみて、そこから自分たちにあった形に変えていくとよいです。

スクラムの理論のところ

スクラムの三本柱のところが一番重要なポイントで、ここだけは覚えておきましょう。 スクラムのイベントはスクラムの三本柱を実現するための手段なので、スクラムイベントをやることではなくこの3本柱を意識して、各イベントをすることが目的にならないように注意しましょう。

スクラムの価値基準のところ

スクラムを成功させるには各メンバーが5つの価値基準を実践できるかがポイントです。 例えば確約ができないと各スプリントでゴール達成できないですし、公開がされないと透明化されないので検査ができなくなってしまいます。 価値基準の実践に対して前向きになれない問題があればまずそれを対処していく必要があるかなと思います。

スクラムチームのところ

スクラムマスターが名前的にリーダーっぽいので勘違いする場合もありますが、スクラムマスターがやることを指示したり決めるのではなくスクラムチームが誰がなにをいつどのようにやるかを決定します。 ただ、全員スクラムはじめてで進め方全然わからんみたいなケースだとスクラムマスターの介入度は高くなるケースもあるかもですが、それはそれでOKでその状態をずっと続けず本来のあるべき姿に近づけていくと良いかなと思います。

スクラムイベント

スプリントのところ

スクラムの期間は一ヶ月以内と書いてありますが、最初のうちは短めにしておいたほうがよいと思います。最初のうちは失敗することも多いと思うので、スプリントが短いほうがより早く学びのサイクルをまわせます。はじめのうちは1週間とかでやってみるとよいのではと思っています。「決まった長さで」ってかいてあるから短いとダメなのではとつっこみがあるかもしれませんが、ずっと同じ期間でやらなければならないってわけではないと思っています。なぜ決まった長さとしているかは、スプリントの間隔がバラバラだと、スプリントの見積りで前回のベロシティが参考にならなかったり、毎スプリントで「今回のスプリントの長さどうする?」という余計な議論が必要になってくるなどがあるからかなと思うのです。なのでちゃんとした目的や必要性があればスプリントの期間をかえることは間違いではないと思います。いつもは2週間スプリントだけど、これからのプロダクトゴールは1週間でやったほうが短いスパンで改善サイクルを回してより不確実なことに対応できるみたいなケースもあるかもしれません。思考停止してルールに従うことは危険なので注意してください。

スプリントプランニングのところ

アジャイルだと計画たてないと思っている人もいますが、スプリント内の計画はちゃんと立てます。長期の計画は不確実性が高いので雑に見積りますが、スプリントは短いのである程度予測がたてられるはずです。 それを適当にやってしまうと妥当なスプリントゴールが立てられないので、スプリントゴールが目標として機能しなくなります。ゴールがゆるすぎるとそれにあわせてチームのパフォーマンスは落ちますし、ゴールがきつすぎると達成しないのが当たり前になる恐れがあります。 スプリントの計画をスプリントプランニングできちんとたてるには、プロダクトバックログを小さな作業アイテムに分割できるレベルに準備しておく必要があります。これはプロダクトバックログリファインメントを通してやっていきます。

デイリースクラムのところ

デイリースクラムは開発者のためのイベントです。プロダクトオーナーやスクラムマスターへのデイリー進捗共有会ではありません。 スプリントは短く予測性は高いとはいえ、うまく進まないことが発生するのが常です。それに対して日々チームでどう対処していくか検討する場です。 問題が発生した場合は次のデイリースクラムで話そうと問題解決を先送りしないでください。デイリースクラムが唯一のコミュニケーションの場ではないので、開発者は一日を通して頻繁に話し合ってください。

スプリントレビューのところ

ステークホルダーへの成果物のプレゼンや報告会ではありません。成果物を検査して、今後どうしていくかを決める場です。

スプリントレトロスペクティブのところ

スプリント内で問題や課題を見つけてもスプリントレトロスペクティブまで先送りしないでください。その場やデイリースクラムでチームに共有しましょう。 たくさんやりたい改善がでるかもしれませんが、最初のうちは一番重要なものに絞ってそれを確実にやりきりましょう。他の問題は放置するわけではなく、重要な問題であれば再度スプリントレトロスペクティブで問題として浮上してくるはずです。 スプリントレトロスペクティブで全然課題がでてこない場合は、透明性や検査ができていない可能性があるので、そのあたりに課題がないかチェックしてみるとよいです。

スクラムの成果物

プロダクトバックログのところ

スプリントプランニングのところでも説明しましたが、プロダクトバックログの優先順位が高いものはスプリントプランニングできる状態に準備しておく必要があります。

プロダクトゴールはプロダクトバックログ単体だとこれなんのためにやってるんだっけみたいなことになるのを防ぐためのものと思っておくとわかりやすいかもです。プロダクトバックログを作る際も、これやることでプロダクトゴール達成できるんだっけ?みたいな視点を持つことも重要です。

スプリントバックログのところ

スプリントバックログは一日以内で終わる単位くらいのサイズにしておく必要があります。小さいスプリントバックログであれば前日と比べてなにかしら動きが発生するはずなのでデイリースクラムでその動きをみることで検査ができますが、大きいスプロントバックログだと前日と動きがなかった場合それが普通なのか異常なのかの検査が簡単ではありません。

インクリメントのところ

インクリメントってあまりしっくりこないワードだと思いますが、ユーザーにとって価値のあるリリース可能な機能や機能改善といった感じです。 リリース可能とは完成の定義を満たしていることです。完成の定義が曖昧だと開発者はここまでやれば完成だと思っていたがプロダクトオーナーの認識は違っていたみたいなことが起こりリリースできる状態になかったみたいなことが起こる可能性があります。

最後に

スクラムガイドは何年かに一回内容がアップデートされているので、改定されたら目を通すことをおすすめします。

最後に一番気をつけてほしいことはスクラムガイドに書いてあることを守ることが第一ではないということです。スクラムガイドであってスクラムルールではありません。思考停止してガイドや本に書いてあることに従うのではなく、どういう目的でやっているかということを忘れないでほしいです。スクラムをすることが目的にならないように注意してください。

Phrasieを支える技術

個人開発で2023年5月末にiOSアプリをリリースしました!

apps.apple.com

有料アプリなので課金はしなくてもよいですが(してもいいですよ!)、星5をなにとぞお願いします!!!


どんなアプリかというと、英語勉強を支援するアプリです。 機能としては、日本語で日記を書き、それを英語に自動で翻訳し、翻訳された英語の発音を聞いてシャドーイングの練習をするというアプリとなっています。

これまで個人開発では無料のアプリをつくってきましたが、今回は有料のAPIを用いているというのもあり個人開発では初の月額課金のアプリとしています。

使っている技術

  • フルSwiftUI
    • 個人開発ということでサポートOSを最新のみとしてSwiftUIをフル活用できるようにしました
  • Firestore
    • データはFirestoreで永続化しています。最初はお金がかからないのでCoreDataにしようかと思ったのですが、APIがアレなので 有料サービスということもあり機種変時のデータ移行とかもちゃんとできないといけなかったりするので、そのへんのテストはCoreDataだと確認が結構めんどくさいなということでFirestoreにしました。(リリースしたのがiOS17が発表される前だったのでもう少し遅ければSwiftDataを使ってみたさで意思決定は変わっていたかもしれません。。)
    • ちなみにRealmはアップデートが頻繁すぎて個人開発で最新追従し続けるのがたいへんなので選択肢からははずしています。
  • FirebaseAuthentication
    • データの引き継ぎのためにアカウントと紐付ける必要がありFireStoreと同様にFirebaseのFirebaseAuthenticationを利用しました。
  • RevenueCat
    • 課金周りは自分でサーバー側やるのはたいへんなのでRevenueCatを利用しました。
  • 英語周りのところ
    • こちらに関しては企業秘密ということで・・

ハマりどころ

AppStoreでのリジェクト

その1

"次のサブスクリプション期間の支払いが自動的に開始されることを明確にしていません" と怒られました。結構ちゃんと書いてたつもりでしたが完璧は難しいですね・・

We noticed that one or more of your auto-renewable subscriptions is marketed in the purchase flow in a manner that may mislead or confuse users about the subscription terms or pricing. Specifically: 

- Your app offers a free trial or introductory period but does not make it clear that a payment will be automatically initiated for the next subscription period.

Next Steps

To resolve this issue, please revise your auto-renewable subscription purchase flow to clearly indicate how long the free trial lasts and the amount that will be billed after the free trial is over. 

その2

App Store Connectのプライバシーでユーザー追跡をしているにチェックを入れていたら、AppTrackingTransparencyのポップアップの表示が必須ということでした。

The app privacy information you provided in App Store Connect indicates you collect data in order to track the user, including Product Interaction, Other Usage Data, Customer Support, Search History, Browsing History, User ID, Purchase History, and Crash Data. However, you do not use App Tracking Transparency to request the user's permission before tracking their activity.

Starting with iOS 14.5, apps on the App Store need to receive the user’s permission through the AppTrackingTransparency framework before collecting data used to track them. This requirement protects the privacy of App Store users.

その3

スクショにアプリの画面入れるのが面倒だったので、最初はなくていいかーと思ってたら、ダメ出しされました・・

We noticed that your screenshots do not sufficiently show your app in use. Specifically, your screenshots do not show the actual app in use in the majority of the screenshots.

To help users understand your app’s functionality and value, your screenshots should highlight your app's core concept. For example, a gaming app should feature screenshots that capture actual gameplay within the app.

Next Steps

Please revise your screenshots to ensure that they accurately reflect the app in use on the supported devices.

Keep in mind the following requirements: 

- Marketing or promotional materials that do not reflect the UI of the app are not appropriate for screenshots.
- The majority of the screenshots should highlight your app's main features and functionality.
- Confirm that your app looks and behaves identically in all languages and on all supported devices.
- Make sure that the screenshots show your app in use on the correct device. For example, iPhone screenshots should be taken on iPhone, not on iPad. 

その4

AppleIDログインを必須にしていたらリジェクトされました。 新規登録時には登録不要にしてあとからAppleID連携できるように変更しました。ただ、AppleID連携は機種変時のアカウント移行をトラブルなく行うために必要不可欠にしたかったのでこのリジェクトは結構痛かったです。

We noticed that your app requires users to register with personal information to purchase in-app purchase products that are not account based. 

Apps cannot require user registration prior to allowing access to app content and features that are not associated specifically to the user. User registration that requires the sharing of personal information must be optional or tied to account-specific functionality.

Next Steps

To resolve this issue, please revise your app to not require users to register before purchasing in-app purchase products that are not account based. You may explain to the user that registering will enable them to access the purchased content from any of their iOS devices and provide them a way to register at any time, if they wish to later extend access to additional devices.

Please note that although App Store Review Guideline 3.1.2 requires an app to make subscription content available to all the iOS devices owned by a single user, it is not appropriate to force user registration to meet this requirement; such user registration must be optional.

ここについては初回起動時はFirebase Authenticationの匿名ログインを使ってユーザーを作成し、アカウント移行のために別途AppleIDログインを用意することで対応しました。

実装についてはこちらに記事に書きました。

qiita.com

技術的なところ

Firestore

ちょっとDB設計に戸惑いました。

今回の場合、各ユーザーが作成したユーザーのみが閲覧できる日記データを持つといった構造になります。

RDBの場合だと雑に考えるとdiaryテーブルとuserテーブルをつくってdiaryのカラムにuser_idもたせるみたいな感じになるかなと思います。

Firestoreの場合セキュリティルールで自分に関するデータしか見れないようにしたりしたいので、

- users
  - userID
    - diaries(サブコレクション)

このようなデータスキームにすることで、ユーザーIDに対しそのユーザーの日記のみを紐付け、必要な日記データのみを取ってこれるようにしました。Firestoreは読み取り、書き込み、削除したドキュメントの数で料金がかかってくるので、無駄な読み込みが減りコスト削減にもなっているかなと思います。

日記数も、countをつかうとデータを全部舐める必要が出てくると思うので日記数も保存するようにするなどといったこともやりました。

RevenueCat

RevenueCatはものの数十行で課金処理ができちゃうのでめちゃくちゃ便利でした。 実装自体はすごい簡単なのですが、どちらかというと設定のほうに苦労しました。 というのもRevenueCatはiOS/Androidといったプラットフォームによる課金の違いを別の概念で抽象化して表現しているので、その概念を理解するのにちょっと戸惑いました。

その他

アプリを公開するにあたって、プライバシーポリシーや利用規約などを用意しなければならず、これらのWebページをどうやって用意するかがちょっとこれまでは面倒でした。 今回Notionを使って試しにやってみたところ、普通に審査でも突っ込まれず、実装や運用コストもかからずめっちゃ楽で最高でした。

失敗したなというところ

Firestoreで検索ができない

like検索くらいはできるだろうと思っていたら、あとから検索機能をつけようとしたらないことが判明して結構困っております。。 ElasticやAlgoliaなどの3rd partyのサービスと連携すればできるみたいですが、ちょっと個人開発だと財政面できびしそうです

firebase.google.com

ローカライズ

海外展開すれば人口で単純計算するとダウンロード数めっちゃ増えるだろうと思って頑張って韓国語、中国語対応もいれたのですが、びっくりするほどダウンロードされていない、というか閲覧すらされていないので全然コスパよくなかったです。。 単純に翻訳しただけでは駄目という学び。

最後に

今回はじめて無料ではなく有料アプリをリリースしてみました。 幸い数名の方にご利用いただきとてもありがたいです。 今後も少しずつ改善していきたいなと思っています!

UIViewControllerがMainActorだから安心できるわけではない

前回の記事でUIViewControllerとMainActorのことについてかきました。UIViewControllerはMainActorなのでMainスレッドで実行されることは保証されていて安心!かと思いきやそうでもなかったのです。

以下のMainActorであるViewModelをMainActorではないクラスから呼び出そうとするとコンパイル時にエラーになります。MainActorを使うことでこのようにコンパイル時にチェックされて安全です。

class
 Good {
    let viewModel = ViewModel() // Error: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
}

@MainActor class ViewModel {
    func hello() {
        print("hello: \(Thread.current.description)")
    }
}

UIViewControllerもMainActorなので上と同様なことをしてみます。 するとどうでしょうコンパイルに通ってしまいます。

class Bad {
    let vc = UIViewController() // エラーにならない
}

このBadというクラスをMainスレッド以外で生成するとクラッシュしてしまいます。

どうしてこうなっているかというとSwift5ではSwiftConcurrencyのSendableのチェックがゆるくなっているためです。 これは、SwiftConcurrencyに対応していないモジュールとのやりとりを楽にするためにSendableを完全に強制していないからです。 Swift5, 6でのConcurrencyの違いはこちらの Concurrency in Swift 5 and 6 などを見てみると思想を知ることができます。

Swift5系のXcodeでは、BuildSettingの Strict Concurrency Checking はデフォルトでMinimalになっています。 これをCompleteに変えると先程のBadというクラスはコンパイルエラーになるようになります。

なので Strict Concurrency Checking はCompleteにしておいたほうがより安全です。 まだかなり先になると思いますがSwift6ではCompleteになるようですので対応できるなら先に対応しておいたほうが後々のSwift6への移行も楽になるかと思います。

ただ既存のコードだと利用しているライブラリも直さなければならなかったりとかなり大変だったりするので、既存のプロジェクトはTargetedからはじめ、新規プロジェクトでは最初からCompleteにしてやるなどするとよいかなと思います。

UIViewControllerからMainActorを呼ぶときにawaitしなくてよい理由

SwiftConcurrencyでは普通actorのメソッドを呼んだりするときはawaitしなければならないですが、UIViewControllerからMainActorのメソッドを呼ぶときはawaitせずに普通に呼べてしまいます。

これはなぜかというとUIViewControllerもMainActorだからです。

UIViewControllerの定義を見ると以下のようになっています。

@MainActor open class UIViewController : UIResponder, NSCoding, UIAppearanceContainer, UITraitEnvironment, UIContentContainer, UIFocusEnvironment {

MainActorとは

MainActorはGlobalActorの一種です。 GlobalActorは普通のActorだとそのActor内でしかisolatedな状態にできませんが、GlobalActorを使うことでその範囲を外部に広げることができるようになります。

このように別のactor間だとそれぞれでactor isolatedされてるのでお互いを呼ぶときはawaitしなければなりません。

actor A {
    func execA() {
        Task {
            let b = B()
            await b.execB()
        }
    }
}

actor B {
    func execB() {

    }
}

ここでGlobalActorをA,Bに適用するとこれらを同じActor isolatedな状態にできるため、awaitを使わずに呼び出すことが可能となります。

@MyGlobalActor
struct A {
    func execA() {
        let b = B()
        b.execB()
    }
}

@MyGlobalActor
struct B {
    func execB() {

    }
}

@globalActor final public actor MyActor : GlobalActor {
    public static let shared: MyActor
}

さらにMainActorはメインスレッドのみで実行されるという特徴を持ちます。UI関連の処理を行う場合はMainActorを使うと便利です。

MainActorの実装について

ところで、MainActorはこのようにGlobalActorとして定義してやるだけでつくれるでしょうか?

@globalActor final public actor MyActor : GlobalActor {
    public static let shared: MyActor
}

これだけではMainActorの重要な要素である メインスレッドで実行する が適用されません。

MainActorではメインスレッドでの実行保証をどうやって実現しているのでしょうか?

実行スレッドの固定は CustomExecutor によって実現されています。

GlobalActorはactor上でメソッドなどが実行されるときにどのように実行されるかをカスタムできるようになっています。

このようにMainActorではenqueue(job:)でジョブがキューイングされたら順番にメインスレッドで走るようなCustomExecutorを実現しているのでメインスレッドで動く様になっていると思われます。

@globalActor public actor MyMainActor: GlobalActor {
    public static var shared = MyMainActor()

    nonisolated let executor: SerialExecutor
    nonisolated public let unownedExecutor: UnownedSerialExecutor

    init() {
        let executor = MainExecutor()
        self.executor = executor
        unownedExecutor = executor.asUnownedSerialExecutor()
    }
}

final class MainExecutor: SerialExecutor {
    func asUnownedSerialExecutor() -> UnownedSerialExecutor {
        UnownedSerialExecutor(ordinary: self)
    }

    func enqueue(_ job: UnownedJob) {
        DispatchQueue.main.async {
            job._runSynchronously(on: self.asUnownedSerialExecutor())
        }
    }
}

参考: How @MainActor works

MainActorの場合はこのようにメインスレッドで実行されますが、普通のGlobalActorはデフォルトのSerialExecutorによって順次実行されます。このSerialExecutorはSerial Dispatch Queueのときと同様で順次実行はされますが同一のスレッドで実行されるわけではないということは頭に入れておいたほうが良いかなと思います。(同一スレッドでの実行を前提としたコードを書かない) どうしてもMainActorみたいな同一スレッドを保証したGlobalActorを作りたい場合はNSThreadとかをつかって固定スレッドになるようにSerialExecutorを実装すればできると思いますが、固定スレッドをつかってしまうとSwiftConcurrencyのCPUに対して1スレッドが割り当ててスレッドの切り替えを少なくするという特性を阻害することになると思うのでなるべくやめといたほうが良いかと思います。

SwiftUIのモーダルの表示・非表示

※ iOS16でのやり方です

モーダルの非表示

説明の都合上、先に非表示(モーダルを閉じるとき)についてです。

モーダルを閉じる場合はこのようにEnvironmentのdismissを使って画面を閉じます。

// モーダルで表示する画面
struct ModalContents: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Button("Done") {
            dismiss()
        }
    }
}

このコードを最初見たときに違和感感じませんでしたか?わたしは感じました。

dismissってぱっと見valueっぽいですがdismiss()で呼んでいるのでclosureなのでしょうか。

dismissの正体

EnvironmentValuesにdismissは定義してあり、型は DismissAction でした

public var dismiss: DismissAction { get }

DismissAction はなにかというとstructです。

struct DismissAction

https://developer.apple.com/documentation/swiftui/dismissaction

structなのになぜ関数のように呼ぶことができるかというとcallAsFunction()が定義されているからです。 https://developer.apple.com/documentation/swiftui/dismissaction/callasfunction()

つまり、dismiss()とかくと暗黙的にdismissのcallAsFunction()を呼んでいることになるのです。

struct ModalContents: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Button("Done") {
            dismiss() // Implicitly calls dismiss.callAsFunction()
        }
    }
}

DismissActionのcallAsFunction()内で画面を閉じる処理が実行されていると考えられます。

モーダルの表示

モーダルを画面を開く場合は、 完全に全画面の場合fullScreenCover, 完全に全画面ではなく上にちょっと浮いた感じにする場合はsheetをを使います。

https://developer.apple.com/documentation/swiftui/view/fullscreencover(ispresented:ondismiss:content:) https://developer.apple.com/documentation/swiftui/view/sheet(ispresented:ondismiss:content:)

struct ContentView: View {
    @State private var isFullScreenCoverViewPresented = false

    var body: some View {
        NavigationStack {
            Group {
                    Button {
                        isFullScreenCoverViewPresented = true
                    } label: {
                        Text("+")
                    }
            }
            .fullScreenCover(isPresented: $isFullScreenCoverViewPresented) {
                ModalContents()
            }
        }
    }

モーダルで開いた画面のナビゲーションバーに閉じるボタンをつける

モーダルで表示する画面側をNavigationStackでwrapしてToolbarItemを追加します。

struct ModalContents: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
          Group {
          }
          .toolbar {
              ToolbarItem(placement: .navigationBarLeading) {
                  Button {
                      dismiss()
                  } label: {
                      Label("Close", systemImage: "xmark")
                  }
              }
          }
      }
    }
}

同じような画面が何個もある場合はこういったContainerViewを用意しておくと ModalView { ModalContents() } とするだけで閉じるボタンが追加できるので便利かもしれません。

struct ModalView<Content: View>: View {
    @Environment(\.dismiss) var dismiss

    var content: () -> Content

    var body: some View {
        NavigationStack {
            content()
                .toolbar {
                    ToolbarItem(placement: .navigationBarLeading) {
                        Button {
                            dismiss()
                        } label: {
                            Label("Close", systemImage: "xmark")
                        }
                    }
                }
        }
    }
}

2022振り返り

2022年の振り返りです。

年末に下の子が胃腸炎になりその後家族全滅する事態となり正月過ぎての振り返りとなってしまいました・・

仕事

アウトプット

会社ブログ

ちょっと油断していたところだいぶ世間から遅れてきてそうだったので、がんばって負債の返済やモダン化を進めました。

tech.connehito.com

tech.connehito.com

tech.connehito.com

tech.connehito.com

マネジメントに関する良い本もどんどん出版されていてとても勉強になるのでありがたいです。

新一分間マネージャー https://www.amazon.co.jp/gp/product/B0113I0NNQ

Clean Craftmanship https://www.amazon.co.jp/gp/product/B0B9LPZ4R7

ソフトウェアアーキテクチャの基礎 https://www.oreilly.co.jp/books/9784873119823/

並行プログラミング入門 https://www.oreilly.co.jp/books/9784873119595/

リーダーの作法 https://www.oreilly.co.jp/books/9784873119892/

エンジニアリングマネージャーのしごと https://www.oreilly.co.jp/books/9784873119946/

プライベート

子供が6歳、2歳になりました。来年から小学生はやいですね。 全然子育て楽になる気配がありません。

GitHub Actionsを自作している人はwarningに注意

GitHub Actionsは実行に成功していても、密かにwarningが出ていたりするので、GitHub Actionsを自作している人は気をつけましょう、将来的に急に動かなくなります。

わたしの自作のGitHub Actionsでは以下のようなwarningが出ていました。

The `set-output` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/
Node.js 12 actions are deprecated. For more information see: https://github.blog/changelog/2022-09-22-github-actions-all-actions-will-begin-running-on-node16-instead-of-node12/. Please update the following actions to use Node.js 16

set-output command is deprecated

https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/

私の場合は@actions/core のsetOutputを使っていたためこのwarningが発生していました。

対応としては @actions/core をv1.10.0以上にすればOKです。

Node.js 12 actions are deprecated

https://github.blog/changelog/2022-09-22-github-actions-all-actions-will-begin-running-on-node16-instead-of-node12/

こちらについてはactions.ymlで using: "node12" となっているところをnode16に変えるだけでOKでしたが、Nodeのバージョンに依存するようなコードがある場合は修正が必要になります。

まとめ

割と簡単に対応できるのでOSSで見つけた場合はさらっと対応するとコントリビュートチャンスかなと思います

Xcode Cloud所感

Xcode CloudがXcode 13.4.1から使えるようになったので試してみました。

Xcodeの左のナビゲーションの一番右の Report navigator もcloudタブからワークフローを作ることができるようになっています。

ここからXcode上でポチポチしていくだけで、ワークフローの設定だけでなく、リポジトリとの認証、AppStoreConnectでアプリの追加までも行うことができます。

ただAppStoreConnectと密結合されているのでアプリではなくSwiftのライブラリの開発でXcode Cloudを利用することはできません。

Xcodeでできるワークフローの設定

Environment

Environmentではビルド環境の設定ができます。変更できる設定は以下です。

  • Xcodeバージョン
    • 利用するXcodeのバージョンが指定できます
    • beta版などもすぐに使えるようになっているようです
  • macOS Version
    • 利用するmacOSのバージョンが指定できます
  • Clean
    • cleanにチェックをいれるとderived dataなどのキャッシュが使われません
    • archiveする際にはこの設定は必須になります
  • Environment Variable

Start Conditions

Start Conditionsではワークフローを発火するトリガーを設定できます。 Start Conditionsは以下の4種類があります

  • Branch Changes
    • ブランチに変更があった場合に発火
    • 特定のブランチを指定することもできます
    • 特定の文字から始まるブランチも指定できます(branches beginning with "release-" など)
    • 発火する条件にフォルダやファイルを指定することができます
  • Pull Request Changes
    • Pull Requestが作成された場合に発火
    • 指定できる条件はBranch Changesと同じもの、プラス targetブランチが指定できます
  • Tag Changes
    • タグに変更があった場合に発火
    • 特定のタグを指定することもできます
    • 特定の文字から始まるタグも指定できます(tags beginning with "v" など)
    • 発火する条件にフォルダやファイルを指定することができます
  • On a Schedule for a Branch
    • 設定したスケジュールで特定のブランチで発火
    • cloneみたいな感じで設定できます。

Actions

Actionsはワークフローで実行されるものです。Actionsには以下の4種類があります。

  • Build
    • アプリをビルドします。Schemeを選んだり、ビルド対象をデバイスかシミュレーターか選んだりできます
  • Test
    • テストを実行します。
    • ビルドと大体同じなのですが、テストを実行するデバイスを選べます。複数選ぶと並行に複数環境でテストすることが可能です。
  • Analyze
    • メモリリークなどの問題を発見することができます。やっていることはxcodebuild analyzeと同じです。
  • Archive
    • アプリをアーカイブします。
    • アーカイブするだけでなく、TestFlightやAppStoreConnectにアップロードすることができます。

Post-Actions

Post-ActionsではActions実行後に行うActionを設定できます。Post-Actionsには以下の3種類があります

  • TestFlight Internal Testing
    • Internal TesterにTestFlightで配布
  • TestFlight External Testing
    • External TesterにTestFlightで配布
  • Notify
    • ワークフローが成功や失敗した場合にslackやemailで通知することができます

このようにワークフローのトリガー、Action、Post-Actionをポチポチするだけで簡単に設定することが可能となっています。 ただしこれらの設定は設定ファイルに残るわけではなくXcode Cloud側で保持されています(コードで管理できない)

ポチポチするだけではできないこと

ビルドの事前準備

2022/9時点ではXcode Cloud上で提供されているツールは、macOSやXcodeに含まれているものとHomebrewだけになります。

よって、例えば外部ライブラリを利用するのにCocoaPodsやCarthageが必要であったり、XcodeGenをつかってプロジェクトを生成したりする場合にはこれらをインストールする必要があります。

CocoaPodsやCathageなどのツールのインストール方法

  1. Xcode Projectやworkspaceと同じ階層に ci_scripts という名前のディレクトリを作成
  2. ci_scriptsの中に ci_post_clone.sh ファイルを作成し、このファイルにCocoaPodsやCathageのインストールスクリプトを記載します
## ci_post_clone.sh

#!/bin/sh

brew install cocoapods
pod install

ci_post_clone.sh以外にも、ci_pre_xcodebuild.sh, ci_post_xcodebuild.shも配置可能です。それぞれxcodebuildの実行前後にやりたいことがあればここにスクリプトを記載すれば実現可能です。

まとめ

これまではiOSアプリのCI/CD導入には証明書の取り扱い、xcodebuildやAppStoreConnect API, fastlaneなどといった知識が必要で導入にハードルがありましたが、Xcode CloudによってCI/CDの導入ハードルは非常に下がり誰でも導入しやすくなったことが大きいと思います。

逆に既にCI/CD導入済みの場合は現時点では不満に思えるところは少なからずあるように思います。個人的には、設定をコードで管理できない点とderived data以外のキャッシュがきかない(CocoaPodsなどのtool系のビルドが毎回必要)という点のデメリットが大きいなと思っています。

個人的には

  • 現時点でCI/CD環境が整っている場合
    • 現状維持でXcode Cloudに乗り換える必要はなく、上記問題が解消されたら移行を検討
    • SwiftPackageManagerでビルドするだけのシンプルなプロジェクトの場合は移行してもよさそう
    • 利用頻度の低いアーカイブ系だけ移行してみる
  • 現時点でCI/CD環境がなにもできていない場合
    • SwiftPackageManagerでビルドするだけのシンプルなプロジェクトの場合
    • SwiftPackageManagerでビルドするだけのシンプルなプロジェクトじゃない場合
      • CI/CDを導入するコストがあまりかけられない場合はXcode Cloud(ただしお金はかかりそう)
      • CI/CDを導入するコストがかけられる場合は他(GitHub ActionsやBitrise, CircleCIなど)

のような考え方が良いのではと思いました。

Xcode Cloudはまだできたばかりなのでこれからに期待ですね

leetcodeのeasyだけどeasyじゃなかった問題1

543. Diameter of Binary Tree

2分木の直径(左右ノードが最長となるときの長さ)を求める問題です。

この問題はノードの高さを求めるアルゴリズムを知ってる前提だと確かに簡単なのですが、知らないとそこから考えないといけないのでちょっと大変です。

前提としてのノードの高さの求め方

左右のノードの高いほうに1を足すとノードの高さになるというのを再帰的に行うとノードの高さが求められます。

int tree_height(Node* root) {
    if (root == NULL) 
        return 0;
    else {
        int left_height = tree_height(root->left);
        int right_height = tree_height(root->right);
         
        return max(left_height, right_height) + 1;
}

2分木の直径

簡単な方法

全てのノードで左右の高さを計算し最大のものを求める方法です。

ただこれだと無駄に計算量がかかってしまいます。O(N2)

class Solution {
public:
    int diameter = 0;
    
    int diameterOfBinaryTree(TreeNode* root) {
        traverse(root);
        return diameter;
    }
    
    // 全てのノードを探索
    void traverse(TreeNode* root) {
        if (root == NULL) return;
        
        int left_height = tree_height(root->left);
        int right_height = tree_height(root->right);
        diameter = max(diameter, left_height + right_height);
        
        traverse(root->left);
        traverse(root->right);
    }
    
    // ノードの高さを取得
    int tree_height(TreeNode* root) {
        if (root == NULL) 
            return 0;
        else {
            int left_height = tree_height(root->left);
            int right_height = tree_height(root->right);
         
            return max(left_height, right_height) + 1;
        }
    }
};

最適な方法

ノードの高さを求めるアルゴリズムの途中で最大値を求めてしまえばO(N)で求めることができます。

class Solution {
public:
    int diameter = 0;
    
    int diameterOfBinaryTree(TreeNode* root) {
        tree_height(root);
        return diameter;
    }
    
    int tree_height(TreeNode* root) {
        if (root == NULL) 
            return 0;
        else {
            int left_height = tree_height(root->left);
            int right_height = tree_height(root->right);
            
            // 左右が最大になるものを記録
            diameter = max(diameter, left_height + right_height);
         
            return max(left_height, right_height) + 1;
        }
    }
};

Swift Concurrencyのパフォーマンス

Swift Concurrencyは並列プログラミングの書きやすさや便利さがよくなっただけでなくパフォーマンスも改善されています。

これまでの問題

これまでのGCDを使った並列処理では、CPU Coreに対して一つスレッドを割り当てそれをスレッドプールにプールして再利用するのでnon blockingな処理を並列処理する分にはスレッドの切り替えが発生しないためパフォーマンスには問題ありません。

しかし、スレッドをブロックするような処理する場合はスレッドが止まってしまい、止まっている間CPUが無駄にならないように別の処理を実行するためには、新たにスレッドを生成してそのスレッドに切り替えが発生します。スレッドを切り替えるには、今あるスレッドの状態(レジスタとスタック)を退避し、次に実行するスレッドの状態を復元する必要があります。これらスレッドの生成や状態の退避と復元がコンテキストスイッチで、これを行うにはもちろんコストがかかるのでパフォーマンスに影響します。

また、スレッドをブロックする処理が大量に発生し、スレッドが枯渇(スレッド生成数の限界を超える)する可能性もあります。これをThread Explosionと呼びます。 Thread Explosionが発生するとそれ以上スレッドが生成できなくなるだけでなく、dead lockを起こす可能性があります。(ちなみにlimitは64らしい)

Thread Explosionによるdead lock

WWDC2015のビデオの例です。 f:id:yanamura:20220223235954p:plain

①main threadからmain threadに対してdispatch_syncするタスクを大量にdispatch_asyncしてスレッドを枯渇させます。

②つぎに、dispatch_syncで呼ばれた処理内でserial queueにdispatch_asyncするとそのserial queueはスレッド不足のためスレッドの空き待ちになります。そのあと同じserial queueにdispatch_syncするとスレッドのスレッドの空き待ちが解消するまでdispatch_syncの処理は実行されないです。このdispatch_syncの処理が終わらないとmain threadはブロックされ、更にmain threadがブロックされると①のdispatch_syncも終わらないのでスレッドが開放されずdead lockとなります。

これまでの問題のまとめ

  • スレッドをブロックするような処理があるとコンテキストスイッチが発生してパフォーマンスが落ちる
  • スレッドが枯渇するとdead lockを起こすおそれがある

Swift Concurrencyの場合

Swift Concurrencyでは、CPUに対して1スレッドが割り当てられ、スレッドの切り替えが起こらないようになっています。

どうやっているかというとGCDのようにスレッドを内包して使いやすくしているのではなく、continuationという軽量スレッド的な仕組みを使っているようです。 (詳細はこちらのSwift concurrency:Behind the scenes

asyncが呼ばれてsuspendされた際に元の状態に戻すための情報(stack上に持っているlocal変数など)をheapに保存し、resumeするときにheapからstackに戻すことで再開されているようです。

f:id:yanamura:20220309000016p:plain

スレッドとの主な違いは、

  • スレッドは生成時にスタックサイズ分のメモリの確保が必要だが、continuationの場合は必要な分だけ確保?
  • スレッドの場合は切替時にレジスタの退避復帰が必要だが、continuationの場合はheapからstackに戻すだけ

ということなのかなと思われます。(違ってたらご指摘いただけると幸いです)

結局のところはスレッドであろうとcontinuationであろうと大量生成するとメモリは食うし切り替えコストは発生します。ですので、いくらSwift Concurrencyで改善されたからといって大量に並列処理を行っても速くなるとは限りません。

このようにSwift Concurrencyではこれまでよりパフォーマンスは改善されていますが、依然として並列処理にはメモリや切り替えコストは発生するのでその点は意識して使っていく必要はありますね

UIViewRepresentableのCoordinatorはなぜ必要か

UIKitのViewをSwiftUIとして使うには、UIViewRepresentableを使います。

そしてUIKitのViewのdelegateやtarget actionをハンドリングしたい場合structではハンドリングできないので、Coordinatorという仕組みが用意されています。

makeCoordinator()がmakeUIViewより先に呼ばれるのでそこでCoordinatorを作成し、その後はmakeUIView()やupdateUIView()などで渡されるcontextにcoordinatorが含まれているので、それを用いればcoordinatorとやり取りができます。

struct WebView: UIViewRepresentable {
    var loadUrl:String

    func makeUIView(context: Context) -> WKWebView {
        let webview = WKWebView()
        webview.navigationDelegate = context.coordinator
        return webview
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        // ※ 再描画されるたびにURLが読み込まれます
        uiView.load(URLRequest(url: URL(string: loadUrl)!))
    }

    class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate {
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            print("読み込み完了")
        }
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator()
    }
}

しかし、このようにstruct内にclassを保持させてやればもっとシンプルにできちゃうのになぜこんな仕組みが用意されているのでしょう?

struct WebView: UIViewRepresentable {
    var loadUrl:String

    private let coordinator = OreoreCoordinator()

    func makeUIView(context: Context) -> WKWebView {
        let webview = WKWebView()
        webview.navigationDelegate = coordinator
        return webview
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        uiView.load(URLRequest(url: URL(string: loadUrl)!))
    }

    class OreoreCoordinator: NSObject, WKUIDelegate, WKNavigationDelegate {
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            print("読み込み完了")
        }
    }
}

それはSwiftUIがUIKitのようなclassベースではなくstructベースで、描画されるたびに破棄されて生成されるからです。

structにclassを保持させるパターンだと描画される度にcoordinatorも再生成されます。 makeUIViewの時点でdelegateに渡しているcoordinatorは再描画時には破棄されるので、このコードは再描画されるとdelegateは呼ばれなくなります。 makeUIViewではなくupdateUIViewでdelegateにセットすれば動きはしそうですが、毎回coordinatorを生成されてしまう分が無駄なのでやめたほうがいいかなと思います。

こういった問題を解決するためにCoordinatorというかContextが用意されていると思われます。

UIViewRepresentableではmakeCoordinatorで生成されたCoordinatorはstructが再描画されても同じCoordinatorが参照できるような仕組みが用意されているわけです。

@Stateなども似たような仕組みかなと思います。

UIKitで慣れ親しんだ人はこのSwiftUIはstructで再描画時には再生成されるといった点は大きな違いで考え方を改めないと間違った実装をしてしまいがちかなと思います。SwiftUIで用意された仕組み以外を使って状態や参照を保持しようとしないようにしましょう。

Swift ConcurrencyのActor reentrancy

SwiftでActorsが導入され、actorを使えば簡単にスレッドセーフにすることができるようになりました。

ただこれでrace conditionやdead lockといった並行プログラミングにおける問題が解決されるわけではありません。

Actor reentrancy

SwiftのActors(正確にはActor-isolated function)はreentrant(再入可能)となっています。そのため、あるActorのActor-isolated functionの中断中にそのActor-isolated functionは排他されるわけではなく実行可能となります。

Actorsのproposalから例を持ってくると

actor Person {
  let friend: Friend
  
  // actor-isolated opinion
  var opinion: Judgment = .noIdea

  func thinkOfGoodIdea() async -> Decision {
    opinion = .goodIdea                       // <1>
    await friend.tell(opinion, heldBy: self)  // <2>
    return opinion // 🤨                      // <3>
  }

  func thinkOfBadIdea() async -> Decision {
    opinion = .badIdea                       // <4>
    await friend.tell(opinion, heldBy: self) // <5>
    return opinion // 🤨                     // <6>
  }
}

このように実行すると、thinkOfGoodIdea()を実行中断している間にthinkOfBadIdea()が実行されるとopinionが中断中に変わってしまい、.goodIdeaを返すはずが.badIdeaが返ってくるといったことが起こる可能性があります。つまりrace conditionが発生してしまうのです。

let goodThink = detach { await person.thinkOfGoodIdea() }  // runs async
let badThink = detach { await person.thinkOfBadIdea() } // runs async

let shouldBeGood = await goodThink.get()
let shouldBeBad = await badThink.get()

await shouldBeGood // could be .goodIdea or .badIdea ☠️
await shouldBeBad

こういった問題を起こさないためには、Actor-isolated functionは同時に呼ばれても問題ないように実装する必要があります。

じゃnon-reentrant(再入可能にしない)のほうがよいのでは?と思うかもしれません。non-reentrantにした場合を見ていきます。

non-reentrant actors

actorをnon-reentrantにした場合は、Actor-isolated functionの呼び出し中はactorがロックされるので呼び出しが終わるまでは他のActor-isolated functionは実行されません。そのためreentrantであげた例と同様のことを行うとnon-reentrantの場合は意図した通りの動きとなります。

non-reentrantの場合はなにが問題かというと、大きく2つあります。

1つ目は、dead lockを引き起こすことです。

このようにDecisionMakerのActor-isolated functionから更に別のDecisionMakerのActor-isolated functionを呼び出しを行うと、お互いに実行終了待ちとなりdead lockが発生します。

extension DecisionMaker {
  func tell(_ opinion: Judgment, heldBy friend: DecisionMaker) async {
    if opinion == .badIdea {
      await friend.convinceOtherwise(opinion)
    }
  }
}

2つ目はパフォーマンスです。

このImageDownloaderの例だと、non-reentrantだとgetImage中は排他されるので、キャッシュにヒットするURLの場合も、異なるURLの場合でも前の画像のダウンロードが終わるまで実行できないので、並行処理を活かせていません。

actor ImageDownloader { 
  var cache: [URL: Image] = [:]

  func getImage(_ url: URL) async -> Image {
    if let cachedImage = cache[url] {
      return cachedImage
    }
    
    let data = await download(url)
    let image = await Image(decoding: data)
    return cache[url, default: image]
  }
}

まとめ

reentrant, non-reentrantにはどちらもメリット・デメリットがあります。

  • reentrantは正しく実装しないとrace conditionを起こしますが、dead lockは起こしません。中断中に割り込みできるのでパフォーマンスがよいです。
  • non-reentrantはrace conditionは起こしませんが、dead lockを起こす可能性があります。中断中でも割り込みできないのでパフォーマンスがよくないです。

SwiftのActorsではdead lockを起こさないこととパフォーマンスが優先されreentrantがデフォルトとして採用されましたが、dead lockを起こしてくれたほうがバグとしては気づきやすかったりもするので、どちらがよいとは一概には言えないと思います。

将来的にはnon-reentrantにすることもできるようになるので状況に応じて使い分ける必要が出てくると思いますので、これらの違いについては理解しておいたほうがよさそうです。

2021振り返り

2021年の振り返りです。

仕事

  • プレイングマネージャとして一年過ごしました。
  • SwiftUIを使い始めました

f:id:yanamura:20211231110142p:plain

アウトプット

コネヒトの開発者ブログを書いたり

tech.connehito.com tech.connehito.com tech.connehito.com

アドベントカレンダーを書いたりしました。

yanamura.hatenablog.com yanamura.hatenablog.com

しょぼい変更ですが、OSSにコントリビュートしました

アメリカ海軍に学ぶ「最強のチーム」のつくり方―――一人ひとりの能力を100%高めるマネジメント術

アジャイルなチームをつくる ふりかえりガイドブック 始め方・ふりかえりの型・手法・マインドセット

ニュー・エリートの時代 ポストコロナ「3つの二極化」を乗り越える (角川書店単行本)

プロダクトマネジメントのすべて 事業戦略・IT開発・UXデザイン・マーケティングからチーム・組織運営まで

チームトポロジー 価値あるソフトウェアをすばやく届ける適応型組織設計

プライベート

子供が5歳、1歳になりました。上の子は楽になるかと思いきや赤ちゃん返りしてしまって全然楽になりませんでした。。

SwiftUI-Introspectの仕組み

これはiOSアドベントカレンダー2021その3の25日目の記事です!

SwiftUIのViewに対応するUIKitの参照をとってくるSwiftUI-Introspectという謎ライブラリがあります。

なんのことを言っているのかわからないと思うので例をあげると、SwiftUI-Introspectを使うと以下のようにSwiftUIのTextFieldをUITextFieldのAPIを使って変更したりすることができるのです。

TextField("Text Field", text: $textFieldValue)
.introspectTextField { textField in
    textField.layer.backgroundColor = UIColor.red.cgColor
}

なぜこのようなことができるかというと、SwiftUIには色々Viewがありますが、PureなSwiftUIで実装されているViewはXcode13の時点でText, Image, Buttonだけで、それ以外は中身は実はUIKitだからです。以下の表がSwiftUIとUIKitの対応表になっています。

f:id:yanamura:20211223233914p:plain
SwiftUI-IntrospectのGitHubより抜粋

SwiftUI-IntrospectiveはSwiftUIの中身のUIKitの参照をとってきているわけなのです。

それではSwiftUI-IntrospectiveがどうやってUIKitの参照を取得しているのか具体的にみていきたいと思います。

PureじゃないSwiftUIのViewの実装

まず、PureじゃないSwiftUIのView(つまりText, Image, Button以外のView)はどうやって実装されているのか調べてみます。

仮説としてはUIKitをSwiftUIとして使うためにUIViewRepresentableが用意されているのでこれが使われているのではないかと思われます。この仮説があっているか検証してみます。

まずUIViewRepresentableにするとどうなるのか確認してみます。 このようにWKWebViewをUIViewRepresentableでSwiftUI化します。

struct HogeView: UIViewRepresentable {

    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        uiView.load(URLRequest(url: URL(string: "https://google.com")!))
    }
}

struct ContentView: View {
    @State private var isOn = true
    var body: some View {
        HogeView()
    }
}

そしてDebug View HierarchyでViewf:id:yanamura:20211224000229p:plainの構造を見てみます。

そうするとContentViewと同階層にWKWebViewを内包したViewHostが存在しています。 f:id:yanamura:20211223235706p:plain

ViewHostは厳密には PlatformViewHost<PlatformViewRepresentableAdaptor<HogeView>> というClassになっていました。どうやらUIViewRepresentableを使うとViewHostというViewでUIKitのViewを内包するようです。

次にSwiftUIのViewのToggleを見てみます

struct ContentView: View {
    @State private var isOn = true
    var body: some View {
        Toggle(isOn: $isOn, label: {
            Text("Label")
        })
    }
}

こちらもViewHostにUISwitchが内包されていました。

f:id:yanamura:20211224000229p:plain

ということで、ToggleなどといったSwiftUIのViewも実はUIViewRepresentableを使ってUIKitをwrapしてSwiftUIのように扱えるようにしていたということになりそうです。

どうやってUIKitの参照を取得するか

SwiftUI-IntrospectiveのようにUIViewRepresentableでwrapされたUIKitの参照をとってくるにはどうすればよいでしょうか? すぐに思いつくのはView階層をたどるというアイデアです。

しかし残念ながらSwiftUIのViewにはUIViewのsuperviewやsubviewsのようにView階層をたどるようなAPIは提供されていません。

ではどうするかというと、UIKitからはView階層をたどっていくことができるので、UIViewRepresentableで見えないViewを用意し、目当てのViewにoverlayする形で配置します。そして、UIViewRepresentableのupdateUIView でUIViewRepresentableがwrapしているUIKitにアクセスしそのViewから階層をたどっていくことで目当てのViewのUIKitを取得します。

言葉だとわかりづらいので簡単なサンプルをつくってみました。

struct InspectorView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIView {
        // 見えないUIViewを生成
        let view = UIView(frame: .zero)
        view.isHidden = true
        view.isUserInteractionEnabled = false
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        DispatchQueue.main.async {
            // InspectorViewの親がViewHostで、さらにその親の子を探索してUISwitchを探す
            guard let viewhost = uiView.superview else { return }
            guard let superview = viewhost.superview else { return }
            let targetView = findChild(type: UISwitch.self, in: superview)
            print(targetView)
        }
    }

    func findChild<T: UIView>(type: T.Type, in view: UIView) -> UIView? {
        for subview in view.subviews {
            if let foundView = subview as? T {
                return foundView
            } else if let foundView = findChild(type: type, in: subview) {
                return foundView
            }
        }
        return nil
    }
}

あとは、対象のViewにこのInspectorViewをoverlayしてやるだけです。

struct ContentView: View {
    @State private var isOn = true
    var body: some View {
        Toggle(isOn: $isOn, label: {
            Text("Label")
        })
        .overlay(InspectorView())
    }
}

こちらを実行すると、以下のようにUISwitchのインスタンスが取得できていることが確認できます。

Optional(<UISwitch: 0x7fd2df411590; frame = (0 0; 51 31); layer = <CALayer: 0x600000aef400>>)

Viewの構造はこのようになっており、一番下のViewHostがIntrospectorViewのViewHostでこれを介してUISwitchを探しています。ViewHost同士が同じ階層にあるので、他にもたくさんViewがあったりすると目当てのUIKit以外を間違って探してしまわないかといった不安も感じます。Viewの構造に依存しているので危うさは非常にありますね。 f:id:yanamura:20211224003823p:plain

まとめ

SwiftUIのViewのうちText, Image, ButtonだけがいまのところPureなSwiftUIのViewで、それ以外はUIViewRepresentableを使ってUIKitを内包しているだけでした。

SwiftUI-IntrospectiveはUIViewRepresentableを使ってView階層をたどってUIKitを取得しています。よって、Viewの構造が変わってしまうと動かなくなってしまうので、今後のアップデートにより動かなくなる可能性があり注意する必要があります。