当前位置: > > > Swift - 表格图片加载优化(拖动表格时不加载,停止时只加载当前页图片)

Swift - 表格图片加载优化(拖动表格时不加载,停止时只加载当前页图片)

(本文代码已升级至Swift3)

列表的单元格中包含有图片在开发中很常见。通常我们可以直接在 tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) 中给单元格设置图片。 

但有时这些图片要从远程加载,或者要给图片作裁减,添加滤镜等操作。如果这些操作还是直接在主线程中进行,由于上下拖动表格滚动条的时候,单元格渲染是实时进行的。那么单元格便会不断地进行图片加载,渲染,影响效率造成卡顿。如果图片大的话还会浪费流量。 

下面通过一个展示“热门电影”表格,演示如何进行优化。 

一、最初的代码

这个是功能的最简单实现,后面我们要在这个基础上进行改进。
UITableView 里除了显示文字,还从网络下载海报图片。同时为了使CPU资源占用更明显,还给图片添加了棕褐色滤镜。
由于这些都在主线程操作,可以发现滚动表格的时候,界面比较卡。(而且下载过的图片由于没有保存本地,来回拖动滚动条会发现图片在重复下载。)


movies.plist - 存储电影名字和对应的海报url地址
(下面是部分代码片断)
<plist version="1.0">
<dict>
	<key>港囧</key>
	<string>http://img3.doubanio.com/view/movie_poster_cover/spst/public/p2266145079.jpg</string>
	<key>第三种爱情</key>
	<string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2267236332.jpg</string>
	<key>碟中谍5</key>
	<string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2263582212.jpg</string>
	<key>小黄人大眼萌</key>
	<string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2265761240.jpg</string>
	<key>夏洛特烦恼</key>
	<string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2264377763.jpg</string>
	<key>魔镜</key>
	<string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2266110047.jpg</string>
	<key>长江7号</key>
	<string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2268754172.jpg</string>
	<key>九层妖塔</key>
	<string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2267502788.jpg</string>
	<key>hangge.com</key>
	<string>NO URL</string>
</dict>
</plist>

ListViewController.swift - 主页面代码
import UIKit
import CoreImage

//图片数据源地址
let dataSourcePath = Bundle.main.path(forResource: "movies", ofType: "plist")

class ListViewController: UITableViewController {
    
    //电影图片字典集合(使用了懒加载)
    lazy var movies = NSDictionary(contentsOfFile: dataSourcePath!)!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.title = "热门电影"
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    //获取记录数
    override func tableView(_ tableView: UITableView?, numberOfRowsInSection section: Int) -> Int {
        return movies.count
    }
    
    //创建单元格
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
        -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier",
            for: indexPath) as UITableViewCell
        
        //设置单元格文本
        let rowKey = movies.allKeys[indexPath.row] as! String
        cell.textLabel?.text = rowKey
        
        //设置单元格图片
        var image : UIImage?
        if let imageURL = URL(string:movies[rowKey] as! String) {
            let imageData = try? Data(contentsOf: imageURL)
            let unfilteredImage = UIImage(data:imageData!)
            image = self.applySepiaFilter(unfilteredImage!)
        }
        
        if image != nil {
            cell.imageView?.image = image!
        }else{
            //cell.imageView?.image = nil //未加载到海报则空白
            cell.imageView?.image = UIImage(named: "failed") //未加载到海报显示默认的“暂无图片”
        }
        
        return cell
    }
    
    //给图片添加棕褐色滤镜
    func applySepiaFilter(_ image:UIImage) -> UIImage? {
        let inputImage = CIImage(data:UIImagePNGRepresentation(image)!)
        let context = CIContext(options:nil)
        let filter = CIFilter(name:"CISepiaTone")
        filter!.setValue(inputImage, forKey: kCIInputImageKey)
        filter!.setValue(0.8, forKey: "inputIntensity")
        if let outputImage = filter!.outputImage {
            let outImage = context.createCGImage(outputImage, from: outputImage.extent)
            return UIImage(cgImage: outImage!)
        }
        return nil
    }
}
源码下载hangge_890_1.zip

二、功能改进:使用后台线程进行图片下载,及滤镜添加

