跳到主要内容
教程目录

iOS 扩展签名怎么配?分享扩展、通知服务扩展的描述文件

最后更新

iOS 扩展签名记住四条:扩展的 Bundle ID 以主 App 的 Bundle ID 加点开头;每个扩展有自己的 App ID 和描述文件,和主 App 用同一个团队、同一张证书签;要共享数据时两边都开 App Groups、填同一个 group id;扩展和主 App 的版本号、构建号保持一致。下文用工程 MyApp 演示分享扩展、通知服务扩展的配置,以及在 KXBuild 的 keychain 目录里怎么放描述文件,Apple 规则截至 2026 年 10 月。

文中的工程已经能用 KXBuild 编译并装到真机上。还没走到这一步的,先看第一个 SwiftUI App。证书和描述文件还不熟的,先看 Bundle ID、App ID、证书和描述文件的关系。

iOS 扩展签名要满足哪些规则?

扩展(.appex)是打包在主 App 里的独立程序,有自己的 Bundle ID 和签名。主 App 签名正确不代表扩展也正确,每一个都要单独满足下面几条。

规则 要求 违反时
Bundle ID 前缀 以主 App 的 Bundle ID 加点开头,后面只再加一段,例如 com.example.myapp.myappshare 上传时报 ITMS-90347
Bundle ID 唯一 包里每个 App、扩展、框架的 Bundle ID 互不相同 上传时报 ITMS-90685
独立 App ID 每个扩展在开发者后台注册自己的精确 App ID 构建时找不到描述文件
独立描述文件 每个扩展、每种分发方式各一份描述文件 构建或安装失败
同一团队、同一证书 主 App 和扩展用同一个团队的同一类证书签名 安装时报 entitlements 无效
能力两边都开 要共享数据的,主 App 和扩展的 App ID 都开 App Groups,选同一个 group 读不到对方写的数据,或描述文件不含该能力
版本一致 扩展的版本号、构建号和主 App 相同 上传时收到版本不一致的提示

ITMS-90347 的提示原文是 Bad bundle identifier. The bundle identifier ‘…’ of the application extension ‘…’ should start with the application’s bundle identifier ‘…’ and not contain more than one period “.” after the application’s bundle ID,也就是前缀必须是主 App 的 Bundle ID,后面只能再有一个点。

怎么用 KXBuild 添加分享扩展和通知服务扩展?

以下两种做法任选其一。

  • VS Code 插件。在 KXBuild 视图中右键 MyApp 这个 target,选 KXBuild: 添加 Target…。类型先选「分享」,名称用 MyAppShare,App Group 填 group.com.example.myapp。再添加一次,类型选「通知服务」,名称用 MyAppNotification,App Group 留空。
  • 命令行。在工程所在目录执行下面两条命令。
1kxbuild project add-target -P .\MyApp.xcodeproj --type share --name MyAppShare --app-group group.com.example.myapp
2kxbuild project add-target -P .\MyApp.xcodeproj --type notification-service --name MyAppNotification

扩展的 Bundle ID 默认是 App 的 Bundle ID 加点和小写的名称,这里分别是 com.example.myapp.myappshare 和 com.example.myapp.myappnotification,正好满足前缀规则。

要自己指定时用 --bundle-id,同样必须以 App 的 Bundle ID 加点开头。填了 App Group 的,App 和分享扩展会同时加上这个 App Group 的 entitlements。添加完成后的提示里会列出要注册的 App ID 和要开启的能力,照着做就行。

参数和全部类型见添加 Target 和 kxbuild 命令参考。

两个扩展都随 App 一起构建、打包和安装,不用再改工程设置。

开发者后台要准备哪些 App ID 和 App Group?

登录 developer.apple.com,进入 Certificates, Identifiers & Profiles,按下面的顺序操作。

  1. 创建 App Group。在 Identifiers 页点「+」,选 App Groups,填描述和标识符 group.com.example.myapp,点 Register。步骤见 Apple 的注册 App Group。
  2. 主 App 的 App ID。点开 com.example.myapp,勾选 App Groups 并点 Configure 选中刚建的 group。App 要收远程推送的,同时勾选 Push Notifications。保存。
  3. 分享扩展的 App ID。点「+」选 App IDs → App,Bundle ID 选 Explicit,填 com.example.myapp.myappshare,勾选 App Groups 并选中同一个 group。
  4. 通知服务扩展的 App ID。同样注册 com.example.myapp.myappnotification。它只是在显示前改通知内容,不需要勾选 Push Notifications,要和 App 共享数据时才勾 App Groups。

