iOS16 から使える SwiftUI の機能をおさらいする

はてなのマンガアプリチームで GigaViewer for Apps を作っています、 id:fxwx23 です。

タイトルを見て今更?と思ったかもしれませんが、iOSのアップデートで追加される機能の多くはバックポートされていないため、アプリ側のサポートする下限のOSバージョンが追いついて初めて利用可能になります。その結果、WWDCで発表された機能が実際の日々のプロダクト開発に取り入れられるまでには、2〜3年かかることがあります。

2022年に発表された iOS16 で SwiftUI に追加された機能は、ユーザー体験の向上やコードをシンプルにするために役立つものが多くあります。このタイミングであらためて見直すことで鮮度を保ったまま実践投入することができるのではと思い、今回はその中から個人的におさらいしておきたい機能をピックアップして紹介します。

lineLimit(_:)

View の中で Text が占有できる最大行数を設定するモディファイアです。iOS 15までは Int? を受け取る形のみでしたが、細かい進化を遂げています。

範囲型のサポート

iOS16 で TextField に複数行入力サポートが入ったため、範囲型を受け付けるようになっています。指定した範囲よりもスペースが必要になるとスクローラブルになってくれます。チャットUIの入力部分などがイメージしやすいと思います。

Form {
    TextField("Bio", text: $bio, axis: .vertical)
        .lineLimit(3...)
}

テキストフィールドの高さも自動で追従してくれます

余白空間を保持

指定した行数に満たなくても想定されるスペース分を確保してくれるようになっています。Grid レイアウトで各要素に表示する説明文は最大行数は指定したいけど、要素自体の高さは均一にしたい時などに使えますね。地味に嬉しい...

developer.apple.com

Text("description here")
        .font(.subheadline)
        .lineLimit(2, reservesSpace: true)

contentTransition(_:)

View のコンテンツの変更をアニメーション化させることができるモディファイアです。また一部をアニメーションさせないということも可能です。

developer.apple.com

例えば、数値のカウントアップやカウントダウンの演出のためににいい感じのアニメーションをつけることができます。コンテンツのカウント数が上限するタイミングでアニメーションがあるとおしゃれですね。

VStack {
    Text("\(count)")
        .contentTransition(.numericText())
    Button("Count Up") {
        withAnimation {
            count += 1
        }
    }
}

numericText(coundDown: false)

defersSystemGestures()

OS独自の組み込みジェスチャー(コントロールセンターや通知センター)よりもジェスチャーが優先されるように要求できるモディファイアです。これはユーザーが頻繁にスワイプする可能性のあるゲームや、画面の端に独自のジェスチャーを置く場合などに良さそうです。例えば Edge.Set.vertical で有効にすると Home Indicator がグレーアウトします。

developer.apple.com

ちなみに完全に非表示にしたりできる persistentSystemOverlays(_:)) も iOS16+ です。UIKit でできたことも SwiftUI で完結するようになってきています。

ViewThatFits

ViewThatsFits は複数の View の中から親 View のレイアウト条件に最も適した View を選択して表示してくれます。レスポンシブなデザインや、画面サイズやレイアウトの制約に応じた View の選択を簡素化するのに役立ちますね。

developer.apple.com

例えば、 View 内に収まるテキスト量なら Text で、収まらない場合はスクローラブルに表示したいというシーンを考えます。ViewThatsFits を使うと振り分けたい要素を優先順位順に書いておけばOKです。楽ちん!

struct ContentView: View {
    let text = String(repeating: "Long text ...", count: 100)

    var body: some View {
        ViewThatFits(in: .vertical) {
            // 収まる場合はText
            Text(text)
            // 収まらない場合はScrollView
            ScrollView {
                Text(text)
            }
        }
        .padding()
    }
}

デフォルトでは ViewThatFits にテキストの水平軸と垂直軸の両方を気にするようになっているため、常に ScrollView が選択されてしまいます。 この例の場合は垂直軸のみを測定するように制限する必要があるので注意が必要です。

収まる場合は Text

収まらない場合は ScrollView

AnyLayout

