Browse Source

Add downloadSpeed convenience properties to NetworkMetrics

- Add downloadSpeed property returning bytes per second
- Add downloadSpeedMBps property for easier readability
- Both properties use retrieveImageDuration for accurate data transfer rate
- Return nil when duration is unavailable or no data received
- Update Demo apps to use new convenience properties instead of manual calculation
- Documented with clear usage notes and edge case handling
onevcat 6 months ago
parent
commit
9c1dca64fc

+ 0 - 1
Demo/Demo/Kingfisher-Demo/SwiftUIViews/MainView.swift

@@ -55,7 +55,6 @@ struct MainView: View {
                 NavigationLink(destination: LoadTransitionDemo()) { Text("Load Transition") }
                 NavigationLink(destination: ProgressiveJPEGDemo()) { Text("Progressive JPEG") }
                 NavigationLink(destination: LoadingFailureDemo()) { Text("Loading Failure") }
-                NavigationLink(destination: NetworkMetricsDemo()) { Text("Network Metrics") }
             }
             
             Section(header: Text("Regression Cases")) {

+ 0 - 303
Demo/Demo/Kingfisher-Demo/SwiftUIViews/NetworkMetricsDemo.swift

@@ -1,303 +0,0 @@
-//
-//  NetworkMetricsDemo.swift
-//  Demo
-//
-//  Created by FunnyValentine on 2025/07/25.
-//
-
-import SwiftUI
-import Kingfisher
-
-@available(iOS 14.0, *)
-struct NetworkMetricsDemo: View {
-    @State private var imageURL = URL(string: "https://picsum.photos/200/150?random=\(Int.random(in: 1...1000))")!
-    @State private var metricsInfo = "Tap a button to load image..."
-    @State private var isLoading = false
-    @State private var showImage = true
-    
-    var body: some View {
-        ScrollView {
-            VStack(spacing: 20) {
-                imageSection
-                metricsInfoSection
-                buttonsSection
-                
-                Spacer(minLength: 20)
-            }
-            .padding()
-        }
-        .navigationTitle("Network Metrics")
-        .navigationBarTitleDisplayMode(.inline)
-    }
-    
-    // MARK: - UI Components
-    
-    private var imageSection: some View {
-        VStack {
-            if showImage {
-                KFImage(imageURL)
-                    .onProgress { _, _ in
-                        isLoading = true
-                    }
-                    .onSuccess { result in
-                        isLoading = false
-                        displayMetrics(result: result)
-                    }
-                    .onFailure { error in
-                        isLoading = false
-                        metricsInfo = "Failed to load image: \(error.localizedDescription)"
-                        print("error: \(error)")
-                    }
-                    .placeholder {
-                        placeholderView(text: isLoading ? "Loading..." : "Tap button to load")
-                    }
-                    .resizable()
-                    .aspectRatio(contentMode: .fit)
-                    .frame(maxHeight: 150)
-                    .clipShape(RoundedRectangle(cornerRadius: 8))
-            } else {
-                placeholderView(text: "Reloading...")
-            }
-        }
-        .frame(width: 200, height: 150)
-        .padding(.bottom, 10)
-    }
-    
-    private var metricsInfoSection: some View {
-        VStack(alignment: .leading, spacing: 8) {
-            Text("Metrics Information")
-                .font(.headline)
-            
-            VStack {
-                Text(metricsInfo)
-                    .font(.system(.caption, design: .monospaced))
-                    .frame(maxWidth: .infinity, alignment: .leading)
-                    .padding()
-                Spacer()
-            }
-            .frame(height: 400)
-            .background(Color.gray.opacity(0.1))
-            .clipShape(RoundedRectangle(cornerRadius: 8))
-        }
-    }
-    
-    private var buttonsSection: some View {
-        VStack(spacing: 12) {
-            actionButton(
-                title: "From Network",
-                icon: "wifi",
-                color: .red,
-                action: loadFromNetwork
-            )
-            
-            HStack(spacing: 12) {
-                actionButton(
-                    title: "From Memory",
-                    icon: "memorychip",
-                    color: .orange,
-                    action: loadFromMemory
-                )
-                
-                actionButton(
-                    title: "From Disk",
-                    icon: "internaldrive",
-                    color: .purple,
-                    action: loadFromDisk
-                )
-            }
-        }
-    }
-    
-    // MARK: - Helper Views
-    
-    private func placeholderView(text: String) -> some View {
-        RoundedRectangle(cornerRadius: 8)
-            .fill(Color.gray.opacity(0.3))
-            .frame(width: 200, height: 150)
-            .overlay(
-                Text(text)
-                    .foregroundColor(.gray)
-            )
-    }
-    
-    private func actionButton(title: String, icon: String, color: Color, action: @escaping () -> Void) -> some View {
-        Button(action: action) {
-            HStack {
-                Image(systemName: icon)
-                Text(title)
-            }
-            .frame(maxWidth: .infinity)
-            .padding()
-            .background(color)
-            .foregroundColor(.white)
-            .clipShape(RoundedRectangle(cornerRadius: 8))
-        }
-    }
-    
-    private func loadFromNetwork() {
-        // Refresh image
-        showImage = false
-        // Clear all cache to force network download
-        KingfisherManager.shared.cache.clearCache()
-        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
-            showImage = true
-        }
-    }
-    
-    private func loadFromMemory() {
-        // Refresh image
-        showImage = false
-        // Clear disk cache only, keep memory cache
-        KingfisherManager.shared.cache.clearDiskCache()
-        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
-            showImage = true
-        }
-    }
-    
-    private func loadFromDisk() {
-        // Refresh image
-        showImage = false
-        // Clear memory cache only, keep disk cache
-        KingfisherManager.shared.cache.clearMemoryCache()
-        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
-            showImage = true
-        }
-    }
-    
-    private func displayMetrics(result: RetrieveImageResult) {
-        var info = "=== Image Load Results ===\n\n"
-        
-        // Basic info
-        info += "Cache Type: \(cacheTypeDescription(result.cacheType))\n\n"
-        
-        // Network Metrics
-        if let metrics = result.metrics {
-            info += "=== Network Metrics ===\n"
-            info += "✅ Downloaded from network\n\n"
-            
-            // Timing metrics
-            info += "📊 Timing Breakdown:\n"
-            info += "Total Request: \(String(format: "%.3f", metrics.totalRequestDuration))s\n"
-
-            if let dnsTime = metrics.domainLookupDuration {
-                info += "DNS Lookup: \(String(format: "%.3f", dnsTime))s\n"
-            } else {
-                info += "DNS Lookup: N/A (cached or skipped)\n"
-            }
-            
-            if let connectTime = metrics.connectDuration {
-                info += "TCP Connect: \(String(format: "%.3f", connectTime))s\n"
-            } else {
-                info += "TCP Connect: N/A (reused connection)\n"
-            }
-            
-            if let tlsTime = metrics.secureConnectionDuration {
-                info += "TLS Handshake: \(String(format: "%.3f", tlsTime))s\n"
-            } else {
-                info += "TLS Handshake: N/A (HTTP or reused)\n"
-            }
-            
-            // Data transfer
-            info += "\n📈 Data Transfer:\n"
-            info += "Request Body: \(formatBytes(metrics.requestBodyBytesSent))\n"
-            info += "Response Body: \(formatBytes(metrics.responseBodyBytesReceived))\n"
-            
-            if metrics.responseBodyBytesReceived > 0 {
-                let speed = Double(metrics.responseBodyBytesReceived) / metrics.totalRequestDuration
-                info += "Download Speed: \(formatBytes(Int64(speed)))/s\n"
-            }
-            
-            // HTTP details
-            info += "\n🌐 HTTP Details:\n"
-            if let statusCode = metrics.httpStatusCode {
-                info += "Status Code: \(statusCode) \(httpStatusDescription(statusCode))\n"
-            }
-            info += "Redirects: \(metrics.redirectCount)\n"
-            
-            
-        } else {
-            info += "=== Network Metrics ===\n"
-            info += "💾 Loaded from cache\n"
-            info += "No network request was made\n\n"
-            
-            info += "This image was served from:\n"
-            switch result.cacheType {
-            case .memory:
-                info += "• Memory cache (fastest)\n"
-            case .disk:
-                info += "• Disk cache (fast)\n"
-            case .none:
-                info += "• Network (but no metrics available)\n"
-            @unknown default:
-                info += "• Unknown cache type\n"
-            }
-        }
-        
-        metricsInfo = info
-    }
-    
-    private func cacheTypeDescription(_ cacheType: CacheType) -> String {
-        switch cacheType {
-        case .memory:
-            return "Memory Cache 🚀"
-        case .disk:
-            return "Disk Cache 💽"
-        case .none:
-            return "Network Download 🌐"
-        @unknown default:
-            return "Unknown"
-        }
-    }
-    
-    private func httpStatusDescription(_ statusCode: Int) -> String {
-        switch statusCode {
-        case 200:
-            return "OK"
-        case 201:
-            return "Created"
-        case 204:
-            return "No Content"
-        case 301:
-            return "Moved Permanently"
-        case 302:
-            return "Found"
-        case 304:
-            return "Not Modified"
-        case 400:
-            return "Bad Request"
-        case 401:
-            return "Unauthorized"
-        case 403:
-            return "Forbidden"
-        case 404:
-            return "Not Found"
-        case 500:
-            return "Internal Server Error"
-        default:
-            return ""
-        }
-    }
-    
-    private func formatBytes(_ bytes: Int64) -> String {
-        let formatter = ByteCountFormatter()
-        formatter.allowedUnits = [.useBytes, .useKB, .useMB]
-        formatter.countStyle = .file
-        return formatter.string(fromByteCount: bytes)
-    }
-    
-    private func formatTime(_ date: Date) -> String {
-        let formatter = DateFormatter()
-        formatter.timeStyle = .medium
-        formatter.dateStyle = .none
-        return formatter.string(from: date)
-    }
-}
-
-@available(iOS 14.0, *)
-struct NetworkMetricsDemo_Previews: PreviewProvider {
-    static var previews: some View {
-        NavigationView {
-            NetworkMetricsDemo()
-        }
-    }
-}