App Group 的标识符格式是 group. 加名字,见 Apple 的 App Groups entitlement 文档。名字在 App、扩展的代码和 entitlements 里必须一字不差。通配 App ID(com.example.*)不能开 App Groups,开了能力的 target 都要用精确 App ID。

能力是在生成描述文件之后才开的,原来的描述文件不包含它,要点开描述文件 Edit 后直接 Save,重新下载。

带扩展的 App 要准备哪些描述文件?

每个 target、每种用途各一份。下表是这个工程三个 target 的完整清单,名字是在开发者后台创建描述文件时填的。

用途 证书 MyApp MyAppShare MyAppNotification
Debug 真机调试 Apple Development MyApp Dev MyAppShare Dev MyAppNotification Dev
Ad Hoc 测试分发 Apple Distribution MyApp AdHoc MyAppShare AdHoc MyAppNotification AdHoc
App Store、TestFlight Apple Distribution MyApp AppStore MyAppShare AppStore MyAppNotification AppStore

几个容易漏的地方。

  • Development 和 Ad Hoc 要包含同样的设备。三份描述文件里都要勾上测试机,只有 App 的勾了,安装时扩展会失败。新增测试机后三份都要重新生成。
  • 三份必须是同一个团队、同一张证书。Ad Hoc 和 App Store 描述文件创建时选的是同一张 Apple Distribution 证书。
  • 只做开发、没开任何能力时可以偷懒。一份通配 App ID(com.example.*)的 Development 描述文件能同时签 App 和所有扩展。本文的分享扩展用了 App Groups,就不能用通配的。

创建步骤见 Apple 的创建开发描述文件、创建 Ad Hoc 描述文件和创建 App Store 描述文件。

描述文件在 keychain 目录里怎么放、怎么匹配?

在 Windows、Linux 上用 KXBuild 构建时,把证书、描述文件和密码文件放进 keychain 证书目录,规则以 Windows 给 iOS App 签名为准。日常开发和上架两套文件放在一起的样子如下。

 1keychain\
 2├─ development.p12
 3├─ distribution.p12
 4├─ MyApp_Dev.mobileprovision
 5├─ MyAppShare_Dev.mobileprovision
 6├─ MyAppNotification_Dev.mobileprovision
 7├─ MyApp_AppStore.mobileprovision
 8├─ MyAppShare_AppStore.mobileprovision
 9├─ MyAppNotification_AppStore.mobileprovision
10└─ password.properties

构建时每个 target 各自挑选描述文件,规则见证书选择规则。

  • 证书类型看 CODE_SIGN_IDENTITY,Apple Development 用开发证书,Apple Distribution 用发布证书。
  • 团队看 DEVELOPMENT_TEAM,只用这个团队的证书和描述文件。
  • 描述文件:自动签名按 target 的 Bundle ID 和团队匹配,三个 target 的 Bundle ID 不同,各自会匹配到自己的那一份,不用在工程里指定。手动签名按 PROVISIONING_PROFILE_SPECIFIER 里填的描述文件名查找。

新建的工程和添加的扩展默认都是自动签名,按上面放好就能直接构建。证书目录里留着开通能力前下载的旧描述文件时,删掉旧的,免得匹配到不含 App Groups 的那份。

Ad Hoc 和 App Store 放在一起时

同一个 Bundle ID 同时有 Ad Hoc 和 App Store 两份描述文件时,两份都留在证书目录里即可。打上架包用 --export app-store,只用 App Store 描述文件;打 Ad Hoc 包用 --export ad-hoc,只用 Ad Hoc 描述文件。每个 target 各自挑到对应的那份,不用在工程里指定。

1kxbuild build .\MyApp.xcodeproj -c Release --export ad-hoc

需要同时出 Ad Hoc 和上架两种包的,把两套设置分别写进 xcconfig,做法见 iOS 多环境打包。

分享扩展的最小示例怎么写?

分享扩展出现在系统分享面板里,接收其他 App 分享过来的文字、链接和图片。

Info.plist 的 NSExtension

添加 target 时生成的 MyAppShare/Info.plist 已经配好,内容如下。

 1<key>NSExtension</key>
 2<dict>
 3	<key>NSExtensionAttributes</key>
 4	<dict>
 5		<key>NSExtensionActivationRule</key>
 6		<dict>
 7			<key>NSExtensionActivationSupportsImageWithMaxCount</key>
 8			<integer>1</integer>
 9			<key>NSExtensionActivationSupportsText</key>