最後は AnyLayout です。AnyLayout は異なるレイアウト( HStackVStack など)を動的に切り替えるための型消去ラッパーです。SwiftUI では通常レイアウトを静的に決定しますが、AnyLayout を使用すると条件に応じてレイアウトを動的に変更することができます。

developer.apple.com

horizontalSizeClass や、ダイナミックフォントに応じてレイアウトを切り替えたい時に便利です。AnyLayout なら subview の状態を破壊せずにレイアウトコンテナの型を動的に変更できるのでいいですね。 if 分岐がなくなるのも良い。

struct ContentView: View {
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass

    var body: some View {
        let layout = horizontalSizeClass == .compact
        ? AnyLayout(VStackLayout())
        : AnyLayout(HStackLayout())

        VStack {
            layout {
                Rectangle()
                    .fill(.red)
                    .frame(maxWidth: .infinity)
                    .frame(height: 200)

                VStack(spacing: 0) {
                    Rectangle()
                        .fill(.blue)
                        .frame(maxWidth: .infinity)
                        .frame(height: 100)

                    Rectangle()
                        .fill(.green)
                        .frame(maxWidth: .infinity)
                        .frame(height: 100)
                        .background(.blue)
                }
            }

            Spacer()
        }
        .padding()
    }
}

horizontalSizeClass == .compact

horizontalSizeClass != .compact

Layout プロトコルに準拠したものなら利用できるので、自前のカスタムレイアウトと組み合わせることもできます。早く使いたいですね!


ここまでいくつかの機能を紹介しましたが、もちろんこれがすべてではありません!まだまだ活用しきれていない面白い機能がたくさんあるのでぜひ調べて活用してみてください。iOS 16 以降の機能を再発見する良いきっかけになれば幸いです。

この記事は はてなエンジニア Advent Calendar 2024 の23日目の記事でした。明日は id:masawada さんです!

SwiftPM 6 は同時ビルド/テストを回避するために Package の .build ディレクトリをロックする

Swift version

swift-driver version: 1.115 Apple Swift version 6.0 (swiftlang-6.0.0.9.10 clang-1600.0.26.2)
Target: arm64-apple-macosx14.0

挙動について

SwiftPM 6 (release/6.0 以降)は、同時ビルド/テストを回避するために Package の .build ディレクトリをロックをするようです。残念ながら、このロックする挙動は一部のケースでプラグイン実行やテストの動作を止めてしまう可能性があります。 その影響を受けた場合以下のような warning が発生します。

Another instance of SwiftPM is already running using '/path/to/your/package-directory/.build', waiting until that process has finished execution...

プロセスが終了するまでロックされ続けるので、CIなどの実行で処理が完了せずにタイムアウトするようになってしまうなどの影響があります。

どのようなパッケージや実装が対象になるかというと、実行プロセスの中でさらに dump-packagebuild コマンドを実行しているケースです。厳密には try swiftCommandState.getActiveWorkspace() の箇所で起こりうるので、他のコマンドを同プロセスの中で実行していれば起きると思います。

Swift の VSCode Extension ではパッケージの依存解決をよしなにやってくれますが、従来の挙動だとユーザーがビルドしている最中にも依存解決が実行されたりしてリポジトリを破損されることが起きていたので同時にビルドが起きないようにこの対応が入ったようです。(という理解)

対応

escape-hatch として従来の挙動に戻せるコマンドオプション --ignore-lock が同時に追加されています。プロセスの中で実行している SPM コマンドに Swift 6 以上での実行かをチェックした上で --ignore-lock オプションを追加することで対処が可能です。

// swift --version などで Swift 6 以上での実行かを判断する(Regex)
if swift6Plus {
  let output = try sh("swift package dump-package --ignore-lock")
  ...
} else {
  ...
}

問題なくオプションを渡せていれば Another instance of SwiftPM is already running using '/path/to/your/package-directory/.build', but this will be ignored since --ignore-lock has been passed というwarningと共に処理がロックされずに後続に進むようになります。

参考

iOSDC Japan 2024 に行ってきた

8/22、23 に iOSDC Japan 2024 に参加してきました。24日のDay2は残念ながら都合が悪く参加できませんでしたが、実際に聞いたり&話したりできた範囲で感想を書きたいと思います。ちなみに iOSDC は初参加でした。


