1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

iOSの招待機能、どう実装する?Universal LinksとShareLinkを使った実装例

Last updated at Posted at 2025-12-17

この記事は フリュー Advent Calendar 2025の18日目の記事です

フリューでエンジニアをしています佐藤です。
業務でiOSアプリに招待機能を追加する要望があり、その技術検証として他ユーザーへの招待方法について試したのでこちらでまとめようかと思います。

前提

今回追加する招待機能は、事前にサーバで発行された招待コードをAPIで取得し、それを含めたURLを共有します。
参加者側がURLをタップすると、アプリ未インストールの場合はストアに遷移し、インストール済みの場合はアプリが起動して参加フローに進むという仕様を想定しています。

図で示すと以下のような流れで動作します。

ユーザーA側:招待URLの生成

ユーザーB側:招待URLからの参加

本記事では④URL作成⑤共有について説明します。

招待URLの作成

まずは招待URLの作成について説明します。
招待相手がをタップするとアプリインストール状況に応じて遷移が変わるURLを実現します。

①Adjustカスタムリンク+カスタムURLスキーム

1つ目は、AdjustのカスタムリンクとカスタムURLスキームを使用する方法です。
Adjustのカスタムリンクを使用することで、あらかじめ設定したアプリの特定の画面へ遷移させることは可能です。ただし、今回の要件のように都度発行される招待コードを付与することはできません。
アプリ内でカスタムリンクが発行可能なAdjustのディープリンクジェネレーターAPIを使えば実現可能ですが、契約プランによっては使用できない場合や、APIのリクエスト制限があるなど、別途懸念事項があります。

Adjustカスタムリンクの設定方法

Adjustの管理画面から設定します。
設定方法はこちらを参照ください。
カスタムリンクを設定すると下の画像のようにリンクトークンが作成されます。
これを使ってURLを組み立てていきます。
スクリーンショット 2025-12-11 20.26.58.png

招待URL

上記で作成したリンクトークンを使ったパスにdeeplinkパラメータとしてカスタムURLスキームを指定します

https://app.adjust.com/{リンクトークン}?deeplink={カスタムURLスキーム}

カスタムURLスキームに招待コードなど必要なパラメータを付けることで、起動したアプリ側で取得することができます。

https://app.adjust.com/{リンクトークン}?deeplink=myapp://invitaion?code={招待コード}

②ユニバーサルリンク

もう一つの方法はユニバーサルリンクを使用する方法です。
ユニバーサルリンクとは、リンクに紐づくアプリがインストール済みであればアプリを起動し、未インストールであればWebページが開く仕組みです。
未インストールの場合はWebページを経由する必要がありますが、ストアへの動線を設置することで遷移させることも可能です。

apple-app-site-association(AASA)ファイルの準備

AASAファイルとは、特定のWebドメインとiOSアプリを紐付けるためのJSON形式の設定ファイルです。ユニバーサルリンクを実現するには、このファイルをWebサーバー上に配置する必要があります。
以下でJSONの形式を説明します。

apple-app-site-association
{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAM_ID.com.example.app"],
        "components": [
          {
            "/": "/invite/*",
            "comment": "招待URL"
          }
        ]
      }
    ]
  }
}
  • appIDs
    • 対象のアプリを指定します
    • フォーマットは<Team ID>.<Bundle Identifier>
    • 配列で設定できるため複数のアプリを指定することが可能です
  • components
    • URLの条件パターンを細かく指定することができます
    • 評価は上から順に行われ、最初にマッチしたルールが適用されます

conponentsで指定できる条件

