You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Swift中如何按条件调度不同的重复通知?

问题:调度带条件检查的重复通知时API请求未执行

调度需执行条件检查的重复通知时遇到问题:原本使用Timer触发通知,计划在发送前通过API请求执行条件检查,但API日志未收到任何请求。已确认API可用、NotificationConfig对象正常,暂不考虑后台任务立即结束及重调度问题,相关Swift代码如下:

let notifications: [NotificationConfig] = [...] // notification objects here

func buildMap() -> [Int: [NotificationConfig]] {
    var times = [Int: [NotificationConfig]]()
    
    for notif in self.notifications {
        for time in notif.times {
            if let value = times[time] {
                times[time]!.append(notif)
            } else {
                times[time] = [notif]
            }
        }
    }
    
    return times
}

func makeCheck(notification: NotificationConfig) -> () async -> Bool {
    return {
        print("Called function already")
        return await self.checkCondition(value: notification.type, hours: notification.hours)
    }
}

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    var task: UIBackgroundTaskIdentifier = .invalid
    
    task = UIApplication.shared.beginBackgroundTask(withName: "BackgroundTask") {
        UIApplication.shared.endBackgroundTask(task)
    }
    
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
        if granted {
            print("Notification authorization granted")
            
            let times: [Int: [NotificationConfig]] = self.buildMap()
            let calendar = Calendar.current
            let timeZone = TimeZone.current
            
            for (desiredHour, notifs) in times {
                for (i, notif) in notifs.enumerated() {
                    let currentTime = Date()
                    
                    var dateComponents = calendar.dateComponents(in: timeZone, from: currentTime)
                    dateComponents.hour = desiredHour
                    dateComponents.minute = 0
                    dateComponents.second = 0
                    
                    guard var desiredTime = calendar.date(from: dateComponents) else {
                        continue
                    }
                    
                    if desiredTime.compare(currentTime) == .orderedAscending {
                        desiredTime = calendar.date(byAdding: .day, value: 1, to: desiredTime)!
                    }
                    
                    let timeDifference = desiredTime.timeIntervalSince(currentTime)
                    let shouldSendNotification = self.makeCheck(notification: notif)
                    
                    Timer.scheduledTimer(withTimeInterval: timeDifference, repeats: true) { _ in
                        Task {
                            print("Starting timed function")
                            if await shouldSendNotification() {
                                print("Sending notificaiton")
                                self.sendNotification(notif: notif, hour: desiredHour, minute: i)
                            }
                        }
                    }
                    print("Timer Interrupt scheduled in \(timeDifference) seconds for \(notif.id) at \(desiredHour)")
                }
            }
            
            UIApplication.shared.endBackgroundTask(task)
            print("background task ended")
        } else {
            print("Notification authorization denied")
        }
    }
    return true
}

func sendNotification(notif: NotificationConfig, hour: Int, minute: Int?) {
    let content = UNMutableNotificationContent()
    content.title = notif.title
    content.body = notif.body
    content.sound = UNNotificationSound.default
    
    var dateComponents = DateComponents()
    dateComponents.hour = hour
    dateComponents.minute = minute ?? 0
    let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
    
    let identifier = "notification_\(hour)_\(minute ?? 0)"
    let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
    
    UNUserNotificationCenter.current().add(request) { error in
        if let error = error {
            print("Error scheduling notification for hour \(hour): \(error)")
        } else {
            print("Notification scheduled successfully for hour \(hour)")
        }
    }
}

func checkCondition(value: NotificationType, hours: Int) async -> Bool {
    var urlString = MY_URL + "/Notification"
    urlString += "?value="+value.rawValue+"&hours="+String(hours)
    
    guard let url = URL(string: urlString) else {
        print("Error: Something wrong with url.")
        return false
    }

    var urlRequest = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10)
    urlRequest.allHTTPHeaderFields = [
        "Token": API_KEY
    ]
    urlRequest.httpMethod = "GET"
    
    do {
        let (data, response) = try await URLSession.shared.data(for: urlRequest)
    
        guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
            print("Notification response not 200")
            return false
        }

        return try JSONDecoder().decode(Bool.self, from: data) 
    }
    catch {
        print("Error: something went wrong calling api", error)
        return false
    }
}

解决方案

核心问题分析

原代码无效的根本原因:

  • Timer依赖App的RunLoop,当App进入后台后RunLoop会被挂起,Timer无法触发,导致后续的条件检查逻辑完全不会执行。
  • 用Timer二次调度本地通知属于冗余操作,本地通知框架本身支持重复触发,无需Timer介入。

方案一:使用UNNotificationServiceExtension(官方推荐)