(1)通过使用 Operation OperationQueue,创建两个队列。一个用来下载图片,一个用来处理图片。由于这些不在主线程中进行,所以拖动滚动条的时候就不会卡顿了。
(2)下载和滤镜是分别实现,因为有可能在图片下载时,用户将图片划动出屏幕了,这时不会再添加滤镜。当下一次用户划到当前行时,就不需要再下载图片了;只需要添加滤镜!这样可以提高效率。
(3)为便于观察,这里将 maxConcurrentOperationCount 设置为 1。表示一个队列中同时只有一个线程运行。(实际应用中可根据情况多开几个线程)
(4)开始的时候,会显示默认占位图片。图片下载成功后,会替换显示真实图片。图片下载失败,则显示失败图片。滤镜处理完毕后,替换显示处理后的图片。
(5)为增强用户体验,刚开始单元格尾部附件图片是一个转圈动画。一旦图片处理完毕或下载失败,便将其去除。


(1)电影条目类和状态枚举 - MovieRecord.swift
import UIKit

// 这个枚举包含所有电影图片的状态
enum MovieRecordState {
    case new, downloaded, filtered, failed
}

// 电影条目类
class MovieRecord {
    let name:String
    let url:URL
    var state = MovieRecordState.new
    //默认初始图片
    var image = UIImage(named: "placeholder")
    
    init(name:String, url:URL) {
        self.name = name
        self.url = url
    }
}

(2)队列管理类 - MovieOperations.swift
import Foundation

//队列管理类,追踪每个操作的状态
class MovieOperations {
    //追踪进行中的和等待中的下载操作
    lazy var downloadsInProgress = [IndexPath:Operation]()
    //图片下载队列
    lazy var downloadQueue:OperationQueue = {
        var queue = OperationQueue()
        queue.name = "Download queue"
        queue.maxConcurrentOperationCount = 1
        return queue
        }()
    
    //追踪进行中的和等待中的滤镜操作
    lazy var filtrationsInProgress = [IndexPath:Operation]()
    //图片处理队列
    lazy var filtrationQueue:OperationQueue = {
        var queue = OperationQueue()
        queue.name = "Image Filtration queue"
        queue.maxConcurrentOperationCount = 1
        return queue
        }()
}

(3)图片下载操作任务类 - ImageDownloader.swift
import UIKit

//图片下载操作任务
class ImageDownloader: Operation {
    //电影条目对象
    let movieRecord: MovieRecord
    
    init(movieRecord: MovieRecord) {
        self.movieRecord = movieRecord
    }
    
    //在子类中重载Operation的main方法来执行实际的任务。
    override func main() {
        //在开始执行前检查撤消状态。任务在试图执行繁重的工作前应该检查它是否已经被撤消。
        if self.isCancelled {
            return
        }
        //sleep(1) //这个只是为了便于测试观察
        
        //下载图片。
        let imageData = try? Data(contentsOf: self.movieRecord.url as URL)
        
        //再一次检查撤销状态。
        if self.isCancelled {
            return
        }
        
        //如果有数据,创建一个图片对象并加入记录,然后更改状态。如果没有数据,将记录标记为失败并设置失败图片。
        if imageData != nil {
            self.movieRecord.image = UIImage(data:imageData!)
            self.movieRecord.state = .downloaded
        }
        else
        {
            self.movieRecord.state = .failed
            self.movieRecord.image = UIImage(named: "failed")
        }
    }
}

(4)滤镜处理任务类 - ImageFiltration.swift
import UIKit
import CoreImage

//滤镜处理任务
class ImageFiltration: Operation {
    //电影条目对象
    let movieRecord: MovieRecord
    
    init(movieRecord: MovieRecord) {
        self.movieRecord = movieRecord
    }
    
    //在子类中重载Operation的main方法来执行实际的任务。
    override func main () {
        if self.isCancelled {
            return
        }
        
        if self.movieRecord.state != .downloaded {
            return
        }
        
        if let filteredImage = self.applySepiaFilter(self.movieRecord.image!) {
            self.movieRecord.image = filteredImage
            self.movieRecord.state = .filtered
        }
    }
    
    //给图片添加棕褐色滤镜
    func applySepiaFilter(_ image:UIImage) -> UIImage? {
        let inputImage = CIImage(data:UIImagePNGRepresentation(image)!)
        
        if self.isCancelled {
            return nil
        }
        let context = CIContext(options:nil)
        let filter = CIFilter(name:"CISepiaTone")
        filter?.setValue(inputImage, forKey: kCIInputImageKey)
        filter?.setValue(0.8, forKey: "inputIntensity")
        let outputImage = filter?.outputImage
        
        if self.isCancelled {
            return nil
        }
        
        let outImage = context.createCGImage(outputImage!, from: outputImage!.extent)
        let returnImage = UIImage(cgImage: outImage!)
        return returnImage
    }
}

(5)主页代码 - ListViewController.swift
import UIKit
import CoreImage

class ListViewController: UITableViewController {
    
    var movies = [MovieRecord]()
    let movieOperations = MovieOperations()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.title = "热门电影"
        