+ 3 - 3
Demo/Demo/Kingfisher-Demo/ViewControllers/NetworkMetricsViewController.swift

@@ -309,9 +309,9 @@ class NetworkMetricsViewController: UIViewController {
             info += "Request Body: \(formatBytes(metrics.requestBodyBytesSent))\n"
             info += "Response Body: \(formatBytes(metrics.responseBodyBytesReceived))\n"
             
-            if metrics.responseBodyBytesReceived > 0 {
-                let speed = Double(metrics.responseBodyBytesReceived) / metrics.totalRequestDuration
-                info += "Download Speed: \(formatBytes(Int64(speed)))/s\n"
+            if let speed = metrics.downloadSpeed {
+                info += "Download Speed: \(formatBytes(Int64(speed)))/s"
+                info += "\n"
             }
             
             // HTTP details

+ 0 - 4
Demo/Kingfisher-Demo.xcodeproj/project.pbxproj

@@ -81,7 +81,6 @@
 		D1F78A662589F17200930759 /* SingleViewDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F78A632589F17200930759 /* SingleViewDemo.swift */; };
 		D1FAB06F21A853E600908910 /* HighResolutionCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FAB06E21A853E600908910 /* HighResolutionCollectionViewController.swift */; };
 		F344AEF22E1AD74F00BFE702 /* LoadTransitionDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F344AEF12E1AD74F00BFE702 /* LoadTransitionDemo.swift */; };
