کش تصاویر با قابلیت استفاده مجدد در سوئیفت — از صفر تا صد

تقریباً همه اپلیکیشنها شامل نوعی گرافیک هستند. به همین جهت است که دانلود کردن و نمایش تصاویر در اپلیکیشن موبایل یکی از رایجترین وظایف برای توسعهدهندگان اپلیکیشن محسوب میشود. اما در صورتی که قرار باشد اپلیکیشن برخی تصاویر را به طور مکرر دانلود و نمایش دهد، این فرایند موجب کارهای غیرضروری میشود. در این مقاله روش بهینهسازی این وضعیت را با ایجاد یک Image Cache و یکپارچهسازی آن با Image Loader با استفاده از فریمورک Combine توضیح میدهیم. بدین ترتیب به امکان کش تصاویر با قابلیت استفاده مجدد در سوئیفت دست مییابیم.
استفاده از NSCache به عنوان محل ذخیرهسازی
زمانی که میخواهیم یک سازوکار کش در یک پروژه iOS بسازیم، در اغلب موارد باید از کلاس NSCache استفاده کنیم. این کلاس برخی مزایا مانند thread-safe بودن و حذف آیتمها از کش در زمان استفاده دیگر اپلیکیشنها از حافظه دارد، اما از برخی عیبها نیز مانند داشتن فرایند خروجی ناروشن برخوردار است.
در هر حال کلاس NSCache گزینه بهتری برای کش کردن در مقایسه با کلاسهای collection از کتابخانه استاندارد سوئیفت یا فریمورک Foundation محسوب میشود. در این مقاله، ما از NSCache به عنوان یک ذخیرهگاه کش تصویر درونی استفاده میکنیم. البته میتوانید آن را با راهحل دیگری نیز جایگزین کنید.
Pipeline رندرینگ تصویر
اگر اپلیکیشن شما تصاویر را از وب دانلود میکند، یکی از چالشهای رایج واکنشگرا بودن و عملکرد اپلیکیشن است. برای نمونه ممکن است در زمان اسکرول کردن یک «نمای جدولی» (Table View) شامل تصاویر با کندی مواجه شوید. مشکل این است که رندرینگ تصویر به یکباره هنگام انتساب تصویر به یک «نمای تصویر» (Image View) رخ نمیدهد. pipeline رندرینگ تصویر شامل چندین مرحله است:
- بارگذاری – تصویر فشرده شده در حافظه بارگذاری میشود.
- دیکدینگ – دادههای تصویر کد شده به اطلاعات تصویر مبتنی بر پیکسل تبدیل میشود.
- رندرینگ – دادههای تصویر از بافر تصویر درون بافر فریم کپی و مقیاسبندی میشود.
این فرایند میتواند حجم کار بالایی به نخ اصلی اضافه کند، به طوری که اپلیکیشن شما دیگر پاسخگو نباشد. البته میتوان برخی بهینهسازیها مانند دیکدینگ و رندرینگ تصویر قبل از انتساب به UIImageView انجام داد.
این تابع یک UIImage معمولی استفاده میکند و یک نسخه نافشرده و رندر شده بازگشت میدهد. داشتن یک کش از تصاویر نافشرده ایده مناسبی به نظر میرسد. بدین ترتیب عملکرد رسم بهبود مییابد، اما هزینه جانبی به صورت استفاده بیشتر از فضای ذخیرهسازی دارد.
extension UIImage { func decodedImage() -> UIImage { guard let cgImage = cgImage else { return self } let size = CGSize(width: cgImage.width, height: cgImage.height) let colorSpace = CGColorSpaceCreateDeviceRGB() let context = CGContext(data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: cgImage.bytesPerRow, space: colorSpace, bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue) context?.draw(cgImage, in: CGRect(origin:.zero, size: size)) guard let decodedImage = context?.makeImage() else { return self } return UIImage(cgImage: decodedImage) } }
اگر دوست دارید در این مورد اطلاعات بیشتری کسب کنید، پیشنهاد میکنیم این سخنرانیهای WWDC مربوط به بررسی عمیق حافظه iOS (+) و رویههای مناسب برای تصاویر و گرافیکها (+) را ببینید.
کش تصاویر درون حافظه
آغاز کار با تعریف کردن الزامات کش تصاویر، میتواند یک شروع خوب باشد. کش باید تابعهای CRUD (یعنی ایجاد، خواندن، بهروزرسانی و حذف) را تعریف بکند. کش باید یک «زیرنویس» (subscript) داشته باشد تا کد خوانایی بیشتری پیدا کند. در اغلب موارد تصویری که از شبکه بارگذاری شده را کش میکنیم و از این رو بهتر است از URL به عنوان کلید آن استفاده کنیم. در نهایت میتوانیم ImageCacheType را به صورت زیر اعلان کنیم:
// Declares in-memory image cache protocol ImageCacheType: class { // Returns the image associated with a given url func image(for url: URL) -> UIImage? // Inserts the image of the specified url in the cache func insertImage(_ image: UIImage?, for url: URL) // Removes the image of the specified url in the cache func removeImage(for url: URL) // Removes all images from the cache func removeAllImages() // Accesses the value associated with the given key for reading and writing subscript(_ url: URL) -> UIImage? { get set } }
پیادهسازی کش تصویر
با در نظر گرفتن همه نکات فوق میتوانیم کلاس ImageCache را اعلان کنیم. این کلاس به صورت داخلی دو فیلد NSCache دارد تا تصاویر فشرده و تصاویر نافشرده را ذخیره سازد. اندازه کش را برحسب بیشینه تعداد اشیا و هزینه کلی مثلاً بنا بر اندازه تصاویر به بایت محدود میکنیم. وهله NSLock برای ارائه دسترسی منحصراً متقابل و thread-safe ساختن کش استفاده شده است.
final class ImageCache { // 1st level cache, that contains encoded images private lazy var imageCache: NSCache<AnyObject, AnyObject> = { let cache = NSCache<AnyObject, AnyObject>() cache.countLimit = config.countLimit return cache }() // 2nd level cache, that contains decoded images private lazy var decodedImageCache: NSCache<AnyObject, AnyObject> = { let cache = NSCache<AnyObject, AnyObject>() cache.totalCostLimit = config.memoryLimit return cache }() private let lock = NSLock() private let config: Config struct Config { let countLimit: Int let memoryLimit: Int static let defaultConfig = Config(countLimit: 100, memoryLimit: 1024 * 1024 * 100) // 100 MB } init(config: Config = Config.defaultConfig) { self.config = config } }
اکنون باید چند تابع را برای تأمین الزامات ImagecacheType که قبلاً تعریف کردیم پیادهسازی کنیم. بدین ترتیب میتوانیم تصاویر را در کش درج و حذف کنیم:
extension ImageCache: ImageCacheType { func insertImage(_ image: UIImage?, for url: URL) { guard let image = image else { return removeImage(for: url) } let decodedImage = image.decodedImage() lock.lock(); defer { lock.unlock() } imageCache.setObject(decodedImage, forKey: url as AnyObject) decodedImageCache.setObject(image as AnyObject, forKey: url as AnyObject, cost: decodedImage.diskSize) } func removeImage(for url: URL) { lock.lock(); defer { lock.unlock() } imageCache.removeObject(forKey: url as AnyObject) decodedImageCache.removeObject(forKey: url as AnyObject) } }
ممکن است متوجه شده باشید که ما هزینهای برای تصویر کدگشایی شده تعیین میکنیم. decodedImageCache پیکربندی شده است. این متد باید برخی عناصر را در زمانی که هزینه کلی از بیشینه مجاز تجاوز کرد، حذف کند. برای دریافت یک تصویر از کش ابتدا باید تصاویر دیکد شده را به عنوان سناریوی بهترین حالت بررسی کنیم. سپس به دنبال تصویر در imageCache بگردیم و یا مقدار nil را به عنوان بازخورد بازگشت دهیم.
extension ImageCache { func image(for url: URL) -> UIImage? { lock.lock(); defer { lock.unlock() } // the best case scenario -> there is a decoded image if let decodedImage = decodedImageCache.object(forKey: url as AnyObject) as? UIImage { return decodedImage } // search for image data if let image = imageCache.object(forKey: url as AnyObject) as? UIImage { let decodedImage = image.decodedImage() decodedImageCache.setObject(image as AnyObject, forKey: url as AnyObject, cost: decodedImage.diskSize) return decodedImage } return nil } }
ما میتوانیم از تابع فوق برای تعریف زیرنویس برای ImageCache استفاده کنیم:
extension ImageCache { subscript(_ key: URL) -> UIImage? { get { return image(for: key) } set { return insertImage(newValue, for: key) } } }
بدین ترتیب کش تصاویر را ایجاد کردیم که میتواند درون پروژهها مورد استفاده مجدد قرار گیرد و آنها را سریعتر و پاسخگوتر سازد.
یکپارچهسازی با بخش بارگذاری تصویر
اینک به بررسی شیوه یکپارچهسازی ImageCache در پروژههای مختلف میپردازیم. فرض کنید یک Image Loader دارید که از قبل تعریف شده است. اگر چنین نیست میتوانید آن را به صورت زیر با استفاده از فریمورک Combine تعریف کنید:
final class ImageLoader { func loadImage(from url: URL) -> AnyPublisher<UIImage?, Never> { return URLSession.shared.dataTaskPublisher(for: url) ➊ .map { (data, _) -> UIImage? in return UIImage(data: data) } ➋ .catch { error in return Just(nil) } ➌ .subscribe(on: backgroundQueue) ➍ .receive(on: RunLoop.main) ➎ .eraseToAnyPublisher() ➏ } }
- dataTaskPublisher یک ناشر ایجاد میکند که نتایج اجرای وظایف دادهای نشست URL را تحویل میدهد. این ناشر یک pipeline از چندتایی (data: Data, response: URLResponse) بازگشت میدهد.
- عملگر map برای ایجاد یک شیء UIImage اختیاری مورد استفاده قرار میگیرد.
- ما از عملگر catch برای مدیریت خطا استفاده میکنیم. این عملگر ناشر بالادستی را با ناشر Just(nil) عوض میکند.
- کارهایی را روی صف پسزمینه اجرا میکند.
- برای دریافت تصویر از صف اصلی سوئیچ میکند.
- eraseToAnyPublisher عمل پاک کردن نوع را روی زنجیرهای از عملگرها اجرا میکند تا تابع loadImage(from:) یک شیء با نوع AnyPublisher<UIImage?, Never> بازگشت دهد.
سپس باید تغییراتی در ImageLoader ایجاد کنیم تا در صورتی که تصویری در کش داشته باشیم، بیدرنگ بازگشت یابد و زمانی که بارگذاری تصویر پایان یافت، آن را کش کند. در نهایت ImageLoader به صورت زیر درمیآید:
final class ImageLoader { private let cache = ImageCache() func loadImage(from url: URL) -> AnyPublisher<UIImage?, Never> { if let image = cache[url] { return Just(image).eraseToAnyPublisher() ➊ } return URLSession.shared.dataTaskPublisher(for: url) .map { (data, response) -> UIImage? in return UIImage(data: data) } .catch { error in return Just(nil) } .handleEvents(receiveOutput: {[unowned self] image in ➋ guard let image = image else { return } self.cache[url] = image }) .subscribe(on: backgroundQueue) .receive(on: RunLoop.main) .eraseToAnyPublisher() } }
ناشر Just را با تصویر کش شده (در صورت وجود) بازگشت میدهد.
دادهها به receiveOutput ارسال میشوند تا به عنوان ناشر آنها را عرضه کند. در این بخش تصویر را به محض انجام یافتن بارگذاری داده اجرا میکنیم و dataTaskPublisher یک مقدار جدید صادر میکند.
سخن پایانی
با استفاده از ImageCache میتوان بارگذاری تصویر را درون اپلیکیشن بهینهسازی کرد و تجربه کاربری را بهبود بخشید. در نهایت بارگذاری تصاویر از کش باید سریعتر از دریافت آنها از شبکه باشد.
لازم به ذکر است که میتوانید برخی بهینهسازیها در راهحل فوق ایجاد کنید:
- از کش LRU به جای NSCache استفاده کنید.
- ذخیرهسازی دائمی (Persistence) را اضافه کنید.
- از قفل خواندن-نوشتن برای عملکرد بهتر استفاده کنید.
همه کدهای معرفیشده در این مقاله را میتوانید در این ریپوی گیتهاب (+) ملاحظه کنید.
اگر این نوشته برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی
- آموزش برنامه نویسی Swift (سوئیفت) برای برنامه نویسی iOS
- مجموعه آموزشهای دروس علوم و مهندسی کامپیوتر
- آموزش سوئیفت (Swift) — مجموعه مقالات مجله فرادرس
- فریمورک Combine در سوئیفت — راهنمای شروع به کار
==