0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUIでデジタル名刺アプリ「MYCA」を作り、App Store公開前まで仕上げた話

0
Posted at

SwiftUI でデジタル名刺アプリ「MYCA」を作り、App Store 公開前まで仕上げた話

SwiftUI でデジタル名刺アプリ「MYCA」を開発しました。プロフィール情報を QR コードとして表示し、相手に共有できる iOS アプリです。

「SwiftUI なら速く作れる」は本当です。動くものを作るだけなら、思ったより早くできました。
ただ、公開品質まで仕上げるフェーズは別の話でした。UI の外側、つまり非同期処理・ウィジェット・ローカライズ・提出設定まで、想定より広いレイヤーを触ることになりました。

この記事では、実装面で特に手を入れたポイントを技術寄りにまとめます。SwiftUI で iOS アプリを仕上げようとしている方の参考になれば幸いです。


アプリの構成

主な技術スタックは以下の通りです。

  • SwiftUI
  • SwiftData
  • WidgetKit
  • StoreKit
  • Firebase Analytics / Crashlytics
  • Google Mobile Ads

画面は大きく 2 つです。

  • QR コード表示画面
  • プロフィール編集画面

加えて、ウィジェットと App Store スクリーンショット用のダミーデータ表示も追加しています。


QR コード生成は同期処理から非同期処理へ変更した

最初は QR タブ表示時に、vCard の生成と QR 画像の生成をその場で同期実行していました。この構成だと、画面表示の初動でメインスレッドが重くなり、Xcode のメインスレッドチェッカーで警告が出ていました。

対応としては次の 2 つをやりました。

  1. Profile をそのまま別スレッドに渡さない(SwiftData のモデルはスレッド境界を越えさせない)
  2. QR 画像生成を UI 更新から分離する

SwiftData のモデルをそのままスレッド間で共有したくなかったため、vCard 用の軽量スナップショットを定義しました。

struct ProfileSnapshot: Sendable {
    let name: String
    let email: String
    let phone: String
    // 必要な情報だけを値型で持つ
}

生成処理は Task.detached でバックグラウンドに逃がし、UI 側は @State で QR 画像を持ちます。

.task {
    let snapshot = ProfileSnapshot(from: profile)
    qrImage = await Task.detached(priority: .userInitiated) {
        QRGenerator.generate(from: snapshot.vCardString)
    }.value
}

生成中だけ ProgressView を表示する構成にした結果、QR タブ遷移時のラグはほぼ気にならないレベルまで改善されました。


横画面では「情報表示」ではなく「カード」として設計した

縦画面は QR を中心にしたレイアウトですが、横画面はビジネスカード風のレイアウトに切り替えています。単に GeometryReader で横幅を見て分岐するだけでなく、横向き時の UI を別物として設計しました。

具体的には以下を調整しています。

  • safe area を含めて背景色で全面塗りつぶし
  • 外周のカード枠や余白を削除
  • QR の下に共有ボタンを配置
  • SNS 表示数を 5 件まで増加
  • スタイルごとに背景色と文字色を出し分け

ここは ZStackignoresSafeArea() の組み合わせで実現しています。

ZStack {
    style.backgroundColor
        .ignoresSafeArea()
    // カードコンテンツ
}

「どこまでを背景として扱うか」「safe area をどう飲み込むか」を整理するのが、見た目の問題というよりはレイアウト設計の問題でした。


SNS アイコンはアセット優先、SF Symbols フォールバックにした

SNS アイコンは、ブランドロゴをアセットとして置ける場合はそれを使い、ない場合は SF Symbols にフォールバックするようにしました。

この構成にした理由は 2 つです。

  • ブランドアイコンを使いたいケースがある
  • すべてのサービスに画像を用意するとは限らない

PlatformIconView を用意して、アセット名が存在すればそれを使い、なければ fallbackSystemName を使う設計にしています。

struct PlatformIconView: View {
    let assetName: String
    let fallbackSystemName: String

    var body: some View {
        if UIImage(named: assetName) != nil {
            Image(assetName)
                .resizable()
                .scaledToFit()
        } else {
            Image(systemName: fallbackSystemName)
        }
    }
}

アイコン解決をビューに閉じ込めることで、UI 側に分岐が漏れなくなり、後から画像を差し替えるときもアセットを追加するだけで済みます。


ダミーデータ表示は「実データを壊さない」ことを優先した

App Store 用スクリーンショットを作るために、見栄えの良いダミーデータ表示モードを追加しました。ただし、実プロフィール自体は絶対に変更しない方針にしました。

