☰
iOS 10自定义通知UI:Notification Content Extension实战避坑
2026/11/6 6:01:36 网站建设 项目流程

长按一条通知,弹出来的还是系统默认的那一坨白底黑字,顶多多一张缩略图——这个场景做过消息类产品的朋友应该都不陌生。iOS 10 之前,通知能定制的东西少得可怜,你能改的无非是标题、正文和一个小小的附件图,想在通知上放个播放控件、放个进度条、放个商品卡片,基本只能靠“点开 App 看详情”这一条路。iOS 10 把 UserNotifications 这套框架推出来之后,情况变了:通知权限、通知分类、通知交互、通知样式被拆成了三个互相独立又能串起来的扩展点,其中负责“自定义通知 UI”的,就是 Notification Content Extension。这篇东西聊的就是它——怎么让一条通知被你自己的 ViewController 接管,怎么让它在通知中心里长成你想要的样子,以及在这一路上我踩过的、文档里基本不写的那些坑。适合已经能跑通普通本地推送、想往“通知即界面”这个方向再走一步的 iOS 开发看,也适合产品同学拿来评估“这个样式到底做不做得到”。

1. 通知展不开的那半屏,才是 Content Extension 的战场

很多人第一次接触 UserNotifications,注意力都放在怎么弹出一条通知上,UNMutableNotificationContent塞个 title、body,UNNotificationRequest一加,通知就出来了,感觉这套 API 平平无奇。真正有意思的分水岭出现在用户长按(或者下拉)通知的那一刻:系统会把这条通知从“一行摘要”展开成“一块卡片”,而这块卡片长什么样,就是 Content Extension 说了算。

1.1 iOS 10 把通知拆成三个扩展,别再混着用

先把这个体系理清楚,不然后面全是糊涂账。iOS 10 之后,通知相关的能力被拆到了三个位置,各自职责非常明确,混用会出各种莫名其妙的问题。

第一个是主 App 内的UNUserNotificationCenter,负责权限申请、注册通知分类(UNNotificationCategory)、调度本地通知、处理用户点击回调。第二个是 Notification Service Extension,它在通知到达设备、展示之前被唤起,给你大概三十秒的时间去改内容——下载图片、解密文本、修改标题都行,但它不负责渲染任何界面,改完就把内容交还给系统。第三个才是 Notification Content Extension,它只在用户把通知展开的时候才被创建,负责画那块卡片。

这个分工带来的一个直接后果是:你想在通知上显示一张网络图片,正确姿势是在 Service Extension 里先把图下下来存到共享目录,而不是在 Content Extension 里现场URLSession拉。因为 Content Extension 被唤起时用户已经盯着屏幕了,你在那儿转圈加载三秒,体验比不显示还糟。我见过有同学把网络请求写在didReceive里,测试机上 Wi-Fi 好好的没什么感觉,一到弱网环境就是白块,这个坑后面还会详细说。

扩展类型触发时机能否渲染 UI典型用途
Notification Service Extension远程通知到达、展示前否下载附件、解密、改文案
Notification Content Extension用户展开通知时是自定义卡片、播放器、商品卡
主 App用户点击通知或 Action是(但已离开通知中心)跳转详情页、处理业务逻辑

1.2 什么样的通知才会唤起自定义 UI

这里有个硬性条件,很多人卡在这一步:不是装了 Content Extension,通知就自动变成自定义样式。系统判断的逻辑是,这条通知的categoryIdentifier必须非空,并且这个 identifier 必须在主 App 里注册过对应的UNNotificationCategory,同时 Content Extension 的 Info.plist 里配置的UNNotificationExtensionCategory要和它对得上。三者缺一,系统就当无事发生,老老实实给你渲染默认样式,而且不会有任何报错。

换句话说,Content Extension 是一种“按类别生效”的机制。你可以给“音乐播放”类通知配一套带播放按钮的 UI,给“订单状态”类通知配一套带进度条的 UI,同一个 App 下的普通文本通知依然走系统默认样式。这个设计其实挺合理——毕竟不是所有通知都值得占用那么大的屏幕空间。