        //加载,处理电影列表数据
        fetchMovieDetails();
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    //加载,处理电影列表数据
    func fetchMovieDetails() {
        //图片数据源地址
        let dataSourcePath = Bundle.main.path(forResource: "movies", ofType: "plist")
        let datasourceDictionary = NSDictionary(contentsOfFile: dataSourcePath!)
        
        for(key,value) in datasourceDictionary!{
            let name = key as? String
            let url = URL(string:value as? String ?? "")
            if name != nil && url != nil {
                let movieRecord = MovieRecord(name:name!, url:url!)
                self.movies.append(movieRecord)
            }
        }
        
        self.tableView.reloadData()
    }
    
    //获取记录数
    override func tableView(_ tableView: UITableView?, numberOfRowsInSection section: Int) -> Int {
        return movies.count
    }
    
    //创建单元格
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
        -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier",
                                                     for: indexPath) as UITableViewCell
            
            //为了提示用户,将cell的accessory view设置为UIActivityIndicatorView。
            if cell.accessoryView == nil {
                let indicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
                cell.accessoryView = indicator
            }
            let indicator = cell.accessoryView as! UIActivityIndicatorView
            
            //获取当前行所对应的电影记录。
            let movieRecord = movies[indexPath.row]
            
            //设置文本和图片
            cell.textLabel?.text = movieRecord.name
            cell.imageView?.image = movieRecord.image
            
            //检查图片状态。设置适当的activity indicator 和文本,然后开始执行任务
            switch (movieRecord.state){
            case .filtered:
                indicator.stopAnimating()
            case .failed:
                indicator.stopAnimating()
                cell.textLabel?.text = "Failed to load"
            case .new:
                indicator.startAnimating()
                startDownloadForRecord(movieRecord, indexPath: indexPath)
            case .downloaded:
                indicator.startAnimating()
                startFiltrationForRecord(movieRecord, indexPath: indexPath)
            }
            
            return cell
    }
    
    //执行图片下载任务
    func startDownloadForRecord(_ movieRecord: MovieRecord, indexPath: IndexPath){
        //判断队列中是否已有该图片任务
        if let _ = movieOperations.downloadsInProgress[indexPath] {
            return
        }
        
        //创建一个下载任务
        let downloader = ImageDownloader(movieRecord: movieRecord)
        //任务完成后重新加载对应的单元格
        downloader.completionBlock = {
            if downloader.isCancelled {
                return
            }
            DispatchQueue.main.async(execute: {
                self.movieOperations.downloadsInProgress.removeValue(forKey: indexPath)
                self.tableView.reloadRows(at: [indexPath], with: .fade)
            })
        }
        //记录当前下载任务
        movieOperations.downloadsInProgress[indexPath] = downloader
        //将任务添加到队列中
        movieOperations.downloadQueue.addOperation(downloader)
    }
    
    //执行图片滤镜任务
    func startFiltrationForRecord(_ movieRecord: MovieRecord, indexPath: IndexPath){
        if let _ = movieOperations.filtrationsInProgress[indexPath]{
            return
        }
        
        let filterer = ImageFiltration(movieRecord: movieRecord)
        filterer.completionBlock = {
            if filterer.isCancelled {
                return
            }
            DispatchQueue.main.async(execute: {
                self.movieOperations.filtrationsInProgress.removeValue(forKey: indexPath)
                self.tableView.reloadRows(at: [indexPath], with: .fade)
            })
        }
        movieOperations.filtrationsInProgress[indexPath] = filterer
        movieOperations.filtrationQueue.addOperation(filterer)
    }
}
源码下载:hangge_890_2.zip

三、功能再次改进:拖动滚动条的时候不加载,停止后只加载当前页

当滚动表格时,比如快速的滚动到最后一条数据。虽然前面的单元格都已移出可视区域。但其实这些图片的下载和处理任务已经添加到后台队列中,并默默地在执行。
(1)解决这个问题,就要保证表格在滚动的情况下,不添加任务到队列中。而且只有当表格停止后,只把当前可见区域的图片添加到任务队列中。
(2)同时,对于那些原来在可视区域的队列任务。如果对应单元格被移出可视区域,那么其任务也要取消。

只需在主页面代码里稍作修改即可:
import UIKit
import CoreImage

class ListViewController: UITableViewController {
    
    var movies = [MovieRecord]()
    let movieOperations = MovieOperations()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.title = "热门电影"
        
