iOS App Extension
最近在在帮朋友写一个笔记应用,需要一个笔记的导入功能,场景如下:在File App中选择一个文件,比如pdf,点击分享,展现分享面板,分享面板下面的Action菜单,需要展示“在xxx中打开”,点击此菜单,打开app,并将文件导入到App中。
在介绍这个功能如何实现之前,我们先了解一下系统分享面板的构成。
第一行为Share Extension,这里也可以通过设置Document Type来展示在自己的app所支持的文件类型。 第二行位Action Extension,通过Action扩展设置自己的app支持的文件类型和操作。这里只说Action Extension。
- 创建Action Extension
创建过程笔记简单,不做过多说明,但需要注意的是,我们要根据情况来创建带UI和不带用的Action Extension。不带UI的创建出来是一个ActonHandler,带UI的创建出来是一个ActonViewController。区别还是很大的。如果你希望点击扩展的菜单打开自己的UI或者打开自己的App,一定要创建带UI的Action Extension。这里想不说为什么,后面讲到。
- 了解插件工作机制
官方链接 :Understand How an App Extension Works (opens new window)
重点如下:
- 插件只能与Host App通过上下文直接通信
- 插件可通过共享资源区与Containing App间接通信
- Host App就是第三方app,比如File App, Containing App就是你自己的app
Host App-Extension-Containing App工作流程
- Host App通过点击系统分享菜单中的插件图标/Action菜单调起扩展程序——Share/ActionExtension
- iOS系统(Host App)通过扩展上下文(NSExtensionContext)向Share/ActionExtension传递欲分享的数据。
- Share/Action Extension提取数据并序列化到以AppGroup ID标识的共享资源区NSUserDefaults/AppGroup Container(containerURLForSecurityApplicationGroupIdentifier)中。
- Share/Action Extension通过URL Scheme呼起ContainingApp,同时插件通过上下文向iOS系统(HostApp)发出request completion通知,以便返回到Host App(iOS系统会dismiss插件UIViewController)。
- Containing App通过App Group ID从NSUserDefaults/containerURL中读取分享过来的数据,并对分享数据进行后续处理。
- 扩展插件将Host App与Containing App勾搭起来,而App Group Container则架起了数据交互的桥梁。
- 创建App Group,设置URL Scheme
既然需要从Host App中获取数据并在自己的app中打开,首先Extension和Container App直接要想数据共享,只能通过创建App Group,通过共享区域来共享资源,其次,通过插件打开自己的app,那么就主app需要设置URL Scheme。插件中通过URL Scheme打开主App。
- 打开Container App
要想打开主App,创建插件的时候一定要选择UI插件,并且不可以用NSExtensionContext的open方法。因为官方文档已经明确说明,只有Today Extension 才支持通过调用 -[NSExtensionContext openURL:completionHandler:] 访问 URL Scheme 链接打开 Containing App。
而我们要想打开主App,目前只能用下面的方法,支持当前新最新的iOS 18 版本:
private func openURL(_ url: URL) -> Bool {
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
if #available(iOS 18.0, *) {
application.open(url, options: [:], completionHandler: nil)
return true
} else {
return application.perform(#selector(openURL(_:)), with: url) != nil
}
}
responder = responder?.next
}
return false
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
由于这里用到了UIApplication,UIResponder,所以创建Extension的时候一定要用带UI的,否则在ActionHandler中会找不到UIApplication。
注意:如果你的主App是用SwiftUI创建的,那么在app struct中需要添加@Environment(\.openURL) var openURL
,否则也是打不开主app的。
- 文件类型的支持
在插件的Info.plist中添加如下内容(NSExtension ->NSExtensionAttributes -> NSExtensionActivationRule):
SUBQUERY (
extensionItems,
$extensionItem,
SUBQUERY (
$extensionItem.attachments,
$attachment,
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.pdf" ||
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" ||
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.zip-archive" ||
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.pkware.zip-archive"
).@count == 1
).@count == 1
2
3
4
5
6
7
8
9
10
11
12
根据自己的情况配置,我这里支持的事pdf,图片和zip包。
- 插件完整代码
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers
enum FileType: String, CaseIterable {
case pdf = "pdf"
case image = "image"
case zip = "zip"
case unknown = "unknown"
}
struct ProcessedFile {
let url: URL
let originalName: String
let fileType: FileType
let size: Int
}
class ActionViewController: UIViewController {
var context: NSExtensionContext?
override func beginRequest(with context: NSExtensionContext) {
self.context = context
guard let extensionItems = context.inputItems as? [NSExtensionItem] else {
print("没有找到输入项")
context.completeRequest(returningItems: nil, completionHandler: nil)
return
}
processExtensionItems(extensionItems, context: context)
}
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .clear
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
}
private func processExtensionItems(_ extensionItems: [NSExtensionItem], context: NSExtensionContext) {
var processedFiles: [ProcessedFile] = []
let dispatchGroup = DispatchGroup()
for extensionItem in extensionItems {
guard let attachments = extensionItem.attachments else { continue }
for attachment in attachments {
dispatchGroup.enter()
processAttachment(attachment) { file in
if let file = file {
processedFiles.append(file)
}
dispatchGroup.leave()
}
}
}
dispatchGroup.notify(queue: .main) {
if processedFiles.isEmpty {
print("没有找到支持的文件")
context.completeRequest(returningItems: nil, completionHandler: nil)
} else {
print("处理了 \(processedFiles.count) 个文件")
self.openMainAppWithFiles(processedFiles, context: context)
}
}
}
private func processAttachment(_ attachment: NSItemProvider, completion: @escaping (ProcessedFile?) -> Void) {
// 检查PDF
if attachment.hasItemConformingToTypeIdentifier(UTType.pdf.identifier) {
loadAttachment(attachment, typeIdentifier: UTType.pdf.identifier, fileType: .pdf, completion: completion)
}
// 检查图片
else if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
loadAttachment(attachment, typeIdentifier: UTType.image.identifier, fileType: .image, completion: completion)
}
// 检查ZIP
else if attachment.hasItemConformingToTypeIdentifier(UTType.zip.identifier) {
loadAttachment(attachment, typeIdentifier: UTType.zip.identifier, fileType: .zip, completion: completion)
}
// 检查通用文件
else if attachment.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
loadAttachment(attachment, typeIdentifier: UTType.data.identifier, fileType: .unknown, completion: completion)
}
else {
completion(nil)
}
}
private func loadAttachment(_ attachment: NSItemProvider, typeIdentifier: String, fileType: FileType, completion: @escaping (ProcessedFile?) -> Void) {
attachment.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in
if let error = error {
print("加载文件失败: \(error)")
completion(nil)
return
}
if let url = item as? URL {
// 文件URL - 需要复制到共享容器
self.copyFileToSharedContainer(url: url, fileType: fileType, completion: completion)
} else if let data = item as? Data {
// 文件数据 - 直接保存到共享容器
self.saveDataToSharedContainer(data: data, fileType: fileType, completion: completion)
} else if let image = item as? UIImage {
// 图片对象 - 转换为数据后保存
if let imageData = image.pngData() {
self.saveDataToSharedContainer(data: imageData, fileType: .image, completion: completion)
} else {
completion(nil)
}
} else {
completion(nil)
}
}
}
private func copyFileToSharedContainer(url: URL, fileType: FileType, completion: @escaping (ProcessedFile?) -> Void) {
guard url.startAccessingSecurityScopedResource() else {
print("无法访问文件: \(url)")
completion(nil)
return
}
defer {
url.stopAccessingSecurityScopedResource()
}
do {
let data = try Data(contentsOf: url)
let fileName = url.lastPathComponent
saveDataToSharedContainer(data: data, fileName: fileName, fileType: fileType, completion: completion)
} catch {
print("读取文件失败: \(error)")
completion(nil)
}
}
private func saveDataToSharedContainer(data: Data, fileName: String? = nil, fileType: FileType, completion: @escaping (ProcessedFile?) -> Void) {
guard let sharedContainer = getSharedContainerURL() else {
print("无法获取共享容器")
completion(nil)
return
}
let finalFileName = fileName ?? generateFileName(for: fileType)
let destinationURL = sharedContainer.appendingPathComponent(finalFileName)
do {
try data.write(to: destinationURL)
let processedFile = ProcessedFile(
url: destinationURL,
originalName: finalFileName,
fileType: fileType,
size: data.count
)
completion(processedFile)
} catch {
print("保存文件到共享容器失败: \(error)")
completion(nil)
}
}
private func getSharedContainerURL() -> URL? {
// 使用App Groups共享容器
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.painotes.shared")
}
private func generateFileName(for fileType: FileType) -> String {
let timestamp = Int(Date().timeIntervalSince1970)
switch fileType {
case .pdf:
return "document_\(timestamp).pdf"
case .image:
return "image_\(timestamp).png"
case .zip:
return "archive_\(timestamp).zip"
case .unknown:
return "file_\(timestamp).dat"
}
}
private func openMainAppWithFiles(_ files: [ProcessedFile], context: NSExtensionContext) {
// 创建文件信息JSON
let fileInfos = files.map { file in
return [
"url": file.url.path,
"name": file.originalName,
"type": file.fileType.rawValue,
"size": file.size
]
}
guard let jsonData = try? JSONSerialization.data(withJSONObject: fileInfos),
let jsonString = String(data: jsonData, encoding: .utf8) else {
print("无法创建文件信息JSON")
context.completeRequest(returningItems: nil, completionHandler: nil)
return
}
// 创建URL Scheme
var urlComponents = URLComponents()
urlComponents.scheme = "painotes"
urlComponents.host = "import"
urlComponents.queryItems = [
URLQueryItem(name: "files", value: jsonString),
URLQueryItem(name: "count", value: "\(files.count)")
]
guard let url = urlComponents.url else {
print("无法创建URL Scheme")
context.completeRequest(returningItems: nil, completionHandler: nil)
return
}
// 打开主应用
openURL(url)
context.completeRequest(returningItems: nil, completionHandler: nil)
}
@objc @discardableResult private func openURL(_ url: URL) -> Bool {
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
if #available(iOS 18.0, *) {
application.open(url, options: [:], completionHandler: nil)
return true
} else {
return application.perform(#selector(openURL(_:)), with: url) != nil
}
}
responder = responder?.next
}
return false
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
- 主app处理导入的文件
在主App的View中添加openURL方法
.onOpenURL { url in
handleImportFromExtension(url)
}
private func handleImportFromExtension(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems else {
return
}
var filesJSON: String = ""
var fileCount = 0
for item in queryItems {
switch item.name {
case "files":
filesJSON = item.value ?? ""
case "count":
fileCount = Int(item.value ?? "0") ?? 0
default:
break
}
}
// 解析文件信息
guard let jsonData = filesJSON.data(using: .utf8),
let fileInfos = try? JSONSerialization.jsonObject(with: jsonData) as? [[String: Any]] else {
print("无法解析文件信息")
return
}
print("从Extension接收到 \(fileCount) 个文件:")
for fileInfo in fileInfos {
if let name = fileInfo["name"] as? String,
let type = fileInfo["type"] as? String {
print(" - \(name) (\(type))")
}
}
// 处理导入的文件
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
总结:
- 创建Action Extension,并选择带UI,Bundle display name即为Action菜单中展示的内容
- 插件info.plist中添加支持打开的类型
- 创建App Group,主App和Extension都关联,插件和主App通过App Group进行数据共享
- 主App创建URL Scheme,插件通过URL Scheme打开主App