我个人的经验是,category 的命名最好带上前缀,比如com.yourapp.music、com.yourapp.order这种,因为 category 是全局字符串匹配,纯英文单词很容易和别的模块撞车。撞车的后果比你想的严重:两个 category 同名,后注册的会覆盖先注册的,扩展里拿到的 action 列表可能完全不是你预期的那个。

1.3 自定义 UI 到底能改哪些东西,先划清预期

在动手之前,建议先把“能做什么、不能做什么”想明白,省得做到一半发现方向错了。Content Extension 给你的是一个完整的UIViewController,理论上你可以往里塞任何 UIKit 控件——UILabel、UIImageView、UIProgressView、UIButton、甚至一个AVPlayerLayer来做视频预览。宽度是固定的(等于屏幕宽度减去系统留白),高度可以按比例配置也能动态调整,四周会被系统加上圆角。

但它不能做的事同样明确:不能横向滚动,不能弹出自己的模态窗口,不能长时间驻留后台,不能直接访问主 App 的内存单例,可用内存也很紧张。所以像“通知里做一个可滑动的商品列表”这种需求,直接劝退,改成“显示主推商品 + 点击进 App 看全部”才是能落地的方案。我一般会跟产品这样说:自定义通知是一块会动的广告牌,不是一个小程序。

2. 建 Target 只是开始:Info.plist 里那三个键决定扩展会不会被唤起

创建扩展这一步本身没什么难度,Xcode 里 File > New > Target,选 Notification Content Extension,一路 Next,工程里就会多出一个.appex,附带一个NotificationViewController.swift、一个MainInterface.storyboard和一份 Info.plist。真正容易出问题的是后面那份 plist 的配置,我见过的“扩展完全不生效”案例里,八成都能在这三个键上找到原因。

2.1 UNNotificationExtensionCategory:最容易写错的一个字符串

这个键的作用就是告诉系统“我负责哪个 category 的通知”。在 iOS 10 的年代,它只接受一个字符串值;从 iOS 12 开始才支持传数组,让一个扩展同时接管多个 category。如果你的最低支持版本是 iOS 10,那就老老实实一个扩展对应一个 category,多个样式就建多个扩展,别想着取巧。

写错的典型场景有三种:一是主 App 注册 category 的时候用了常量,plist 里手写字符串,改了一处忘了另一处;二是大小写不一致,Music和music在系统眼里是两个东西;三是主 App 里压根没调用setNotificationCategories,或者调用时机太晚。第三种特别隐蔽——有人把注册写在某个页面的viewDidLoad里,结果冷启动直接从通知进来,页面还没加载,category 自然不存在,扩展也就不会被唤起。

提示:setNotificationCategories建议放在application(_:didFinishLaunchingWithOptions:)里,越早越好。这个调用是幂等的,覆盖式写入,不用担心重复调用产生副作用。

还有一个细节值得提一句:注册 category 之后,如果你改了它的 actions 列表,已经投递到通知中心但还没被展开的通知,可能会保留旧的行为。测试的时候养成习惯,改完 category 相关代码先卸载重装一次 App,别在旧数据上反复折腾自己。

2.2 UNNotificationExtensionInitialContentSizeRatio:比例的坑在后面

这个键控制自定义视图的初始高度,是个浮点数,含义是“高度 / 宽度”。默认值 1.0,也就是说一个正方形的卡片。你设 0.5,高度就是宽度的一半;设 2.0,就是两倍宽那么高。

听起来很直观,但实际用起来会发现两个问题。第一,这个比例只在初始渲染时生效,如果你的内容高度是动态的(比如文案长短不一),最终显示的高度可能和你预期差很远,得配合preferredContentSize一起用。第二,比例算出来的高度如果超过系统允许的上限,会被直接截断,下面的内容你压根看不见,而且没有任何提示。

我的做法是:静态内容(比如固定布局的音乐播放器)直接用比例值,把比例算准;动态内容先用一个保守的初始比例,然后在didReceive里量完实际高度再更新preferredContentSize。这样用户展开的瞬间不会看到明显的跳变。