这是iOS官方提供的在通知展示前执行自定义逻辑的方案,能确保通知触发时执行API检查:

  1. 添加Notification Service Extension
    在Xcode中选择File > New > Target > Notification Service Extension,按照向导完成添加。

  2. 在Extension中实现条件检查逻辑
    替换Extension的默认代码,加入API检查逻辑:

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,
              let typeRaw = content.userInfo["notificationType"] as? String,
              let hours = content.userInfo["hours"] as? Int,
              let notificationType = NotificationType(rawValue: typeRaw) else {
            contentHandler(request.content)
            return
        }
        
        // 执行API条件检查
        Task {
            let shouldShow = await checkCondition(value: notificationType, hours: hours)
            if shouldShow {
                contentHandler(content)
            } else {
                // 不展示通知,返回空内容
                contentHandler(UNNotificationContent())
            }
        }
    }
    
    // 复制原checkCondition方法,注意Extension的网络权限配置
    func checkCondition(value: NotificationType, hours: Int) async -> Bool {
        var urlString = MY_URL + "/Notification"
        urlString += "?value="+value.rawValue+"&hours="+String(hours)
        
        guard let url = URL(string: urlString) else {
            print("Error: Something wrong with url.")
            return false
        }

        var urlRequest = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10)
        urlRequest.allHTTPHeaderFields = [
            "Token": API_KEY
        ]
        urlRequest.httpMethod = "GET"
        
        do {
            let (data, response) = try await URLSession.shared.data(for: urlRequest)
        
            guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
                print("Notification response not 200")
                return false
            }

            return try JSONDecoder().decode(Bool.self, from: data) 
        }
        catch {
            print("Error: something went wrong calling api", error)
            return false
        }
    }

    override func serviceExtensionTimeWillExpire() {
        // 超时 fallback,直接返回原通知内容
        if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }
}
  1. 修改主App的通知调度逻辑
    在sendNotification方法中,将条件检查所需参数放入通知的userInfo,并设置重复触发:
func sendNotification(notif: NotificationConfig, hour: Int, minute: Int?) {
    let content = UNMutableNotificationContent()
    content.title = notif.title
    content.body = notif.body
    content.sound = UNNotificationSound.default
    // 传递参数给Extension
    content.userInfo = [
        "notificationType": notif.type.rawValue,
        "hours": notif.hours
    ]
    
    var dateComponents = DateComponents()
    dateComponents.hour = hour
    dateComponents.minute = minute ?? 0
    // 设置repeats为true实现每日重复
    let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
    
    // 使用唯一标识符避免重复调度
    let identifier = "notification_\(notif.id)_\(hour)_\(minute ?? 0)"
    let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
    
    UNUserNotificationCenter.current().add(request) { error in
        if let error = error {
            print("Error scheduling notification for hour \(hour): \(error)")
        } else {
            print("Notification scheduled successfully for hour \(hour)")
        }
    }
}
  1. 移除原Timer逻辑
    删除application(_:didFinishLaunchingWithOptions:)中的Timer相关代码,简化为:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
        if granted {
            print("Notification authorization granted")
            let times = self.buildMap()
            let calendar = Calendar.current
            let timeZone = TimeZone.current
            
            for (desiredHour, notifs) in times {
                for (i, notif) in notifs.enumerated() {
                    self.sendNotification(notif: notif, hour: desiredHour, minute: i)
                }
            }
        } else {
            print("Notification authorization denied")
        }
    }
    return true
}

方案二:后台刷新任务+通知调度

如果不想使用Extension,可以通过后台刷新任务定期唤醒App,执行条件检查后调度通知:

  1. 开启Background Fetch权限
    在项目的Signing & Capabilities中添加Background Modes,勾选Background fetch。

  2. 实现后台刷新逻辑
    在AppDelegate中添加以下方法:

func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    // 遍历所有通知配置,执行条件检查并调度通知
    Task {
        var hasNewData = false
        for notif in notifications {
            for desiredHour in notif.times {
                let shouldSend = await checkCondition(value: notif.type, hours: notif.hours)
                if shouldSend {
                    sendNotification(notif: notif, hour: desiredHour, minute: 0)
                    hasNewData = true
                }
            }
        }
        completionHandler(hasNewData ? .newData : .noData)
    }
}
  1. 初始化后台刷新请求
    在application(_:didFinishLaunchingWithOptions:)中请求后台刷新:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
        if granted {
            print("Notification authorization granted")
            // 请求系统定期唤醒App执行后台刷新
            UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)
        } else {
            print("Notification authorization denied")
        }
    }
    return true
}

内容的提问来源于stack exchange,提问作者abhibabi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.10 20:27:32