Skip to main content

Implement a basic LRU cache to limit memory usage

ID
777309f
date
2023-06-14 21:38:34+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
ca1cb3f
message
Implement a basic LRU cache to limit memory usage
changed files
4 files, 120 additions, 30 deletions

Changed files

BlinkReviewer/Blink.xcodeproj/project.pbxproj (31950) → BlinkReviewer/Blink.xcodeproj/project.pbxproj (33529)

diff --git a/BlinkReviewer/Blink.xcodeproj/project.pbxproj b/BlinkReviewer/Blink.xcodeproj/project.pbxproj
index f9cbf6a..d90a971 100644
--- a/BlinkReviewer/Blink.xcodeproj/project.pbxproj
+++ b/BlinkReviewer/Blink.xcodeproj/project.pbxproj
@@ -34,6 +34,8 @@
 		94D751222A31BD8E005859E7 /* PhotoReviewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D751212A31BD8E005859E7 /* PhotoReviewer.swift */; };
 		94D7512B2A31D6AC005859E7 /* AssetHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D7512A2A31D6AC005859E7 /* AssetHelpers.swift */; };
 		94F7E39E2A331A9E00763DB9 /* Statistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F7E39D2A331A9E00763DB9 /* Statistics.swift */; };
+		94FCD4F42A3A62A800D884A1 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 94FCD4F32A3A62A800D884A1 /* OrderedCollections */; };
+		94FCD4F62A3A64F800D884A1 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94FCD4F52A3A64F800D884A1 /* LRUCache.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -85,6 +87,7 @@
 		94D751212A31BD8E005859E7 /* PhotoReviewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoReviewer.swift; sourceTree = "<group>"; };
 		94D7512A2A31D6AC005859E7 /* AssetHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetHelpers.swift; sourceTree = "<group>"; };
 		94F7E39D2A331A9E00763DB9 /* Statistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics.swift; sourceTree = "<group>"; };
+		94FCD4F52A3A64F800D884A1 /* LRUCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -92,6 +95,7 @@
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				94FCD4F42A3A62A800D884A1 /* OrderedCollections in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -159,6 +163,7 @@
 			children = (
 				94D2C8B82A320E6F00BEE15B /* ReviewState.swift */,
 				94C5FFF12A33ADD4004ADDF5 /* PHFetchResultCollection.swift */,
+				94FCD4F52A3A64F800D884A1 /* LRUCache.swift */,
 			);
 			path = Model;
 			sourceTree = "<group>";
@@ -261,6 +266,9 @@
 			dependencies = (
 			);
 			name = Blink;
+			packageProductDependencies = (
+				94FCD4F32A3A62A800D884A1 /* OrderedCollections */,
+			);
 			productName = BlinkReviewer;
 			productReference = 94D750EC2A31A796005859E7 /* Blink.app */;
 			productType = "com.apple.product-type.application";
@@ -333,6 +341,9 @@
 				Base,
 			);
 			mainGroup = 94D750E32A31A796005859E7;
+			packageReferences = (
+				94FCD4F22A3A62A800D884A1 /* XCRemoteSwiftPackageReference "swift-collections" */,
+			);
 			productRefGroup = 94D750ED2A31A796005859E7 /* Products */;
 			projectDirPath = "";
 			projectRoot = "";
@@ -394,6 +405,7 @@
 				945F17B82A33DAC7004FC479 /* ReviewStateSaturation.swift in Sources */,
 				94D751222A31BD8E005859E7 /* PhotoReviewer.swift in Sources */,
 				94D2C8B92A320E6F00BEE15B /* ReviewState.swift in Sources */,
+				94FCD4F62A3A64F800D884A1 /* LRUCache.swift in Sources */,
 				94F7E39E2A331A9E00763DB9 /* Statistics.swift in Sources */,
 				94D2C8C12A32FCE300BEE15B /* PHAssetImage.swift in Sources */,
 				941E18FA2A35362600A2EA98 /* Info.swift in Sources */,
@@ -706,6 +718,25 @@
 			defaultConfigurationName = Release;
 		};
 /* End XCConfigurationList section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+		94FCD4F22A3A62A800D884A1 /* XCRemoteSwiftPackageReference "swift-collections" */ = {
+			isa = XCRemoteSwiftPackageReference;
+			repositoryURL = "https://github.com/apple/swift-collections.git";
+			requirement = {
+				kind = upToNextMajorVersion;
+				minimumVersion = 1.0.0;
+			};
+		};
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+		94FCD4F32A3A62A800D884A1 /* OrderedCollections */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = 94FCD4F22A3A62A800D884A1 /* XCRemoteSwiftPackageReference "swift-collections" */;
+			productName = OrderedCollections;
+		};
+/* End XCSwiftPackageProductDependency section */
 	};
 	rootObject = 94D750E42A31A796005859E7 /* Project object */;
 }

BlinkReviewer/Blink.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved (0) → BlinkReviewer/Blink.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved (316)

diff --git a/BlinkReviewer/Blink.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/BlinkReviewer/Blink.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..3b7a76a
--- /dev/null
+++ b/BlinkReviewer/Blink.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,14 @@
+{
+  "pins" : [
+    {
+      "identity" : "swift-collections",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-collections.git",
+      "state" : {
+        "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2",
+        "version" : "1.0.4"
+      }
+    }
+  ],
+  "version" : 2
+}

BlinkReviewer/Blink/Model/LRUCache.swift (0) → BlinkReviewer/Blink/Model/LRUCache.swift (1255)