2.3 UNNotificationExtensionDefaultContentHidden:藏不藏系统那部分

这个布尔值决定系统默认的标题、副标题、正文要不要显示。设成YES,整块区域完全交给你;设成NO,系统内容在上,你的视图在下,形成一个拼接的效果。

大部分教程会告诉你设YES,干净。但我想提醒一句,设成YES之后,通知的标题和正文就完全靠你自己画了。用户如果没有展开通知,看到的仍然是锁屏上那一行摘要,这没问题;但一旦展开,他看不到任何系统渲染的文字,全靠你的 UI 传达信息。如果你的自定义视图里忘了放标题,用户长按之后会觉得“怎么什么都没有”。

我一般的处理是:需要强视觉冲击的场景(音乐播放器、大图预览)设YES全接管;信息型通知(订单、日程)设NO,让系统负责文字,我只在下半部分加一个进度条或者操作区。这样既省事,也不会因为自定义 UI 的加载失败导致用户什么都看不到——扩展崩了系统会回退到默认样式,但如果默认内容被隐藏了,回退之后就只剩一块空白。

另外补充一个实操点:如果用纯代码写 ViewController 而不想用 storyboard,需要在 Info.plist 的NSExtension字典里删掉NSExtensionMainStoryboard,改成NSExtensionPrincipalClass,值填$(PRODUCT_MODULE_NAME).NotificationViewController这种带模块名的形式。漏了模块名,扩展启动就会因为找不到类而直接崩溃,然后你看到的现象是“通知展开后闪一下又变回默认样式”,日志里什么都抓不到。

3. Category 注册顺序与 payload 设计:自定义 UI 的数据从哪来

UI 有了,接下来是数据。通知展开时,扩展拿到的只有一个UNNotification对象,里面是request.content和request.trigger。这条通知是你自己发出来的,理论上你想塞什么就塞什么,但怎么塞、塞多少、塞在哪里,是有讲究的。

3.1 userInfo 是唯一可靠的通道,但要控制体积

自定义数据统一走content.userInfo,这是一个[AnyHashable: Any]字典,序列化之后随通知一起投递。本地通知和远程通知都一样,扩展里通过notification.request.content.userInfo取出来用。

这里有个体积限制的问题。远程推送的 payload 总大小是有限的(APNs 那边有明确上限,超过就直接被拒),本地通知虽然没有那么严格的限制,但塞太多数据也没意义——你完全可以把大块数据存在共享容器里,userInfo 里只放一个 key。我在做一个带封面的通知时就是这么干的:Service Extension 下载完图片写到 App Group 的共享目录,userInfo 里只留下文件名,Content Extension 读文件渲染。这样 payload 干净,扩展加载也快。

另外,userInfo 的值类型要小心。它走的是属性列表序列化,Date、Data、NSNumber、String、数组、字典都可以,但你自定义的模型对象不行。有人图省事直接塞了个 struct 进去,编译能过(因为是 Any),运行时通知投递失败,日志里就一行看不懂的错误。稳妥做法是自己写一层编解码,或者干脆只传基础类型。

3.2 扩展是独立进程,共享数据必须走 App Group

这一点是新手最容易忽略的:Content Extension 跑在一个独立的进程里,和主 App 不共享内存,你主 App 里的单例、静态变量、甚至UserDefaults.standard里的数据,在扩展里统统拿不到。想共享数据,必须给主 App 和扩展都勾上同一个 App Group,然后用UserDefaults(suiteName:)或者共享目录来读写。

// 主 App 与扩展使用同一个 group identifier let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared") sharedDefaults?.set(["cover": "cover_1024.jpg"], forKey: "lastMusicPayload")

配 App Group 的时候记得两边都要在 Signing & Capabilities 里加上,只加一边是无效的,而且不会报错,只是UserDefaults(suiteName:)返回 nil 或者读不到值。我第一次配的时候只给主 App 加了,调了半天以为是 key 写错了,最后发现是扩展那边压根没开这个能力。

