From 6ae3c91deb0dcb3006757ba37cf45fdc43d6412f Mon Sep 17 00:00:00 2001 From: jdpf28 Date: Sun, 23 Nov 2025 02:19:35 -0500 Subject: [PATCH] Add performance profiling utilities --- .../Profiling/OptimizedImageCache.swift | 260 ++++++++++++++++++ .../Classes/Profiling/PerformanceLogger.swift | 143 ++++++++++ .../WooCommerce.xcodeproj/project.pbxproj | 5 + .../performance/PerformanceUITests.swift | 225 +++++++++++++++ 4 files changed, 633 insertions(+) create mode 100644 WooCommerce/Classes/Profiling/OptimizedImageCache.swift create mode 100644 WooCommerce/Classes/Profiling/PerformanceLogger.swift create mode 100644 WooCommerce/WooCommerceUITests/performance/PerformanceUITests.swift diff --git a/WooCommerce/Classes/Profiling/OptimizedImageCache.swift b/WooCommerce/Classes/Profiling/OptimizedImageCache.swift new file mode 100644 index 00000000000..308c1a6bd22 --- /dev/null +++ b/WooCommerce/Classes/Profiling/OptimizedImageCache.swift @@ -0,0 +1,260 @@ +import UIKit + +/// A specialized image cache that performs image decoding off the main thread +/// and stores fully decoded images in memory for optimal scrolling performance. +/// +/// This cache is designed as a micro-optimization for image-heavy views such as +/// product catalogs, image galleries, and carousels. +/// +/// Key characteristics: +/// - Uses NSCache for automatic memory management and eviction. +/// - Forces image decoding in a background queue. +/// - Returns ready-to-display UIImage instances on the main thread. +/// - Provides profiling hooks via PerformanceLogger. +final class OptimizedImageCache { + + // MARK: - Singleton + static let shared = OptimizedImageCache() + + // MARK: - Types + + /// Represents a cache key for images. + struct CacheKey: Hashable { + let url: URL? + let identifier: String? + + init(url: URL? = nil, identifier: String? = nil) { + self.url = url + self.identifier = identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(url?.absoluteString) + hasher.combine(identifier) + } + + static func == (lhs: CacheKey, rhs: CacheKey) -> Bool { + return lhs.url == rhs.url && lhs.identifier == rhs.identifier + } + + var debugDescription: String { + if let url = url { + return "URL(\(url.absoluteString))" + } else if let identifier = identifier { + return "ID(\(identifier))" + } else { + return "Unknown" + } + } + } + + /// Represents a request token that can be used to cancel in-flight decoding. + final class RequestToken { + fileprivate var isCancelled = false + + func cancel() { + isCancelled = true + } + } + + // MARK: - Properties + + private let cache = NSCache() + private let decodeQueue: DispatchQueue + private let lock = NSLock() + + /// The maximum number of concurrent decode operations. + private let maxConcurrentDecodes: Int = 4 + private var currentDecodes: Int = 0 + + // MARK: - Initialization + + private init() { + cache.countLimit = 512 // Tunable based on memory constraints + cache.totalCostLimit = 200 * 1024 * 1024 // ~200 MB + + decodeQueue = DispatchQueue( + label: "com.automattic.woocommerce.optimizedImageDecodeQueue", + qos: .userInitiated, + attributes: .concurrent + ) + } + + // MARK: - Public API + + /// Retrieves an image for the given key, decoding it off the main thread if needed. + /// + /// - Parameters: + /// - key: The cache key identifying the image. + /// - sourceProvider: A closure that returns the image data (e.g., from disk or network). + /// - completion: Called on the main thread with the decoded image or nil. + /// - Returns: A RequestToken that can be used to cancel the request. + @discardableResult + func image( + for key: CacheKey, + sourceProvider: @escaping () -> Data?, + completion: @escaping (UIImage?) -> Void + ) -> RequestToken { + let token = RequestToken() + let wrappedKey = WrappedCacheKey(key) + + // 1. Fast path: look up in cache + if let cachedImage = cache.object(forKey: wrappedKey) { + PerformanceLogger.shared.event(.imageCacheHit) + DispatchQueue.main.async { + completion(cachedImage) + } + return token + } + + PerformanceLogger.shared.event(.imageCacheMiss) + + // 2. Background decode path + decodeQueue.async { [weak self] in + guard let self = self, !token.isCancelled else { return } + + self.lock.lock() + if self.currentDecodes >= self.maxConcurrentDecodes { + self.lock.unlock() + // Throttle by sleeping a tiny amount if overloaded. + // This keeps memory and CPU usage under control. + Thread.sleep(forTimeInterval: 0.005) + _ = self.image(for: key, sourceProvider: sourceProvider, completion: completion) + return + } + self.currentDecodes += 1 + self.lock.unlock() + + let signpostID = PerformanceLogger.shared.makeSignpostID() + PerformanceLogger.shared.begin(.imageDecode, id: signpostID) + + defer { + self.lock.lock() + self.currentDecodes -= 1 + self.lock.unlock() + + PerformanceLogger.shared.end(.imageDecode, id: signpostID) + } + + guard !token.isCancelled else { return } + guard let data = sourceProvider() else { + DispatchQueue.main.async { + completion(nil) + } + return + } + + guard let decodedImage = self.decodeImage(data: data) else { + DispatchQueue.main.async { + completion(nil) + } + return + } + + self.cache.setObject(decodedImage, forKey: wrappedKey, cost: decodedImage.memoryCost) + + guard !token.isCancelled else { return } + DispatchQueue.main.async { + completion(decodedImage) + } + } + + return token + } + + /// Clears the entire image cache. + func clear() { + cache.removeAllObjects() + } + + /// Removes the image for a specific key. + func removeImage(for key: CacheKey) { + cache.removeObject(forKey: WrappedCacheKey(key)) + } + + // MARK: - Private Helpers + + private func decodeImage(data: Data) -> UIImage? { + guard let image = UIImage(data: data, scale: UIScreen.main.scale) else { + return nil + } + + return forceDecodeImage(image) + } + + /// Forces decode of the given image by drawing it into a bitmap context. + /// + /// - Parameter image: The UIImage to decode. + /// - Returns: A new UIImage instance that is fully decoded and ready for display. + private func forceDecodeImage(_ image: UIImage) -> UIImage { + guard let cgImage = image.cgImage else { + return image + } + + let size = CGSize(width: cgImage.width, height: cgImage.height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + + let bytesPerPixel = 4 + let bytesPerRow = Int(size.width) * bytesPerPixel + let bitsPerComponent = 8 + + guard let context = CGContext( + data: nil, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: bitsPerComponent, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return image + } + + let rect = CGRect(origin: .zero, size: size) + context.draw(cgImage, in: rect) + + guard let decodedCGImage = context.makeImage() else { + return image + } + + let decodedImage = UIImage( + cgImage: decodedCGImage, + scale: image.scale, + orientation: image.imageOrientation + ) + return decodedImage + } +} + +// MARK: - UIImage Helpers + +private extension UIImage { + /// Rough estimate of the image's memory cost in bytes. + var memoryCost: Int { + guard let cgImage = self.cgImage else { return 0 } + let bytesPerPixel = 4 + return cgImage.width * cgImage.height * bytesPerPixel + } +} + +// MARK: - NSCache Key Wrapper + +private final class WrappedCacheKey: NSObject { + let key: OptimizedImageCache.CacheKey + + init(_ key: OptimizedImageCache.CacheKey) { + self.key = key + } + + override var hash: Int { + return key.hashValue + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? WrappedCacheKey else { + return false + } + return key == other.key + } +} + diff --git a/WooCommerce/Classes/Profiling/PerformanceLogger.swift b/WooCommerce/Classes/Profiling/PerformanceLogger.swift new file mode 100644 index 00000000000..34ffcdb76c9 --- /dev/null +++ b/WooCommerce/Classes/Profiling/PerformanceLogger.swift @@ -0,0 +1,143 @@ +import Foundation +import os.log +import os.signpost + +/// Centralized performance logging and profiling infrastructure using os_signpost +/// for detailed performance analysis in Instruments. +final class PerformanceLogger { + + // MARK: - Singleton + static let shared = PerformanceLogger() + + // MARK: - OSLog Configuration + private let perfLog: OSLog + private let subsystemIdentifier = "com.automattic.woocommerce" + + // MARK: - Signpost Categories + enum SignpostCategory: String { + // Product Catalog Performance + case catalogLoad = "CatalogLoad" + case catalogScroll = "CatalogScroll" + case catalogFilter = "CatalogFilter" + + // Product Detail Performance + case productDetailLoad = "ProductDetailLoad" + case productDetailGallery = "ProductDetailGallery" + + // Cart & Checkout Performance + case addToCart = "AddToCart" + case cartUpdate = "CartUpdate" + case checkoutStart = "CheckoutStart" + case checkoutComplete = "CheckoutComplete" + + // Image Loading & Caching + case imageDecode = "ImageDecode" + case imageCacheHit = "ImageCacheHit" + case imageCacheMiss = "ImageCacheMiss" + + // Network & API + case apiRequest = "APIRequest" + case apiResponse = "APIResponse" + + // App Lifecycle / Launch + case coldStart = "ColdStart" + case warmStart = "WarmStart" + + // Miscellaneous + case backgroundProcessing = "BackgroundProcessing" + case databaseQuery = "DatabaseQuery" + + // Custom + case custom1 = "Custom1" + case custom2 = "Custom2" + case custom3 = "Custom3" + } + + // MARK: - Init + private init() { + self.perfLog = OSLog(subsystem: subsystemIdentifier, category: "Performance") + } + + // MARK: - Public API + + /// Creates a unique signpost ID for an interval. + func makeSignpostID() -> OSSignpostID { + if #available(iOS 12.0, *) { + return OSSignpostID(log: perfLog) + } else { + return .invalid + } + } + + /// Begins a signposted interval for a specific category. + func begin(_ category: SignpostCategory, + id: OSSignpostID, + _ message: StaticString = "") { + guard #available(iOS 12.0, *), id != .invalid else { return } + os_signpost(.begin, log: perfLog, name: category.osSignpostName, signpostID: id, message) + } + + /// Ends a signposted interval for a specific category. + func end(_ category: SignpostCategory, + id: OSSignpostID, + _ message: StaticString = "") { + guard #available(iOS 12.0, *), id != .invalid else { return } + os_signpost(.end, log: perfLog, name: category.osSignpostName, signpostID: id, message) + } + + /// Logs a single-point event for a specific category. + func event(_ category: SignpostCategory, + _ message: StaticString = "") { + guard #available(iOS 12.0, *) else { return } + os_signpost(.event, log: perfLog, name: category.osSignpostName, message) + } + + /// Measures the execution time of a synchronous block. + @discardableResult + func measure(_ category: SignpostCategory, block: () throws -> T) rethrows -> T { + let id = makeSignpostID() + begin(category, id: id) + defer { end(category, id: id) } + return try block() + } + + /// Measures the execution time of an async block. + @discardableResult + func measureAsync(_ category: SignpostCategory, block: () async throws -> T) async rethrows -> T { + let id = makeSignpostID() + begin(category, id: id) + defer { end(category, id: id) } + return try await block() + } +} + +// MARK: - Helper + +private extension PerformanceLogger.SignpostCategory { + var osSignpostName: StaticString { + switch self { + case .catalogLoad: return "CatalogLoad" + case .catalogScroll: return "CatalogScroll" + case .catalogFilter: return "CatalogFilter" + case .productDetailLoad: return "ProductDetailLoad" + case .productDetailGallery: return "ProductDetailGallery" + case .addToCart: return "AddToCart" + case .cartUpdate: return "CartUpdate" + case .checkoutStart: return "CheckoutStart" + case .checkoutComplete: return "CheckoutComplete" + case .imageDecode: return "ImageDecode" + case .imageCacheHit: return "ImageCacheHit" + case .imageCacheMiss: return "ImageCacheMiss" + case .apiRequest: return "APIRequest" + case .apiResponse: return "APIResponse" + case .coldStart: return "ColdStart" + case .warmStart: return "WarmStart" + case .backgroundProcessing: return "BackgroundProcessing" + case .databaseQuery: return "DatabaseQuery" + case .custom1: return "Custom1" + case .custom2: return "Custom2" + case .custom3: return "Custom3" + } + } +} + diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 8cbb193824f..141cc730a69 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -5782,6 +5782,8 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 0F6EAB792ED2DFE000089A40 /* Profiling */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Profiling; sourceTree = ""; }; + 0F6EAB802ED2E0AE00089A40 /* performance */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = performance; sourceTree = ""; }; 2D33E6B02DD1453E000C7198 /* WooShippingPaymentMethod */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = WooShippingPaymentMethod; sourceTree = ""; }; 3F0904022D26A40800D8ACCE /* WordPressAuthenticator */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3F09041C2D26A40800D8ACCE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WordPressAuthenticator; sourceTree = ""; }; 3F09040E2D26A40800D8ACCE /* WordPressAuthenticatorTests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3FD28D5E2D271391002EBB3D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WordPressAuthenticatorTests; sourceTree = ""; }; @@ -9747,6 +9749,7 @@ B56DB3F12049C0B800D4AA8E /* Classes */ = { isa = PBXGroup; children = ( + 0F6EAB792ED2DFE000089A40 /* Profiling */, 646A2C682E9FCD7E003A32A1 /* Routing */, 640DA3472E97DE4F00317FB2 /* SceneDelegate.swift */, DEDB5D342E7A68950022E5A1 /* Bookings */, @@ -10622,6 +10625,7 @@ CCDC49CB23FFFFF4003166BA /* WooCommerceUITests */ = { isa = PBXGroup; children = ( + 0F6EAB802ED2E0AE00089A40 /* performance */, 3F271A9728A2684400E656AE /* UITests.xctestplan */, 800A5B9C275623E9009DE2CD /* Flows */, CCDC49D9240000B7003166BA /* Tests */, @@ -13269,6 +13273,7 @@ 3F0904132D26A40800D8ACCE /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + 0F6EAB792ED2DFE000089A40 /* Profiling */, 2D33E6B02DD1453E000C7198 /* WooShippingPaymentMethod */, 646A2C682E9FCD7E003A32A1 /* Routing */, 64EA08E42EC214FA00050202 /* MultilineEditableTextRow */, diff --git a/WooCommerce/WooCommerceUITests/performance/PerformanceUITests.swift b/WooCommerce/WooCommerceUITests/performance/PerformanceUITests.swift new file mode 100644 index 00000000000..5280fc98dee --- /dev/null +++ b/WooCommerce/WooCommerceUITests/performance/PerformanceUITests.swift @@ -0,0 +1,225 @@ +import XCTest + +/// UI test suite focused on measuring performance characteristics of +/// common customer journeys in the WooCommerce iOS app. +/// +/// These tests are designed to be used in conjunction with Instruments +/// and os_signpost markers provided by PerformanceLogger. +final class PerformanceUITests: XCTestCase { + + private var app: XCUIApplication! + + // MARK: - Setup + + override func setUp() { + super.setUp() + + continueAfterFailure = false + + app = XCUIApplication() + app.launchArguments.append(contentsOf: [ + "-UITest_PerformanceMode", + "YES" + ]) + + app.launch() + } + + override func tearDown() { + app.terminate() + app = nil + super.tearDown() + } + + // MARK: - Helpers + + private func scroll(element: XCUIElement, times: Int = 5) { + for _ in 0..