ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

iOS App Clips 实战:从扫码触发到停车缴费 Demo 完整指南

iOS App Clips 实战:从扫码触发到停车缴费 Demo 完整指南

你正站在柜台前,手里刚接过一张印着二维码的小票。手机扫了一下,没有跳转应用商店,也没有提示“下载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,我的建议是按这个顺序走:

  1. 先不写任何代码,把苹果官方概览页面读一遍,理解 10MB、短会话、任务导向这几个核心概念。
  2. 用空工程创建一个 App Clip target,跑通最基本的“点击卡片打开界面”。
  3. 配置关联域名和 AASA,用二维码做一次端到端验证。这是最容易卡住、也最需要耐心的环节。
  4. 加入 App Group、权限请求、支付等功能,逐步测试边界情况。
  5. 最后再思考产品层面:这个流程是否真的适合 App Clips。

最后再说一点自己的体会。App Clips 的工程难度并不高,真正的难度是产品判断。我做完这个 Demo 之后最大的感受是,技术上的问题都能查文档解决,但“用户扫码之后到底要完成什么、每一步用时多少、什么时候该退出”这些问题,才是决定一个 Clip 体验好坏的关键。如果你正准备接 App Clips,建议先把这段使用路径写在纸上,理清用户从扫码到离开的每一步,再开始写代码。这个过程比我预想的更有价值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进