10			<true/>
11			<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
12			<integer>1</integer>
13		</dict>
14	</dict>
15	<key>NSExtensionPointIdentifier</key>
16	<string>com.apple.share-services</string>
17	<key>NSExtensionPrincipalClass</key>
18	<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
19</dict>
  • NSExtensionPointIdentifier 是扩展类型,分享扩展固定为 com.apple.share-services。
  • NSExtensionPrincipalClass 是入口类。不用 Storyboard 时写类名,带上模块名前缀。
  • NSExtensionActivationRule 决定什么内容出现你的扩展。这里是最多 1 张图片、文字、最多 1 个网址。不支持的类型删掉对应的键或写 0。

调试时可以把 NSExtensionActivationRule 写成字符串 TRUEPREDICATE,让扩展对所有内容都出现。Apple 的扩展编程指南写明,包含 TRUEPREDICATE 的 App 提交审核会被拒,上架前一定改回具体规则。要改这些键,双击 Info.plist 用属性列表编辑器编辑。

ShareViewController

把 MyAppShare/ShareViewController.swift 换成下面的内容。它用系统自带的编辑框,用户点「发布」时把文字和网址写进 App Group。

 1import UIKit
 2import Social
 3import UniformTypeIdentifiers
 4
 5let appGroup = "group.com.example.myapp"
 6
 7var sharedDefaults: UserDefaults {
 8    UserDefaults(suiteName: appGroup) ?? .standard
 9}
10
11/// 系统自带的分享编辑框:一个文本框加「发布」「取消」,不用 Storyboard
12class ShareViewController: SLComposeServiceViewController {
13
14    override func isContentValid() -> Bool {
15        // 文字不超过 200 个字才允许发布
16        (contentText ?? "").count <= 200
17    }
18
19    override func didSelectPost() {
20        sharedDefaults.set(contentText, forKey: "sharedText")
21        sharedDefaults.set(Date().timeIntervalSince1970, forKey: "sharedAt")
22
23        // 在分享来的附件里找网址,和文字一起存进 App Group
24        let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? [])
25            .flatMap { $0.attachments ?? [] }
26        guard let provider = providers.first(where: {
27            $0.hasItemConformingToTypeIdentifier(UTType.url.identifier)
28        }) else {
29            extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
30            return
31        }
32        provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
33            if let url = item as? URL {
34                sharedDefaults.set(url.absoluteString, forKey: "sharedURL")
35            }
36            DispatchQueue.main.async {
37                self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
38            }
39        }
40    }
41
42    override func configurationItems() -> [Any]! {
43        []
44    }
45}

completeRequest 必须调用,它告诉系统分享结束、关闭面板。在它之后系统随时可能结束扩展进程,耗时的上传要放在 App 里做,或者用后台 URLSession。

主 App 读取分享的内容

App 这边用同一个 App Group 读。把 App 的 ContentView.swift 换成下面的内容,每次回到前台刷新一次。

 1import SwiftUI
 2
 3struct ContentView: View {
 4    @Environment(\.scenePhase) private var scenePhase
 5    @State private var sharedText = ""
 6    @State private var sharedURL = ""
 7
 8    private let defaults = UserDefaults(suiteName: "group.com.example.myapp") ?? .standard
 9
10    var body: some View {
11        List {
12            Section("最近一次分享") {
13                Text(sharedText.isEmpty ? "还没有收到分享" : sharedText)
14                if !sharedURL.isEmpty {
15                    Text(sharedURL)
16                        .font(.footnote)
17                        .foregroundStyle(.secondary)
18                }
19            }
20        }
21        .onAppear(perform: load)
22        .onChange(of: scenePhase) { phase in
23            if phase == .active { load() }
24        }
25    }
26
27    private func load() {
28        sharedText = defaults.string(forKey: "sharedText") ?? ""
29        sharedURL = defaults.string(forKey: "sharedURL") ?? ""
30    }
31}

两边只要有一处没开 App Groups,或者 group id 拼写不同,UserDefaults(suiteName:) 不会报错,只是各读各的,App 永远看不到分享的内容。分享扩展的图标自动使用主 App 的图标,不用单独准备。

通知服务扩展的最小示例怎么写?

通知服务扩展在远程推送显示之前拿到内容,可以改标题正文、解密、下载图片附件。它不显示任何界面。

Info.plist 的 NSExtension

生成的 MyAppNotification/Info.plist 内容如下,扩展点固定为 com.apple.usernotifications.service。

1<key>NSExtension</key>
2<dict>
3	<key>NSExtensionPointIdentifier</key>
4	<string>com.apple.usernotifications.service</string>
5	<key>NSExtensionPrincipalClass</key>
6	<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
7</dict>

NotificationService