        //加载,处理电影列表数据
        fetchMovieDetails();
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    //加载,处理电影列表数据
    func fetchMovieDetails() {
        //图片数据源地址
        let dataSourcePath = Bundle.main.path(forResource: "movies",
                                              ofType: "plist")
        let datasourceDictionary = NSDictionary(contentsOfFile: dataSourcePath!)
        
        for(key,value) in datasourceDictionary!{
            let name = key as? String
            let url = URL(string:value as? String ?? "")
            if name != nil && url != nil {
                let movieRecord = MovieRecord(name:name!, url:url!)
                self.movies.append(movieRecord)
            }
        }
        
        self.tableView.reloadData()
    }
    
    //获取记录数
    override func tableView(_ tableView: UITableView?, numberOfRowsInSection section: Int)
        -> Int {
            return movies.count
    }
    
    //创建单元格
    override func tableView(_ tableView: UITableView,
                            cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier",
                                                 for: indexPath) as UITableViewCell
        
        //为了提示用户,将cell的accessory view设置为UIActivityIndicatorView。
        if cell.accessoryView == nil {
            let indicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
            cell.accessoryView = indicator
        }
        let indicator = cell.accessoryView as! UIActivityIndicatorView
        
        //获取当前行所对应的电影记录。
        let movieRecord = movies[indexPath.row]
        
        //设置文本和图片
        cell.textLabel?.text = movieRecord.name
        cell.imageView?.image = movieRecord.image
        
        //检查图片状态。设置适当的activity indicator 和文本,然后开始执行任务
        switch (movieRecord.state){
        case .filtered:
            indicator.stopAnimating()
        case .failed:
            indicator.stopAnimating()
            cell.textLabel?.text = "Failed to load"
        case .new, .downloaded:
            indicator.startAnimating()
            //只有停止拖动的时候才加载
            if (!tableView.isDragging && !tableView.isDecelerating) {
                self.startOperationsForMovieRecord(movieRecord, indexPath: indexPath)
            }
        }
        