diff --git a/BlinkReviewer/Blink/Model/LRUCache.swift b/BlinkReviewer/Blink/Model/LRUCache.swift
new file mode 100644
index 0000000..36007e6
--- /dev/null
+++ b/BlinkReviewer/Blink/Model/LRUCache.swift
@@ -0,0 +1,43 @@
+import OrderedCollections
+
+/// This implements a very basic LRU (least-recently used) cache.
+///
+/// The cache has a maximum size, and objects get evicted when the cache grows
+/// too large.  This is primarily used to cache images, and avoid the memory
+/// footprint of the application growing forever.
+struct LRUCache<Key: Hashable, Value> {
+    private var contents: Dictionary<Key, Value>
+    private var keyHistory: OrderedSet<Key>
+    
+    var maxSize: Int
+    
+    init(withMaxSize maxSize: Int) {
+        self.maxSize = maxSize
+        
+        self.contents = Dictionary()
+        self.keyHistory = OrderedSet()
+    }
+    
+    subscript(key: Key) -> Value? {
+        get {
+            contents[key]
+        }
+        
+        set {
+            contents[key] = newValue
+            
+            // Move the key to the beginning of the key history
+            keyHistory.remove(key)
+            keyHistory.insert(key, at: 0)
+            
+            assert(contents.count == keyHistory.count)
+            
+            while contents.count > self.maxSize {
+                let lastKey = keyHistory.last!
+                
+                contents.removeValue(forKey: lastKey)
+                keyHistory.remove(lastKey)
+            }
+        }
+    }
+}

BlinkReviewer/Blink/Photos/PhotosLibrary.swift (8762) → BlinkReviewer/Blink/Photos/PhotosLibrary.swift (9005)

diff --git a/BlinkReviewer/Blink/Photos/PhotosLibrary.swift b/BlinkReviewer/Blink/Photos/PhotosLibrary.swift
index be05ac5..cc79a6a 100644
--- a/BlinkReviewer/Blink/Photos/PhotosLibrary.swift
+++ b/BlinkReviewer/Blink/Photos/PhotosLibrary.swift
@@ -1,4 +1,5 @@
 import Foundation
+import OrderedCollections
 import Photos
 
 /// Manage most of the interactions with the Photos Library.
@@ -174,26 +175,29 @@ class PhotosLibrary: NSObject, ObservableObject, PHPhotoLibraryChangeObserver {
     // smart enough to debug that.  If I don't cache it, there's a "flash" as
     // it reloads the thumbnails every time.
     //
-    // TODO: Investigate using SwiftUI to do this.
-    // TODO: If that doesn't work, replace this Dictionary with NSCache or an
-    // LRU cache.  For some reason NSCache didn't store entries when I tried it,
-    // but I didn't try for very long.
-    private var thumbnailCache = Dictionary<PHAsset, PHAssetImage>()
+    // TODO: Investigate the SwiftUI caching behaviour.
+    //
+    // Note: the size of both this and the following cache are designed to balance
+    // memory usage and performance.  Everything on the screen and just off it
+    // should be kept in cache, so I can e.g. switch between all the variants
+    // of a single shot, but I don't need more than that.
+    //
+    // On my M2 MacBook Air, these numbers mean the app peaks at ~250MB of memory,
+    // which seems pretty reasonable.
+    private var thumbnailCache = LRUCache<PHAsset, PHAssetImage>(withMaxSize: 100)
     
     func getThumbnail(for asset: PHAsset) -> PHAssetImage {
-        if let cachedThumbnail = thumbnailCache[asset] {
-            return cachedThumbnail
+        if thumbnailCache[asset] == nil {
+            let newImage = PHAssetImage(
+                asset,
+                size: CGSize(width: 70, height: 70),
+                deliveryMode: .opportunistic
+            )
+            
+            thumbnailCache[asset] = newImage
         }
-        
-        let newThumbnail = PHAssetImage(
-            asset,
-            size: CGSize(width: 70, height: 70),
-            deliveryMode: .opportunistic
-        )
-        
-        thumbnailCache[asset] = newThumbnail
-        
-        return newThumbnail
+
+        return thumbnailCache[asset]!
     }
     
     // Implement a similar cache for full-sized images.
@@ -204,21 +208,19 @@ class PhotosLibrary: NSObject, ObservableObject, PHPhotoLibraryChangeObserver {
     //
     // TODO: Surely it should be possible to make SwiftUI cache views like
     // this for us?
-    private var fullSizeImageCache = Dictionary<PHAsset, PHAssetImage>()
+    private var fullSizeImageCache = LRUCache<PHAsset, PHAssetImage>(withMaxSize: 10)
     
     func getFullSizedImage(for asset: PHAsset) -> PHAssetImage {
-        if let cachedImage = fullSizeImageCache[asset] {
-            return cachedImage
+        if fullSizeImageCache[asset] == nil {
+            let newImage = PHAssetImage(
+                asset,
+                size: PHImageManagerMaximumSize,
+                deliveryMode: .opportunistic
+            )
+            
+            fullSizeImageCache[asset] = newImage
         }
-        
-        let newImage = PHAssetImage(
-            asset,
-            size: PHImageManagerMaximumSize,
-            deliveryMode: .opportunistic
-        )
-        
-        fullSizeImageCache[asset] = newImage
-        
-        return newImage
+
+        return fullSizeImageCache[asset]!
     }
 }