把 MyAppNotification/NotificationService.swift 换成下面的内容。它给标题加前缀,并在 payload 带了 attachment-url 时下载图片作为附件。

 1@preconcurrency import UserNotifications
 2
 3/// payload 的 aps 里带 "mutable-content": 1 时,系统在显示前先交给这里处理
 4class NotificationService: UNNotificationServiceExtension {
 5
 6    var contentHandler: ((UNNotificationContent) -> Void)?
 7    var bestAttemptContent: UNMutableNotificationContent?
 8
 9    override func didReceive(_ request: UNNotificationRequest,
10                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
11        self.contentHandler = contentHandler
12        guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
13            contentHandler(request.content)
14            return
15        }
16        bestAttemptContent = content
17
18        // 1. 改文字:给标题加前缀,证明扩展生效了
19        content.title = "【新消息】" + content.title
20
21        // 2. 下载图片附件:payload 里自定义字段 attachment-url
22        guard let link = content.userInfo["attachment-url"] as? String,
23              let url = URL(string: link) else {
24            contentHandler(content)
25            return
26        }
27        URLSession.shared.downloadTask(with: url) { location, _, _ in
28            if let location {
29                let file = FileManager.default.temporaryDirectory
30                    .appendingPathComponent(UUID().uuidString)
31                    .appendingPathExtension(url.pathExtension)
32                if (try? FileManager.default.moveItem(at: location, to: file)) != nil,
33                   let attachment = try? UNNotificationAttachment(identifier: "image", url: file) {
34                    content.attachments = [attachment]
35                }
36            }
37            contentHandler(content)
38        }.resume()
39    }
40
41    override func serviceExtensionTimeWillExpire() {
42        // 大约 30 秒用完时系统调用这里,把手头已经改好的内容交出去
43        if let contentHandler, let bestAttemptContent {
44            contentHandler(bestAttemptContent)
45        }
46    }
47}

Swift 6 语言模式下,编译器会对下载回调里捕获的 contentHandler 给出一条 Sendable 警告,不影响运行。

测试用的 payload 如下。mutable-content 必须是 1,alert 必须有内容,attachment-url 写在 aps 外面。

1{"aps":{"alert":{"title":"订单已发货","body":"点开查看物流"},"mutable-content":1,"sound":"default"},"attachment-url":"https://example.com/images/parcel.jpg"}

Apple 的 UNNotificationServiceExtension 文档写明扩展运行的条件:App 的这条推送配置为显示提醒,并且 aps 里 mutable-content 为 1。静默推送、只有声音或角标的推送不会经过扩展。

didReceive 大约只有 30 秒,超时后系统调用 serviceExtensionTimeWillExpire,两个方法都没调用 completion 时系统显示原始内容,见修改新送达的通知内容。

主 App 本身要能收推送:App ID 开 Push Notifications,entitlements 里有 aps-environment,代码里注册推送拿到设备令牌。这部分按给 iOS App 加推送通知做,发送测试推送的脚本也在那篇里。推送凭据选 .p8 还是 .p12 见推送用 .p8 还是 .p12。

扩展的版本号和构建号怎么和主 App 保持一致?

扩展 target 有自己的构建设置,主 App 改了版本号而扩展没跟着改,上传时就会提示不一致。最稳的做法是把 MARKETING_VERSION 和 CURRENT_PROJECT_VERSION 只定义在工程级(PROJECT),所有 target 都不单独定义。

打开工程设置编辑器,在 Build Settings 页切到 Levels 视图,搜索这两个设置,确认 MyApp、MyAppShare、MyAppNotification 三个 target 的 Target 列都是空的,生效值来自 Project 列。原因和改法的完整说明见 iOS 版本号与构建号。

怎么构建、安装并打出带扩展的 ipa?

调试:在状态栏选好 iPhone,按 F5。KXBuild 会把 App 和两个扩展一起构建、各自签名并安装,然后启动 App。

  • 测分享扩展:打开 Safari 的任一网页,点分享按钮,在 App 列表里找到 MyApp。第一次没出现的,滑到最右点「更多」把它打开。发布后回到 MyApp,列表里显示刚才的文字和网址。
  • 测通知服务扩展:按推送教程拿到设备令牌,发上面的 payload,锁屏上的标题带「【新消息】」前缀并显示图片缩略图。

F5 调试的是 App,扩展运行在自己的进程里,App 里的断点管不到它。扩展的逻辑尽量简单,用 print 或 Logger 输出日志,看法见 iOS 真机日志怎么看。

出包:以下两种做法任选其一。

  • VS Code 插件。在命令面板或工程的右键菜单运行 KXBuild: 生成 App Store ipa。
  • 命令行。在工程所在目录执行下面的命令,用发布证书签名。
