feat(background-task): 实现iOS原生后台任务V2系统并重构锻炼通知消息模板

- 新增iOS原生BackgroundTaskBridge桥接模块,支持后台任务注册、调度和完成
- 重构BackgroundTaskManager为V2版本,集成原生iOS后台任务能力
- 在AppDelegate中注册后台任务处理器,确保应用启动时正确初始化
- 重构锻炼通知消息生成逻辑,使用配置化模板提升可维护性
- 扩展健康数据类型映射,支持更多运动项目的中文显示
- 替换原有backgroundTaskManager引用为backgroundTaskManagerV2
This commit is contained in:
richarjiang
2025-11-04 09:41:10 +08:00
parent fbffa07f74
commit f80a1bae78
10 changed files with 1319 additions and 78 deletions

View File

@@ -16,6 +16,8 @@
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
B6B9273B2FD4F4A800C6391C /* BackgroundTaskBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */; };
B6B9273D2FD4F4A800C6391C /* BackgroundTaskBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */; };
91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; };
AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
@@ -35,6 +37,8 @@
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackgroundTaskBridge.swift; path = OutLive/BackgroundTaskBridge.swift; sourceTree = "<group>"; };
B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = BackgroundTaskBridge.m; path = OutLive/BackgroundTaskBridge.m; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
@@ -102,6 +106,8 @@
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */,
79B2CB712E7B954F00B51753 /* HealthKitManager.m */,
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */,
B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */,
B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */,
13B07FAE1A68108700A75B9A /* OutLive */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
@@ -369,6 +375,8 @@
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */,
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
B6B9273D2FD4F4A800C6391C /* BackgroundTaskBridge.m in Sources */,
B6B9273B2FD4F4A800C6391C /* BackgroundTaskBridge.swift in Sources */,
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */,
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */,

View File

