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