Jelajahi Sumber

Add network connection retry strategy

Vladislav Komkov 4 bulan lalu
induk
melakukan
42b5ad77a5

+ 4 - 0
Kingfisher.xcodeproj/project.pbxproj

@@ -7,6 +7,7 @@
 	objects = {
 
 /* Begin PBXBuildFile section */
+		00A8E26E2E81B89600ABB84F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A8E26D2E81B89600ABB84F /* NetworkMonitor.swift */; };
 		07292245263B02F00089E810 /* KFAnimatedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07292244263B02F00089E810 /* KFAnimatedImage.swift */; };
 		078DCB4F2BCFEB7D0008114E /* PHPickerResultImageDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 078DCB4E2BCFEB7D0008114E /* PHPickerResultImageDataProvider.swift */; };
 		22FDCE0E2700078B0044D11E /* CPListItem+Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22FDCE0D2700078B0044D11E /* CPListItem+Kingfisher.swift */; };
@@ -153,6 +154,7 @@
 /* End PBXCopyFilesBuildPhase section */
 
 /* Begin PBXFileReference section */
+		00A8E26D2E81B89600ABB84F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
 		07292244263B02F00089E810 /* KFAnimatedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KFAnimatedImage.swift; sourceTree = "<group>"; };
 		078DCB4E2BCFEB7D0008114E /* PHPickerResultImageDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPickerResultImageDataProvider.swift; sourceTree = "<group>"; };
 		185218B51CC07F8300BD58DE /* NSButtonExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSButtonExtensionTests.swift; sourceTree = "<group>"; };
@@ -370,6 +372,7 @@
 		D12AB69C215D2BB50013BA68 /* Networking */ = {
 			isa = PBXGroup;
 			children = (
+				00A8E26D2E81B89600ABB84F /* NetworkMonitor.swift */,
 				D12AB69D215D2BB50013BA68 /* RequestModifier.swift */,
 				D8FCF6A721C5A0E500F9ABC0 /* RedirectHandler.swift */,
 				D12AB69F215D2BB50013BA68 /* ImageDownloader.swift */,
@@ -856,6 +859,7 @@
 				D1132C9725919F69003E528D /* KFOptionsSetter.swift in Sources */,
 				D18B3222251852E100662F63 /* KF.swift in Sources */,
 				D12AB704215D2BB50013BA68 /* Kingfisher.swift in Sources */,
+				00A8E26E2E81B89600ABB84F /* NetworkMonitor.swift in Sources */,
 				D1AEB09725890DEA008556DF /* KFImage.swift in Sources */,
 				F39B68C82E33AC2A00404B02 /* NetworkMetrics.swift in Sources */,
 				D1BA781D2174D07800C69D7B /* CallbackQueue.swift in Sources */,

+ 32 - 11
Sources/Documentation.docc/Topics/Topic_Retry.md

@@ -4,34 +4,55 @@ Managing the retry mechanism when an error happens during loading.
 
 ## Overview
 
-Use ``KingfisherOptionsInfoItem/retryStrategy(_:)`` along with a `RetryStrategy` implementation to easily set up a 
-retry mechanism for image setting operations when an error occurs. 
+Use ``KingfisherOptionsInfoItem/retryStrategy(_:)`` along with a `RetryStrategy` implementation to easily set up a
+retry mechanism for image setting operations when an error occurs.
 
-This combination allows you to define retry logic, including the number of retries and the conditions under which a 
+This combination allows you to define retry logic, including the number of retries and the conditions under which a
 retry should be attempted, ensuring a more resilient image loading process.
 
 
-## Basic Retry Strategy
+## Built-in Retry Strategies
 
-``DelayRetryStrategy`` is a predefined retry strategy in Kingfisher. It allows you to specify the `maxRetryCount` and 
-the `retryInterval` to easily configure retry behavior. This setup enables quick implementation of a retry mechanism: 
+Kingfisher provides two built-in retry strategies to handle different scenarios:
+
+### DelayRetryStrategy
+
+``DelayRetryStrategy`` is a time-based retry strategy that allows you to specify the `maxRetryCount` and
+the `retryInterval` to easily configure retry behavior. This setup enables quick implementation of a retry mechanism:
 
 ```swift
 let retry = DelayRetryStrategy(
-  maxRetryCount: 5, 
+  maxRetryCount: 5,
   retryInterval: .seconds(3)
 )
 imageView.kf.setImage(with: url, options: [.retryStrategy(retry)])
 ```
 
 This implements a retry mechanism that attempts to reload the target URL up to 5 times, with a fixed 3-second interval
-between each try. 
+between each try.
 
 #### Other retry interval
 
-For a more dynamic approach, you can also select `.accumulated(3)` as the retry interval results in progressively 
-increasing delays between attempts, specifically `3 -> 6 -> 9 -> 12 -> 15` seconds for each subsequent retry. 
-Additionally, for ultimate flexibility, `.custom` allows you to define a unique pattern for retry intervals, tailoring 
+For a more dynamic approach, you can also select `.accumulated(3)` as the retry interval results in progressively
+increasing delays between attempts, specifically `3 -> 6 -> 9 -> 12 -> 15` seconds for each subsequent retry.
+Additionally, for ultimate flexibility, `.custom` allows you to define a unique pattern for retry intervals, tailoring
 the retry logic to your specific requirements.
 
+### NetworkRetryStrategy
+
+``NetworkRetryStrategy`` is a network-aware retry strategy that handles network connectivity issues.
+It only retries when the network becomes available after a disconnection, this is suitable to handle unstable user connection.
+
+```swift
+// Basic usage - retries immediately when network becomes available
+let networkRetry = NetworkRetryStrategy()
+imageView.kf.setImage(with: url, options: [.retryStrategy(networkRetry)])
+
+// With timeout - stops waiting after specified duration
+let networkRetryWithTimeout = NetworkRetryStrategy(timeoutInterval: 30.0)
+imageView.kf.setImage(with: url, options: [.retryStrategy(networkRetryWithTimeout)])
+```
+
+## Custom Retry Strategies
+
 If you need more control for the retry strategy, implement your own type that conforms to ``RetryStrategy``.

+ 188 - 0
Sources/Networking/NetworkMonitor.swift

@@ -0,0 +1,188 @@
+//
+//  NetworkMonitor.swift
+//  Kingfisher
+//
+//  Created by Vladislav Komkov on 2025/09/22.
+//
+//  Copyright (c) 2020 Wei Wang <onevcat@gmail.com>
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included in
+//  all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+//  THE SOFTWARE.
+
+import Network
+import Foundation
+
+/// A protocol for network connectivity monitoring that allows for dependency injection and testing.
+public protocol NetworkMonitoring: Sendable {
+    /// Whether the network is currently connected.
+    var isConnected: Bool { get }
+
+    /// Observes network connectivity changes with an optional timeout.
+    /// - Parameters:
+    ///   - timeoutInterval: The timeout for waiting for network reconnection. If nil, no timeout is applied.
+    ///   - callback: The callback to be called when network state changes or timeout occurs.
+    /// - Returns: A cancellable observer that can be used to cancel the observation.
+    func observeConnectivity(timeoutInterval: TimeInterval?, callback: @escaping @Sendable (Bool) -> Void) -> NetworkObserver
+}
+
+/// A protocol for network observers that can be cancelled.
+public protocol NetworkObserver: Sendable {
+    /// Cancels the network observation.
+    func cancel()
+}
+
+/// A shared singleton that manages network connectivity monitoring.
+/// This prevents creating multiple NWPathMonitor instances when many NetworkRetryStrategy instances are used.
+/// The monitor is created lazily only when first accessed.
+public final class NetworkMonitor: @unchecked Sendable, NetworkMonitoring {
+    public static let `default` = NetworkMonitor()
+
+    /// Whether the network is currently connected.
+    public var isConnected: Bool {
+        return monitor.currentPath.status == .satisfied
+    }
+
+    /// The network path monitor for observing connectivity changes.
+    private let monitor = NWPathMonitor()
+
+    /// The queue for monitoring network changes.
+    private let monitorQueue = DispatchQueue(label: "com.onevcat.Kingfisher.NetworkMonitor", qos: .utility)
+
+    /// Observers waiting for network reconnection.
+    private var observers: [NetworkObserverImpl] = []
+    private let observersQueue = DispatchQueue(label: "com.onevcat.Kingfisher.NetworkMonitor.Observers", attributes: .concurrent)
+
+    /// Whether the monitor has been started.
+    private var isStarted = false
+    private let startQueue = DispatchQueue(label: "com.onevcat.Kingfisher.NetworkMonitor.Start")
+
+    private init() {
+        // Set up path monitoring
+        monitor.pathUpdateHandler = { [weak self] path in
+            self?.handlePathUpdate(path)
+        }
+    }
+
+    /// Starts monitoring if not already started.
+    private func startMonitoring() {
+        startQueue.sync {
+            guard !isStarted else { return }
+            monitor.start(queue: monitorQueue)
+            isStarted = true
+        }
+    }
+
+    /// Handles network path updates and notifies observers.
+    private func handlePathUpdate(_ path: NWPath) {
+        let connected = path.status == .satisfied
+        guard connected else { return }
+
+        // Notify all observers that network is available
+        observersQueue.async(flags: .barrier) {
+            let activeObservers = self.observers
+            self.observers.removeAll()
+
+            DispatchQueue.main.async {
+                activeObservers.forEach { $0.notify(isConnected: true) }
+            }
+        }
+    }
+
+    /// Adds an observer for network reconnection.
+    private func addObserver(_ observer: NetworkObserverImpl) {
+        startMonitoring()
+
+        observersQueue.async(flags: .barrier) {
+            self.observers.append(observer)
+        }
+    }
+
+    /// Removes an observer.
+    internal func removeObserver(_ observer: NetworkObserverImpl) {
+        observersQueue.async(flags: .barrier) {
+            self.observers.removeAll { $0 === observer }
+        }
+    }
+
+    // MARK: - NetworkMonitoring
+
+    public func observeConnectivity(timeoutInterval: TimeInterval?, callback: @escaping @Sendable (Bool) -> Void) -> NetworkObserver {
+        let observer = NetworkObserverImpl(
+            timeoutInterval: timeoutInterval,
+            callback: callback,
+            monitor: self
+        )
+        addObserver(observer)
+        return observer
+    }
+}
+
+/// Internal implementation of network observer that manages timeout and callbacks.
+internal final class NetworkObserverImpl: @unchecked Sendable, NetworkObserver {
+    let timeoutInterval: TimeInterval?
+    let callback: @Sendable (Bool) -> Void
+    private weak var monitor: NetworkMonitor?
+    private var timeoutWorkItem: DispatchWorkItem?
+    private let queue = DispatchQueue(label: "com.onevcat.Kingfisher.NetworkObserver", qos: .utility)
+
+    init(timeoutInterval: TimeInterval?, callback: @escaping @Sendable (Bool) -> Void, monitor: NetworkMonitor) {
+        self.timeoutInterval = timeoutInterval
+        self.callback = callback
+        self.monitor = monitor
+
+        // Set up timeout if specified
+        if let timeoutInterval = timeoutInterval {
+            let workItem = DispatchWorkItem { [weak self] in
+                self?.notify(isConnected: false)
+            }
+            timeoutWorkItem = workItem
+            queue.asyncAfter(deadline: .now() + timeoutInterval, execute: workItem)
+        }
+    }
+
+    func notify(isConnected: Bool) {
+        queue.async { [weak self] in
+            guard let self else { return }
+
+            // Cancel timeout if we're notifying
+            timeoutWorkItem?.cancel()
+            timeoutWorkItem = nil
+
+            // Remove from monitor
+            monitor?.removeObserver(self)
+
+            // Call the callback
+            DispatchQueue.main.async {
+                self.callback(isConnected)
+            }
+        }
+    }
+
+    public func cancel() {
+        queue.async { [weak self] in
+            guard let self else { return }
+
+            // Cancel timeout
+            timeoutWorkItem?.cancel()
+            timeoutWorkItem = nil
+
+            // Remove from monitor
+            monitor?.removeObserver(self)
+        }
+    }
+}

+ 86 - 9
Sources/Networking/RetryStrategy.swift

@@ -30,7 +30,7 @@ import Foundation
 ///
 /// The instance of this type can be shared between different retry attempts.
 public class RetryContext: @unchecked Sendable {
-    
+
     private let propertyQueue = DispatchQueue(label: "com.onevcat.Kingfisher.RetryContextPropertyQueue")
 
     /// The source from which the target image should be retrieved.
@@ -40,7 +40,7 @@ public class RetryContext: @unchecked Sendable {
     public let error: KingfisherError
 
     private var _retriedCount: Int
-    
+
     /// The number of retries attempted before the current retry happens.
     ///
     /// This value is `0` if the current retry is for the first time.
@@ -48,10 +48,10 @@ public class RetryContext: @unchecked Sendable {
         get { propertyQueue.sync { _retriedCount } }
         set { propertyQueue.sync { _retriedCount = newValue } }
     }
-    
+
     private var _userInfo: Any? = nil
 
-    /// A user-set value for passing any other information during the retry. 
+    /// A user-set value for passing any other information during the retry.
     ///
     /// If you choose to use ``RetryDecision/retry(userInfo:)`` as the retry decision for
     /// ``RetryStrategy/retry(context:retryHandler:)``, the associated value of ``RetryDecision/retry(userInfo:)`` will
@@ -105,18 +105,18 @@ public struct DelayRetryStrategy: RetryStrategy {
 
     /// Represents the interval mechanism used in a ``DelayRetryStrategy``.
     public enum Interval : Sendable{
-        
-        /// The next retry attempt should happen in a fixed number of seconds. 
+
+        /// The next retry attempt should happen in a fixed number of seconds.
         ///
         /// For example, if the associated value is 3, the attempt happens 3 seconds after the previous decision is
         /// made.
         case seconds(TimeInterval)
-        
-        /// The next retry attempt should happen in an accumulated duration. 
+
+        /// The next retry attempt should happen in an accumulated duration.
         ///
         /// For example, if the associated value is 3, the attempts happen with intervals of 3, 6, 9, 12, ... seconds.
         case accumulated(TimeInterval)
-        
+
         /// Uses a block to determine the next interval.
         ///
         /// The current retry count is given as a parameter.
@@ -184,3 +184,80 @@ public struct DelayRetryStrategy: RetryStrategy {
         }
     }
 }
+
+/// A retry strategy that observes network state and retries on reconnect.
+///
+/// This strategy only retries when network becomes available after a disconnection.
+/// It does not use any delay mechanisms - it retries immediately when network is restored.
+///
+/// The network monitor is created lazily only when this strategy is first used,
+/// ensuring no unnecessary resource usage when the strategy is not in use.
+public struct NetworkRetryStrategy: RetryStrategy {
+
+    /// The timeout for waiting for network reconnection (in seconds).
+    private let timeoutInterval: TimeInterval?
+
+    /// The network monitoring service used to observe connectivity changes.
+    private let networkMonitor: NetworkMonitoring
+
+    /// Creates a network-aware retry strategy.
+    ///
+    /// - Parameters:
+    ///   - timeoutInterval: The timeout for waiting for network reconnection. If nil, no timeout is applied. Defaults to 30 seconds.
+    ///   - networkMonitor: The network monitoring service. Defaults to the shared NetworkMonitor instance.
+    public init(
+        timeoutInterval: TimeInterval? = 30,
+        networkMonitor: NetworkMonitoring = NetworkMonitor.default
+    ) {
+        self.timeoutInterval = timeoutInterval
+        self.networkMonitor = networkMonitor
+    }
+
+    public func retry(context: RetryContext, retryHandler: @escaping @Sendable (RetryDecision) -> Void) {
+        // Dispose of any previous disposable from userInfo
+        if let previousObserver = context.userInfo as? NetworkObserver {
+            previousObserver.cancel()
+        }
+
+        // User cancel the task. No retry.
+        guard !context.error.isTaskCancelled else {
+            retryHandler(.stop)
+            return
+        }
+
+        // Only retry for a response error.
+        guard case KingfisherError.responseError = context.error else {
+            retryHandler(.stop)
+            return
+        }
+
+        // Check if we have network connectivity
+        if networkMonitor.isConnected {
+            // Network is available, retry immediately
+            retryHandler(.retry(userInfo: nil))
+        } else {
+            // Network is not available, wait for reconnection
+            waitForReconnection(context: context, retryHandler: retryHandler)
+        }
+    }
+
+    // MARK: - Private helpers
+
+    private func waitForReconnection(
+        context: RetryContext,
+        retryHandler: @escaping @Sendable (RetryDecision) -> Void
+    ) {
+        let observer = networkMonitor.observeConnectivity(timeoutInterval: timeoutInterval) { isConnected in
+            if isConnected {
+                // Connection is restored, retry immediately
+                retryHandler(.retry(userInfo: context.userInfo))
+            } else {
+                // Timeout reached or cancelled
+                retryHandler(.stop)
+            }
+        }
+
+        // Store the observer in userInfo so it can be cancelled if needed
+        context.userInfo = observer
+    }
+}