Action Extension 工具矩阵:一个 App 如何长出七只手

0 阅读17分钟

一、产品要的不是一个功能,是分享面板里的七行

录屏 App 做到后期,增长来源之一是"别人的视频":用户在相册、文件、微信里长按一个视频,分享面板里出现你的名字——压缩、转音频、降噪、加前置摄像画面、AI 翻译、加进录制、直接编辑。七个入口,每一个都是一次拉活。

你打开 Xcode,File → New → Target → Action Extension,一个模板工程出来了。你心想:一个 Extension,里面放七个按钮,一周搞定。

然后产品把话说清楚了:不是一个入口里有七个按钮,是分享面板里有七行。 用户长按视频,往下滑,看到"视频压缩"就点"视频压缩",看到"视频转音频"就点"视频转音频",不要再进一层菜单。

这一句话决定了整个工程的形状:iOS 分享面板里的每一行对应一个 Extension target。七行 = 七个 target = 七个 bundle = 七个进程 = 七套 provisioning profile = 七套本地化。 数量是产品决定的,不是代码决定的。

我参与的录屏 App 里就有这样七个 Action Extension。这篇讲三件事:七个 target 的代码怎么薄到"复制也无所谓";Extension 和主 App 之间那条官方没给的通道是怎么搭的;以及这条通道上每一个会翻车的点。


二、先看清楚:七只手其实是七个邮筒

先说一个会颠覆直觉的事实:这七个 Extension 自己什么都不做。 不压缩、不转码、不降噪。每一个的全部工作是:

  1. 从 extensionContext 拿到用户选的文件
  2. 拷贝到 App Group 容器
  3. 把路径写进一个共享的"信箱"
  4. 用自定义 URL scheme 唤起主 App
  5. 关掉自己

真正的处理全部在主 App 里。七个 Extension 是七个邮筒,用户把视频投进去,主 App 收信、拆信、干活。

七只手其实是七个邮筒

为什么不在 Extension 里直接处理?三个原因,按重要性排:

内存。 Action Extension 的内存上限比主 App 低得多——Apple 没有公开数字,普遍观察在一百多 MB 的量级,具体以 Instruments 为准。视频压缩要跑 AVAssetExportSession,降噪要跑音频模型,AI 翻译要走网络加模型推理。这些东西在主 App 里是常规操作,在 Extension 里是走钢丝。我在录屏引擎那篇里讲过 Broadcast Extension 的 50MB 红线怎么逼出整套架构,Action Extension 没那么极端,但方向一样:能不在 Extension 里做的,就不在 Extension 里做。

依赖。 视频编辑引擎、AI 能力 SDK、埋点、订阅状态,全在主 App 里。把它们链进 Extension,每个 target 包体翻倍,而且很多 SDK 在 Extension 环境里根本跑不起来(UIApplication.shared 不可用、NS_EXTENSION_UNAVAILABLE 一片)。

产品。 这七个入口的目的是把用户带进 App,不是在分享面板里把事办完。处理完了之后用户要预览、要保存、要顺手再剪一刀——这些都在主 App 里。

一旦接受"Extension 只是邮筒",代码就薄了。七个 Extension 加起来不到一千行 Swift,两两 diff,差异只有三处:

参数决定什么
NSExtensionActivationRule这只手对什么文件伸出来
文件名前缀落在 App Group 容器里的文件叫什么,主 App 靠它识别和清理
URL scheme 的 host主 App 收到后走哪条处理路径

一个 Extension = 三个参数 + 一份模板。 这时候"复用"就不再是问题:七份复制粘贴的代码,改三个字符串,比抽一个共享 framework 再让七个 target 链接它更省事、更好定位问题——而且 plist、entitlements、每个语言的 InfoPlist.strings 本来就没法共享,每个 target 该有的文件一个都少不了。当每份代码只有三个参数的时候,复制是正确的复用方式。


三、坑一:激活规则——字典写不出"排除"

NSExtensionActivationRule 决定你的 Extension 在什么情况下出现在分享面板里。它有两种写法。

字典写法,Apple 文档首推的:

<key>NSExtensionActivationRule</key>
<dict>
    <key>NSExtensionActivationSupportsMovieWithMaxCount</key>
    <integer>10</integer>
    <key>NSExtensionActivationSupportsImageWithMaxCount</key>
    <integer>10</integer>
