damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 366e3565d17b4c41e69b23f970d2d1e3129895fd
parent 7da7bcdfd5aa490e4c913f2d0568ed53f901c967
Author: William Casarin <jb55@jb55.com>
Date:   Sat,  6 Aug 2022 19:22:54 -0700

pfp: profile pic image cache

So we don't have to download 60MB of profile pics every time we load the
app..

Changelog-Added: Added profile picture cache
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus/Models/HomeModel.swift | 3++-
Mdamus/Util/ImageCache.swift | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mdamus/Views/ProfilePicView.swift | 37+++++++++++++++++--------------------
3 files changed, 113 insertions(+), 48 deletions(-)

diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -509,7 +509,8 @@ func process_metadata_event(image_cache: ImageCache, profiles: Profiles, ev: Nos let picture = tprof.profile.picture ?? robohash(ev.pubkey) if let url = URL(string: picture) { Task<UIImage?, Never>.init(priority: .background) { - let res = await load_image(cache: image_cache, from: url) + let pfp_key = pfp_cache_key(url: url) + let res = await image_cache.lookup_or_load_image(key: pfp_key, url: url) DispatchQueue.main.async { notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) } diff --git a/damus/Util/ImageCache.swift b/damus/Util/ImageCache.swift @@ -7,7 +7,7 @@ import Foundation import SwiftUI -import Combine +import UIKit enum ImageProcessingStatus { case processing @@ -30,27 +30,27 @@ class ImageCache { state[key] = new_state } - lazy var cache: NSCache<AnyObject, UIImage> = { - let cache = NSCache<AnyObject, UIImage>() + lazy var cache: NSCache<NSString, UIImage> = { + let cache = NSCache<NSString, UIImage>() cache.totalCostLimit = 1024 * 1024 * 100 // 100MB return cache }() // simple polling until I can figure out a better way to do this - func wait_for_image(_ url: URL) async { + func wait_for_image(_ key: String) async { while true { let why_would_this_happen: ()? = try? await Task.sleep(nanoseconds: 100_000_000) // 100ms if why_would_this_happen == nil { return } - if get_state(url.absoluteString) == .done { + if get_state(key) == .done { return } } } - func lookup_sync(for url: URL) -> UIImage? { - let status = get_state(url.absoluteString) + func lookup_sync(key: String) -> UIImage? { + let status = get_state(key) switch status { case .done: @@ -61,61 +61,115 @@ class ImageCache { return nil } - if let decoded = cache.object(forKey: url as AnyObject) { + if let decoded = cache.object(forKey: NSString(string: key)) { return decoded } return nil } - func lookup(for url: URL) async -> UIImage? { - let status = get_state(url.absoluteString) + func lookup_or_load_image(key: String, url: URL?) async -> UIImage? { + if let img = await lookup(key: key) { + return img + } + + guard let url = url else { + return nil + } + + return await load_image(cache: self, from: url, key: key) + } + + func get_cache_url(key: String, suffix: String, ext: String = "png") -> URL? { + let urls = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + + guard let root = urls.first else { + return nil + } + + return root.appendingPathComponent("\(key)\(suffix).\(ext)") + } + + private func lookup_file_cache(key: String, suffix: String = "_pfp") -> UIImage? { + guard let img_file = get_cache_url(key: key, suffix: suffix) else { + return nil + } + + guard let img = UIImage(contentsOfFile: img_file.path) else { + //print("failed to load \(key)\(suffix).png from file cache") + return nil + } + + save_to_memory_cache(key: key, img: img) + + print("loaded \(key)\(suffix).png from file cache") + + return img + } + + func lookup(key: String) async -> UIImage? { + let status = get_state(key) switch status { case .done: break case .processing: - await wait_for_image(url) + await wait_for_image(key) case .none: - return nil + return lookup_file_cache(key: key) } - if let decoded = cache.object(forKey: url as AnyObject) { + if let decoded = cache.object(forKey: NSString(string: key)) { return decoded } return nil } - func remove(for url: URL) { + func remove(key: String) { lock.lock(); defer { lock.unlock() } - cache.removeObject(forKey: url as AnyObject) + cache.removeObject(forKey: NSString(string: key)) } - func insert(_ image: UIImage, for url: URL) async -> UIImage? { + func insert(_ image: UIImage, key: String) async -> UIImage? { let scale = await UIScreen.main.scale let size = CGSize(width: PFP_SIZE * scale, height: PFP_SIZE * scale) - let key = url.absoluteString - set_state(key, new_state: .processing) let decoded_image = await image.byPreparingThumbnail(ofSize: size) + save_to_memory_cache(key: key, img: decoded_image ?? UIImage()) + if let img = decoded_image { + if !save_to_file_cache(key: key, img: img) { + print("failed saving \(key) pfp to file cache") + } + } + + return decoded_image + } + + func save_to_file_cache(key: String, img: UIImage, suffix: String = "_pfp") -> Bool { + guard let url = get_cache_url(key: key, suffix: suffix) else { + return false + } + + guard let data = img.pngData() else { + return false + } + + return (try? data.write(to: url)) != nil + } + + func save_to_memory_cache(key: String, img: UIImage) { lock.lock() - cache.setObject(decoded_image ?? UIImage(), forKey: url as AnyObject) + cache.setObject(img, forKey: NSString(string: key)) state[key] = .done lock.unlock() - - return decoded_image } } -func load_image(cache: ImageCache, from url: URL) async -> UIImage? { - if let image = await cache.lookup(for: url) { - return image - } - +func load_image(cache: ImageCache, from url: URL, key: String) async -> UIImage? { guard let (data, _) = try? await URLSession.shared.data(from: url) else { return nil } @@ -124,5 +178,18 @@ func load_image(cache: ImageCache, from url: URL) async -> UIImage? { return nil } - return await cache.insert(img, for: url) + return await cache.insert(img, key: key) +} + + +func hashed_hexstring(_ str: String) -> String { + guard let data = str.data(using: .utf8) else { + return str + } + + return hex_encode(sha256(data)) +} + +func pfp_cache_key(url: URL) -> String { + return hashed_hexstring(url.absoluteString) } diff --git a/damus/Views/ProfilePicView.swift b/damus/Views/ProfilePicView.swift @@ -53,8 +53,8 @@ struct ProfilePicView: View { .padding(2) } - func ProfilePic(_ url: URL) -> some View { - return Group { + var MainContent: some View { + Group { if let img = self.img { img .resizable() @@ -66,27 +66,23 @@ struct ProfilePicView: View { Placeholder } } - .task { - let ui_img = await load_image(cache: image_cache, from: url) - if let ui_img = ui_img { - self.img = Image(uiImage: ui_img) - } - } - } - - var MainContent: some View { - Group { - let picture = picture ?? profiles.lookup(id: pubkey)?.picture ?? robohash(pubkey) - if let pic_url = URL(string: picture) { - ProfilePic(pic_url) - } else { - Placeholder - } - } } var body: some View { MainContent + .task { + let pic = picture ?? profiles.lookup(id: pubkey)?.picture ?? robohash(pubkey) + guard let url = URL(string: pic) else { + return + } + let pfp_key = pfp_cache_key(url: url) + let ui_img = await image_cache.lookup_or_load_image(key: pfp_key, url: url) + + if let ui_img = ui_img { + self.img = Image(uiImage: ui_img) + return + } + } .onReceive(handle_notify(.profile_updated)) { notif in let updated = notif.object as! ProfileUpdate @@ -96,7 +92,8 @@ struct ProfilePicView: View { if let pic = updated.profile.picture { if let url = URL(string: pic) { - if let ui_img = image_cache.lookup_sync(for: url) { + let pfp_key = pfp_cache_key(url: url) + if let ui_img = image_cache.lookup_sync(key: pfp_key) { self.img = Image(uiImage: ui_img) } }