パスのマッチング"/"
パスのマッチング("/")
{
  "components": [
    {
      "/": "/invite/*",
      "comment": "全ての招待URL"
    },
    {
      "/": "/invite/*/details",
      "comment": "中間パスにワイルドカード"
    },
    {
      "/": "/join/",
      "comment": "完全一致"
    }
  ]
}
クエリパラメータのマッチング"?"
クエリパラメータのマッチング("?")
{
  "components": [
    {
      "/": "/invite/*",
      "?": {
        "code": "*"
      },
      "comment": "codeパラメータが必須"
    }
  ]
}
複数の値を許可
複数の値を許可
{
  "?": {
    "type": ["friend", "family", "coworker"]
  }
}
フラグメントのマッチング"#"
フラグメントのマッチング("#")
{
  "components": [
    {
      "/": "/invite/*",
      "#": "details",
      "comment": "URLフラグメントも条件に"
    }
  ]
}
除外パターン("exclude"
除外パターン("exclude")
{
  "components": [
    {
      "/": "/invite/test/*",
      "exclude": true,
      "comment": "このパターンマッチしたらユニバーサルリンクとして動作させない"
    },
    {
      "/": "/invite/*",
      "comment": "それ以外の招待URLは有効"
    }
  ]
}

注意: "exclude": "true" のパターンを先に書く必要があります。

大文字小文字の区別("caseSensitive"
大文字小文字の区別("caseSensitive")
{
  "components": [
    {
      "/": "/Invite/*",
      "caseSensitive": true,
      "comment": "大文字小文字を区別"
    }
  ]
}
パーセントエンコーディング("percentEncoded"
パーセントエンコーディング("percentEncoded")
{
  "components": [
    {
      "/": "/invite/%E6%8B%9B%E5%BE%85/*",
      "percentEncoded": true,
      "comment": "パーセントエンコードされたパス"
    }
  ]
}

レガシーな書き方

上記で説明したフォーマットはiOS13以降で対応しており、それより前のiOSバージョンではこれから説明するレガシーな書き方のみ対応しています。
こちらは細かいパターンを指定することはできない形式となっておりますが、iOS13以降でも動作はするため、シンプルなパスマッチングのみであればこちらで十分です。

レガシーな書き方のAASAファイル
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAM_ID.com.example.app",
        "paths": [
          "/invite/*",
          "/join/*"
        ]
      }
    ]
  }
}
  • apps
    • 基本的に常に空配列 [] を指定します
    • iOS 9の初期実装時の名残で、現在は使用されていません
    • 省略できません(必須項目)
  • details
    • 複数アプリやパターンを定義する配列です
  • appID
    • 新フォーマットのappIDs同様、対象のアプリを指定します
    • ただし複数指定することはできません
  • paths
    • マッチさせたいURLパスのパターンを指定します
    • NOTをつけることで除外パターンの指定が可能です
"paths": [
  "NOT /invite/test/*",   // テストページを除外
  "/invite/*"             // それ以外の招待URL
]

こちらも除外パターンを先に記述する必要があります

XcodeでAssociated Domainsの設定

XcodeでTARGETS > Signing&Capabilities+CapabilityからAssociated Domainsを選択し追加します
XcodeでAssociatedDomainsの追加
追加されたAssociated DomainsのDomainsapplinks:ユニバーサルリンクのドメインの形式を設定を追加します
スクリーンショット 2025-12-11 17.12.00.png

AppIDのCapabilitiesでAssociated Domainsの有効化

Apple DeveloperのAppIDの設定に遷移し、CapabilitiesAssociated Domainsを有効化します
スクリーンショット 2025-12-11 17.25.02.png

Webサーバーへの配置

以下のパスにAASAファイルを設置します

https://example.com/.well-known/apple-app-site-association

ルート直下でも動作しますが、.well-known配下が推奨されます

ファイルの要件

  • 拡張子なし:ファイル名に .json などの拡張子は不要
  • Content-Typeapplication/json で配信
  • HTTPS必須:HTTPでは動作しません

動作のしくみ

  1. AASAファイルをWebサーバーに配置すると、Apple側のCDNが定期的にAASAファイルをクロールします
  2. iOS端末がアプリをインストールするとApple CDNにキャッシュされているAASAファイルを取得します
  3. 取得したAASAファイルをiOS端末内でキャッシュします
  4. ユーザーが該当URLをタップした際に、キャッシュされたAASAファイルのパターンに合致すればアプリが起動します

キャッシュの問題

AASAファイルはApple CDN側にキャッシュされており、AASAファイルを変更しても変更が反映されるまで数時間〜1日程度かかることがあるため注意が必要です。
Apple CDNには以下のURLでアクセス可能であるため、AASAファイルが変更されているか確認できます。

https://app-site-association.cdn-apple.com/a/v1/example.com

Webページの準備

ユニバーサルリンクは、アプリが未インストールの場合Webページに遷移するため、リンクと同じパスにWebページを配置しておく必要があります。
未インストールユーザーはApp Storeへ遷移させたいため、ストアへのリンクを設置するか、直接リダイレクトさせるように実装する方法があります。
Webページの実装についてはここでは割愛します。

ユニバーサルリンクの挙動確認

WebページとAASAファイルの配置、XcodeとApple Developer側の設定が完了すると、ユニバーサルリンクの挙動を確認できます。
紐づくアプリがインストール済みの状態でURLを長押しすると、アプリで開くかSafariで開くか選択できるようになっていれば、ユニバーサルリンクとして正常に動作しています。