Inner Source Practice

開発を加速する共有Swift Package実践 by el_metal | トーク | iOSDC Japan 2024 #iosdc - fortee.jp を見て、お恥ずかしながら Inner Source Practice という言葉を知りました。

Inner Source Practice はOSS開発におけるベストプラクティスを活用して、非OSSな開発にもオープンソースの手法を取り入れ品質を高めようねという理解をしました。サイボウズは異なる時期に開発されたアプリ(kintone、サイボウズ Office、Garoon?)で、認証機能などの共通部分がそれぞれ再実装されていたことが問題としてあったそう。プロジェクトごとに実装が分かれているため新しいメンバーへのオンボーディングにコストがかかってしまう課題もあると言っていて、これは複数プロダクトを抱えるとどうしてもある話だなと思いました。これらの課題を共通の Swift Pacakage を実装することで解決していったとのことでした。利用側のアプリ分バグもスケールしてしまうけど、SSoT になったおかげでバグ修正のスピードも早まったとおっしゃっていていい話でした。開発体制は OSS のように Owner 、Contributer などを定めて進めていて、ちゃんとワークさせているのがすごいなと。これはチーム内の理解もかなり必要そう。利用側のパッケージ更新はひとまず main 指定でやっていると言っていて、この辺りは今後の課題なのかな〜?という感想です。自分が所属するチームでも共通 Package を取り組む将来はあり得なくはないので、興味深く聞いていました。

Swift Concurrency

みんな大好き(?) Swift Concurrency。 Swift 6 もきて盛り上がっていますが、まだまだ理解が十分でない部分が多いので、それらを補完するようにトークを聞いてきました。

まず、大前提として Concurrency が防いでくれるのは Data race(競合)で、Race condition(競合状態)じゃないよというのは確かにでごっちゃになるので要注意ですね。個人的に印象的だったのは隔離境界(Isolation Boundary)を越えなければ難しくないという部分でした。所属チームのプロジェクト でも ViewModel は MainActor にしていこうねというところで、MainActor でいいところは怖がらずに MainActor にしていけばいいという話を聞けたのは良かったです。 境界を越えようとすると途端に難しくなるので、そういうシチュエーションになったらこの時のトークを振り返ったりしたいと思います。

Mergeable Library

WWDC23 で発表された Static/Dynamic に続く新たなライブラリの形式「Mergeable Library」のお話。

開発時に嬉しい Dynamic Framework と、 リリース時に嬉しい Static Framework のいいとこ取りしてくれるので最高ではという印象でしたが、ベンチマークはあんまりいい結果になっていない?ようでした(色々考察がある)。リソースバンドルもいい感じに扱ってくれるようなのでうまくいけば絶対嬉しいはずです。ワードは知っていたけど深く終えていなかった部分なので、とても面白かったです。 活用事例は大募集中のようなので、実際のプロダクトでやってみないとわからない部分も多いだろうし機会を作って試してみたいですね。

参加してみて

自分は残念ながらプロポーザルが採択されなかったので登壇することはできませんでしたが、プロポーザルに出した内容は、アフターイベントである iOSDC 2024 後夜祭 - connpass でお話しできる機会をいただいたのでLTで発表してきました。

speakerdeck.com

来年こそはスピーカーとして参加したいですね。いいネタができるようまた一年頑張っていきたいと思います。

FCM Batch send API の v1 API への移行方法

以前書いたブログで、バッチ送信と呼ばれるFCMへの単一のHTTPリクエストに複数の送信リクエストを含めることができるAPIが廃止される件について書きました。 fxwx23.hatenablog.com

この件がなんとか決着をつけられそうなので、どう対応していくかについてまとめます。

Legacy API を引き続き使えるように要請する(Optional)

前提として該当の API は6月20日以降に動作しなくなると言われていました。終了日2日前に "[Reminder] Update your apps to the latest Firebase Cloud Messaging APIs and SDKs" というメールが届いていて、そこにはサービスに影響させたくない場合は延長リクエストを送る事ができますよと書いてあったので、さっそく Firebase Support に連絡します。

