Loading...
Loading...
Compare original and translation side by side
When should you ask for notification permission?
├─ User explicitly wants notifications
│ └─ After user taps "Enable Notifications" button
│ Highest acceptance rate (70-80%)
│
├─ After demonstrating value
│ └─ After user completes key action
│ "Get notified when your order ships?"
│ Context-specific, 50-60% acceptance
│
├─ First meaningful moment
│ └─ After onboarding, before home screen
│ Explain why, 30-40% acceptance
│
└─ On app launch
└─ AVOID — lowest acceptance (15-20%)
No context, feels intrusive何时请求通知权限?
├─ 用户明确想要通知
│ └─ 用户点击「启用通知」按钮后
│ 接受率最高(70-80%)
│
├─ 体现价值之后
│ └─ 用户完成关键操作后
│ 「订单发货时通知您?」
│ 场景特定,接受率50-60%
│
├─ 首次有意义时刻
│ └─ 引导流程完成后,进入主屏幕前
│ 说明原因,接受率30-40%
│
└─ 应用启动时
└─ 避免——接受率最低(15-20%)
无上下文,显得突兀What's the notification purpose?
├─ Background data sync
│ └─ Silent notification (content-available: 1)
│ No user interruption, wakes app
│
├─ User needs to know immediately
│ └─ Visible alert
│ Messages, time-sensitive info
│
├─ Informational, not urgent
│ └─ Badge + silent
│ User sees count, checks when ready
│
└─ Needs user action
└─ Visible with actions
Reply, accept/decline buttons通知的用途是什么?
├─ 后台数据同步
│ └─ 静默通知(content-available: 1)
│ 不打扰用户,唤醒应用
│
├─ 用户需立即知晓
│ └─ 可见提醒
│ 消息、时间敏感信息
│
├─ 信息类,非紧急
│ └─ 角标 + 静默通知
│ 用户看到角标后,可自行查看
│
└─ 需要用户操作
└─ 带操作按钮的可见通知
回复、接受/拒绝按钮Do you need to modify notifications?
├─ Download images/media
│ └─ Notification Service Extension
│ mutable-content: 1 in payload
│
├─ Decrypt end-to-end encrypted content
│ └─ Notification Service Extension
│ Required for E2EE messaging
│
├─ Custom notification UI
│ └─ Notification Content Extension
│ Long-press/3D Touch custom view
│
└─ Standard text/badge
└─ No extension needed
Less complexity, faster delivery是否需要修改通知内容?
├─ 下载图片/媒体
│ └─ Notification Service Extension
│ payload中设置mutable-content: 1
│
├─ 解密端到端加密内容
│ └─ Notification Service Extension
│ E2EE消息必备
│
├─ 自定义通知UI
│ └─ Notification Content Extension
│ 长按/3D Touch自定义视图
│
└─ 标准文本/角标
└─ 无需扩展
复杂度更低,送达速度更快How should you handle device tokens?
├─ Single device per user
│ └─ Replace token on registration
│ Simple, most apps need this
│
├─ Multiple devices per user
│ └─ Register all tokens
│ Send to all active devices
│
├─ Token changed (reinstall/restore)
│ └─ Deduplicate on server
│ Same device, new token
│
└─ User logged out
└─ Deregister token from user
Prevents notifications to wrong user如何处理设备令牌?
├─ 单用户单设备
│ └─ 注册时替换令牌
│ 简单,多数应用适用
│
├─ 单用户多设备
│ └─ 注册所有令牌
│ 向所有活跃设备发送通知
│
├─ 令牌变更(重装/恢复)
│ └─ 服务器端去重
│ 同一设备,新令牌
│
└─ 用户登出
└─ 从用户账号中注销令牌
防止通知发送给错误用户// ❌ First thing on app launch — user denies
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
return true
}
// ✅ After user action that demonstrates value
func userTappedEnableNotifications() {
showPrePermissionExplanation {
Task {
let granted = try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
if granted == true {
await MainActor.run { registerForRemoteNotifications() }
}
}
}
}// ❌ Keeps trying, annoys user
func checkNotifications() {
Task {
let settings = await UNUserNotificationCenter.current().notificationSettings()
if settings.authorizationStatus == .denied {
// Ask again! <- User already said no
requestPermission()
}
}
}
// ✅ Respect denial, offer settings path
func checkNotifications() {
Task {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .denied:
showSettingsPrompt() // "Enable in Settings to receive..."
case .notDetermined:
showPrePermissionScreen()
case .authorized, .provisional, .ephemeral:
ensureRegistered()
@unknown default:
break
}
}
}// ❌ 应用启动即请求——用户会拒绝
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
return true
}
// ✅ 在用户完成体现价值的操作后
func userTappedEnableNotifications() {
showPrePermissionExplanation {
Task {
let granted = try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
if granted == true {
await MainActor.run { registerForRemoteNotifications() }
}
}
}
}// ❌ 持续尝试,惹恼用户
func checkNotifications() {
Task {
let settings = await UNUserNotificationCenter.current().notificationSettings()
if settings.authorizationStatus == .denied {
// 再次请求! <- 用户已经拒绝过
requestPermission()
}
}
}
// ✅ 尊重用户拒绝,提供设置路径
func checkNotifications() {
Task {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .denied:
showSettingsPrompt() // 「前往设置启用通知...」
case .notDetermined:
showPrePermissionScreen()
case .authorized, .provisional, .ephemeral:
ensureRegistered()
@unknown default:
break
}
}
}// ❌ Token may change without app knowing
class TokenManager {
static var cachedToken: String? // Stale after reinstall!
func getToken() -> String? {
return Self.cachedToken // May be invalid
}
}
// ✅ Always use fresh token from registration callback
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.hexString
// Send to server immediately — this is the source of truth
Task {
await sendTokenToServer(token)
}
}// ❌ Token format is not guaranteed
let tokenString = String(data: deviceToken, encoding: .utf8) // Returns nil!
// ✅ Convert bytes to hex
extension Data {
var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
}
let tokenString = deviceToken.hexString// ❌ 令牌可能在应用不知情的情况下变更
class TokenManager {
static var cachedToken: String? // 重装后失效!
func getToken() -> String? {
return Self.cachedToken // 可能无效
}
}
// ✅ 始终使用注册回调中的新鲜令牌
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.hexString
// 立即发送到服务器——这是可信来源
Task {
await sendTokenToServer(token)
}
}// ❌ 令牌格式不固定
let tokenString = String(data: deviceToken, encoding: .utf8) // 返回nil!
// ✅ 将字节转换为十六进制
extension Data {
var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
}
let tokenString = deviceToken.hexString// ❌ Silent notifications are low priority
// Server sends: {"aps": {"content-available": 1}}
// Expecting: Immediate delivery
// Reality: iOS may delay minutes/hours or drop entirely
// ✅ Use visible notification for time-critical content
// Or use silent for prefetch, visible for alert
{
"aps": {
"alert": {"title": "New Message", "body": "..."},
"content-available": 1 // Also prefetch in background
}
}// ❌ System will kill your app
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
await downloadLargeFiles() // Takes too long!
await processAllData() // iOS terminates app
return .newData
}
// ✅ Quick fetch, defer heavy processing
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
// 30 seconds max — fetch metadata only
do {
let hasNew = try await checkForNewContent()
if hasNew {
scheduleBackgroundProcessing() // BGProcessingTask
}
return hasNew ? .newData : .noData
} catch {
return .failed
}
}// ❌ 静默通知优先级低
// 服务器发送:{"aps": {"content-available": 1}}
// 预期:立即送达
// 实际:iOS可能延迟数分钟/小时,甚至直接丢弃
// ✅ 时间敏感内容使用可见通知
// 或静默通知预取,可见通知提醒
{
"aps": {
"alert": {"title": "新消息", "body": "..."},
"content-available": 1 // 同时在后台预取
}
}// ❌ 系统会终止应用
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
await downloadLargeFiles() // 耗时过长!
await processAllData() // iOS会终止应用
return .newData
}
// ✅ 快速获取,延迟繁重处理
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
// 最多30秒——仅获取元数据
do {
let hasNew = try await checkForNewContent()
if hasNew {
scheduleBackgroundProcessing() // BGProcessingTask
}
return hasNew ? .newData : .noData
} catch {
return .failed
}
}// ❌ System shows unmodified notification
class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
// Start async work...
downloadImage { image in
// Never called if timeout!
contentHandler(modifiedContent)
}
}
// Missing serviceExtensionTimeWillExpire!
}
// ✅ Always implement expiration handler
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
downloadImage { [weak self] image in
guard let self, let content = self.bestAttemptContent else { return }
if let image { content.attachments = [image] }
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() {
// Called ~30 seconds — deliver what you have
if let content = bestAttemptContent {
contentHandler?(content)
}
}
}// ❌ 系统会显示未修改的通知
class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
// 启动异步任务...
downloadImage { image in
// 超时后不会被调用!
contentHandler(modifiedContent)
}
}
// 缺少serviceExtensionTimeWillExpire!
}
// ✅ 始终实现过期处理程序
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
downloadImage { [weak self] image in
guard let self, let content = self.bestAttemptContent else { return }
if let image { content.attachments = [image] }
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() {
// 约30秒后调用——交付已处理好的内容
if let content = bestAttemptContent {
contentHandler?(content)
}
}
}@MainActor
final class NotificationPermissionManager: ObservableObject {
@Published var status: UNAuthorizationStatus = .notDetermined
func checkStatus() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
status = settings.authorizationStatus
}
func requestPermission() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
UIApplication.shared.registerForRemoteNotifications()
}
await checkStatus()
return granted
} catch {
return false
}
}
func openSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}
// Pre-permission screen
struct NotificationPermissionView: View {
@StateObject private var manager = NotificationPermissionManager()
@State private var showSystemPrompt = false
var body: some View {
VStack(spacing: 24) {
Image(systemName: "bell.badge")
.font(.system(size: 60))
Text("Stay Updated")
.font(.title)
Text("Get notified about new messages, order updates, and important alerts.")
.multilineTextAlignment(.center)
Button("Enable Notifications") {
Task { await manager.requestPermission() }
}
.buttonStyle(.borderedProminent)
Button("Not Now") { dismiss() }
.foregroundColor(.secondary)
}
.padding()
}
}@MainActor
final class NotificationPermissionManager: ObservableObject {
@Published var status: UNAuthorizationStatus = .notDetermined
func checkStatus() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
status = settings.authorizationStatus
}
func requestPermission() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
UIApplication.shared.registerForRemoteNotifications()
}
await checkStatus()
return granted
} catch {
return false
}
}
func openSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}
// 预授权界面
struct NotificationPermissionView: View {
@StateObject private var manager = NotificationPermissionManager()
@State private var showSystemPrompt = false
var body: some View {
VStack(spacing: 24) {
Image(systemName: "bell.badge")
.font(.system(size: 60))
Text("保持更新")
.font(.title)
Text("获取新消息、订单更新及重要提醒通知。")
.multilineTextAlignment(.center)
Button("启用通知") {
Task { await manager.requestPermission() }
}
.buttonStyle(.borderedProminent)
Button("暂不启用") { dismiss() }
.foregroundColor(.secondary)
}
.padding()
}
}@MainActor
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationHandler()
private let router: DeepLinkRouter
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
// App is in foreground
let userInfo = notification.request.content.userInfo
// Check if we should show banner or handle silently
if shouldShowInForeground(userInfo) {
return [.banner, .sound, .badge]
} else {
handleSilently(userInfo)
return []
}
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
let userInfo = response.notification.request.content.userInfo
switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier:
// User tapped notification
await handleNotificationTap(userInfo)
case "REPLY_ACTION":
if let textResponse = response as? UNTextInputNotificationResponse {
await handleReply(text: textResponse.userText, userInfo: userInfo)
}
case "MARK_READ_ACTION":
await markAsRead(userInfo)
case UNNotificationDismissActionIdentifier:
// User dismissed
break
default:
await handleCustomAction(response.actionIdentifier, userInfo: userInfo)
}
}
private func handleNotificationTap(_ userInfo: [AnyHashable: Any]) async {
guard let deepLink = userInfo["deep_link"] as? String,
let url = URL(string: deepLink) else { return }
await router.navigate(to: url)
}
}@MainActor
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationHandler()
private let router: DeepLinkRouter
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
// 应用在前台
let userInfo = notification.request.content.userInfo
// 判断是否显示横幅或静默处理
if shouldShowInForeground(userInfo) {
return [.banner, .sound, .badge]
} else {
handleSilently(userInfo)
return []
}
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
let userInfo = response.notification.request.content.userInfo
switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier:
// 用户点击了通知
await handleNotificationTap(userInfo)
case "REPLY_ACTION":
if let textResponse = response as? UNTextInputNotificationResponse {
await handleReply(text: textResponse.userText, userInfo: userInfo)
}
case "MARK_READ_ACTION":
await markAsRead(userInfo)
case UNNotificationDismissActionIdentifier:
// 用户关闭了通知
break
default:
await handleCustomAction(response.actionIdentifier, userInfo: userInfo)
}
}
private func handleNotificationTap(_ userInfo: [AnyHashable: Any]) async {
guard let deepLink = userInfo["deep_link"] as? String,
let url = URL(string: deepLink) else { return }
await router.navigate(to: url)
}
}class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
Task {
// Download and attach media
if let mediaURL = request.content.userInfo["media_url"] as? String {
if let attachment = await downloadAttachment(from: mediaURL) {
content.attachments = [attachment]
}
}
// Decrypt if needed
if let encrypted = request.content.userInfo["encrypted_body"] as? String {
content.body = decrypt(encrypted)
}
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() {
if let content = bestAttemptContent {
contentHandler?(content)
}
}
private func downloadAttachment(from urlString: String) async -> UNNotificationAttachment? {
guard let url = URL(string: urlString) else { return nil }
do {
let (localURL, response) = try await URLSession.shared.download(from: url)
let fileExtension = (response as? HTTPURLResponse)?
.mimeType.flatMap { mimeToExtension[$0] } ?? "jpg"
let destURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
try FileManager.default.moveItem(at: localURL, to: destURL)
return try UNNotificationAttachment(identifier: "media", url: destURL)
} catch {
return nil
}
}
}class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
Task {
// 下载并附加媒体
if let mediaURL = request.content.userInfo["media_url"] as? String {
if let attachment = await downloadAttachment(from: mediaURL) {
content.attachments = [attachment]
}
}
// 如需解密
if let encrypted = request.content.userInfo["encrypted_body"] as? String {
content.body = decrypt(encrypted)
}
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() {
if let content = bestAttemptContent {
contentHandler?(content)
}
}
private func downloadAttachment(from urlString: String) async -> UNNotificationAttachment? {
guard let url = URL(string: urlString) else { return nil }
do {
let (localURL, response) = try await URLSession.shared.download(from: url)
let fileExtension = (response as? HTTPURLResponse)?
.mimeType.flatMap { mimeToExtension[$0] } ?? "jpg"
let destURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
try FileManager.default.moveItem(at: localURL, to: destURL)
return try UNNotificationAttachment(identifier: "media", url: destURL)
} catch {
return nil
}
}
}| Field | Purpose | Value |
|---|---|---|
| alert | Visible notification | {title, subtitle, body} |
| badge | App icon badge | Number |
| sound | Notification sound | "default" or filename |
| content-available | Silent/background | 1 |
| mutable-content | Service extension | 1 |
| category | Action buttons | Category identifier |
| thread-id | Notification grouping | Thread identifier |
| 字段 | 用途 | 值 |
|---|---|---|
| alert | 可见通知 | {title, subtitle, body} |
| badge | 应用图标角标 | 数字 |
| sound | 通知音效 | "default" 或文件名 |
| content-available | 静默/后台 | 1 |
| mutable-content | 服务扩展 | 1 |
| category | 操作按钮 | 分类标识符 |
| thread-id | 通知分组 | 线程标识符 |
| Status | Meaning | Action |
|---|---|---|
| notDetermined | Never asked | Show pre-permission |
| denied | User declined | Show settings prompt |
| authorized | Full access | Register for remote |
| provisional | Quiet delivery | Consider upgrade prompt |
| ephemeral | App clip temporary | Limited time |
| 状态 | 含义 | 操作 |
|---|---|---|
| notDetermined | 从未请求过 | 显示预授权界面 |
| denied | 用户已拒绝 | 显示设置引导 |
| authorized | 完全权限 | 注册远程通知 |
| provisional | 静默送达 | 考虑引导升级权限 |
| ephemeral | App Clip临时权限 | 有效期有限 |
| Extension | Time Limit | Use Case |
|---|---|---|
| Service Extension | ~30 seconds | Download media, decrypt |
| Content Extension | User interaction | Custom UI |
| Background fetch | ~30 seconds | Data refresh |
| 扩展类型 | 时间限制 | 适用场景 |
|---|---|---|
| Service Extension | ~30秒 | 下载媒体、解密 |
| Content Extension | 用户交互阶段 | 自定义UI |
| Background fetch | ~30秒 | 数据刷新 |
| Smell | Problem | Fix |
|---|---|---|
| Permission on launch | Low acceptance | Wait for user action |
| Cached device token | May be stale | Always use callback |
| String(data:encoding:) for token | Returns nil | Use hex encoding |
| Silent for time-critical | May be delayed | Use visible notification |
| Heavy work in silent handler | App terminated | Quick fetch, defer work |
| No serviceExtensionTimeWillExpire | Unmodified content shown | Always implement |
| Ignoring denied status | Frustrates user | Offer settings path |
| 问题迹象 | 潜在问题 | 修复方案 |
|---|---|---|
| 启动即请求权限 | 接受率低 | 等待用户操作后再请求 |
| 缓存设备令牌 | 令牌可能失效 | 始终使用注册回调中的令牌 |
| 使用String(data:encoding:)处理令牌 | 返回nil | 使用十六进制编码 |
| 时间敏感内容用静默通知 | 可能延迟 | 使用可见通知 |
| 静默通知处理程序执行繁重任务 | 应用被终止 | 快速获取,延迟繁重处理 |
| 未实现serviceExtensionTimeWillExpire | 显示未修改的内容 | 始终实现该方法 |
| 忽略已拒绝的权限状态 | 惹恼用户 | 提供设置路径 |