招待URLの共有

次に作成した招待URLを共有する方法についてです。
共有機能を実装しなくても、クリップボードにコピーしてメールやLINEなどアプリ外で共有してもらう方法でも問題ありませんが、アプリ内で共有まで完了できた方がユーザビリティは高くなります。

ShareLink

iOS16以降ではSwiftUIのShareLinkを使うことで簡単に共有機能を実装できます。

ShareLink(item: URL(string:"https://developer.apple.com/documentation/SwiftUI/ShareLink")!)

ShareLinkを使えばアプリ内でユーザーが共有方法を選択することができます。
また、ShareLinkにはitem以外にもパラメータが用意されており、リンクの外観や内容をカスタマイズすることが可能です。

今回のユースケースではURLを共有できれば十分ですが、共有方法によってはURLと一緒にテキストも送れるとより良いです。
テキストはmessageパラメータとして設定することができます。

ShareLink(item: URL(string:"https://developer.apple.com/documentation/SwiftUI/ShareLink")!,
          message: Text("これはShareLinkのドキュメントへのリンクです"))

ただし、選択する共有方法によって表示のされ方は変わります。
以下のように、メッセージでは表示されますが、メモでは表示されないという場合があります。

また、他にもメッセージではテキストと一緒に共有されてほしいが、コピーする場合はURLだけにしたいというケースも考えられます。

このようなケースでは、ShareLinkでは実現できないため、代わりにUIActivityViewControllerを使って実装する必要があります。

UIActivityViewController

基本的には共有する内容であるactivityItemsと、アプリ独自の共有アクションを追加できるapplicationActivitiesを指定します。

struct InviteView: View {
    @State private var showingShareSheet = false
    
    var body: some View {
        Button {
            showingShareSheet = true
        } label: {
            Label("共有する", systemImage: "square.and.arrow.up")
        }
        .sheet(isPresented: $showingShareSheet) {
            ActivityViewController()
        }
    }
}

// ActivityViewController wrapper
struct ActivityViewController: UIViewControllerRepresentable {
    let url = URL(string: "https://developer.apple.com/documentation/SwiftUI/ShareLink")!

    func makeUIViewController(context: Context) -> UIActivityViewController {
        let controller = UIActivityViewController(
            activityItems: [url],
            applicationActivities: nil
        )
        return controller
    }

    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

以下では、ShareLinkでは対応できないケースと代わりの実装方法について説明します。

共有完了をトラッキングする方法

UIActivityViewControllercompletionWithItemsHandlerに対して共有完了後のアクションを指定することで実現可能です。

struct InviteView: View {
    @State private var showingShareSheet = false
    @State private var shareCompleted = false
    
    var body: some View {
        Button {
            showingShareSheet = true
        } label: {
            Label("共有する", systemImage: "square.and.arrow.up")
        }
        .sheet(isPresented: $showingShareSheet) {
            ActivityViewController(
                    activityItems: [
                        URL(string: "https://developer.apple.com/documentation/SwiftUI/ShareLink")!
                    ],
                    onComplete: { activityType, completed in
                        if completed {
                            shareCompleted = true
                            // ここで分析トラッキングなどを実行
                            trackShareEvent(type: activityType)
                        }
                    }
            )
        }

        // 共有が完了したことを表示
        if shareCompleted {
            Text("招待を送信しました!")
                .foregroundColor(.green)
        }
    }
}

// ActivityViewController wrapper
struct ActivityViewController: UIViewControllerRepresentable {
    let activityItems: [Any]
    let onComplete: (UIActivity.ActivityType?, Bool) -> Void

    func makeUIViewController(context: Context) -> UIActivityViewController {
        let controller = UIActivityViewController(
            activityItems: [url],
            applicationActivities: nil
        )

        // 共有完了後の動作を設定
        controller.completionWithItemsHandler = { activityType, completed, returnedItems, error in
            onComplete(activityType, completed)
        }
        
        return controller
    }

    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

不要なアクティビティを除外する方法

UIActivityViewControllerexcludedActivityTypesに除外したいアクティビティを指定することで、共有シートに表示されなくなります。

struct InviteView: View {
    @State private var showingShareSheet = false
    @State private var shareCompleted = false
    