サポートの返事には、沢山リクエストがあったのでひとまず7月20日まで伸ばしましたという報告と、さらに延長したい場合はGoogleフォームに必要項目を記載して提出してねと言われました。フォームには最長9月末まで延長できそうな感じだったので9月末を選択、延長理由は公式SDKがHTTP2にまだ対応してないからと記載しました。提出から数日後に延長許可のメールが届いたので無事に受理されたようでした。

ひとまず引き伸ばしたいという方はまず Firebase の Support に連絡してみると良さそうです。

公式SDKを HTTP/2 対応ver に更新する

前回のブログでも対応方針として HTTP/2 対応するしかないと紹介しましたが、公式の Firebase Admin SDK 側でも対応が進みつつあります。 7月30日時点ではまだ NodeJS SDK しか対応されていません。 Go や Java のほうでは issue はありますが、PR などはまだ出てきていない状況です。

実際に対応された NodeJS SDKv12.3.0 に移行してみます。

- "firebase-admin": "12.2.0"
+ "firebase-admin": "12.3.0" 

やることとしては、 sendAllsendMulticastsendEachsendEachForMulticast に入れ替えるだけです。 12.3.0 では基本的に HTTP/2 のクライアントを利用してくれるようなので特別な設定は不要のようです。

実装は feat(fcm): Add HTTP2 support for `sendEach()` and `sendEachForMulticast()` by jonathanedey · Pull Request #2550 · firebase/firebase-admin-node · GitHub で確認することができます。いい感じにリトライ処理もしてくれるし、Token の最大数も 500 のままで大丈夫そうなので安心です。

ただ基本的にすべての送信で HTTP/2 が採用されてしまうことには注意が必要です。12.3.0 に更新して一部条件では延長期限まで sendMulticast を使い、その他では sendEachForMulticast に移行するというケースを例にします。

const messaging = getMessaging(app);

let response: BatchResponse;
const useHttpV1API = true; // 任意の条件
if (useHttpV1API) {
  response = await messaging.sendEachForMulticast(message);
} else {
  response = await messaging.sendMulticast(message);
}

Node.js の Admin SDKsendEachsendEachForMulticast では基本的に HTTP/2 のクライアントが利用されます。場合によって引き続き HTTP/1.1 のクライアントを使いたいケースがあるかもしれません。 その場合は送信処理を実行する前に enableLegacyTransport() メソッドで切り替えることができます。

気になるのはパフォーマンスですが、Legacy API と比較する必要があるのでそれはまた別途やってみて共有できそうならまたブログを書きたいと思います。

Plugin から別ディレクトリにあるネイティブコードを参照する時は Symbolic Link を使う

Swift Package Manager の Plugin から Sources や 親階層の別ディレクトリにあるネイティブコードを参照したい時は、シンボリックリンクを使えばいいようです。 swift-docc-plugin が参考になりました。

github.com

この方法は Plugin 間でコードの共有ができるまでの間の一時的なワークアラウンドとされています。

This is a workaround until SwiftPM has native support for sharing code between plugins.

Sources/YourTool があったとして、 Plugins/YourTool から参照としたいとします。 この時 Plugins/YourTool/Symbolic-Links を作ってそこにシンボリックリンクを作成してあげるだけです。

$ cd Plugins/YourTool/Symbolic-Links
$ ln -s ../../../Sources/YourTool YourTool

とても単純な話ですが、 Command plugin を自作する際にインプットした情報の備忘録として残しておこうと思います。

Privacy report を生成できる Command Plugin を作った

その名の通り。All Swift, no dependencies.

github.com

Privacy report とは、アプリと利用するパッケージが収集しているプライバシー情報を要約した PDF ファイルです。 App Store の Privacy Nutrition Labels に対応しており、 このレポートを見ながらストア上の情報を更新することができます。

Privacy report - Get started with privacy manifests より

レポートは Xcode の Organizer からしか生成することができず、出力も PDF なのでパッケージ更新時の差分比較がしにくいな〜と感じていました。理想はストア上のプライバシー情報が自動で反映されてくれることなのですが、いまのところそうなる気配もなく、ドキュメントにもレポートを参照して設定するといいよというニュアンスなので、