3.3 什么时候该在 Service Extension 里做预处理

判断标准很简单:凡是需要等待的操作,全部前置到 Service Extension。网络请求、图片解码、文件解压、大 JSON 解析,这些都不该出现在didReceive里。因为 Content Extension 的生命周期极短,用户展开通知的那一两秒就是你的全部时间窗口,你在这里做耗时操作,用户看到的就是一块空白或者一个转圈。

Service Extension 有三十秒左右的执行时间(实际会比这个短,系统会提前收走),足够下载一张中等尺寸的图片。下载完存到共享目录,Content Extension 里直接UIImage(contentsOfFile:)同步读取,速度完全够用。如果图片特别大,还可以在 Service 里顺手做一次降采样,生成一个适合通知卡片尺寸的小图,避免在扩展里解码原图导致内存告警。

还有一点,Service Extension 里写的content是可以被修改的,包括title、subtitle、body和userInfo。所以你完全可以在 Service 里把 userInfo 补全,比如把“图片已下载”的标记写进去,Content Extension 里根据这个标记决定是显示图片还是显示占位符。这种前后配合的写法比在扩展里做判断要清爽得多。

4. didReceive 之后:把 UNNotification 变成一棵可用的视图树

UNNotificationContentExtension这个协议要实现的回调不多,核心就一个didReceive。但就是这一个方法,写得好和写得差,体验差距能拉开一大截。

4.1 两个版本的 didReceive,别用混了

协议里其实有两个didReceive。一个是func didReceive(_ notification: UNNotification),只传通知内容,用于首次渲染;另一个是func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping (UNNotificationContentExtensionResponseOption) -> Void),在用户点击了自定义视图里的 Action 按钮时被调用,你需要调用 completionHandler 告诉系统接下来怎么办。

func didReceive(_ notification: UNNotification) { let userInfo = notification.request.content.userInfo guard let title = userInfo["title"] as? String else { return } titleLabel.text = title // 根据内容算高度 view.layoutIfNeeded() preferredContentSize = CGSize(width: view.bounds.width, height: calculatedHeight) }

第一个方法里做的事情就是解析数据、填充 UI、必要时更新高度。这里要注意,didReceive被调用时,视图可能还没完成布局,view.bounds.width拿到的值不一定准确。稳妥的做法是把布局计算放到viewDidLayoutSubviews里,或者在didReceive里手动调一次layoutIfNeeded再取值。我遇到过宽度取到 0,结果算出来的高度也是 0,卡片直接塌成一条线的情况,排查了挺久。

第二个方法的completionHandler有三个枚举值:.dismiss表示处理完就收起通知,.dismissAndForwardAction表示收起并把这次点击转发给主 App 的 delegate,.doNotDismiss表示不收起。做播放控制这种场景,显然要选.doNotDismiss,不然用户点一下播放通知就没了,体验很怪。

4.2 UI 更新必须在主线程,而且要考虑重复渲染

理论上didReceive是在主线程调用的,但我不敢完全赌这一点,尤其是内容里还牵涉到异步回调的时候。所以我的习惯是,任何触碰 UI 的代码都显式包一层主线程派发,成本几乎为零,省心。

更重要的是重复渲染的问题。同一条通知在通知中心里可能会被反复展开收起,扩展实例可能被复用,didReceive也可能被多次调用(比如同一条通知被更新了内容)。如果你的初始化逻辑写在viewDidLoad里,每次展开都不会重新执行;如果写在didReceive里,又会重复执行。我的做法是把“一次性”的视图搭建放viewDidLoad,把“每次可能变化”的数据填充放didReceive,并且填充前先清理上一次的状态,避免出现两张图片叠在一起这种画面。

4.3 图片加载:同步读取也要有兜底

通知卡片里的图片是最常见的需求,也是最容易出问题的地方。前面说过,正确做法是在 Service Extension 里下载好,Content Extension 里读文件。但读文件这件事本身也得注意两点。

第一是路径。扩展和主 App 的沙盒路径是分开的,绝对路径写死在代码里必挂。要用FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)拿到共享目录,再拼相对路径。第二是要有兜底,文件不存在、损坏、格式不支持,都得有个占位图,不能让UIImageView空着——空着的UIImageView在自动布局里高度可能是 0,整个布局会跟着塌。