    var body: some View {
        Button {
            showingShareSheet = true
        } label: {
            Label("共有する", systemImage: "square.and.arrow.up")
        }
        .sheet(isPresented: $showingShareSheet) {
            ActivityViewController(
                    activityItems: [
                        URL(string: "https://developer.apple.com/documentation/SwiftUI/ShareLink")!
                    ],
                    // AirDropとコピーを除外
                    excludedActivityTypes: [
                        .airDrop,
                        .copyToPasteboard
                    ]
            )
        }
    }
}

// ActivityViewController wrapper
struct ActivityViewController: UIViewControllerRepresentable {
    let activityItems: [Any]
    let excludedActivityTypes: [UIActivity.ActivityType]?

    func makeUIViewController(context: Context) -> UIActivityViewController {
        let controller = UIActivityViewController(
            activityItems: [url],
            applicationActivities: nil
        )

        // 除外するアクティビティを設定
        controller.excludedActivityTypes = excludedActivityTypes
        
        return controller
    }

    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

共有方法によって異なるコンテンツを送る

共有方法によって異なるコンテンツを送りたい場合は、UIActivityItemSourceを使って実装します。
以下は、コピーする場合はURLのみ、それ以外で共有する場合はテキストとURLとする場合の例です。

// カスタムActivityItemSource
class CustomActivityItemSource: NSObject, UIActivityItemSource {
    let url: URL

    init(url: URL) {
        self.url = url
    }

    // プレースホルダー(初期表示用)
    func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
        return url
    }

    // 実際に共有されるアイテム(アプリごとに変更可能)
    func activityViewController(_ activityViewController: UIActivityViewController,
                                itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {

        // コピーの場合はURLのみ、それ以外はテキストつき
        switch activityType {
            case .copyToPasteboard:
                return url
            default:
                let message = """
                    \(url.absoluteString)
                    これはShareLinkのドキュメントへのリンクです
                    """
                return message
        }
    }
}

// SwiftUIでの使用
struct InviteView: View {
    @State private var showingShareSheet = false
    
    var body: some View {
        Button {
            showingShareSheet = true
        } label: {
            Label("共有する", systemImage: "square.and.arrow.up")
        }
        .sheet(isPresented: $showingShareSheet) {
            ActivityViewController(
                activityItems: [
                    CustomActivityItemSource(url: URL(string: "https://developer.apple.com/documentation/SwiftUI/ShareLink")!)
                ]
            )
        }
    }
}

// ActivityViewController wrapper
struct ActivityViewController: UIViewControllerRepresentable {
    let activityItems: [Any]

    func makeUIViewController(context: Context) -> UIActivityViewController {
        let controller = UIActivityViewController(
            activityItems: activityItems,
            applicationActivities: nil
        )
        return controller
    }

    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

ActivityTypeについて

UIActivity.ActivityTypeは、共有先の種類を識別するための識別子です。

extension UIActivity {
    public struct ActivityType : Hashable, Equatable, RawRepresentable {
        public init(rawValue: String)
    }
}

以下のようなActivityTypeはiOS標準で用意されています。

.mail          // メールアプリ

.message       // メッセージ(SMS/iMessage)

.airDrop       // AirDrop

.copyToPasteboard  // コピー

LINESlackDiscordなどのSNS系アプリは基本的に用意されていません。
これらはactivityType.rawValueでそれぞれの値を確認できますが、アプリのバージョンによって変わる可能性があるため、実装に組み込む場合は工夫が必要です。

まとめ

招待機能の実装における、「招待URLの作成」と「共有」部分について説明しました。

招待URLに関しては、Adjustカスタムリンクを使うと工数が比較的少なく、Adjustの計測を使えるというメリットがありますが、URLスキームがユーザーに見えてしまうという懸念があり、より自然なユーザー体験を提供できるユニバーサルリンクを採用することにしました。実装の工数は増えましたが、ユーザーにとって違和感のないURL形式にできた点で正しい選択だったと思います。ユニバーサルリンクの実装では、AASAファイルの準備とWebページの配置が必要となり、Apple CDNのキャッシュによる反映遅延に注意が必要です。

共有については、シンプルなケースではShareLinkを使うことで容易に実装できます。一方で、共有完了のトラッキングや共有方法ごとに異なるコンテンツを送信したい場合は、UIActivityViewControllerを使うことで柔軟な実装が可能です。要件に応じて両者を適切に使い分けることが重要だと感じました。

今回の検証を通じて、iOS標準の機能だけでも実用的な招待機能を実装できることが確認できました。この記事が同じような機能を実装する方の参考になれば幸いです。

バージョン情報

  • Xcode 16.1
  • iOS 18.5
1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?