1kxbuild build .\MyApp.xcodeproj -c Release --export app-store

生成的 build\MyApp.ipa 里带着两个扩展,每个都用自己的描述文件签好名。上传和提交审核的流程见在 Windows 上上架 App Store,Ad Hoc 包的分发见发布与上架。

iOS 扩展签名常见报错怎么处理?

报错或现象 原因 处理
No profiles for 'com.example.myapp.myappshare' were found 证书目录里没有这个扩展的描述文件,或类型不对 给报错里的 Bundle ID 生成对应类型的描述文件放进 keychain,见签名报错
构建报描述文件不含 App Groups(com.apple.security.application-groups) 扩展的 App ID 开能力后没重新生成描述文件,或用的是通配描述文件 编辑描述文件后保存,重新下载替换
Provisioning profile "…" has app ID "…", which does not match the bundle ID "…" 手动签名时给扩展填了 App 的描述文件名,或用 -D 覆盖了所有 target 按 target 分别填 PROVISIONING_PROFILE_SPECIFIER
安装报 The executable was signed with invalid entitlements(0xe8008016) 某个扩展签进去的能力超出了它的描述文件,常见于扩展用了别的团队的描述文件、或扩展缺能力 主 App 和每个扩展用同一团队、各自 Bundle ID 的描述文件,用 KXBuild 重新构建整个工程,不要重签旧包
安装报 A valid provisioning profile for this executable was not found 扩展的 Development 或 Ad Hoc 描述文件里没有这台手机 三份描述文件都勾上这台设备,重新下载后重新构建
上传报 ITMS-90347 Bad bundle identifier 扩展的 Bundle ID 不以主 App 的开头,或后面多了一个以上的点 改成 主 App Bundle ID.名称,重新注册 App ID 和描述文件
上传报 ITMS-90685 CFBundleIdentifier Collision 包里有两个 bundle 的 Bundle ID 相同,例如扩展和 App 用了同一个,或同一个框架同时嵌进了 App 和扩展 给每个 target 唯一的 Bundle ID;框架只嵌进 App,扩展只链接不嵌入
上传提示扩展版本号或构建号和主 App 不一致(常见为 ITMS-90473) 扩展 target 单独定义了版本号 按上文把两个设置放到工程级
分享面板里找不到 App 扩展没装上,或分享的内容不符合 NSExtensionActivationRule 先确认 F5 时扩展一起安装了,再用规则支持的内容(文字、网址、图片)测试,必要时在「更多」里打开
推送到了但扩展没生效 payload 没有 mutable-content: 1,或 alert 为空、是静默推送 按上文 payload 补齐;确认手机系统版本不低于扩展的部署目标

ITMS-90685 的原文是 CFBundleIdentifier Collision. There is more than one bundle with the CFBundleIdentifier value ‘…’ under the iOS application ‘…’,报错里会带出重复的那个 Bundle ID,按它找到是哪两个 bundle。iOS 扩展签名以外的上传错误见 App Store 上传 ITMS 错误码速查表,签名和安装的其他报错见 iOS 签名报错与 ipa 安装失败。

常见问题

iOS 扩展需要单独的描述文件吗?

需要。每个扩展有自己的 Bundle ID 和 App ID,每种用途(开发、Ad Hoc、App Store)都要一份它自己的描述文件。只做开发、扩展和 App 都没开能力时,一份通配 App ID 的 Development 描述文件可以同时签所有 target。

扩展的 Bundle ID 必须怎么起?

必须以主 App 的 Bundle ID 加点开头,后面只能再有一段,例如 com.example.myapp.myappshare。不符合时上传 App Store Connect 会报 ITMS-90347。

通知服务扩展要开 Push Notifications 能力吗?

不用。推送能力和 aps-environment 开在主 App 上,通知服务扩展只负责在显示前修改内容,它的 App ID 不需要勾选 Push Notifications。要和 App 共享数据时才给它开 App Groups。

分享扩展和 App 怎么共享数据?

在开发者后台建一个 App Group,主 App 和扩展的 App ID 都开启 App Groups 并选中它,重新生成两边的描述文件。代码里两边都用 UserDefaults(suiteName: "group.com.example.myapp") 读写,group id 要一字不差。

keychain 目录里有多个描述文件,KXBuild 怎么知道给哪个 target 用?

自动签名时每个 target 按自己的 Bundle ID 和团队匹配描述文件,App 和扩展的 Bundle ID 不同,会各自匹配到自己的那份。同一个 Bundle ID 同时有 Ad Hoc 和 App Store 两份时,两份都留着,--export app-store 和 --export ad-hoc 会各自挑对应类型的那份。