guard let container = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: "group.com.yourapp.shared") else { coverView.image = placeholderImage return } let fileURL = container.appendingPathComponent(fileName) coverView.image = UIImage(contentsOfFile: fileURL.path) ?? placeholderImage

另外一个细节是图片的裁剪模式。通知卡片的宽高比是固定的,但服务端给的图未必是这个比例。用.scaleAspectFill加clipsToBounds = true是最稳的,配合系统自动加的圆角,视觉上不会出问题。用.scaleAspectFit的话,图片上下会留白,看起来像是没加载完。

5. 高度、圆角与安全区:自定义通知被裁切的真实原因

如果说 Content Extension 有什么让人抓狂的地方,高度控制绝对排第一。我见过太多人把界面画得好好的,一跑起来发现底部被切掉一半,或者整个卡片贴在最上面下面一大片空白。

5.1 初始比例算出来的高度,只是起点

前面提过UNNotificationExtensionInitialContentSizeRatio,它决定了第一次显示时的高度。但很多人不知道的是,这个高度只是一个“初始值”,系统在渲染完成之后,会读取视图控制器的preferredContentSize,如果这个值和初始值不一样,会以它为准做一次调整。

这就带来一个策略选择:如果你的内容是固定高度的(比如一个 200pt 高的播放器),那把比例设准,preferredContentSize都不用碰,最省事。如果内容高度可变,就用一个偏小的初始比例先撑住,等didReceive里数据填充完、layoutIfNeeded算完约束,再把真实高度写进preferredContentSize。这样展开的瞬间虽然会有一个高度变化,但至少内容不会被截断。

要注意的是,preferredContentSize的宽度部分基本可以忽略,系统只关心高度。而且这个高度有上限,超过屏幕高度一定比例之后会被强制截断,具体比例随系统版本变化,我一般把自定义卡片的高度控制在屏幕高度的 40% 以内,超过了就说明内容太多,该考虑精简了。

5.2 自动布局的三个必设约束

扩展里的自动布局和普通页面没本质区别,但因为高度是由内容撑开的,约束写漏了不会像普通页面那样“看起来还行”,而是直接导致高度计算错误。我总结下来,有三类约束必须齐全。

一是顶部到底部的完整链路。从最上面的视图到最下面的视图,必须有一条不间断的垂直约束链,中间不能断,否则系统不知道该以谁为准算高度。二是宽度方向的约束。视图宽度不能靠内容自适应,必须有明确的左右约束,否则多行文本的换行计算会出问题。三是内容视图的优先级。有些时候内容本身有固有尺寸(比如图片的 intrinsic size),这个优先级可能会和你的固定高度冲突,该降的降,该提高的提高。

我的习惯是给根视图加一个明确的底部约束,然后在viewDidLayoutSubviews里打印一次view.bounds,确认高度和我预期的一致。这个小动作能省掉大量“为什么显示不对”的猜测时间。

5.3 圆角、透明度与系统背景

系统会给通知卡片自动加圆角,并且会裁掉超出边界的部分。这意味着你自己画的背景色如果填满整个视图,会在四角被切出圆角,视觉上是正常的;但如果你的内容贴边太紧(比如文字距离左边只有 2pt),圆角处的文字会被削掉一点点,看起来很不舒服。留白至少留到 12pt 以上,这个数值是我试下来比较安全的。

还有一个容易被忽略的点:如果你的根视图背景色设成了透明或者clear,底下露出来的会是系统的通知背景(浅色模式下是白色,深色模式下是深灰)。这本身不算错,但如果你在里面放了一张半透明的图片,两层的颜色叠在一起,最终效果和你设计稿上看到的不一样。想让视觉效果可控,就给自己画一个不透明的背景色,别依赖系统。