Refer to this report when you provide your app’s privacy details in App Store Connect.

比較しやすいフォーマットのファイルで出力できたらいいなということで作ったのがこのツールです。Xcode で生成するレポートと同じ内容で出力されるはずです(違ったら教えてほしい)。Plist と JSON にひとまず対応しています。SPM の Command plugin 経由で実行できるようにしてあります。

$ swift package plugin --allow-writing-to-package-directory generate-privacy-report --xcarchive-path '/path/to/your/App.xcarchive' --json

JSON だとこんな↓感じで出力してくれます。各プライバシーデータの項目に対して、どの経路で利用されているのかわかるようになっています。

{
  "NSPrivacyCollectedDataTypeAdvertisingData" : {
    "SampleAd_resources.bundle" : {
      "bundlePath" : "SampleApp.app/SampleAd_resources.bundle/PrivacyInfo.xcprivacy",
      "collectionPurposes" : [
        "NSPrivacyCollectedDataTypePurposeAnalytics",
        "NSPrivacyCollectedDataTypePurposeDeveloperAdvertising",
        "NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising"
      ],
      "isLinkedToUser" : true,
      "isUsedForTracking" : true
    },
    "SampleApp.app" : {
      "bundlePath" : "SampleApp.app/PrivacyInfo.xcprivacy",
      "collectionPurposes" : [
        "NSPrivacyCollectedDataTypePurposeAnalytics",
        "NSPrivacyCollectedDataTypePurposeAppFunctionality"
      ],
      "isLinkedToUser" : true,
      "isUsedForTracking" : false
    }
  }
}

その他オプションなどは README に書いてあります。実用的はまだわからないけど、定期配布の時にレポートに差分がでたらPRを作ってくれるとかやれると良さそうとか妄想中です。

ひとまず作ったよ報告でした👋

【iOS】XcodeSelectiveTesting を使う際の Tips

XcodeSelectiveTesting という OSS のツールを活用して、変更箇所に依存するテストターゲットのみを実行することができます。

github.com

このツールは Git の変更履歴から差分を分析してどのモジュールに対して変更があったのかを検知し、モジュール間の依存関係をもとにして、あるモジュールの変更が他のどのモジュールに影響を与え得るかを検出してくれます。パッケージ間の依存関係は swift package dump-package コマンドを使い、 Xcodeプロジェクトとターゲット間の依存関係は tuist/XcodeProj を使って解析しています。

マルチターゲット、マルチモジュール構成のプロジェクトであることが前提で、利用方法は README に書いてある通りなんですが、実際にプロジェクトに導入を進める際にいくつかハマりポイントがあったので掻い摘んで紹介します。

XcodeGen との併用

導入しようとするプロジェクトが XcogeGen を利用している場合、上述した tuist/XcodeProj を両方のツールが利用しておりバージョン解決ができません。Package.swift を分けるか、 artifactbundle をサポート済みなのでそちらを使うことをお勧めします。

ただ、本家のほうはpre-built なバイナリを使った Command Plugin をまだ用意してくれていないので、自前で用意したものを使っています。

github.com

上記を使うことで XcodeGen と同じ Package.swift で管理しつつ、ビルド時間の削減も実現できるので一石二鳥というわけです。

fastlane との連携

テストの実行で fastlane を使っている場合は一手間が必要です。 TestPlan を利用せずに fastlane の run_tests で意図したターゲットのみを実行するには only_testing のパラメーターにターゲットの情報を渡す必要があります。only_testing には Test Bundle/Test Suite/Test Cases の文字列の配列を渡すことができるので、その情報を渡せるよう lane を調整する必要があります。

lane :selective_test do |options|
  if options[:targets].nil? then
    UI.user_error!("targets parameter is invalid or missing.")
  end
  
  if options[:targets].empty? then
    UI.user_error!("targets parameter is empty")
  end

  run_tests(
    ...
    only_testing: options[:targets],
  )
end

fastlane ではパラメーターを渡すことができ、配列データを渡す場合はカンマ区切りの文字列として渡すことで実現できます。

$ bundle exec fastlane selective_test targets:"ATests,BTests"

