你正站在柜台前,手里刚接过一张印着二维码的小票。手机扫了一下,没有跳转应用商店,也没有提示“下载App”,屏幕上直接弹出一张带商家Logo的卡片,点一下,选套餐、付款、走人。这张卡片,就是 iOS App Clips 在真实场景里的样子。
这篇文章不是官方文档的翻译,而是我从“这功能到底怎么运作”到“用停车缴费 Demo 完整跑通一遍”之后沉淀下来的学习笔记。内容包括产品定位、工程结构、扫码触发链路、权限与支付等容易踩坑的地方,以及最后我给自己的调试顺序建议。适合两类人看:一是还在纠结“要不要接 App Clips”的开发者,二是已经在接但被各种隐藏细节卡住的人。
1. 先厘清 App Clips 的产品定位:它不是迷你版 App,而是一张“任务卡片”
1.1 它和完整 App 最本质的区别:没有“安装”这个概念
传统的 App 使用流程是这样的:搜应用商店、看评分、点下载、等进度条、注册账号、登录、找到功能、使用。这串流程里任何一步让用户犹豫,转化就断了。
App Clips 把中间的步骤全部砍掉。用户通过 NFC 标签、二维码、Safari 链接等方式触发后,系统会在当前位置动态获取一个轻量片段,一两秒内弹出带有商家图标和名称的卡片。用户点击“打开”,直接进入功能界面,不需要在主屏生成图标,也不需要经历安装确认。
这就是我理解里最核心的差异:App Clips 面对的不是“我要不要装这个 App”的问题,而是“我要不要马上把这个事做完”的问题。产品设计上必须尊重这种语境——用户手里可能拿着咖啡,可能站在停车位旁边,可能正要赶地铁,他的耐心窗口非常短。
1.2 能力边界写得很清楚:10MB 体积与短会话
苹果官方对 App Clips 的硬性约束主要有两条,大家在规划功能时最好一开始就记住:
- 包体积:最终下载体积必须小于 10MB。注意这里说的是压缩后的下载体积,不是源码大小,也不是 .app 解压后的体积。
- 会话时长:系统会限制单次会话的使用时长,官方建议是把核心任务控制在几分钟内完成。超过一定时间,系统可能终止进程,用户下次触发时会重新开始一个新的会话。
这两条限制决定了 App Clips 天生不适合做内容浏览、社区签到、复杂表单填写这类长流程任务。它更适合的是一类“高频、单目标、马上完成”的事情:停车缴费、咖啡到店自取、共享设备开锁、预约签到、优惠券核销。
我做停车缴费 Demo 之前,最初想塞进去的功能包括“查看历史停车记录、绑定车牌、充值余额、开具发票”,后来全部砍掉了。不是技术上做不出来,而是用户站在闸机前,他的目标只有一个:把车开出去。多余的功能都是干扰。
1.3 触发方式一大堆,但本质上它们都在传递同一个东西:URL
很多人看到 App Clips 文档里一堆触发方式会有点懵:NFC 标签、二维码、Safari 智能横幅、地图位置、iMessage 链接,好像很复杂。实际拆开看,所有触发方式背后的机制都是同一个:把一个 HTTP/HTTPS URL 交给系统,系统根据 URL 匹配到你预先配置好的“App Clip Experience”,然后弹出卡片。
| 触发方式 | 用户动作 | 底层传递内容 |
|---|---|---|
| NFC 标签 | 手机靠近标签 | NDEF 消息中的 URL |
| 二维码 | 相机扫码 | 二维码编码的 URL |
| Safari 链接 | 点击网页链接 | 常规网页 URL |
| 地图 | 在位置页点击 | Apple 开发者后台绑定的 URL |
| iMessage | 点击消息链接 | URL |
这个认知很重要。排障的时候,与其去怀疑 NFC 标签坏了、二维码不清晰,不如先确认一件事:最终到达系统的那串 URL 是否和你配置的 Experience 前缀匹配。我后来所有触发问题的排查,基本都是沿着这条主线走的。
2. 在 Xcode 里创建 App Clip Target 后,我才理解的工程边界问题
2.1 新增 Target 的过程比想象中简单,但有个设置千万别删
在工程里选择 File → New → Target,往下拉能找到 App Clip 模板。创建时 Xcode 会自动帮你做好几件事:生成一个独立的 target、Bundle Identifier 默认拼接为$(APP_BUNDLE_ID).clip、自动加入主 App 的 Embed App Clips 构建阶段、设置好配套的 Info.plist。
有一点刚开始容易被忽略:App Clip Target 的 Build Settings 里会多一个IS_APP_CLIP = YES的标志。这个标志是系统识别“这是一个 App Clip”的关键,不要手滑删掉。它决定了编译产物会被打进主 App 的AppClips目录,也决定了最终打包上传时的身份。
签名方面,App Clip 必须和主 App 使用同一个 Team ID,否则后续关联域名、App Group、Keychain 共享都会出问题。我建议在创建 Target 之后,先到 Signing & Capabilities 里确认主 App 和 Clip 用的都是同一个开发者账号,再做其他配置。
2.2 主 App 与 App Clip 的容器相互独立,共享数据要靠三个渠道
App Clip 和主 App 虽然代码上可以复用大量内容,但在系统眼里它们是两个完全独立的 App。不配置任何共享机制时,它们的 UserDefaults、文件目录、Keychain 互不可见。这个“默认隔离”的设定会让第一次接触的人很意外——你会发现用户在 Clip 里登录了,主 App 仍然不认识他。
如果你确实需要让两个 target 共享数据,常规做法有三种:
- 通过 App Group 共享 UserDefaults 和文件
- 通过 Keychain Sharing 共享登录凭据
- 通过服务端保存业务数据,App 端只做读取
我实际用的是 App Group 加服务端组合。App Group 适合存一些轻量的、非敏感的状态,比如“用户是否已经在这个设备上完成引导”;真正涉及账号和订单的数据,还是以服务端为准,App 端只做缓存。
// 写入:在 App Clip 里保存一个登录标记 let sharedDefaults = UserDefaults(suiteName: "group.com.example.parking") sharedDefaults?.set("clip-登录成功", forKey: "loginState") // 读取:在主 App 里检查 if let sharedDefaults = UserDefaults(suiteName: "group.com.example.parking"), let state = sharedDefaults.string(forKey: "loginState") { print("主App读取到Clip状态:\(state)") }注意,UserDefaults(suiteName:)使用的前提是主 App 和 App Clip 都添加了 App Groups 能力,并且使用同一个 group ID。否则代码不会报错,但读写都是各自容器里的数据,现象就是“共享了个寂寞”。
2.3 我推荐一开始就把共享业务代码抽成本地 Swift Package
写第一个 Demo 的时候,我是把 Clip 里的业务代码直接复制一份到主 App target 里用的。结果改了一处逻辑,另一个 target 忘了同步,调试了半天才发现两边行为不一致。后来我把网络层、支付逻辑、模型定义全部抽到一个本地 Swift Package 里,主 App 和 Clip 都依赖这个包,才算真正解决问题。
这里有一个工程细节值得注意:App Clip 对动态库的支持非常有限,官方建议不要在里面使用动态库。如果团队用的是 CocoaPods 且开启了use_frameworks!,建议改成use_frameworks! :linkage => :static,或者改用 Swift Package Manager 并以静态库方式链接。原因很简单:动态库会引入额外的依赖解析和启动时间,对 10MB 压缩包限制和秒开体验都不友好。
使用 Swift Package 的好处还有一点:依赖关系变得更清晰。主 App 依赖Core和MainUI,Clip 依赖Core和ClipUI,这样就不会出现 Clip 里误编译了一大堆主 App 才需要用到的控制器。
3. 用一个停车缴费 Demo 走通扫码触发的完整链路
3.1 关联域名配置:AASA 文件才是触发的地基
扫码后系统能不能正确识别你的 URL,第一步取决于关联域名(Associated Domains)是否配置正确。主 App 和 App Clip 都要在 Signing & Capabilities 里添加applinks:你的域名,然后在域名服务器根目录或.well-known路径下放一个apple-app-site-association文件。
这个文件的核心作用是告诉系统:这个域名下的哪些路径属于你的 App。里面的 appID 需要按“TeamID.BundleID”的格式填写,且建议把主 App 和 App Clip 的 Bundle ID 都配置进去。
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.example.parking", "paths": ["/clip/*"] }, { "appID": "TEAMID.com.example.parking.clip", "paths": ["/clip/*"] } ] } }配置完成后,先用浏览器访问一下https://你的域名/.well-known/apple-app-site-association,确认能正常返回 JSON 且没有跳转重定向。这一步只要卡住,后面扫码就一定起不来。
3.2 App Store Connect 里创建一个 Experience
本地工程配置好之后,还要到 App Store Connect 的后台里创建一个 App Clip Experience,把触发 URL 和具体的 App Clip 版本绑定起来。
需要填写的核心内容是:Experience 名称(显示在卡片上的文字)、图标、副标题,以及一个 URL 前缀。这个 URL 前缀是匹配规则,我建议把不同场景用路径区分开。比如停车项目用一个体验,路径前缀是/clip/parking;点餐体验用另一个体验,路径前缀是/clip/order。不要所有场景共用一个前缀,否则后面想针对不同场景做统计和参数处理会非常麻烦。
这里有个新手容易忽略的点:创建 Experience 之后,配置不是立即生效的。系统需要在苹果服务器上同步这些信息,有时候扫二维码仍然打开 Safari,等一段时间再试往往就好了。
3.3 真机扫码验证:从“跳到 Safari”到“弹出卡片”的排障过程
我把这段排障过程记录下来,是因为它几乎覆盖了新手会遇到的各类情况:
| 现象 | 最可能原因 | 检查点 |
|---|---|---|
| 扫码后完全没反应 | 域名关联未配置或 AASA 文件不可访问 | 浏览器直接访问 AASA 地址,检查返回 JSON |
| 扫码后跳转 Safari 而不是弹出卡片 | Experience 未创建、未生效,或 URL 与配置前缀不匹配 | App Store Connect 后台检查 Experience 状态和 URL |
| 弹出卡片但内容和预期不符 | 匹配到了错误的 Experience | 检查使用的是哪个 URL 前缀,是否正确区分场景 |
| 真机调式时点击卡片打不开 | App Clip target 没有安装或签名失效 | 确认 Xcode 运行的是 Clip target,且签名 Team 一致 |
我自己遇到过最隐蔽的问题是 AASA 文件路径。当时我把文件放到了域名的根目录,但项目里配置的 applinks 是example.com,而 AASA 必须能通过https://example.com/.well-known/apple-app-site-association访问。后来我用了一个对比工具,发现虽然根目录也能读到,但系统更规范的做法是放在.well-known路径下。
3.4 NFC 标签的写入没那么玄,但要选对格式
二维码方案验证通过后,我又用普通 NFC 标签做了验证。不需要申请苹果官方的 App Clip Code,直接买支持 NDEF 格式的空白标签,用手机上的 NFC 写入工具把 URL 写进 NDEF 消息,然后靠近手机顶部感应区。
注意不要让标签里的 NDEF 消息携带自定义的协议格式,必须是一个标准的 HTTP/HTTPS URL。NFC 触发和二维码触发最终走的是同一条匹配链路,所以标签本身只是一个“URL 搬运工”,不要把业务参数硬编码在标签里。标签里只需要带上场景标识,比如https://example.com/clip/parking?spot=A12,其余信息尽量在打开 Clip 后通过服务端获取。
4. 测试途中逐一踩过的坑:权限、体积、支付,一个都不少
4.1 权限弹窗不能一上来就弹
App Clip 是一个“用完即走”的轻量会话,但它依然可以通过系统弹窗请求通知、定位、相机等权限。问题在于,用户还没有理解自己打开的是什么,弹窗就糊上脸,他会本能地点击“不允许”。
我刚开始做的版本,扫码进来第一件事就是请求通知权限。后来看数据,允许率非常低,而且大量用户在这个弹窗出现后就关闭了卡片。调整方案是把权限请求绑定到“用户已经完成某个明确动作之后”,比如停车缴费完成后,再询问“需要缴费提醒吗”。这时候用户已经获得了价值,对 App Clip 的信任度也有所提升,授权意愿会好很多。
另一个需要注意的细节是:App Clip 的通知权限和主 App 是独立的。用户在 Clip 里点了允许,不代表主 App 也允许;反过来也一样。所以不要假设“用户之前允许过主 App 通知,Clip 就可以直接发通知”。
4.2 场景参数的传递:从 NSUserActivity 里解析 URL
用户通过扫码或 NFC 触发时,App Clip 需要通过continue userActivity拿到本次调用的上下文。参数如何设计,直接影响后面的业务逻辑。
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard let url = userActivity.webpageURL else { return } let components = URLComponents(url: url, resolvingAgainstBaseURL: false) let spotId = components?.queryItems?.first(where: { $0.name == "spot" })?.value print("触发场景参数 spotId:\(spotId ?? "")") }这里要记住一个原则:URL 里的参数是不可信的。二维码可以被复制,NFC 标签可以被重写,所以 URL 里只放“标识类”信息,比如车位号、门店 ID,绝对不要放金额、优惠折扣这类需要校验的数据。真实的价格和订单信息,必须等 Clip 打开后通过服务端接口获取。
4.3 包里塞了什么,决定了你离 10MB 有多远
10MB 的限制听起来不算小,但真机跑起来就会发现,体积像滚雪球一样涨。最常见的超限原因是把主 App 的资源文件误拖进了 App Clip target 的 Copy Bundle Resources 阶段;其次是图片、字体、本地化文件没有压缩。
我在第一次打包时,把一套用于主 App 引导页的插画资源放进了 Clip target,体积直接上涨近 2MB。最后把这些资源移到服务端,通过 URL 加载,才把安装包控制在 6MB 左右。
检查最终体积的标准做法是:Xcode 里 Archive 之后,在 Organizer 中查看 “App Size Details”,它会按不同设备类型展示 App Clip 的预估下载体积。不要只看 Debug 模式下的报告,那个和实际发布体积差距很大。
4.4 StoreKit 在 App Clip 里表现不一样的地方
做停车缴费 Demo,支付是绕不开的。App Clip 的支付底层同样是 StoreKit,代码写法和主 App 基本一致,但我在测试中发现两个不太一样的地方。
第一,支付弹窗和其他系统弹窗同时出现时,处理顺序要格外小心。如果用户在支付确认的同时又被请求了通知权限,两个弹窗叠加,极容易出现误触。我后来的处理是把所有系统弹窗串行化:先完成支付,回调成功后再请求通知,绝不同时出现两个弹窗。
第二,支付回调要尽早挂上队列观察者。如果在 Clip 的入口处没有注册SKPaymentQueue的观察者,而只是等用户点“支付”时才注册,可能漏掉支付进程已经回调的状态。稳妥做法是在 App Clip 启动时就注册观察者,并且在应用进入后台或终止前保持监听。
另外一个经验是:沙盒测试账号在 App Clip 和主 App 之间是共享的。如果你在测试主 App 和 Clip 各自跑同一笔订单,会发现测试记录混在一起。建议测试 Clip 支付时用独立的沙盒账号,避免和主 App 的验证流程互相干扰。
5. 会话结束之后:共享数据与引导下载完整 App 的取舍
5.1 用 SKOverlay 做“温和”的拉新引导
App Clips 的天然弱点是做不了长期留存。用户完成一次任务后关闭卡片,下次可能再也不会主动想起这个入口。如果你想引导用户下载完整 App,Apple 提供了 SKOverlay,它会在 App Clip 界面顶部弹出一个小型卡片,展示完整 App 的下载入口,但不会打断当前操作流程。
我把 SKOverlay 放在用户支付成功的页面上,用户看到“下载完整 App 可以查看历史记录”,有兴趣就点,没兴趣就继续完成支付后的流程。相比弹窗式引导,这种方式对会话的干扰最小。
需要记住的是,不要在每个页面都展示 SKOverlay。它本质上是一种转化工具,频繁出现只会让用户反感。最好是选择一个“用户已经获得价值”的时刻,比如支付完成、核销成功,这时候推荐是顺理成章的。
5.2 共享容器的数据同步粒度:别指望实时
App Group 能共享 UserDefaults 和文件,但它的同步不是实时的。App Clip 写入数据后,主 App 下次启动时读取,拿到的是上一次写入的值;反过来也一样。如果你需要“Clip 完成订单,主 App 立刻能查到这个订单”,那就必须通过服务端,而不是共享容器。
这个点我在初期理解不足,曾经尝试用 App Group 的 UserDefaults 做订单状态同步,结果发现两边读取经常不一致。后来我在关键动作完成时写共享容器,业务数据仍然依赖服务端查询,问题就解决了。
5.3 什么时候不该用 App Clips
把功能做得越多,越容易偏离 App Clips 的定位。我总结了一张适合与不适合的对比表,帮助自己判断:
| 适合用 App Clips | 不适合用 App Clips |
|---|---|
| 停车缴费、点单取餐、扫码开锁 | 资讯阅读、视频浏览、社区签到 |
| 单次交易,几分钟内完成 | 需要长期账号体系、会员等级 |
| 线下场景触达,用户有明确意图 | 线上功能单纯为了减少下载步骤 |
| 用完即走,不依赖长期留存 | 需要“日活”和“留存”作为核心指标 |
我砍掉停车缴费 Demo 里“历史记录”功能的时候才发现,用户真正需要的是一个“从扫码到抬杆”的最短路径。任何让这个路径变长、变复杂的设计,都不适合放进 App Clip。
6. 调试方法与我建议的学习顺序
6.1 模拟器够用,但完整链路必须真机
App Clip 的界面逻辑完全可以在模拟器里调试,但涉及到 NFC、二维码、关联域名匹配这些部分,模拟器是验证不了的。我建议至少准备一台真机,配合一个二维码,就能走通最基本的端到端流程。
调试的时候不需要老是写 NFC 标签,直接把二维码截图存到相册,用相机扫描就能触发。NFC 标签主要留到最终交付前做一轮完整的实机验证。
6.2 用 Xcode Scheme 预置 Experience 的小技巧
在 Xcode 里编辑 App Clip target 的 Scheme,可以在 Run 配置中找到 App Clip Experiences 列表。这里可以预置不同的 Experience,每次运行时会自动带入对应的 URL。这个功能对调试场景参数特别有用。
我配置了两个 Experience:一个模拟正常停车入口,一个模拟预约入口。切换 Scheme 就能测试不同 URL 下的参数解析逻辑,不需要每次都生成新的二维码。
6.3 我建议的学习顺序
如果完全是从零开始接触 App Clips,我的建议是按这个顺序走:
- 先不写任何代码,把苹果官方概览页面读一遍,理解 10MB、短会话、任务导向这几个核心概念。
- 用空工程创建一个 App Clip target,跑通最基本的“点击卡片打开界面”。
- 配置关联域名和 AASA,用二维码做一次端到端验证。这是最容易卡住、也最需要耐心的环节。
- 加入 App Group、权限请求、支付等功能,逐步测试边界情况。
- 最后再思考产品层面:这个流程是否真的适合 App Clips。
最后再说一点自己的体会。App Clips 的工程难度并不高,真正的难度是产品判断。我做完这个 Demo 之后最大的感受是,技术上的问题都能查文档解决,但“用户扫码之后到底要完成什么、每一步用时多少、什么时候该退出”这些问题,才是决定一个 Clip 体验好坏的关键。如果你正准备接 App Clips,建议先把这段使用路径写在纸上,理清用户从扫码到离开的每一步,再开始写代码。这个过程比我预想的更有价值。