至于安全区,通知卡片一般不受刘海影响,但在某些横屏场景下会有边距变化。我的做法是直接用view.safeAreaInsets来调整内部间距,不写死数值。成本很低,兼容性好很多。

6. 交互闭环:Action 按钮、媒体控制与跳回主 App

自定义 UI 如果只是“好看”,那价值有限。Content Extension 真正有意思的地方,是它能在通知里完成一次完整的交互,而不需要用户打开 App。

6.1 Action 按钮的注册与回调链路

Action 按钮是通过UNNotificationCategory注册的,最多可以配四个(超过会被折叠成“更多”)。每个 Action 是一个UNNotificationAction,需要 identifier、标题和 options。options 里最常用的是.foreground,表示点击后打开 App;不加这个 option,点击就只会在后台回调,不会切到前台。

let likeAction = UNNotificationAction(identifier: "action.like", title: "点赞", options: []) let replyAction = UNNotificationAction(identifier: "action.reply", title: "回复", options: [.foreground]) let category = UNNotificationCategory(identifier: "com.yourapp.social", actions: [likeAction, replyAction], intentIdentifiers: [], options: []) UNUserNotificationCenter.current().setNotificationCategories([category])

点击 Action 之后的回调路径有两个。如果通知没有被展开、用户直接点了 Action 按钮,回调会走主 App 的UNUserNotificationCenterDelegate;如果通知已经被展开、用户在你自定义的 UI 上点了按钮,回调会先走扩展里的那个带completionHandler的didReceive。这个区别很关键,很多人把逻辑只写在了主 App 的 delegate 里,结果发现展开状态下点击没反应,其实是走了另一条路。

completionHandler传.dismissAndForwardAction是最好用的组合:扩展里先把本地状态更新掉(比如把点赞按钮点亮),然后收起通知,同时把这次点击转发给主 App 去真正发起请求。用户在通知里点了一下,视觉上有即时反馈,业务上也完成了,两全其美。

6.2 媒体播放控件的坑

UNNotificationContentExtension协议里有几个和媒体相关的属性:mediaPlayPauseButtonType、mediaPlay、mediaPause。返回.default系统会给你画标准的播放/暂停按钮,返回.overlay会把按钮叠加在视图上,返回.none就什么都不画。

这块看起来是白送的,实际坑不少。首先,这个按钮的位置和样式是系统决定的,你没法调;其次,它只提供播放和暂停两个动作,快进快退、进度拖动统统没有,想做完整播放器还得自己画控件;第三,音频播放要在扩展进程里完成,意味着你得自己管理AVAudioSession和播放器生命周期,而扩展随时可能被系统回收,播放状态很容易丢。

我实际的建议是:如果只是“能不能先试听一下”这种轻量需求,用系统的播放按钮就够了,省事;如果要做真正的播放器体验(进度、切歌、歌词),别在通知里硬扛,老老实实引导用户点进 App。扩展的内存和时间预算撑不起一个完整的播放器。

6.3 从扩展跳回主 App 的正确方式

在扩展里打开主 App,用的是extensionContext?.open(_:completionHandler:)。这里有个限制:URL 的 scheme 必须是主 App 在 Info.plist 里注册过的自定义 scheme,而且扩展和主 App 必须属于同一个 App Group(或者满足其他允许的条件),否则open会直接返回 false。

