在 Grok 这类移动端 AI 助手逐步进入更多客户端形态的背景下,“iOS 库支持”和“媒体筛选”一直是开发团队绕不开的两个关键词。表面上看,它们只是一个“用户选择图片→上传给模型”的动作,但真正落到 iOS 工程里,至少要处理系统相册权限、媒体类型识别、统一资源读取、内存控制、隐私字段剥离、服务端侧二次校验和多场景回退。对正在做 iOS AI 客户端或想给已有 App 接入大模型多模态能力的开发者来说,这两个能力不只是一次产品更新,更是一套完整的媒体输入链路设计。本文从一个可落地的视角拆解这套链路,并给出一套可用于新功能开发的最小模块结构。
1. 先搞清楚 iOS 上“库支持”和“媒体筛选”到底指什么
1.1 从产品术语到工程语义
先说结论:这里的“库支持”通常指客户端能读取或接入用户媒体资源,可能是系统相册、文件库、临时选择区,也可能是某个知识库集合;而“媒体筛选”指用户在真正把媒体交给模型之前,由客户端或服务端对资源做一轮条件过滤与预处理。两者在产品文案里可能是一个按钮,但在代码里是两个不同的执行阶段。
如果一个 Grok 类客户端把“库支持”做成用户点击后直接弹出系统相册,那么这个功能在技术上的最小闭环是:
- 系统相册提供候选资源;
- 客户端获得一个或多个媒体文件;
- 客户端把文件转为模型可以处理的数据;
- 模型返回结果后,客户端再把结果关联回用户选中的原资源。
“媒体筛选”往往出现在第二个环节。它要回答的问题包括:用户允许访问的媒体类型是什么、文件体积是否超过模型输入上限、GIF 动图和 Live Photo 是否需要特殊处理、视频是否要抽取关键帧、地理位置和拍摄设备信息是否要保留、是否去掉可能泄露个人隐私的 EXIF 元数据。
如果单纯把“筛选”理解成界面上的条件选择器,会遗漏真正影响成功率的部分:文件体积、编码格式、媒体类型白名单、请求超时和媒体删除后的空引用。这些规范应当在前端预校验一次,在服务端再校验一次。
1.2 为什么 iOS 上的实现方式不能照搬 Web 端
Web 端处理“用户上传图片给 AI”通常用input[type=file]就能拿到File对象,接着读取二进制、压缩、上传即可。iOS 原生环境则严格拆成两种路径:
| 路径 | 是否申请相册权限 | 库支持能力 | 典型用途 |
|---|---|---|---|
| PHPickerViewController | 不需要 | 用户在系统面板中自选资源 | 单次选择、隐私最小化 |
| PHPhotoLibrary | 需要 | 可读取相册资产、元数据、地理信息 | 需要批量同步、检索、智慧筛选 |
| UIDocumentPickerViewController | 不需要 | 选择文件 App 中的内容 | 文件、PDF、非相册媒体 |
| 文件的沙盒路径与 App Group | 由 App 自己管理 | 读取自己沙盒内资源 | 会话历史、知识库库本体 |
很多时候,开发者在真机上把 PHPicker 和 PHPhotoLibrary 混成一种路径,结果出现两种问题:要么不弹权限却能直接复用相册,但系统只在当次选择中给出临时副本,无法长期引用;要么在理应只让用户选择一次媒体的场景里申请了整个相册的读写权限,被审核方质疑权限必要性。
标题里所说的“库支持”如果要做到“用户可以反复从自己的媒体库中选择内容作为对话上下文”,最稳妥的主路径是每次由 PHPicker 提供用户明确确认的资源副本。只有涉及“自动聚合最近一周图片”“按人脸或地点检索”“后台把用户媒体夹和助手的知识库同步”这类能力时,才应该走 PHPhotoLibrary 的完整权限申请。
1.3 典型接入链路
后续代码会按照下面这条链路组织:
系统相册/文件面板 -> 资源结果回传 -> 类型识别与白名单校验 -> 缩略、压缩、转码 -> EXIF 与敏感元数据剥离 -> 生成统一媒体描述信息 -> 上传或传给模型处理层 -> 模型返回后按资源 ID 回写会话状态这条链路在 Grok 这类产品的实际开发中会拆成独立的媒体预处理模块、上传模块和模型调用模块。这样做的原因是,后续如果想更换模型供应商,或者从“图片理解”扩展到“视频关键帧理解”,不需要改动系统相册对接层。
2. 环境准备与权限设计:很多报错都出在这一步
2.1 先配置 Info.plist,再写权限申请代码
如果 App 需要读取系统相册,Info.plist中必须存在NSPhotoLibraryUsageDescription。如果还要保存图片,需要NSPhotoLibraryAddUsageDescription。iOS 对权限字符串有硬性校验:缺失对应键时,系统会直接崩溃或拒绝弹授权框。
<key>NSPhotoLibraryUsageDescription</key> <string>需要使用相册中的图片或视频来生成回答内容</string> <key>NSPhotoLibraryAddUsageDescription</key> <string>需要保存生成后的图片到相册</string>这里要区分实际用途:
- 只用系统选择面板,不需要配置相册权限相关键,但前提是必须使用 PHPicker;
- 使用 PHPhotoLibrary 读取资产时,必须配置
NSPhotoLibraryUsageDescription; - 只是把生成内容保存到相册,不读取已有相册,则只需要
NSPhotoLibraryAddUsageDescription。
大多数 iOS AI 客户端的常见错误是“只要选了图就申请完整相册权限”。这种行为既不符合最小权限原则,也容易在审核时被要求说明用途。正确习惯是先问自己:这个功能会不会主动从媒体库里读取并建立资源列表。如果不会,就用 PHPicker。
对于确实需要完整权限的“库支持”,请求代码可以写成一类统一管理:
import Photos enum PhotoLibraryPermissionManager { static func requestPhotoAccessIfNeeded( then completion: @escaping (Bool) -> Void ) { let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) switch status { case .authorized: completion(true) case .notDetermined: PHPhotoLibrary.requestAuthorization(for: .readWrite) { next in DispatchQueue.main.async { completion(next == .authorized) } } case .limited, .restricted, .denied: DispatchQueue.main.async { completion(false) } @unknown default: DispatchQueue.main.async { completion(false) } } } }在模拟器里测试时经常出现的现象是:系统弹出了权限框,但相册是空的,用户会误以为权限申请失败。这不是代码问题,而是模拟器没有同步真实照片所致。模拟器里需要手动拖入图片,或先在模拟器 Safari 中保存图片再测试。
2.2 权限状态决定入口是否可见
在设置页面决定“媒体库入口”是否展示,需要使用明确的权限状态驱动:
extension PHPhotoLibrary { static var shouldShowFullLibraryEntry: Bool { let status = authorizationStatus(for: .readWrite) return status == .authorized || status == .limited } }这里有几个产品细节值得注意:
.limited表示用户只允许 App 访问部分照片。对这种用户,不能继续提示“请开启全部权限”,但可以支持用户在系统弹层上调整选择的照片范围。.denied状态不建议单纯用 Alert 反复诱导,更稳妥的是在权限被拒时,把“系统设置→隐私→照片→本 App”的跳转路径和原因说明明确告诉用户。.restricted通常由家长控制、MDM 或其他系统配置引起,不是用户主动拒绝,这种情况连跳转设置都可能无效。
在权限被限制时,界面仍可以保留 PHPicker 入口,因为 PHPicker 本身是一个系统应用扩展,不继承相册授权。也就是说不能访问全部相册,仍然可以由用户在一张张预览中选择需要发给模型的照片。这是很多客户端在隐私合规上的关键设计。
2.3 依赖版本和最低系统版本要提前统一
PHPickerViewController从 iOS 14 开始提供,PhotosPickerSwiftUI 封装从 iOS 16 开始提供。如果 App 的最低支持版本是 iOS 13,就必须写版本兼容分支。建议在架构设计阶段就把“相册读取器”作为协议抽象,底层分别实现 PHPicker 和 PHPhotoLibrary 两种读取器。
| 能力 | iOS 13 | iOS 14 | iOS 16 |
|---|---|---|---|
| PHPickerViewController | 不支持 | 可用 | 可用 |
| 系统权限弹层中的“选中的照片” | 不支持 | 可用 | 可用 |
| SwiftUI PhotosPicker | 不支持 | 不支持 | 可用 |
| 沙盒中访问原始相册资源 | 支持但回调繁琐 | 可用 | 可用 |
开发团队如果只在一个高版本系统上写并通过测试,发布后遇到 iOS 旧版本用户反馈“没有相册入口”时,排查方向通常就是最低版本判断或 API availability 没有写好。
3. 用 PHPicker 实现最小可用的“媒体选择与筛选”模块
3.1 选择器配置与结果回调
推荐在 UIKit 工程中先用 PHPicker 完成最小闭环。它不属于需要完整相册权限的路径,也最容易在开发阶段快速验证“选图→识别→上传→模型返回”的整条链路。
import PhotosUI import UIKit import UniformTypeIdentifiers final class MediaPickerHandler: NSObject, PHPickerViewControllerDelegate { typealias MediaResult = (type: String, data: Data?) private var onPickImage: ((MediaResult) -> Void)? func pickImages( from presenter: UIViewController, limit: Int, completion: @escaping (MediaResult) -> Void ) { var config = PHPickerConfiguration(photoLibrary: .shared()) config.selectionLimit = limit config.filter = .any(of: [.images, .videos]) config.preferredAssetRepresentationMode = .current let picker = PHPickerViewController(configuration: config) picker.delegate = self onPickImage = completion presenter.present(picker, animated: true) } func picker( _ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult] ) { picker.dismiss(animated: true) guard let result = results.first, let provider = result.itemProvider else { return } if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { provider.loadFileRepresentation( forTypeIdentifier: UTType.image.identifier ) { url, _ in guard let url else { return } let data = try? Data(contentsOf: url) DispatchQueue.main.async { self.onPickImage?(("image", data)) } } } } }这段代码只显示了单资源处理。实际工程中要注意三点:
preferredAssetRepresentationMode = .current表示优先使用用户当前编辑过的版本,适合需要“用户所见即所得”的产品;如果产品需要原始 RAW 数据处理,则改为.original。loadFileRepresentation返回临时目录内的文件 URL。这个文件是系统生成的临时副本,调用方如果在回调后异步使用,必须把数据复制到自己的缓存目录,不能只保存 URL。- 在 PHPicker 回调中不要直接使用
Data(contentsOf:)去读取超大视频。对视频类资源,应当加载为文件 URL 后再判断体积,超过模型处理上限时优先转码或抽取关键帧,而不是读入内存。
3.2 资源类型筛选和轻量预处理
假设产品规定“只允许图片,不接收视频”,config.filter = .images就能完成系统层的类型筛选。但服务端仍然可能收到 GIF、HEIC、PNG、RAW 等不同编码,不能假设所有图片都能直接送入模型。更适合的方式是在客户端做一次统一转码:
import CoreImage import ImageIO import UIKit struct ImagePreprocessor { enum OutputFormat { case jpeg(quality: CGFloat) } static func normalizedJPEGData( from data: Data, maxPixelSize: CGFloat = 2048, quality: CGFloat = 0.85 ) -> Data? { guard let image = UIImage(data: data), let cgImage = image.cgImage else { return nil } let width = CGFloat(cgImage.width) let height = CGFloat(cgImage.height) let maxSide = max(width, height) guard maxSide > maxPixelSize else { return image.jpegData(compressionQuality: quality) } let scale = maxPixelSize / maxSide let newSize = CGSize( width: width * scale, height: height * scale ) let renderer = UIGraphicsImageRenderer(size: newSize) let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) } return resized.jpegData(compressionQuality: quality) } }这段代码解决的是“图片太大或 HEIC 太特殊”的问题。Grok 类多模态模型通常有输入尺寸限制,客户端把图片统一压成 JPEG 并限定短边像素,可以显著降低请求体积和失败率。不过要注意下面几个坑:
UIImage(data:)在处理超清全景图时可能吃满内存,生产环境建议先通过CGImageSource读取图片宽高,只有尺寸超限时才解码。- “转成 JPEG 再调用模型”不代表一定保留完整可见内容。如果图片带透明通道,可以直接使用 PNG;如果有动画,需要识别是否为 GIF。
- 转码后的图片会丢失原图的时间和镜头信息,这通常是期望行为。若产品需要保留这些信息,就要在转码前通过
PHAsset或原始数据读取元数据,再单独随请求发送。
3.3 媒体描述信息标准化
客户端上传图片给模型,不能只上传二进制文件,还应当生成一份统一媒体描述信息。这样模型侧可以区分“这是相册图片”“这是来自文件库的 PDF”“这是视频关键帧”,也方便后续做去重和服务端策略判断。
{ "media_id": "asset-uuid-2025010101", "source": "photo_picker", "media_type": "image", "format": "jpeg", "width": 1536, "height": 2048, "size_bytes": 245760, "created_at_local": "2025-01-01T10:20:30+08:00", "gps_removed": true, "quality": "high", "owner_context": "dialog_12345" }其中gps_removed字段不能造假:若工具在剥离 EXIF 时失败,应置为false;服务端发现该字段为false但图片包含地理坐标时,可以选择拒绝处理或继续剥离,不能把风险数据直接送入下游链路。
4. 支持“库检索”时,再接入 PHPhotoLibrary 与资产匹配
4.1 媒体库权限与只读查询
如果功能的“库支持”超过“单次选择”的边界,例如允许用户搜索“相册里最近一周的照片”并直接发送给 AI 处理,那就必须读取媒体资产列表。
import Photos struct MediaLibraryProvider { static func fetchAssets( fromCollection collection: PHAssetCollection?, createdInLastDays days: Int ) -> [PHAsset] { var options = PHFetchOptions() options.sortDescriptors = [ NSSortDescriptor(key: "creationDate", ascending: false) ] if days > 0 { let start = Date().addingTimeInterval(-Double(days) * 86400) options.predicate = NSPredicate( format: "creationDate >= %@", start as NSDate ) } var assets: [PHAsset] = [] let result: PHFetchResult<PHAsset> if let collection { result = PHAsset.fetchAssets(in: collection, options: options) } else { result = PHAsset.fetchAssets(with: options) } result.enumerateObjects { asset, _, _ in assets.append(asset) } return assets } }注意,PHFetchOptions.predicate并不能与所有PHAsset条件组合都兼容,例如视频时长、媒体子类型等字段在部分系统版本中有效。依靠谓词在本地做复杂筛选,会让代码在不同 iOS 版本上表现不一致。更可靠的方案是获取结果后在内存里二次筛选,并把筛选逻辑放在独立的 Strategy 对象中。
4.2 从 PHAsset 获取原图或视频数据
拿到PHAsset后,读取数据推荐使用PHImageManager,并且需要避开把原图一次性解压进内存的做法:
import Photos enum MediaExportResult { case image(Data) case video(URL) } final class PHAssetExporter { func export( _ asset: PHAsset, targetSize: CGSize, completion: @escaping (MediaExportResult?) -> Void ) { let options = PHImageRequestOptions() options.version = .current options.deliveryMode = .opportunistic options.resizeMode = .fast options.isNetworkAccessAllowed = true PHImageManager.default().requestImage( for: asset, targetSize: targetSize, contentMode: .aspectFill, options: options ) { image, _ in guard let image, let data = image.jpegData(compressionQuality: 0.85) else { completion(nil) return } completion(.image(data)) } } }这里的targetSize不应该直接传PHImageManagerMaximumSize,否则遇到一张几十兆像素的原图,会一边请求一边出现内存陡增。正确的做法是:
- 缩略图场景:传入
CGSize(width: 512, height: 512)这类适合列表展示的尺寸; - 模型透传场景:根据模型的图片像素上限传入固定尺寸;
- 视频关键帧场景:通过
AVAssetImageGenerator读取指定秒数的帧。
4.3 媒体筛选条件的可替换设计
当筛选条件增多,代码里容易堆出大量if。更稳妥的方式是把“筛选规则”定义成可组合的条件对象:
struct MediaFilterRule { enum MediaKind { case image case video } var kinds: Set<MediaKind> var allowLivePhoto: Bool var maxPixelSide: Double var maxFileSize: Int var createdAfter: Date? var createdBefore: Date? } func assets(by rule: MediaFilterRule) -> [PHAsset] { var assets = applicableAssets(rule: rule) assets.removeAll { asset in if !rule.allowLivePhoto, asset.mediaSubtypes.contains(.photoLive) { return true } return false } return assets }这种设计让客户端与服务端可以共享同一份“筛选语义”。客户端负责减少不必要的数据读取,服务端负责最终校验。不要把筛选规则只写在 UI 层,否则后面新增“排除截图”“排除隐藏照片”时会非常被动。
5. 常用功能的运行验证与典型问题排查
5.1 验证路径一:只选择图片并查看描述 JSON
开发阶段最直接的验证方式是:用真机选择一张带 GPS 信息的照片,经转码后检查 JSON 中的gps_removed是否为true。
同时把转码后文件大小与原始大小打日志:
原始文件大小: 6231000 字节 转码后大小: 824000 字节 分辨率: 4032x3024 -> 2048x1536 载入耗时: 0.32s预期结果:
- 权限弹窗在首次访问相册时出现;
- PHPicker 不申请相册完整权限即可选择;
- 转码后大小明显下降;
- JSON 中不包含经纬度字段。
如果选择的是视频,应当走视频路径,不能被当作图片转码。否则会出现“视频被裁剪成黑背景图”这类怪异报错。
5.2 问题一:PHPicker 明明能打开,但返回不到媒体数据
现象是系统选择器可以弹出、用户选了照片,但didFinishPicking里拿到的itemProvider没有符合的 type identifier。
排查顺序:
- 检查是否导入了
UniformTypeIdentifiers; - 确认配置了
.images而不是其他 filter; - 打印
provider.registeredTypeIdentifiers,查看系统返回的实际类型; - 对视频不要用
loadDataRepresentation,改为loadFileRepresentation; - 对 iCloud 上的资源,检查
loadFileRepresentation的进度或超时。
这类问题最隐蔽的原因是测试人员选了“照片”但不是“图片”,而是“扫描文稿”或“文件 App 里的 PDF 快捷方式”。此时注册的 content type 是com.adobe.pdf,与public.image不匹配。
5.3 问题二:完整相册权限在真机上被拒后,页面无法恢复
有些产品在被拒绝权限后直接隐藏了所有媒体入口,导致用户无法回头使用 PHPicker。这不是技术必然,而是产品状态分支没有处理干净。
推荐行为:
| 权限状态 | 媒体库入口 | 点击后的提示 |
|---|---|---|
| limited | 展示 | 提示用户可更换允许的照片 |
| denied | 展示 PHPicker | 不弹提示,直接告诉系统会打开选择器 |
| restricted | 隐藏或禁用 | 提示因系统限制不可用 |
| authorized | 展示全部入口 | 正常进入 |
许多情况下,用户只是不想给相册权限,但仍希望临时选择一张图发送。因此,权限被拒后的第一方案应当是 PHPicker,而不是终止功能。
5.4 问题三:测试时把视频压缩或上传逻辑放在主线程,界面卡死
在itemProvider.loadFileRepresentation回调里做视频压缩直接卡住主线程,是 iOS 开发中非常容易出现的错误。正确写法是所有媒体处理都放入后台队列:
let workQueue = DispatchQueue(label: "media.preprocess.queue", qos: .userInitiated) workQueue.async { // 压缩、转码、生成缩略图都放这里 DispatchQueue.main.async { // 回到主线程刷新 UI 或完成回调 } }不要使用DispatchSemaphore去同步异步回调。它会造成线程阻塞,调试时还会出现“莫名其妙的死锁”。
5.5 问题四:模拟器没有照片,测试无法闭环
开发过程中可以快速把资源复制到模拟器。最简单的方式是直接把图片拖到模拟器窗口,它会自动保存到相册。之后调用 PHAsset 查询才能看到数据。
如果测试对象的媒体来源是“自建知识库”,即 App 自己管理媒体文件,则不需要系统相册权限。这种场景更适合在测试包中内置一组 fixture 文件,保证 CI 和回归测试不依赖模拟器相册状态。
6. 从功能实现到生产环境:缓存、隐私与多模型适配
6.1 不要把用户媒体长期保存在临时目录
GroK 这类 AI 客户端在把用户选中的照片发给模型后,通常要按会话上下文保留一段时间。这个保留周期需要明确:
- 客户端收到模型响应后,可以立即释放大图内存;
- 本地缩略图可以保留,但要有基于 LRU 或日期的清理机制;
- 视频临时文件应在会话结束后删除,不能一直堆积在 tmp 目录;
- 若产品允许本地开启“历史消息媒体保留”,需要额外用 Keychain 记录授权状态。
一个稳妥的目录设计是:
Library/Caches/MediaPicker/tmp/ Library/Caches/MediaPicker/thumbnails/ Documents/Conversations/{dialogId}/media/“Documents”下只保存用户明确要求的会话资产,Caches下的资源全部允许系统清理。不要把模型调用产生的中间文件直接落在 Documents 目录。
6.2 服务端必须再次筛选和校验
客户端只是第一道门,生产环境下的服务端需要二次过滤。Grok 类产品面对的是来自不同版本客户端上传的媒体,无法保证老版本客户端一定会正确转码。因此服务端需要维护一张处理策略表:
| 入参情况 | 服务端行为 | 返回策略 |
|---|---|---|
| 图片格式为 HEIC | 转码或拒绝 | 提示用户重试或自动转码 |
| 文件超过 20MB | 拒绝 | 请求重选或自动压缩 |
| 检测到 EXIF GPS | 剥离元数据 | 成功返回处理后文件 |
| 视频超过 30 秒 | 抽取关键帧或截取 | 返回帧列表信息 |
| 内容涉及违规分类 | 安全审核拦截 | 返回对应业务错误码 |
团队应把“媒体上传成功”与“媒体审核通过”分开看待。前者只代表文件进了对象存储,后者代表文件可以进入模型输入层。两者用不同状态位记录,可以在排查问题时快速定位哪一步阻断。
6.3 接入多模型时应当抽象模型调用层
如果客户端未来需要支持不同模型,例如一个负责通用对话、一个负责图像理解,甚至通过同一网关切换参数,就不能把“模型处理”写死在媒体 Picker 的回调里。
基础抽象可以这样设计:
protocol MediaRequestBuilding { func makeRequest( from mediaItem: MediaItem, prompt: String ) async throws -> URLRequest }MediaItem是统一资源对象;MediaExportEngine负责把媒体变成模型需要的数据;MediaPolicyProvider负责客户端筛选规则;RemoteModelGateway负责发起网络请求和重试。
在这样的结构里,“Grok iOS 将迎库支持与媒体筛选功能”就不再是具体页面某一个函数的事情,而是一组可以被单元测试覆盖的工程模块。
6.4 关键清单:上线前逐项打勾
为了不让“媒体库支持”发布后出现大量客服问题,建议在功能发布前用这份清单做回归:
| 检查项 | 验证方法 | 通过标准 |
|---|---|---|
| 首次授权弹窗文案 | 真机新装 App,点击相册入口 | 文案清晰说明用途 |
| 拒绝权限后的路径 | 点击拒绝后重进页面 | 仍可打开 PHPicker |
| limited 权限 | 在系统设置中选择部分照片 | 只展示允许的照片或可手动选择 |
| 图片压缩边界 | 用 40MB 大图测试 | 内存占用可控,转码成功 |
| HEIC 兼容 | 用 iPhone 默认格式拍照上传 | 能转成 JPEG 且方向正确 |
| Live Photo 处理 | 测试 Live Photo | 默认取静态图或明确提示 |
| GPS 剥离 | 查看 JSON 输出 | 不包含经纬度字段 |
| 视频过长 | 上传 1 分钟视频 | 触发服务端关键帧策略 |
| 断网重试 | 上传过程中开飞行模式 | 给出明确错误且不丢会话 |
| 模拟器与真机差异 | 两端同步测试 | 行为一致 |
完成这些检查后,一个类似 Grok 的 iOS 客户端才能说“媒体库支持”和“媒体筛选”已经从产品标题变成了可维护的实现。对于个人开发者和学习项目来说,最小建议是先用 PHPicker + 压缩转码 + 标准 JSON 描述搭一个最小闭环,再逐步补充 PHPhotoLibrary 批量读取和视频关键帧能力;对于团队协作项目,则建议优先确定权限边界、媒体描述协议和服务端二次校验策略,因为这三件事会直接影响后续每一次功能迭代的返工量。