作者修复了 iOS 端设备学习功能后台任务不执行的问题,根源是只调用了 registerBackgroundTasks 而漏掉了 submitBackgroundTasks 导致任务链从未启动。
这周我花了点时间修了一个移动端 AI 功能,模型代码本身没问题。
应用里有一个小型的端侧个性化循环:收集每日日志、合并 HealthKit 指标、定期重新训练本地模型,然后用这个状态来驱动预测和提醒。规模不大。就是那种后台 ML 管道,让一个功能感觉是活的,而不是静态的。
但 bug 更简单,也更烦人:
我注册了后台任务。
我写了它们的处理器。
我在每个处理器内部做了重新调度。
但第一个任务从未被提交过。
BGTaskScheduler 的正确用法是这样的:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
registerBackgroundTasks()
submitBackgroundTasks()
return true
}
第二行才是我漏掉的。
registerBackgroundTasks() 告诉 iOS 这个进程可以处理哪些 identifier。它不会把工作放到队列里。每个处理器可以在运行后重新调度自己,但必须有人先提交第一个请求,否则这条链永远不会启动。
最终版本故意写得很无聊:
private func submitBackgroundTasks() {
scheduleHealthRefresh()
scheduleMLRetrain()
scheduleEngagementDaily()
scheduleWeeklyInsight()
}
而重新训练的请求就是一个普通的 BGProcessingTaskRequest:
func scheduleMLRetrain() {
let request = BGProcessingTaskRequest(
identifier: AppConstants.mlRetrainTaskID
)
request.earliestBeginDate = Date(
timeIntervalSinceNow: 86400 * Double(AppConstants.retrainingIntervalDays)
)
request.requiresExternalPower = true
request.requiresNetworkConnectivity = false
try? BGTaskScheduler.shared.submit(request)
}
这就是整个修复方案。不是新的调度器抽象,不是自定义任务运行器。只是在启动时提交工作,让 iOS 按 identifier 去重。
第二个问题是 actor 隔离。BGTaskScheduler 在后台队列上调用处理器。我的持久化层用的是 container.mainContext,所以每个涉及 SwiftData 的处理器都需要跳回主 actor。
private func handleMLRetrain(task: BGProcessingTask) {
task.expirationHandler = { task.setTaskCompleted(success: false) }
Task { @MainActor in
let context = PersistenceController.shared.container.mainContext
let descriptor = FetchDescriptor<DailyLog>(
sortBy: [SortDescriptor(\DailyLog.date)]
)
let logs = (try? context.fetch(descriptor)) ?? []
try? await MLModelManager.shared.trainPersonalisedModel(from: logs)
scheduleMLRetrain()
task.setTaskCompleted(success: true)
}
}
同样,无聊。但这里恰恰是 AI 功能最容易坏的地方:不在模型本身,而在模型周围的生命周期。
我还把 HealthKit 合并拉到一个共享函数里,这样定时刷新和 HealthKit 后台推送就不会产生偏差:
@MainActor
static func fillTodaysLogFromHealthKit() async {
let metrics = await HealthKitManager.shared.fetchDailyMetrics(for: Date())
let context = PersistenceController.shared.container.mainContext
let log = DailyLog.findOrCreate(for: Date(), in: context)
if log.hrv == nil { log.hrv = metrics.hrv }
if log.restingHeartRate == nil { log.restingHeartRate = metrics.restingHeartRate }
if log.stepCount == nil { log.stepCount = metrics.stepCount }
if log.sleepDuration == nil { log.sleepDuration = metrics.sleepDuration }
try? context.save()
}
重要的细节是:用户手动输入的值优先。后台同步只填空白,不覆盖有人手动录入的数据。
现在有几个回归测试固定住了那些以前看起来太小不值得测的东西:
UserDefaults.bool(forKey:) 对未设置的 key 返回 false最后一条很重要,因为 Watch 队列里同一数组包含两种形状:症状日志和健康打卡。旧的协调路径可能排空症状日志,然后静默删除尚未处理的打卡记录。
在健康类日志应用里静默丢失数据不是 UX bug。是信任 bug。
模型可以没问题,但产品仍然可能坏掉,因为:
这些都不光鲜。但都很重要。
如果模型在本地重新训练了,但调度器从未唤醒,那你的个性化就没真正上线。你只是交付了一条没人调用的漂亮代码路径。
这是我在实际 AI 项目中一直回到的标准:不是"模型存在吗?",而是"应用退到后台后,它周围的那个系统真的还在跑吗?"