CDNMac 工程文章

验收 iOS 分享扩展的输入类型与临时文件

验收 iOS 分享扩展的输入类型与临时文件

用户从相册把图片分享到 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 的回调内将文件复制到可供宿主读取的位置,再交接新文件的标识。

模拟器测试通过后,还需要在设备上检查什么?

至少从目标来源应用发起一次真实分享,核对扩展是否出现、实际提供的类型、较大文件的读取结果,以及宿主能否打开复制后的文件。

CDNMac M4

让下一次 iOS 构建运行在独享云端 Mac 上

使用 M4、16GB RAM 与 256GB SSD 的固定物理环境,从六个节点和四种租期中选择适合当前任务的组合。

开始租用