        return cell
    }
    
    //图片任务
    func startOperationsForMovieRecord(_ movieRecord: MovieRecord, indexPath: IndexPath){
        switch (movieRecord.state) {
        case .new:
            startDownloadForRecord(movieRecord, indexPath: indexPath)
        case .downloaded:
            startFiltrationForRecord(movieRecord, indexPath: indexPath)
        default:
            NSLog("do nothing")
        }
    }
    
    //执行图片下载任务
    func startDownloadForRecord(_ movieRecord: MovieRecord, indexPath: IndexPath){
        //判断队列中是否已有该图片任务
        if let _ = movieOperations.downloadsInProgress[indexPath] {
            return
        }
        
        //创建一个下载任务
        let downloader = ImageDownloader(movieRecord: movieRecord)
        //任务完成后重新加载对应的单元格
        downloader.completionBlock = {
            if downloader.isCancelled {
                return
            }
            DispatchQueue.main.async(execute: {
                self.movieOperations.downloadsInProgress.removeValue(forKey: indexPath)
                self.tableView.reloadRows(at: [indexPath], with: .fade)
            })
        }
        //记录当前下载任务
        movieOperations.downloadsInProgress[indexPath] = downloader
        //将任务添加到队列中
        movieOperations.downloadQueue.addOperation(downloader)
    }
    
    //执行图片滤镜任务
    func startFiltrationForRecord(_ movieRecord: MovieRecord, indexPath: IndexPath){
        if let _ = movieOperations.filtrationsInProgress[indexPath]{
            return
        }
        
        let filterer = ImageFiltration(movieRecord: movieRecord)
        filterer.completionBlock = {
            if filterer.isCancelled {
                return
            }
            DispatchQueue.main.async(execute: {
                self.movieOperations.filtrationsInProgress.removeValue(forKey: indexPath)
                self.tableView.reloadRows(at: [indexPath], with: .fade)
            })
        }
        movieOperations.filtrationsInProgress[indexPath] = filterer
        movieOperations.filtrationQueue.addOperation(filterer)
    }
    
    //视图开始滚动
    override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        //一旦用户开始滚动屏幕,你将挂起所有任务并留意用户想要看哪些行。
        suspendAllOperations()
    }
    
    //视图停止拖动
    override func scrollViewDidEndDragging(_ scrollView: UIScrollView,
                                           willDecelerate decelerate: Bool) {
        //如果减速(decelerate)是 false ,表示用户停止拖拽tableview。
        //此时你要继续执行之前挂起的任务,撤销不在屏幕中的cell的任务并开始在屏幕中的cell的任务。
        if !decelerate {
            loadImagesForOnscreenCells()
            resumeAllOperations()
        }
    }
    
    //视图停止减速
    override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        //这个代理方法告诉你tableview停止滚动,执行操作同上
        loadImagesForOnscreenCells()
        resumeAllOperations()
    }
    
    //暂停所有队列
    func suspendAllOperations () {
        movieOperations.downloadQueue.isSuspended = true
        movieOperations.filtrationQueue.isSuspended = true
    }
    
    //恢复运行所有队列
    func resumeAllOperations () {
        movieOperations.downloadQueue.isSuspended = false
        movieOperations.filtrationQueue.isSuspended = false
    }
    
    //加载可见区域的单元格图片
    func loadImagesForOnscreenCells () {
        //开始将tableview可见行的index path放入数组中。
        if let pathsArray = self.tableView.indexPathsForVisibleRows {
            //通过组合所有下载队列和滤镜队列中的任务来创建一个包含所有等待任务的集合
            let allMovieOperations = NSMutableSet()
            for key in movieOperations.downloadsInProgress.keys{
                allMovieOperations.add(key)
            }
            for key in movieOperations.filtrationsInProgress.keys{
                allMovieOperations.add(key)
            }
            
            //构建一个需要撤销的任务的集合。从所有任务中除掉可见行的index path,
            //剩下的就是屏幕外的行所代表的任务。
            let toBeCancelled = allMovieOperations.mutableCopy() as! NSMutableSet
            let visiblePaths = NSSet(array: pathsArray)
            toBeCancelled.minus(visiblePaths as Set<NSObject>)
            
            //创建一个需要执行的任务的集合。从所有可见index path的集合中除去那些已经在等待队列中的。
            let toBeStarted = visiblePaths.mutableCopy() as! NSMutableSet
            toBeStarted.minus(allMovieOperations as Set<NSObject>)
            
            // 遍历需要撤销的任务,撤消它们,然后从 movieOperations 中去掉它们
            for indexPath in toBeCancelled {
                let indexPath = indexPath as! IndexPath
                if let movieDownload = movieOperations.downloadsInProgress[indexPath] {
                    movieDownload.cancel()
                }
                movieOperations.downloadsInProgress.removeValue(forKey: indexPath)
                if let movieFiltration = movieOperations.filtrationsInProgress[indexPath] {
                    movieFiltration.cancel()
                }
                movieOperations.filtrationsInProgress.removeValue(forKey: indexPath)
            }
            
            // 遍历需要开始的任务,调用 startOperationsForPhotoRecord
            for indexPath in toBeStarted {
                let indexPath = indexPath as! IndexPath
                let recordToProcess = self.movies[indexPath.row]
                startOperationsForMovieRecord(recordToProcess, indexPath: indexPath)
            }
        }
    }
}
源码下载:hangge_890_3.zip
评论6
  • 6楼
    2018-07-12 16:00
    amberoot

    我的项目需要不断下载图片,看到您的分享我很开心,马上就用了。非常感谢站主的分享。但是后来我发现项目内存泄漏了,然后我就发现了图片下载任务中的movieRecord循环强引用了,导致每次的下载任务都不能回收。把movieRecord改成弱引用就好了,在这跟您说一下这个问题。

    站长回复

    谢谢你的反馈,不过我检查了下代码,没有发现下载任务中的movieRecord有循环引用问题,可否详细说明下。

  • 5楼
    2017-01-10 10:24
    mario

    航哥,我的服务器是https的,使用你这种方法加载时显示"2017-01-10 10:23:19.544 pro[15489:2266500] NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -9813)",要怎样解决这个问题?

    站长回复

    我试了几个https地址的图片,都没问题啊。感觉像是你服务器的问题,比如证书是不是有问题。

  • 4楼
    2016-04-27 14:41
    小猿

    请教下站长大人,我用您上诉的优化方法在同一个界面同时优化三个tableView会造成影响么?就是说三个tableView共用的一个下载线程和同一个线程管理类?谢谢

    站长回复

    可以共用一个下载线程,也可以让每个tableView分别使用一个下载线程,这两种方式都是没问题的。

    就是如果共用一个线程的话,下载完图片后就要判断是属于哪个tableView的,然后刷新对应tableView的单元格即可。

  • 3楼
    2016-04-14 11:36
    小白

    请问下站长,MovieOperations.swift 这个类 放在一个自定义的View中,为什么会报没有初始化的错?

    站长回复

    这个我也不太清楚了,这个要你自己调试下代码了。

  • 2楼
    2015-12-10 16:56
    来碗杂酱面

    加油,站长,很有分享精神,待我swift学的不错了,我也来建个站。

    站长回复

    加油!

  • 1楼
    2015-10-08 10:22
    微微鱼

    加油,楼主

    站长回复

    谢谢