@@ -1,6 +1,7 @@
import Expo
import React
import ReactAppDependencyProvider
import BackgroundTasks
@UIApplicationMain
public class AppDelegate: ExpoAppDelegate {
@@ -13,6 +14,11 @@ public class AppDelegate: ExpoAppDelegate {
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
//
if #available(iOS 13.0, *) {
registerBackgroundTasks()
}
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
@@ -31,6 +37,53 @@ public class AppDelegate: ExpoAppDelegate {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// MARK: - Background Task Registration
@available(iOS 13.0, *)
private func registerBackgroundTasks() {
let identifier = "com.anonymous.digitalpilates.task"
//
//
BGTaskScheduler.shared.register(
forTaskWithIdentifier: identifier,
using: nil
) { [weak self] task in
// BackgroundTaskBridge
// bridge
self?.handleBackgroundTask(task, identifier: identifier)
}
NSLog("[AppDelegate] 后台任务已在应用启动时注册: \(identifier)")
}
@available(iOS 13.0, *)
private func handleBackgroundTask(_ task: BGTask, identifier: String) {
// BackgroundTaskBridge
// React Native bridge
guard let bridge = reactNativeFactory?.bridge,
bridge.isValid else {
NSLog("[AppDelegate] React Native bridge 未就绪,直接完成后台任务")
task.setTaskCompleted(success: false)
return
}
// bridge BackgroundTaskBridge
DispatchQueue.main.async {
if let module = bridge.module(for: BackgroundTaskBridge.self) as? BackgroundTaskBridge {
// BackgroundTaskBridge
NotificationCenter.default.post(
name: NSNotification.Name("BackgroundTaskBridge.handleTask"),
object: nil,
userInfo: ["task": task, "identifier": identifier]
)
} else {
NSLog("[AppDelegate] BackgroundTaskBridge 模块未找到,完成后台任务")
task.setTaskCompleted(success: false)
}
}
}
// Linking API
public override func application(

View File

@@ -0,0 +1,31 @@
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface RCT_EXTERN_MODULE(BackgroundTaskBridge, RCTEventEmitter)
RCT_EXTERN_METHOD(configure:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(schedule:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(complete:(NSNumber *)success
rescheduleAfter:(NSNumber *_Nullable)rescheduleAfter
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(cancelAll:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getPendingRequests:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(backgroundRefreshStatus:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(simulateLaunch:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@end

View File

@@ -0,0 +1,420 @@
//
// BackgroundTaskBridge.swift
// OutLive
//
// Native bridge responsible for scheduling and relaying iOS background tasks to the JS runtime.
//
import BackgroundTasks
import Foundation
import React
import UIKit
@objc(BackgroundTaskBridge)
class BackgroundTaskBridge: RCTEventEmitter {
private enum TaskKind: String {
case processing
case refresh
}
private let queue = DispatchQueue(label: "com.anonymous.digitalpilates.background.bridge", qos: .utility)
private var hasListeners = false
private var identifier: String?
private var kind: TaskKind = .processing
private var requiresNetworkConnectivity = false
private var requiresExternalPower = false
private var defaultDelay: TimeInterval = 60 * 30 // 30 minutes
private var isRegistered = false
private var currentTask: BGTask?
override init() {
super.init()
// AppDelegate
NotificationCenter.default.addObserver(
self,
selector: #selector(handleTaskNotification(_:)),
name: NSNotification.Name("BackgroundTaskBridge.handleTask"),
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
override static func requiresMainQueueSetup() -> Bool {
true
}
override func startObserving() {
hasListeners = true
}
override func stopObserving() {
hasListeners = false
}
@objc
private func handleTaskNotification(_ notification: Notification) {
guard #available(iOS 13.0, *),
let userInfo = notification.userInfo,
let task = userInfo["task"] as? BGTask else {
return
}
NSLog("[BackgroundTaskBridge] 收到来自 AppDelegate 的后台任务")
handle(task: task)
}
override func supportedEvents() -> [String]! {
[
"BackgroundTaskBridge.execute",
"BackgroundTaskBridge.expire"
]
}
@objc
func configure(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard #available(iOS 13.0, *) else {
rejecter(
"BACKGROUND_TASK_UNAVAILABLE",
"BGTaskScheduler requires iOS 13 or later.",
nil
)
return
}
guard let identifier = options["identifier"] as? String,
!identifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
rejecter("INVALID_IDENTIFIER", "A non-empty identifier is required.", nil)
return
}
let taskTypeRaw = (options["taskType"] as? String)?.lowercased()
let taskKind = TaskKind(rawValue: taskTypeRaw ?? "") ?? .processing
let requiresNetwork = (options["requiresNetworkConnectivity"] as? Bool) ?? false
let requiresPower = (options["requiresExternalPower"] as? Bool) ?? false
let defaultDelaySeconds = (options["defaultDelay"] as? NSNumber)?.doubleValue
queue.async { [weak self] in
guard let self else { return }
self.identifier = identifier
self.kind = taskKind
self.requiresNetworkConnectivity = requiresNetwork
self.requiresExternalPower = requiresPower
if let delay = defaultDelaySeconds, delay > 0 {
self.defaultDelay = delay
}
do {
try self.registerTaskIfNeeded(identifier: identifier)
try self.scheduleTask(after: self.defaultDelay)
resolver([
"identifier": identifier,
"scheduled": true,
"defaultDelay": self.defaultDelay
])
} catch {
rejecter("BACKGROUND_TASK_ERROR", error.localizedDescription, error)
}
}
}
@objc
func schedule(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard #available(iOS 13.0, *) else {
rejecter(
"BACKGROUND_TASK_UNAVAILABLE",
"BGTaskScheduler requires iOS 13 or later.",
nil
)
return
}
guard let identifier = identifier else {
rejecter("NOT_CONFIGURED", "Call configure() before scheduling.", nil)
return
}
let delay = (options["delay"] as? NSNumber)?.doubleValue
let effectiveDelay = (delay?.isFinite == true && delay! >= 0) ? delay! : defaultDelay
queue.async { [weak self] in
guard let self else { return }
do {
try self.registerTaskIfNeeded(identifier: identifier)
try self.scheduleTask(after: effectiveDelay)
resolver([
"identifier": identifier,
"scheduled": true,
"delay": effectiveDelay
])
} catch {
rejecter("BACKGROUND_TASK_ERROR", error.localizedDescription, error)
}
}
}
@objc
func complete(
_ success: NSNumber,
rescheduleAfter: NSNumber?,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard #available(iOS 13.0, *) else {
rejecter(
"BACKGROUND_TASK_UNAVAILABLE",
"BGTaskScheduler requires iOS 13 or later.",
nil
)
return
}
let shouldRescheduleAfter = rescheduleAfter?.doubleValue ?? defaultDelay
let completionSuccess = success.boolValue
queue.async { [weak self] in
guard let self else { return }
if let task = self.currentTask {
task.setTaskCompleted(success: completionSuccess)
self.currentTask = nil
}
do {
try self.scheduleTask(after: shouldRescheduleAfter)
resolver([
"rescheduled": true,
"delay": shouldRescheduleAfter,
"success": completionSuccess
])
} catch {
rejecter("BACKGROUND_TASK_ERROR", error.localizedDescription, error)
}
}
}
@objc
func cancelAll(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard #available(iOS 13.0, *) else {
rejecter(
"BACKGROUND_TASK_UNAVAILABLE",
"BGTaskScheduler requires iOS 13 or later.",
nil
)
return
}
queue.async {
BGTaskScheduler.shared.cancelAllTaskRequests()
resolver(["cancelled": true])
}
}
@objc
func getPendingRequests(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard #available(iOS 13.0, *) else {
rejecter(
"BACKGROUND_TASK_UNAVAILABLE",
"BGTaskScheduler requires iOS 13 or later.",
nil
)
return
}
BGTaskScheduler.shared.getPendingTaskRequests { [weak self] requests in
guard let self else { return }
let mapped = requests
.filter { request in
guard let identifier = self.identifier else {
return true
}
return request.identifier == identifier
}
.map { request -> [String: Any] in
var payload: [String: Any] = [
"identifier": request.identifier
]
if let date = request.earliestBeginDate {
payload["earliestBegin"] = date.timeIntervalSince1970 * 1000
} else {
payload["earliestBegin"] = NSNull()
}
if let processingRequest = request as? BGProcessingTaskRequest {
payload["requiresNetworkConnectivity"] = processingRequest.requiresNetworkConnectivity
payload["requiresExternalPower"] = processingRequest.requiresExternalPower
payload["type"] = "processing"
} else {
payload["type"] = "refresh"
}
return payload
}
resolver(mapped)
}
}
@objc
func backgroundRefreshStatus(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.backgroundRefreshStatus(resolver, rejecter: rejecter)
}
return
}
let status = UIApplication.shared.backgroundRefreshStatus
switch status {
case .available:
resolver("available")
case .denied:
resolver("denied")
case .restricted:
resolver("restricted")
@unknown default:
resolver("unknown")
}
}
@objc
func simulateLaunch(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard hasListeners else {
rejecter("NO_LISTENERS", "No JS listeners registered for background events.", nil)
return
}
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.sendEvent(
withName: "BackgroundTaskBridge.execute",
body: [
"identifier": self.identifier ?? "simulated",
"timestamp": Date().timeIntervalSince1970 * 1000,
"simulated": true
]
)
resolver(["simulated": true])
}
}
// MARK: - Private helpers
@available(iOS 13.0, *)
private func registerTaskIfNeeded(identifier: String) throws {
guard !isRegistered else { return }
// AppDelegate
// BGTaskScheduler
// AppDelegate
isRegistered = true
NSLog("[BackgroundTaskBridge] 使用 AppDelegate 中预注册的后台任务: \(identifier)")
}
@available(iOS 13.0, *)
private func scheduleTask(after delay: TimeInterval) throws {
guard let identifier else {
throw NSError(
domain: "BackgroundTaskBridge",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Identifier is missing. Configure the bridge first."]
)
}
let request: BGTaskRequest
switch kind {
case .processing:
let processing = BGProcessingTaskRequest(identifier: identifier)
processing.requiresNetworkConnectivity = requiresNetworkConnectivity
processing.requiresExternalPower = requiresExternalPower
processing.earliestBeginDate = Date(timeIntervalSinceNow: delay)
request = processing
case .refresh:
let refresh = BGAppRefreshTaskRequest(identifier: identifier)
refresh.earliestBeginDate = Date(timeIntervalSinceNow: delay)
request = refresh
}
do {
try BGTaskScheduler.shared.submit(request)
} catch {
throw error
}
}
@available(iOS 13.0, *)
private func handle(task: BGTask) {
queue.async { [weak self] in
guard let self else { return }
self.currentTask = task
task.expirationHandler = { [weak self] in
guard let self else { return }
self.queue.async {
self.currentTask = nil
}
guard self.hasListeners else { return }
DispatchQueue.main.async {
self.sendEvent(
withName: "BackgroundTaskBridge.expire",
body: [
"identifier": self.identifier ?? "",
"timestamp": Date().timeIntervalSince1970 * 1000
]
)
}
}
guard self.hasListeners else {
task.setTaskCompleted(success: false)
self.currentTask = nil
return
}
DispatchQueue.main.async {
self.sendEvent(
withName: "BackgroundTaskBridge.execute",
body: [
"identifier": self.identifier ?? "",
"timestamp": Date().timeIntervalSince1970 * 1000
]
)
}
}
}
}