guard let url = URL(string: "yourapp://detail?id=1024") else { return } extensionContext?.open(url) { success in if !success { // scheme 未注册或不被允许 } }

如果只是“点一下通知,打开 App 首页”,其实不用自己写open,调用extensionContext?.performNotificationDefaultAction()就行,行为和用户直接点击通知完全一致,主 App 的 delegate 会收到正常的回调,里面带着原始的 userInfo。这个方法我强烈推荐,比自己拼 URL 稳得多,尤其是涉及冷启动场景的时候,系统帮你处理了各种边界情况。

7. 调试链路复盘:断点挂不上、UI 不出现、闪退回退的三类故障

写扩展最难受的地方是调试。它不是一个能独立启动的 App,你按 Cmd+R 跑起来的是一个宿主进程,断点打在扩展代码上根本不生效。我头几次写的时候,全靠 print 加日志,效率极低。

7.1 让断点真正挂上扩展进程

正确做法有两种。第一种是改 Scheme:给扩展 target 单独配置一个 scheme,在 Run 的 Info 页里把 Executable 设成“Ask on Launch”,然后运行,系统会弹出一个进程列表,你手动选通知中心相关进程。但这种方式在真机上经常选不对,模拟器上也不一定稳定。

第二种我更常用:先正常运行 App,然后让通知出现,此时扩展进程已经起来了,再到 Xcode 的 Debug 菜单里选 Attach to Process by PID or Name,输入扩展的可执行文件名(通常就是 target 名字),附加上去。附加成功之后,把通知收起再重新展开,断点就会命中。这个方式的缺点是每次重启都要重新附加一次,但胜在稳定。

还有一个更省事的办法:在扩展的didReceive里加一段sleep(5),让进程多存活几秒,给你足够的时间去附加。这只是调试手段,上线前记得删掉,忘了删的话线上用户每次展开通知都会卡五秒,那就闹笑话了。

7.2 UI 完全不出现,按这个顺序排查

遇到展开后还是默认样式,我的排查顺序是这样的,基本能覆盖九成情况。

第一步,确认通知的categoryIdentifier到底有没有值,以及值是什么。在发通知的代码里打一行日志,别靠记忆。第二步,确认这个值和你UNNotificationCategory注册时的 identifier 完全一致,逐字符比对,注意有没有不可见字符。第三步,确认扩展 Info.plist 里的UNNotificationExtensionCategory和上面两个一致。第四步,确认 category 注册代码真的被执行了,在setNotificationCategories后面打日志。

这四步都对了还不显示,那就大概率是缓存问题。扩展的注册信息是系统在安装 App 时扫描的,改了 Info.plist 之后如果只是覆盖安装,有时候不会刷新。解决办法是卸载重装,还不行就重启一次设备。这个操作听起来很土,但确实解决过我遇到的大部分“配置都对就是不生效”的问题。

还有一种情况是扩展崩了。扩展崩溃时系统不会给你弹任何提示,只会静默回退到默认样式。判断方法是看设备日志里有没有扩展进程的 crash 记录,或者在有线连接的情况下看 Xcode 的控制台输出。如果确认是崩溃,重点查三件事:类名有没有带模块前缀、storyboard 里的初始 ViewController 有没有设对、有没有访问不存在的 App Group 导致 nil 解包。

7.3 内存告警与性能红线

Content Extension 的内存预算很紧,具体数值随系统版本和机型浮动,但量级上远小于主 App。你在里面加载一张 4K 原图,很可能直接触发内存告警,进程被杀,用户看到的就是卡片闪一下消失。

控制内存的手段其实不多,但都有效。图片一律降采样到卡片实际需要的尺寸,一张宽度 400pt 的卡片,图片宽度给到 800px(@2x)足够了,没必要用原图。避免在扩展里做大量的字符串拼接和 JSON 解析,这些工作前置到 Service Extension。不要把整个数据列表都塞进视图,通知卡片显示的永远只是“最重要的那一条”。

另外,扩展启动是有时间成本的。如果你的didReceive里同步做了很多事,用户会明显感觉到展开动画卡顿。我的经验是把didReceive里的工作压缩到毫秒级——取数据、赋值、算高度,三步搞定,其他一切能提前做的都提前做。

最后再分享一个我自己踩过的小坑:有段时间我的自定义通知在模拟器上怎么测都好,一到真机就变成默认样式,查了很久发现是扩展 target 的部署版本设得比主 App 高,真机系统版本刚好卡在中间,导致扩展压根没被安装上去。现在我的习惯是,扩展的 Minimum Deployment Target 和主 App 保持完全一致,改一处就两边都检查一遍,这个习惯帮我省掉了很多莫名其妙的排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询