実装方針は、実プロフィール編集画面とダミーデータ表示画面を切り替える方式です。ダミーモード時は読み取り専用のサンプル表示のみ出し、Binding で実モデルを書き換えないようにしています。

「編集できないのは不便では?」という問題はありましたが、App Store 素材用途としてはこの方が確実に安全でした。


WidgetKit では「データ同期のタイミング」が一番ハマった

ホーム画面ウィジェットも対応しましたが、ここが最も調整の多かった部分です。

ウィジェットには App Group 経由でデータを渡しています。当初は QR 画面を開いたタイミングだけ UserDefaults(suiteName:) に保存していたため、アプリ起動後でもウィジェットが空のままになるケースが発生しました。

原因は「データを保存するタイミングが遅い」ことでした。ウィジェットが Timeline を更新するのは独自のタイミングで動くため、アプリ側からいつデータを渡すかを設計する必要があります。

最終的には、プロフィール表示中にウィジェット用データを同期する WidgetDataSync を追加して解決しました。

struct WidgetDataSync {
    static func sync(profile: Profile) {
        let defaults = UserDefaults(suiteName: "group.com.example.myca")
        defaults?.set(profile.name, forKey: "widget_name")
        defaults?.set(profile.qrData, forKey: "widget_qrdata")
        WidgetCenter.shared.reloadAllTimelines()
    }
}

データ同期に加えて、レイアウト面でも以下の調整が必要でした。

  • contentMarginsDisabled() によるデフォルト余白の無効化
  • 小サイズウィジェットのテキスト潰れ対策
  • 小サイズだけ中央寄せにする条件分岐
  • widget 文言のローカライズ対応

ウィジェットは SwiftUI の見た目だけ見ていても解決しないことが多く、extension 設定・App Group・WidgetKit 固有の余白仕様まで含めて見る必要がありました。


クイックアクションは Info.plist 固定ではなく動的生成にした

ホーム画面アイコン長押しメニューも入れています。最初は Info.plistUIApplicationShortcutItems に直書きしていましたが、ローカライズしにくいため、アプリ起動時に動的生成する方式に変更しました。

UIApplication.shared.shortcutItems = [
    UIApplicationShortcutItem(
        type: "show_qr",
        localizedTitle: String(localized: "QRコードを表示"),
        localizedSubtitle: nil,
        icon: UIApplicationShortcutIcon(systemImageName: "qrcode")
    )
]

String(localized:) を使えるため、日本語・英語を正しく出し分けられるようになりました。


ストアレビューのダイアログは 2 系統にした

レビュー誘導は SKStoreReviewController を使っています。もともと広告表示回数ベースの導線がありましたが、発火条件が限定的だったため、起動回数ベースの導線も追加しました。

現在の発火条件は以下の通りです。

  • 起動回数が一定回数を超えている
  • 表示回数が上限未満
  • 前回表示から一定期間が経過している

SKStoreReviewController は Apple 側が表示可否を最終判断するため、条件を満たしても必ず表示されるわけではありません。ただ、アプリ側では乱発しない仕組みを入れておくことは必要です。


権限文言やストア提出向けの仕上げも重要だった

公開前は、機能追加よりも「提出できる状態へ整える」作業が多くなりました。

  • NSContactsUsageDescription のローカライズ
  • NSFaceIDUsageDescription のローカライズ
  • ウィジェット文言のローカライズ
  • アプリ名や旧名称の置き換え
  • Firebase 初期化 / Crashlytics 有効化

特に InfoPlist.strings を使った権限文言のローカライズは後回しにしがちですが、提出直前に確実に必要になります。審査でリジェクトされるポイントでもあるため、機能実装と並行して早めに対応しておくことをおすすめします。


まとめ

今回の開発で実感したのは、SwiftUI アプリは「動くものを作る」だけならかなり速い一方で、公開品質まで仕上げるフェーズでは、結局かなり広いレイヤーを見る必要があるということです。

  • UI の体感速度(非同期処理)
  • 画面回転時の設計
  • WidgetKit と App Group
  • StoreKit
  • Firebase
  • 権限文言とローカライズ

これらを一つずつ潰していくと、アプリ全体の完成度が大きく変わります。

個人的に一番想定外だったのはウィジェットで、SwiftUI の書き方よりも「いつ・どこでデータを渡すか」の設計が本質的な問題でした。機能ごとに「見た目の問題ではなく設計の問題」に気づく瞬間が何度もあり、それが今回の開発で一番の学びでした。

同じように SwiftUI で iOS アプリを仕上げている方の参考になれば幸いです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?