-		F39B68D22E33D36500404B02 /* NetworkMetricsDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39B68D12E33D36500404B02 /* NetworkMetricsDemo.swift */; };
 		F39B68D42E33E0D600404B02 /* NetworkMetricsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39B68D32E33E0D600404B02 /* NetworkMetricsViewController.swift */; };
 /* End PBXBuildFile section */
 
@@ -239,7 +238,6 @@
 		D1F78A632589F17200930759 /* SingleViewDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleViewDemo.swift; sourceTree = "<group>"; };
 		D1FAB06E21A853E600908910 /* HighResolutionCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighResolutionCollectionViewController.swift; sourceTree = "<group>"; };
 		F344AEF12E1AD74F00BFE702 /* LoadTransitionDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadTransitionDemo.swift; sourceTree = "<group>"; };
-		F39B68D12E33D36500404B02 /* NetworkMetricsDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMetricsDemo.swift; sourceTree = "<group>"; };
 		F39B68D32E33E0D600404B02 /* NetworkMetricsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMetricsViewController.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
@@ -473,7 +471,6 @@
 				D198F41F25EDC34000C53E0D /* SizingAnimationDemo.swift */,
 				072922422638639D0089E810 /* AnimatedImageDemo.swift */,
 				F344AEF12E1AD74F00BFE702 /* LoadTransitionDemo.swift */,
-				F39B68D12E33D36500404B02 /* NetworkMetricsDemo.swift */,
 			);
 			path = SwiftUIViews;
 			sourceTree = "<group>";
@@ -722,7 +719,6 @@
 			buildActionMask = 2147483647;
 			files = (
 				D16CC3D824E03FEA00F1A515 /* AVAssetImageGeneratorViewController.swift in Sources */,
-				F39B68D22E33D36500404B02 /* NetworkMetricsDemo.swift in Sources */,
 				C959EEE622874DC600467A10 /* ProgressiveJPEGViewController.swift in Sources */,
 				D1CE1BD321A1B45A00419000 /* ImageLoader.swift in Sources */,
 				D12EB83E24DD902300329EE1 /* TextAttachmentViewController.swift in Sources */,

+ 31 - 0
Sources/Networking/NetworkMetrics.swift

@@ -149,3 +149,34 @@ public struct NetworkMetrics: Sendable {
         return (transaction.response as? HTTPURLResponse)?.statusCode
     }
 }
+
+// MARK: - Convenience Properties
+
+extension NetworkMetrics {
+    
+    /// The download speed in bytes per second.
+    ///
+    /// Calculated as `responseBodyBytesReceived / retrieveImageDuration`. 
+    /// Returns `nil` if the duration is unavailable or zero, or if no data was received.
+    ///
+    /// - Note: This uses the actual image retrieval duration, excluding redirects and other overhead,
+    ///   to provide the most accurate representation of the data transfer rate.
+    public var downloadSpeed: Double? {
+        guard responseBodyBytesReceived > 0,
+              let duration = retrieveImageDuration,
+              duration > 0 else { return nil }
+        
+        return Double(responseBodyBytesReceived) / duration
+    }
+    
+    /// The download speed in megabytes per second (MB/s).
+    ///
+    /// This is a convenience property that converts `downloadSpeed` from bytes per second 
+    /// to megabytes per second for easier readability.
+    ///
+    /// - Returns: Download speed in MB/s, or `nil` if `downloadSpeed` is unavailable.
+    public var downloadSpeedMBps: Double? {
+        guard let speed = downloadSpeed else { return nil }
+        return speed / (1024 * 1024)
+    }
+}