最終的にはここに XcodeSelectiveTesting の実行で得たターゲット情報を渡すことで意図したテストのみを実行することが fastlane 経由でも可能になります。

TestPlan で管理していると、 XcodeSelectiveTesting がスキップするべきテストを認識し xctestplan ファイルを更新してくれます。TestPlan を使っていない場合は、そちらに移行することをまず検討してみてもいいかもしれません。

JSON 出力の取り扱い

XcodeSelectiveTesting は JSON 形式の出力をサポートしています。 fastlane や xcodebuild コマンドに情報を与えていく場合は jq を使って JSON から必要な情報を抽出&整形して活用する必要があります。

swift run xcode-selective-test --json で実行したとして、出力されるJSONは変更箇所に依存するターゲットがすべて含まれるため、以下のようになっています。

[
  {
    "name" : "YourApp",
    "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj",
    "type" : "target",
    "testTarget" : false
  },
  {
    "name" : "YourUITests",
    "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj",
    "type" : "target",
    "testTarget" : true
  },
  {
    "name" : "YourTests",
    "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj",
    "type" : "target",
    "testTarget" : true
  }
]

ここからテストターゲットのみを抽出したいので、 jq の select を使います。最終的には YourUITests,YourTests というカンマ区切りの文字列が欲しいので namemap した上で join するといい感じに欲しい形にできました。

jq -r "[.[] | select(.testTarget == true)] | map(.name) | join(\",\")"

-r オプションでRAW出力にしておいてから、"" で囲むとうまく配列のデータとしてfastlaneに渡すことができたので、最終系はこのような感じになりました。

bundle exec fastlane selective_test targets:"$(swift run xcode-selective-test --json | jq -r "[.[] | select(.testTarget == true)] | map(.name) | join(\",\")")"

Command Plugin での実行

XcodeGen との併用のところで自前で用意した Command Plugin を利用していると書きましたが、Command Plugin 経由で実行する場合は少し込み入ったことをするはめになったのでそれを最後に紹介します。

SPM の問題*1なのか詳細はわかっていないのですが、JSON 出力以外の実行中に発生したログもすべて stdout として認識されており jq のパースで失敗してしまいます。JSON 箇所だけが欲しいので、その部分だけを抽出する一手間が必要になりました。JSON の出力は必ず [{ ... }] なのでそこだけ抜き出すようにします。

# OUTPUTS は xcode-selective-test の実行結果
# 
# -- OUTPUTS はこのような感じです --
# Finding changeset for repository at YourApps.xcworkspace
# [WARN]: Path xxx is mentioned from package at xxx but does not exist
# [WARN]: File without path: self=XcodeProj.PBXBuildFile, 
#  self.file=Optional(XcodeProj.PBXVariantGroup), 
#  self.product=nil
# [WARN]: Path xxx is mentioned from package at xxx but does not exist
# [ <-- ここから
#   {
#      "name" : "YourTests",
#      "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj",
#      "type" : "target",
#      "testTarget" : true
#   }
# ] <-- ここまでが欲しい
# -----------
#
# ref: https://github.com/mikeger/XcodeSelectiveTesting/blob/0.9.4/Sources/SelectiveTestLogger/Logger.swift
#
OUTPUT_JSON=$(echo "$OUTPUTS" | grep -v '^\[WARN' | grep -v '^\[ERROR' | sed -n '/\[/,/\]/p')

出力されたログには [WARN][ERROR] が混ざっている場合がありそれらも対象になってしまうので事前に grep しておくと最後のJSONの配列部分だけを抜き出すことができます。

ここまでするなら swift run でもいい気もしますが、どうしても Command Plugin でという方には参考になるかもしれません。

最後に

XcodeSelectiveTesting の導入を実際のプロダクトに導入する際の Tips を紹介しました。コードベースが大きくテストターゲットがたくさんあるプロジェクトでは少なからず効果がありそうな印象です。都度すべてのテストが実行されてしまうのは時間やCIリソースの面で改善余地があるので、そういった面でもどれほどの効果があるかはしばらく運用してみたで紹介しようと思います!

*1:この辺が関係しているかも? https://github.com/apple/swift-package-manager/issues/7589