</dict>

七个里只有"视频编辑"用了它——它就是要接收最多十个视频和图片的混合选择,字典正好表达。

其他六个都不行。"视频压缩"要的是:恰好一个视频,并且没有音频,并且没有图片。 字典只有 Supports...WithMaxCount 这种"最多几个"的键,写不出"没有"。你写 SupportsMovieWithMaxCount = 1,用户多选了一个视频和一张图片,你的 Extension 照样出现,然后在里面处理一个它根本不该处理的输入。

所以要用 谓词写法——NSPredicate 格式的字符串:

SUBQUERY(extensionItems, $item,
    SUBQUERY($item.attachments, $a,
        ANY $a.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie").@count == 1
    AND SUBQUERY($item.attachments, $a,
        ANY $a.registeredTypeIdentifiers UTI-CONFORMS-TO "public.audio").@count == 0
    AND SUBQUERY($item.attachments, $a,
        ANY $a.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image").@count == 0
).@count == 1

读法:恰好一个 extensionItem,它的附件里恰好一个 conforms to public.movie、零个 public.audio、零个 public.image。

激活规则:字典写不出"排除",谓词可以

三个踩了才知道的细节:

谓词写错了不报错,你的 Extension 只是永远不出现。 没有日志,没有崩溃,分享面板里就是没有你。第一次写谓词时我建议先用最宽的规则(TRUEPREDICATE)确认 Extension 能出现,再一点点收紧。每收紧一步,在相册、文件、微信三个来源各试一次——不同宿主给的 registeredTypeIdentifiers 不一样。

public.movie 和 public.audio 是抽象类型,具体来源报的是具体类型。 相册分享的视频通常是 public.mpeg-4 或 com.apple.quicktime-movie,UTI-CONFORMS-TO 会顺着类型树向上匹配,所以写抽象类型是对的。但"加进录制"和"AI 翻译"这两只手要同时接视频和音频,它们的规则里把 public.mp3、com.apple.m4a-audio、public.aac-audio、org.xiph.flac 一个个列了出来——因为 public.audio 在某些宿主给的类型列表里匹配不到。规则宽松的用抽象类型,规则精确的列具体类型,两种写法在一个工程里并存是正常的。

激活规则是第一道门,不是唯一一道。 就算规则写对了,loadItem 拿回来的东西也可能不是你要的:有的宿主给 URL,有的给 Data,有的给 NSSecureCoding 对象。Extension 里要按类型分支处理,主 App 收到后还要按扩展名再过滤一遍——七个入口里有一条统一的"不支持的格式"兜底提示,走到那里的文件都是通过了激活规则又在下一层被拦下的。


四、坑二:在 Extension 里加载附件——别解码,别等,别信文件名

激活规则放行之后,Extension 的第一件事是从 extensionContext.inputItems 里把文件拿出来。这一步在主 App 里是常规操作,在 Extension 里有三条纪律。

别解码。 "视频编辑"那只手最多接十个视频加图片。图片走 loadItem(forTypeIdentifier:) 拿回来的可能是 URL、也可能是 Data、也可能直接是 UIImage。一定要要 Data 或 URL,不要要 UIImage。 一张 4000×3000 的照片,文件是几 MB,解码成位图是 48MB;十张就是接近 500MB——在 Extension 里这是即死。拿到 Data 直接写文件,解码的事留给主 App。

// 图片:只要字节,不要位图
item.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { data, _ in
    if let d = data as? Data           { write(d) }
    else if let url = data as? URL     { copy(url) }
    else if let img = data as? UIImage { write(img.jpegData(compressionQuality: 0.9)) } // 最后的兜底,尽量别走到
}

别串行等。 多个附件要并发加载,DispatchGroup 收口,全部到齐再写信箱、再唤起主 App。串行加载十个附件,用户会在分享面板上盯着一个没有进度的空白页十几秒——Extension 没有给你展示进度的余地,唯一的优化是把这段时间压到最短。

let group = DispatchGroup()
for (i, item) in attachments.enumerated() {
    group.enter()
    item.loadItem(forTypeIdentifier: typeID, options: nil) { data, _ in
        defer { group.leave() }
        stage(data, index: i)                      // 写到 App Group,文件名带 index
    }
}
group.notify(queue: .main) { [self] in
    defer { completeRequest() }                    // 无论成败,Extension 必须退出
    writeMailbox(); openHostApp(url)
}

那个 defer { completeRequest() } 是纪律性的:Extension 的任何一条路径都必须以 completeRequest 或 cancelRequest 结束,否则分享面板挂在那里,用户只能杀掉宿主 App。多附件、多分支、多错误路径之后,漏掉一条就是一次"分享面板卡死"的反馈。

别信文件名。 单文件的入口用时间戳当文件名:ActionExtension_VideoCompress_<秒级时间戳>.mov。同一秒内投递两次会撞名——现在的实现是"存在就先删",结果是后者覆盖前者,和第六节单槽信箱的行为一致,至少不会出现两个文件混在一起。多文件的入口用 index 编号,并且在写之前先清掉容器里同前缀的旧文件。两种策略都在说同一件事:容器里只保留"这一次"投递的文件,上一次的不管有没有被处理都不保留。 这是第六节"信箱不是仓库"的前提。


五、坑三:从 Extension 打开主 App——官方没给的那条路

Extension 把文件准备好了,要唤起主 App。你翻文档,找到 extensionContext.open(_:completionHandler:)。写上,跑,什么都没发生。

这个方法只对 Today Widget 有效。Action Extension 和 Share Extension 调它,静默失败。Apple 没有给 Action Extension 提供打开宿主 App 的官方途径——设计意图是 Extension 应该在面板里把事办完。

业界通用的解法是沿着响应链往上找 UIApplication:

private func openHostApp(_ url: URL) {
    var responder: UIResponder? = self
    while let r = responder {
        if let app = r as? UIApplication {
            app.open(url, options: [:], completionHandler: nil)
            return
        }
        responder = r.next
    }
    // 走到这里:响应链上没有 UIApplication,唤起失败
}

它调用的是公开方法 open(_:options:completionHandler:),只是拿到对象的方式绕了一下。这个写法在很多 App 里过了很多年审核。但要认清它的性质:它不是契约,是一个恰好一直没坏的行为。 三条防御:

  1. 必须有失败分支。 有些宿主 App 的响应链上根本找不到 UIApplication,这时用户点了你的入口,什么都没发生。至少在 Extension 的 UI 上留一句"请打开 XX App 继续"。
  2. 先 open 再 completeRequest,中间留一拍。 completeRequest 一调,Extension 进程就开始被回收。如果 open 还没来得及发出去,唤起就丢了。代码里那个 0.3 秒的延迟就是这么来的——它不优雅,但它是在保护一个没有回调可等的异步操作。
  3. 每次系统大版本 beta 出来第一件事:在七个入口各点一次。 这条路没有文档保证,只有实测保证。

至于 URL 本身,用 host 区分是哪只手:

yourscheme://ActionExtension_VideoCompress
yourscheme://ActionExtension_VideoToAudio
...

主 App 侧一个路由函数,按 host 查表分发。host 只说"是哪只手",不带文件路径——路径太长、有特殊字符、而且多文件时塞不下。文件走下一节的信箱。


六、坑四:信箱——App Group 是邮筒,不是仓库

Extension 和主 App 是两个进程,共享数据只有 App Group 一条正路。这里有两层东西要传:文件和清单。

文件放 App Group 容器。用户选的视频原本在宿主 App 的沙盒里,Extension 拿到的是一个临时可读的 URL,Extension 一结束这个 URL 就失效。所以必须在 Extension 里把文件拷进 App Group 容器:

let container = FileManager.default
    .containerURL(forSecurityApplicationGroupIdentifier: appGroupID)!
let dest = container.appendingPathComponent(
    "ActionExtension_VideoCompress_\(Int(Date().timeIntervalSince1970)).\(ext)")
try FileManager.default.copyItem(at: sourceURL, to: dest)

文件名前缀就是第二节说的三个参数之一——主 App 靠它知道这个文件是哪只手投进来的,也靠它做清理。

清单放 UserDefaults(suiteName:):一个 mediaArr 数组,装着容器里的路径。我在第一篇里说过 UserDefaults(suiteName:) 跨进程不保证及时同步、别用它传实时状态。这里能用,是因为它传的不是状态,是一封信:Extension 写完、synchronize、然后才唤起主 App;主 App 启动后再读。时序上没有并发。

但它是单槽信箱——只有一个 mediaArr,后写的覆盖先写的。用户在三秒内连点两个入口,第二封信盖掉第一封,第一次的文件留在容器里没人认领。所以主 App 收信后做的第一件事不是处理,是清场:

// 主 App:收到 URL 后
NSArray *mediaArr = [ud valueForKey:@"mediaArr"];          // ① 取信
// ② 把文件搬进自己的沙盒(App Group 容器不是仓库)
for (NSString *path in mediaArr) { /* move to Documents/shareExtension/ */ }
// ③ 清掉容器里所有带前缀的文件——包括没人认领的
for (NSURL *item in containerContents) {
    if ([item.lastPathComponent hasPrefix:@"ActionExtension_"]) { [fm removeItemAtURL:item error:nil]; }
}
[ud removeObjectForKey:@"mediaArr"];                       // ④ 清信箱

投递链路:从 Extension 到主 App 的每一步和每一个脆弱点

为什么要搬进自己的沙盒、而不是直接在 App Group 容器里用?因为App Group 容器是七个 Extension 和主 App 共写的地方。"视频编辑"那只手每次启动都会先清掉容器里的旧文件再写新的;如果主 App 还在用容器里的路径处理上一个任务,文件就没了。共享区只能当收发室,东西收到了就搬进自己屋里。

两个我会改的地方,写出来供参考:

  • 搬运用 moveItem 而不是 copyItem + 删除。 同一个卷上 move 是 rename,瞬间完成;copy 一个 2GB 的视频要读写各 2GB。现在的实现是先 copy 再清理,结果正确,代价是一次不必要的全量 IO。
  • 信箱换成文件而不是 UserDefaults。 一个 JSON 清单放在容器里,文件名带任务 ID,URL 的 host 后面挂这个 ID。这样一个任务一个清单,多任务不互相覆盖,"哪只手"和"哪些文件"在同一处,不会出现 host 说压缩、信箱里却是上一次转音频的文件。

七、坑五:主 App 被唤起时有三种状态

Extension 那边 open 出去了。主 App 这边收到 URL 的时机,取决于它当时的状态:

主 App 状态URL 到达时坑
前台open(_:url:options:) 立即回调当前可能正在录制 / 正在编辑,不能直接跳走
后台(挂起)唤醒后回调内存可能被回收过一部分,页面栈未必还在
未启动冷启动,didFinishLaunching 之后回调rootViewController 可能还没建好

第三种最容易翻车。冷启动时你的 App 可能先走闪屏、先初始化各种 SDK、先检查订阅,rootViewController 是 tab bar 还是引导页都不一定。这时候直接 [rootTabBar actionExtensionComeIn:...],要么空指针、要么方法调到了一个还没就绪的对象上。

现在的实现是收到 URL 后延迟 0.3 秒再检查 rootViewController 的类型、再分发。和第五节那个 0.3 秒一样:能工作,但它赌的是"0.3 秒后根视图一定好了"。更稳的写法是把任务挂起来,等根视图就绪了再取:

// 收到 URL:只入队,不分发
PendingJobs.shared.enqueue(job)
// 根视图就绪(tab bar 的 viewDidAppear、或者你自己的"App 就绪"通知):
PendingJobs.shared.drain { job in router.dispatch(job) }

"就绪"的定义由 App 自己给,不由一个延迟猜。前台状态也一样:如果正在录制,任务入队,录制结束后再提示"有一个待处理的视频"。

顺带一个埋点上的教训:七个入口的点击要在主 App 这一侧打,不要在 Extension 里打。Extension 里的埋点 SDK 要么起不来,要么起来了数据发不出去(进程太短命)。主 App 收到 URL、按 host 分发的那一刻,就是"用户点了哪只手"的唯一可靠观测点。


八、坑六:七份本地化、七份图标、七份审核

代码薄了,但每个 target 该有的东西一样都不少。七个 Extension 各自带着十几个语言的 .lproj,里面是 CFBundleDisplayName——分享面板上显示的那几个字。七个 target × 十几种语言,一百多个 strings 文件。改一个入口的名字,改十几处。

这部分没有技术含量,但有工程含量:

  • 用脚本生成,不要手工维护。 一张表(入口 ID × 语言 → 名字),一个脚本生成所有 InfoPlist.strings。手工改的下场是某个语言的某个入口名字还是上个版本的。
  • 每个 target 是一个独立的 bundle ID、独立的 profile。 发版时七个 Extension 的 profile 都要有效,少一个整个 App 打包失败。CI 上要把这七个当成一等公民检查。
  • 审核是按整个 App 来的,但审核员会点开每一个入口。 七个入口里任何一个"点了没反应"(比如第五节的唤起失败)都可能让整个版本被拒。上传前的自测清单里,七个入口 × 三个来源(相册 / 文件 / 第三方 App)是必做项。
  • iPad 的分享面板是 popover。 七个入口在 iPad 上的表现要单独过一遍,分发时也要判断 iPad 的根视图类型(它和 iPhone 的不是同一个类)。

九、诚实对比:什么时候不该这么做

如果处理很轻、不需要 App 的上下文,在 Extension 里直接做完。 比如图片格式转换、简单裁剪:几十 MB 内存能搞定,用户在分享面板里就拿到结果,体验比跳 App 好得多。Action Extension 本来的设计就是这个。本文这套"邮筒"模式是为重处理 + 需要 App 上下文的场景服务的,别把轻活也套上。

如果只需要一个入口,用 Share Extension。 Share Extension 有系统提供的 compose UI,适合"分享到你的 App"这种单一动作。我们的 App 也有一个 Share Extension(带 App 图标的那行),七个 Action Extension 是在它之外的补充——用户长按视频,一眼看到"视频压缩"四个字,比看到一个 logo 再点进去选功能,转化率不在一个量级。

如果你的目标用户在 iOS 16+,看一眼 App Intents。 App Shortcuts 能让功能出现在 Spotlight、Siri、快捷指令里,而且是官方支持的、有契约的路径,不需要第五节那个响应链 hack。它解决的是"从系统入口触发你的功能"这个问题的另一半——分享面板那一半目前还是 Extension 的地盘。

如果你没有专人维护七个 target,做三个。 每个 target 都是一份长期维护成本。入口数量应该由数据决定:先上三个最高频的,看点击,再决定要不要长出第四只手。


十、收尾:三条可以带走的

第一,系统入口的数量是产品决定的,工程要做的是让每一份入口薄到复制也无所谓。 七个 target 是绕不过去的,但七个 target 的代码可以只差三个参数。当"复用"的对象只有三个字符串时,抽象是负担,复制是正解。这条对所有"一个功能 N 个入口"的场景都成立:Widget 的多个尺寸、Siri 的多个 Intent、快捷指令的多个 Action。

第二,共享区是收发室,不是仓库。 App Group 容器被多个进程写,没有任何一方能假设"我放在那里的东西下次还在"。正确的姿势是:投递方写完就走,接收方收到就搬走、搬走就清场。信箱只保留"当前这一封",历史由接收方自己的沙盒负责。任何跨进程、跨服务的共享存储——共享目录、消息队列的 topic、Redis 的一个 key——都是同一个原则。

第三,没有契约的通道,要用测试矩阵代替契约。 从 Extension 唤起主 App 这条路,Apple 没给保证。你能做的不是找到一个"更安全的 hack",而是把它的所有前提列成清单——七个入口 × 三个来源 × 三种 App 状态 × 每个系统 beta——每次都跑一遍。能被穷举的脆弱,就不是风险,是成本。

回到开头那句"一周搞定"。七个 Extension 的代码确实不到一周。剩下的时间花在了:谓词调到在三个来源上都对、唤起链路加失败分支、信箱加清场、冷启动加就绪队列、一百多个 strings 文件的生成脚本。工程量不在七只手上,在七只手和身体之间那根神经上。


如果你的 Action Extension 在分享面板里"时有时无",先别改代码——把 NSExtensionActivationRule 换成 TRUEPREDICATE 看它出不出现。出现了,问题在谓词;不出现,问题在 target 配置或 profile。这一步能省掉半天。


关于本文代码

文中 Swift / Objective-C 片段用于说明投递链路与路由形态,未逐行编译验证。激活规则谓词的语法以 Apple《App Extension Programming Guide》中 "Declaring Supported Data Types" 一节为准,不同宿主 App 提供的 registeredTypeIdentifiers 有差异,请在目标来源上实测。响应链唤起宿主 App 的方式没有官方文档背书,其可用性以你目标系统版本上的实测为准。Action Extension 的内存上限 Apple 未公开,文中只给量级。App Group ID、URL scheme、文件前缀均为示意,不是任何产品的真实值。