用户从相册把图片分享到 iOS App,分享扩展在菜单里出现了,点开后却提示“无法读取”。这类问题不能只靠检查扩展是否成功编译:来源应用提供的是 NSItemProvider,它可能声明多个类型;文件形式的输入还可能只在加载回调期间提供临时路径。把“能显示”“能读取”“能交给宿主应用”拆开验收,才容易找到断点。
先列出输入契约
挑选实际需要支持的来源和数据,不要把“所有图片”写成一条含混的测试。至少记录来源应用、来源内容、registeredTypeIdentifiers、预期读取方式,以及宿主应用最终要得到的结果。同一张图片通过不同来源分享,可能提供文件、数据或两者;扩展不能只依赖数组中的第一个类型。
| 输入 | 扩展应检查 | 验收结果 |
|---|---|---|
| 一张图片 | 是否有符合图片类型的表示 | 能读取内容并生成交接记录 |
| 多张图片 | 每个附件各自声明的类型 | 数量与完成、失败数量一致 |
| 纯文本 | 是否提供文本表示 | 按产品需求接收或明确拒绝 |
| 不支持的内容 | 无匹配类型 | 显示可理解的错误,不生成空记录 |
分享扩展的激活规则决定它何时出现在菜单里;运行时的类型检查决定它能否处理当前附件。两者都要测,不能用“菜单里看到了”代替读取验收。
先看声明,再选表示
开发时把附件的类型标识写进本地诊断记录,但不要连同用户内容上传。选择类型时检查它是否符合目标类型,而不是硬编码某个来源应用给出的完整标识。若产品只接收图片,就让文本输入走明确的拒绝路径;不要尝试把未知内容强行解码成图片。
在回调里复制文件
处理文件表示时,最危险的写法是保存 loadFileRepresentation 返回的 URL,等扩展结束后再让宿主应用打开。该 URL 指向加载过程提供的临时文件,不能当作长期交接地址。
验收标准不是“回调里能打印出路径”,而是“回调结束后,宿主应用仍能读到自己有权限访问的副本”。
下面的 Swift 片段演示图片文件的关键边界。扩展和宿主应用若需共享文件,应配置同一个 App Group,并把组标识作为部署配置传入;不要把示例标识直接写进项目。复制目标采用新文件名,避免覆盖另一笔分享任务。
import Foundation
import UniformTypeIdentifiers
enum ShareCopyError: Error {
case unsupportedType
case containerUnavailable
case missingFile
}
func copySharedImage(
from provider: NSItemProvider,
groupID: String,
completion: @escaping (Result<URL, Error>) -> Void
) {
guard let typeID = provider.registeredTypeIdentifiers.first(where: {
UTType($0)?.conforms(to: .image) == true
}) else {
completion(.failure(ShareCopyError.unsupportedType))
return
}
provider.loadFileRepresentation(forTypeIdentifier: typeID) { source, error in
if let error {
completion(.failure(error))
return
}
guard let source else {
completion(.failure(ShareCopyError.missingFile))
return
}
guard let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: groupID) else {
completion(.failure(ShareCopyError.containerUnavailable))
return
}
do {
let directory = container.appendingPathComponent(
"Incoming", isDirectory: true
)
try FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true
)
let ext = UTType(typeID)?.preferredFilenameExtension ?? "bin"
let target = directory.appendingPathComponent(
UUID().uuidString + "." + ext
)
try FileManager.default.copyItem(at: source, to: target)
completion(.success(target))
} catch {
completion(.failure(error))
}
}
}
这段代码只处理单个图片附件。实际实现还需为多附件汇总结果、限制可接受的文件大小,并决定部分成功时是保留已复制文件还是整体撤销。交给宿主应用的应是受控目录内的文件标识;宿主读取后再按业务规则清理,不能无限积累副本。
把读取边界放进自动化测试
先为类型选择函数和复制函数写测试,再测完整分享界面。测试至少覆盖图片类型、不支持的类型、加载错误、共享容器不可用、多附件部分失败,以及同名输入不互相覆盖。测试替身应能控制回调时机:在回调内提供文件,回调返回后移走来源文件,再断言复制目标仍可读取。这样测到的是文件生命周期,而不只是一个非空 URL。
在 CDNMac 云端 Mac 上,先查看工程公开的 scheme 与可用模拟器,再运行对应测试。下列命令要求执行者把三个环境变量设为当前工程的真实值;结果包放进新建目录,避免复跑时撞上已有路径。
: "${PROJECT:?set PROJECT to an existing .xcodeproj path}"
: "${SCHEME:?set SCHEME to a shared test scheme}"
: "${SIMULATOR_ID:?set SIMULATOR_ID to an available device ID}"
xcodebuild -list -project "$PROJECT"
xcrun simctl list devices available
RESULT_ROOT="$(mktemp -d)"
xcodebuild -project "$PROJECT" \
-scheme "$SCHEME" \
-destination "platform=iOS Simulator,id=$SIMULATOR_ID" \
-resultBundlePath "$RESULT_ROOT/share-input.xcresult" \
test
CI 应保存测试结果包,并让测试进程的退出状态决定任务成败。不要只搜索日志里的“passed”:扩展启动失败、目标测试未被 scheme 包含,也需要被发现。首次接入时故意让一条断言失败,确认流水线确实会阻断。
用真实分享补上测试盲区
单元测试能验证自己的读取逻辑,却不能替来源应用承诺它会提供哪种表示。选一台测试设备,从计划支持的来源逐项分享:先确认扩展出现,再检查实际声明的类型、读取结果和宿主应用中的副本。大图片与多图片输入尤其要看内存占用、等待反馈和取消行为;不要仅凭一张小图通过就结束验收。
排障时按顺序定位:没出现,检查激活规则;出现但类型不匹配,记录来源提供的标识;加载失败,保留错误类别;扩展里读取成功而宿主失败,核对 App Group 权限、复制是否在回调内完成,以及宿主拿到的是不是副本路径。测试记录只保留必要的类型、阶段和错误信息,避免把分享内容写入构建日志。
交付前的检查项
发布前复核四件事:扩展声明与实际支持类型一致;每个文件在加载回调内完成复制;宿主只读取授权目录里的副本;拒绝、取消和部分失败都有可见结果。若改动了 App Group 配置,还要在新安装的测试设备上重新跑交接流程,不能只验证开发机上已有的容器。
把类型矩阵、自动化结果和设备分享记录放在同一次变更里,后续新增输入格式时就有明确的起点:先补契约与失败测试,再增加读取实现,而不是等用户报“分享后没有文件”才回头猜来源。
常见问题
分享扩展拿到的文件 URL 可以直接交给宿主应用吗?
不要直接交接该 URL。先在 loadFileRepresentation 的回调内将文件复制到可供宿主读取的位置,再交接新文件的标识。
模拟器测试通过后,还需要在设备上检查什么?
至少从目标来源应用发起一次真实分享,核对扩展是否出现、实际提供的类型、较大文件的读取结果,以及宿主能否打开复制后的文件。
让下一次 iOS 构建运行在独享云端 Mac 上
使用 M4、16GB RAM 与 256GB SSD 的固定物理环境,从六个节点和四种租期中选择适合当前任务的组合。